a request context on the ipc server, cancelled by Close (V-638)

serveConn dispatched under context.Background(), so a dispatch in flight
during shutdown could not be told to stop and closeGrace could only
abandon it. The server now carries a context, Close cancels it, and each
conn derives its own so nothing outlives the connection.

A zero-value Server built outside Listen falls back to Background; two
wiring tests do that.

Client.Close read c.conn with no lock while roundtrip re-dialed and
dropped it, which -race caught on the new test. The conn field now has
its own mutex, held only across a read or an assignment, so Close and
the watchdog reach the connection without queueing behind the call they
are interrupting.
This commit is contained in:
2026-08-06 22:59:02 +04:00
parent 123b9aa961
commit cbd8077d2c
3 changed files with 117 additions and 13 deletions
+35 -8
View File
@@ -26,7 +26,12 @@ type Client struct {
conn net.Conn
path string // the address as configured, kept for errors and logs
addr netaddr.Addr // parsed, so a dropped conn can be re-dialed (core restart)
mu sync.Mutex
mu sync.Mutex // one request at a time, so a frame and its reply pair up
// connMu guards the conn field alone, and is held only across an assignment
// or a read. It exists so Close and the cancellation watchdog can reach the
// connection without waiting for the call that is holding c.mu (V-638).
connMu sync.Mutex
}
// defaultCallTimeout bounds a call whose context carries no deadline. It is
@@ -109,11 +114,18 @@ func Dial(path string) (*Client, error) {
return &Client{conn: c, path: path, addr: addr}, nil
}
// Close closes the connection out from under a call in flight, on purpose: a
// shutdown must not wait out a parked read. It takes connMu and never c.mu, so
// it cannot block behind the call it is interrupting.
func (c *Client) Close() error {
c.connMu.Lock()
defer c.connMu.Unlock()
if c.conn == nil {
return nil
}
return c.conn.Close()
err := c.conn.Close()
c.conn = nil
return err
}
// DialWait is Dial with patience: it retries with capped backoff until the
@@ -223,14 +235,15 @@ func (c *Client) call(ctx context.Context, m Method, params, result any) error {
// calling drop, because drop wants c.mu and the caller is holding it — the
// closed socket fails the read, and roundtrip drops it on the way out.
func (c *Client) roundtrip(ctx context.Context, m Method, raw json.RawMessage, resp *Response) error {
if c.conn == nil {
conn, err := netaddr.Dial(c.addr)
conn := c.currentConn()
if conn == nil {
dialed, err := netaddr.Dial(c.addr)
if err != nil {
return fmt.Errorf("%w: dial %s: %v", errWriteLost, c.addr, err)
}
c.conn = conn
c.setConn(dialed)
conn = dialed
}
conn := c.conn
if dl, ok := ctx.Deadline(); ok {
_ = conn.SetDeadline(dl)
} else {
@@ -248,11 +261,11 @@ func (c *Client) roundtrip(ctx context.Context, m Method, raw json.RawMessage, r
}
}()
if err := writeFrame(c.conn, Request{Method: m, Params: raw}); err != nil {
if err := writeFrame(conn, Request{Method: m, Params: raw}); err != nil {
c.drop()
return fmt.Errorf("%w: %v", errWriteLost, err)
}
if err := readFrame(c.conn, resp); err != nil {
if err := readFrame(conn, resp); err != nil {
c.drop()
return fmt.Errorf("%w: %v", errReadLost, err)
}
@@ -261,12 +274,26 @@ func (c *Client) roundtrip(ctx context.Context, m Method, raw json.RawMessage, r
// drop closes and forgets the current conn so the next call re-dials.
func (c *Client) drop() {
c.connMu.Lock()
defer c.connMu.Unlock()
if c.conn != nil {
_ = c.conn.Close()
c.conn = nil
}
}
func (c *Client) currentConn() net.Conn {
c.connMu.Lock()
defer c.connMu.Unlock()
return c.conn
}
func (c *Client) setConn(conn net.Conn) {
c.connMu.Lock()
defer c.connMu.Unlock()
c.conn = conn
}
// hydrate rehydrates a wire RpcError into the matching package sentinel. The
// code↔sentinel table is the only place the wire "knows" about errors; keep it
// in sync with codeOf in wire.go.