Skip to content

fix(droplets): synchronize concurrent appends when creating multiple droplets - #2016

Open
aniruddhaadak80 wants to merge 1 commit into
digitalocean:mainfrom
aniruddhaadak80:fix/droplet-create-slice-race
Open

aniruddhaadak80 wants to merge 1 commit into
digitalocean:mainfrom
aniruddhaadak80:fix/droplet-create-slice-race

Conversation

@aniruddhaadak80

Copy link
Copy Markdown

Summary

doctl compute droplet create with multiple names has a data race. Each requested name gets its own goroutine, and every one of them appends to the same createdList slice with no synchronization, so droplets get silently dropped from the result table.

This is a self-identified defect found reading the source, not a reported issue.

Problem

doctl compute droplet create web1 web2 web3

RunDropletCreate fans out one goroutine per name. wg and errs are synchronized; createdList is not. Concurrent append on a shared slice header is undefined behaviour — entries are lost, or the backing array is corrupted.

Expected: all requested droplets appear in the printed table.
Actual: fewer rows than droplets created, nondeterministically.
Reproduces on: current main @ 9248d399.

Only affects the multi-name form, so it has gone unnoticed: the single-name path never has two goroutines racing.

Root cause

commands/droplets.go, inside the per-name goroutine:

d, err := ds.Create(dcr, wait)
if err != nil {
    errs <- err
    return
}

createdList = append(createdList, *d)   // <-- unsynchronized shared write

createdList is declared once outside the loop and captured by every goroutine.

Changes

  • commands/droplets.go — added createdListMu sync.Mutex and lock/unlock around the append. Six added lines, nothing removed. I chose the mutex over a results channel because the surrounding function already uses sync.WaitGroup and a buffered error channel for its other shared state; wg and errs are untouched.
  • commands/droplets_test.go — TestDropletCreateMultipleNames, a barrier-synchronized multi-name create that makes the race detector fire.

Testing

The proof is the race detector, not an assertion on output. Before the fix:

$ go test -race -run TestDropletCreateMultipleNames -count=1 ./commands/
WARNING: DATA RACE
Read at 0x00c000204960 by goroutine 38:
  commands.RunDropletCreate.func1()  .../commands/droplets.go:351 +0x13d
Previous write at 0x00c000204960 by goroutine 23:
  commands.RunDropletCreate.func1()  .../commands/droplets.go:351 +0x1f1
...second report: runtime.growslice() .../commands/droplets.go:351 vs droplets.go:351
--- FAIL: TestDropletCreateMultipleNames (0.02s)
    testing.go:1865: race detected during execution of test
FAIL    github.com/digitalocean/doctl/commands  41.910s

After the fix, clean — zero DATA RACE lines.

go test -race -count=1 -run TestDroplet -v ./commands/                    # ok, 65 PASS, 0 FAIL, 0 DATA RACE
go test -count=1 -timeout 900s -skip TestMaybePrintAgentPublicPreviewTermsNoticeFirstSessionOnly ./commands/  # ok 485s
go test -mod=vendor -count=1 ./do/... ./pkg/... ./internal/... .          # ok (except pkg/extract, see below)
go vet ./commands/                                                        # clean
  • New/updated tests fail without this change
  • Full existing suite passes
  • Lint / format / typecheck pass

Reverting only commands/droplets.go and keeping the test gives 3 WARNING: DATA RACE reports, all at droplets.go:351; re-applying the fix gives PASS.

Please read this before reviewing

CI does not run -race. make test_unit is plain go test -mod=vendor ..., so this new test will be green in CI and the race will not be visible there. The non-race assertion — all 16 names present in the JSON output — is inherently probabilistic, since a lost append is timing-dependent and may not reproduce on a given run. The barrier in the test is what makes the -race signal deterministic; if you'd prefer CI to catch this class of bug generally, adding -race to test_unit would be a separate, larger change.

Pre-existing and unrelated, both reproduced at base with this branch reverted: pkg/extract symlink tests fail on Windows (needs a privilege this account lacks), and TestMaybePrintAgentPublicPreviewTermsNoticeFirstSessionOnly hangs at commands/agents_help_test.go:138, which is the reason for -skip.

I also checked for a second, separate capture bug: go.mod declares go 1.26.0, so per-iteration loop variables apply, and dcr is declared inside the loop body regardless. The closure capture is already correct — no second bug exists.

Compatibility / risk

None. One uncontended mutex lock per requested name. No behaviour, signature, or API change; wg/errs semantics unchanged. Output ordering across droplets remains nondeterministic exactly as before.

Screenshots / logs

Not applicable — no output-format change.


  • I have read the repository's CONTRIBUTING guide.
  • My commits are signed off (git commit -s) where DCO is required.
  • This PR contains one logical change only.
  • I added or updated tests covering the change.
  • Documentation is updated where behaviour changed. (Bug fix restoring intended behaviour; no doc change needed.)

RunDropletCreate fans out one goroutine per requested name, and each one
appended to the shared createdList slice with no synchronization. Only wg
and errs were safe: concurrent appends on the same slice header race, and a
lost append silently drops a droplet the API already created, so the user
is never told about a droplet that is really running.

Guard the append with a mutex, which is how the rest of commands/ protects
shared state. TestDropletCreateMultipleNames parks every Create call until
all goroutines are in flight and then releases them together, so the race
detector fires on the unsynchronized append.
Copilot AI balanced review requested due to automatic review settings October 5, 2026 01:40

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

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