Data race on Client.conn between the auto-reconnect monitor and Client.Close (v0.9.0)
Following up on #882 with a second, distinct data race I found in the same area
(secure-channel / connection lifecycle) during the same -race run.
Version / environment
github.com/gopcua/opcua v0.9.0 (latest release). Present on current master too
(the conn field and the Dial/Close accesses are unchanged).
- Go 1.26, reproduced on
darwin/arm64 (the racing code is arch-independent).
SecurityMode = None, a single *opcua.Client. The application drives connect /
disconnect / reconnect from one goroutine, serialized behind its own mutex — it
never calls the client concurrently from the caller side.
Summary
The race detector reports a data race on the unexported field Client.conn, entirely
within gopcua's own goroutines:
- the background auto-reconnect monitor (
(*Client).monitor, started internally by
Connect) re-dials on connection loss and writes c.conn in (*Client).Dial, while
(*Client).Close reads c.conn to close the underlying connection.
Close cancels the monitor's context (c.mcancel()) but does not wait for the monitor
goroutine to exit before touching c.conn, and c.conn is not guarded by any mutex. So a
Close that overlaps an in-flight reconnect races with the monitor's Dial.
Race report (-race, application frames anonymized as <app>)
WARNING: DATA RACE
Write at 0x… by goroutine 91:
github.com/gopcua/opcua.(*Client).Dial()
github.com/gopcua/opcua@v0.9.0/client.go:621 // c.conn, err = c.cfg.dialer.Dial(...)
github.com/gopcua/opcua/uacp.(*Dialer).Dial()
github.com/gopcua/opcua@v0.9.0/uacp/conn.go:95
github.com/gopcua/opcua.(*Client).monitor()
github.com/gopcua/opcua@v0.9.0/client.go:418 // reconnect loop re-dials
github.com/gopcua/opcua.(*Client).Connect.func1.gowrap1()
github.com/gopcua/opcua@v0.9.0/client.go:283
Previous read at 0x… by goroutine 87:
github.com/gopcua/opcua.(*Client).Close()
github.com/gopcua/opcua@v0.9.0/client.go:668 // if c.conn != nil { c.conn.Close() }
<app> (*Connector).Disconnect() // calls client.Close(ctx)
<app> (*Connector).Connect() // disconnect-before-reconnect path
<app> reconnect routine — single serialized goroutine
Goroutine 91 (running) created at: // the monitor goroutine
github.com/gopcua/opcua.(*Client).Connect.func1()
github.com/gopcua/opcua@v0.9.0/client.go:283 // go c.monitor(mctx)
github.com/gopcua/opcua.(*Client).Connect()
github.com/gopcua/opcua@v0.9.0/client.go:258
<app> (*Connector).Connect() // opcua.NewClient(...).Connect(ctx)
<app> reconnect routine
Goroutine 87 (running):
the application's single serialized connect/disconnect goroutine,
calling Connector.Disconnect() -> Client.Close().
Analysis
c.conn is a plain field with no mutex (unlike secureChannel, which has
SecureChannel()/setSecureChannel() accessors, or the subMux-guarded subscription
state). Every access is unsynchronized — the write in Dial:
// (*Client).Dial — client.go:621
c.conn, err = c.cfg.dialer.Dial(ctx, c.endpointURL) // WRITE
and the read in Close:
// (*Client).Close — client.go:650-669
if c.mcancel != nil {
c.mcancel() // cancels the monitor context, but does NOT wait for it to exit
}
...
if c.conn != nil { // READ, races with monitor's Dial
c.conn.Close()
}
With AutoReconnect enabled (the default), the monitor goroutine started by Connect
(go c.monitor(mctx)) re-dials on connection loss (monitor → Dial → writes c.conn).
Because Close only cancels the monitor's context and then immediately reads c.conn
without joining the goroutine, the two overlap. The application cannot prevent this from
the caller side: even a single, fully-serialized caller trips it, because the monitor
goroutine runs independently inside gopcua and Close does not synchronize with it.
A fix would either guard c.conn with a mutex (as secureChannel already is), or have
Close wait for the monitor goroutine to actually return before closing the connection
(e.g. a done-channel / sync.WaitGroup the monitor signals on exit).
Reproduction
- Create a client with default options (
AutoReconnect on) and a short channel lifetime.
- Connect, then repeatedly drop/restore the connection (or issue
Disconnect/Connect
while the server bounces) so Close overlaps the monitor's reconnect Dial.
- Build/run with
-race.
The race fires when a Close lands while the monitor is mid-reconnect.
Impact
Rare in production (needs a Close concurrent with an in-flight reconnect), but a genuine
data race on connection state → risk of use-after-close / corrupted connection handling.
Related
Data race on
Client.connbetween the auto-reconnect monitor andClient.Close(v0.9.0)Following up on #882 with a second, distinct data race I found in the same area
(secure-channel / connection lifecycle) during the same
-racerun.Version / environment
github.com/gopcua/opcuav0.9.0 (latest release). Present on currentmastertoo(the
connfield and theDial/Closeaccesses are unchanged).darwin/arm64(the racing code is arch-independent).SecurityMode = None, a single*opcua.Client. The application drives connect /disconnect / reconnect from one goroutine, serialized behind its own mutex — it
never calls the client concurrently from the caller side.
Summary
The race detector reports a data race on the unexported field
Client.conn, entirelywithin gopcua's own goroutines:
(*Client).monitor, started internally byConnect) re-dials on connection loss and writesc.connin(*Client).Dial, while(*Client).Closereadsc.connto close the underlying connection.Closecancels the monitor's context (c.mcancel()) but does not wait for the monitorgoroutine to exit before touching
c.conn, andc.connis not guarded by any mutex. So aClosethat overlaps an in-flight reconnect races with the monitor'sDial.Race report (
-race, application frames anonymized as<app>)Analysis
c.connis a plain field with no mutex (unlikesecureChannel, which hasSecureChannel()/setSecureChannel()accessors, or thesubMux-guarded subscriptionstate). Every access is unsynchronized — the write in
Dial:and the read in
Close:With
AutoReconnectenabled (the default), the monitor goroutine started byConnect(
go c.monitor(mctx)) re-dials on connection loss (monitor→Dial→ writesc.conn).Because
Closeonly cancels the monitor's context and then immediately readsc.connwithout joining the goroutine, the two overlap. The application cannot prevent this from
the caller side: even a single, fully-serialized caller trips it, because the monitor
goroutine runs independently inside gopcua and
Closedoes not synchronize with it.A fix would either guard
c.connwith a mutex (assecureChannelalready is), or haveClosewait for the monitor goroutine to actually return before closing the connection(e.g. a done-channel /
sync.WaitGroupthe monitor signals on exit).Reproduction
AutoReconnecton) and a short channel lifetime.Disconnect/Connectwhile the server bounces) so
Closeoverlaps the monitor's reconnectDial.-race.The race fires when a
Closelands while the monitor is mid-reconnect.Impact
Rare in production (needs a
Closeconcurrent with an in-flight reconnect), but a genuinedata race on connection state → risk of use-after-close / corrupted connection handling.
Related
data race, but a distinct field (
Client.conn) with a separate fix site(
Dial/Closevs. the renewal/SendRequestWithTimeoutpath).Fix secure channel races (attempt 1) #462 ("Fix secure channel races"). This instance appears distinct and still open.