Skip to content

fix: guard PID against concurrent access from the monitor goroutine#6

Merged
mudler merged 1 commit into
masterfrom
fix/readpid-data-race
Jul 19, 2026
Merged

fix: guard PID against concurrent access from the monitor goroutine#6
mudler merged 1 commit into
masterfrom
fix/readpid-data-race

Conversation

@mudler

@mudler mudler commented Jul 19, 2026

Copy link
Copy Markdown
Owner

readPID() cached its result into Process.PID on every call. It is reachable from the caller's Stop() and IsAlive() and, at the same time, from the library's own monitor goroutine via reapChildren(), so a bare Run() + Stop() tripped the race detector with no concurrent access from the caller at all.

Two changes remove the race:

  • readPID() no longer writes to the Process. It returns the pidfile contents and callers use the returned value, which turns the three concurrent call sites into pure reads.
  • The remaining PID writes (writePID from Run, release from Stop and from the monitor's Wait-error path) go through a sync.RWMutex.

A mutex rather than an atomic: PID is a string, so an atomic would need atomic.Value/atomic.Pointer indirection for no benefit, and the lock leaves room to cover sibling state under the same guard later. The critical sections are field assignments only, never held across a syscall or a file operation, so no path holding the lock can re-enter it.

CurrentPID() is added so callers can read the PID without touching the exported field, which the monitor goroutine still clears on exit. The field is kept and deprecated rather than unexported, to avoid breaking existing users in a patch release.

The regression is covered by a spec that runs a real process through Run/Stop with concurrent readers, and CI now runs the suite with -race, which is why this went unnoticed: every existing spec already trips the detector, but CI never passed the flag.

Fixes #5

Assisted-by: Claude Code:claude-opus-4-8 [Read] [Edit] [Bash]

readPID() cached its result into Process.PID on every call. It is reachable
from the caller's Stop() and IsAlive() and, at the same time, from the
library's own monitor goroutine via reapChildren(), so a bare Run() + Stop()
tripped the race detector with no concurrent access from the caller at all.

Two changes remove the race:

  - readPID() no longer writes to the Process. It returns the pidfile
    contents and callers use the returned value, which turns the three
    concurrent call sites into pure reads.
  - The remaining PID writes (writePID from Run, release from Stop and from
    the monitor's Wait-error path) go through a sync.RWMutex.

A mutex rather than an atomic: PID is a string, so an atomic would need
atomic.Value/atomic.Pointer indirection for no benefit, and the lock leaves
room to cover sibling state under the same guard later. The critical sections
are field assignments only, never held across a syscall or a file operation,
so no path holding the lock can re-enter it.

CurrentPID() is added so callers can read the PID without touching the
exported field, which the monitor goroutine still clears on exit. The field
is kept and deprecated rather than unexported, to avoid breaking existing
users in a patch release.

The regression is covered by a spec that runs a real process through
Run/Stop with concurrent readers, and CI now runs the suite with -race,
which is why this went unnoticed: every existing spec already trips the
detector, but CI never passed the flag.

Fixes #5

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-4-8 [Read] [Edit] [Bash]
@mudler
mudler merged commit a94e2b7 into master Jul 19, 2026
4 checks passed
mudler added a commit that referenced this pull request Jul 20, 2026
Run's "already started" guard was a check-then-act on p.proc with no lock
around it. Concurrent Run calls on the same handle could all observe a nil
p.proc, all start a process and all launch a monitor goroutine. Every monitor
does `defer close(p.done)`, and done is created once in New and never
recreated, so the second monitor to see its process exit panicked with
"close of closed channel". The same window raced on p.proc itself: it was
written unsynchronized while the previous monitor, its reapChildren child and
release were reading it.

This is the same class as the PID race fixed in #6, one field over. PID was
guarded there; p.proc was left alone because the write happens before
`go p.monitor()` and so happens-before every reader - but only under the
single-Run assumption that nothing stated or enforced.

Make that assumption explicit rather than inventing a restart lifecycle
nobody asked for:

  - Run claims the handle's single run by checking and setting `started`
    under mu, so exactly one caller can ever start a process and launch a
    monitor. The double-close is now unreachable by construction.
  - A second Run returns the new sentinel ErrProcessAlreadyRun, whose message
    keeps the historical "command already started" prefix so callers matching
    on that text keep working.
  - A Run that fails before starting anything (unwritable state dir, missing
    binary) hands the claim back, so retrying a failed start still works.
  - p.proc is guarded by the existing mutex via currentProc/setProc. Readers
    take the pointer under RLock and use it outside, so no path holds mu
    across Wait or Release.

Sequential reuse already returned an error by accident, because release()
never nils p.proc - but with a misleading message, and only for handles that
had reached StartProcess. Stop() followed by Run() looked like it might
restart and never could. The contract is now documented on Run.

No exported signature changes; ErrProcessAlreadyRun is additive.

Adds specs for concurrent Run, reuse after exit, reuse after Stop, and retry
after a failed start. The concurrent spec panics on the unfixed code and
trips -race on p.proc.


Assisted-by: Claude Code:claude-opus-4-8[1m] [Read] [Edit] [Bash]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
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.

data race: readPID() writes Process.pid unsynchronized, reachable from Stop(), IsAlive() and the internal monitor goroutine

1 participant