detached b25a8d3

fix(sessionwire): publish negotiated ceilings through a guard

The preamble is lazy: whichever goroutine first uses a connection establishes it
and publishes the negotiated ceilings under a once. Capabilities does not go
through that once, so it read the same field without any ordering against the
write. A real offline composition caught it under the race detector, writing
from the receive goroutine while the attachment worker built its Hello.

The client and the server both guarded nothing here; both now publish the
ceilings under a mutex and read them under the same mutex. Send and receive
paths keep reading after ensurePreamble returns, which orders the publication
before their read, and the limits accessor documents that.

Verified by mutation: reading the field directly again makes the new test report
the data race, and the app-level race gate that first exposed it now passes.
commit
b25a8d38a96cd1b56d5a0f3cbfeb760d653e3862
author
brice · · 2026-09-19 12:42 UTC
parent
aea1a9b
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +127 -9
M internal/adapters/sessionwire/client.go +31 -6
@@ -16,6 +16,11 @@ import (
1616 type clientConnection struct {
1717 raw wire.Transport
1818 ceilings protoCeilings
19+ // ceilingsMu guards ceilings. The lazy preamble publishes the negotiated
20+ // values from whichever goroutine first uses the connection, while another
21+ // goroutine may already be asking for capabilities or starting a send, so
22+ // the once-guarded write is not by itself a happens-before edge for them.
23+ ceilingsMu sync.Mutex
1924
2025 preambleOnce sync.Once
2126 preambleErr error
@@ -112,8 +117,10 @@ func (c *clientConnection) ensurePreamble() error {
112117 c.preambleOnce.Do(func() {
113118 ctx, cancel := context.WithDeadline(context.Background(), c.deadline)
114119 defer cancel()
115- c.ceilings, c.preambleErr = runProtoClientPreamble(ctx, c.raw, c.ceilings)
116- if c.preambleErr != nil {
120+ next, err := runProtoClientPreamble(ctx, c.raw, c.limits())
121+ c.publishLimits(next)
122+ c.preambleErr = err
123+ if err != nil {
117124 _ = c.raw.Close()
118125 }
119126 c.finishPreamble()
@@ -122,6 +129,21 @@ func (c *clientConnection) ensurePreamble() error {
122129 return c.preambleErr
123130 }
124131
132+// limits returns the negotiated ceilings under the lock the preamble publishes
133+// them through.
134+func (c *clientConnection) limits() protoCeilings {
135+ c.ceilingsMu.Lock()
136+ defer c.ceilingsMu.Unlock()
137+ return c.ceilings
138+}
139+
140+// publishLimits records the ceilings one preamble negotiation produced.
141+func (c *clientConnection) publishLimits(next protoCeilings) {
142+ c.ceilingsMu.Lock()
143+ c.ceilings = next
144+ c.ceilingsMu.Unlock()
145+}
146+
125147 func (c *clientConnection) SendClient(message protocol.ClientMessage) error {
126148 if err := c.ensurePreamble(); err != nil {
127149 return err
@@ -134,7 +156,7 @@ func (c *clientConnection) SendClient(message protocol.ClientMessage) error {
134156 if err != nil {
135157 return err
136158 }
137- if err := checkCategoryCeiling(raw, clientEnvelopeCategory(raw), c.ceilings.maxReceiveEnvelopeBytes); err != nil {
159+ if err := checkCategoryCeiling(raw, clientEnvelopeCategory(raw), c.limits().maxReceiveEnvelopeBytes); err != nil {
138160 return err
139161 }
140162 return c.raw.Send(wire.Envelope{Payload: raw})
@@ -148,7 +170,8 @@ func (c *clientConnection) ReceiveServer() (protocol.ServerMessage, error) {
148170 if err != nil {
149171 return nil, err
150172 }
151- message, decodeErr := decodeServerEnvelopeWithin(envelope.Payload, c.ceilings.maxReceiveEnvelopeBytes, c.ceilings.outputDataLimit)
173+ limits := c.limits()
174+ message, decodeErr := decodeServerEnvelopeWithin(envelope.Payload, limits.maxReceiveEnvelopeBytes, limits.outputDataLimit)
152175 if decodeErr == nil {
153176 return message, nil
154177 }
@@ -156,7 +179,7 @@ func (c *clientConnection) ReceiveServer() (protocol.ServerMessage, error) {
156179 }
157180
158181 func (c *clientConnection) Capabilities() protocol.ConnectionCapabilities {
159- return rawCapabilities(c.raw, c.ceilings.outputDataLimit)
182+ return rawCapabilities(c.raw, c.limits().outputDataLimit)
160183 }
161184
162185 func (c *clientConnection) LinkState() ports.LinkState { return rawLinkState(c.raw) }
@@ -192,7 +215,9 @@ func (c *clientConnection) ensurePreambleWith(ctx context.Context) error {
192215 c.preambleOnce.Do(func() {
193216 ctx, cancel := context.WithDeadline(ctx, c.deadline)
194217 defer cancel()
195- c.ceilings, c.preambleErr = runProtoClientPreamble(ctx, c.raw, c.ceilings)
218+ next, err := runProtoClientPreamble(ctx, c.raw, c.limits())
219+ c.publishLimits(next)
220+ c.preambleErr = err
196221 c.finishPreamble()
197222 })
198223 c.runHandshakeHooks()
@@ -16,6 +16,11 @@ import (
16 type clientConnection struct {16 type clientConnection struct {
17 raw wire.Transport17 raw wire.Transport
18 ceilings protoCeilings18 ceilings protoCeilings
19+ // ceilingsMu guards ceilings. The lazy preamble publishes the negotiated
20+ // values from whichever goroutine first uses the connection, while another
21+ // goroutine may already be asking for capabilities or starting a send, so
22+ // the once-guarded write is not by itself a happens-before edge for them.
23+ ceilingsMu sync.Mutex
19 24
20 preambleOnce sync.Once25 preambleOnce sync.Once
21 preambleErr error26 preambleErr error
@@ -112,8 +117,10 @@ func (c *clientConnection) ensurePreamble() error {
112 c.preambleOnce.Do(func() {117 c.preambleOnce.Do(func() {
113 ctx, cancel := context.WithDeadline(context.Background(), c.deadline)118 ctx, cancel := context.WithDeadline(context.Background(), c.deadline)
114 defer cancel()119 defer cancel()
115- c.ceilings, c.preambleErr = runProtoClientPreamble(ctx, c.raw, c.ceilings)120+ next, err := runProtoClientPreamble(ctx, c.raw, c.limits())
116- if c.preambleErr != nil {121+ c.publishLimits(next)
122+ c.preambleErr = err
123+ if err != nil {
117 _ = c.raw.Close()124 _ = c.raw.Close()
118 }125 }
119 c.finishPreamble()126 c.finishPreamble()
@@ -122,6 +129,21 @@ func (c *clientConnection) ensurePreamble() error {
122 return c.preambleErr129 return c.preambleErr
123 }130 }
124 131
132+// limits returns the negotiated ceilings under the lock the preamble publishes
133+// them through.
134+func (c *clientConnection) limits() protoCeilings {
135+ c.ceilingsMu.Lock()
136+ defer c.ceilingsMu.Unlock()
137+ return c.ceilings
138+}
139+
140+// publishLimits records the ceilings one preamble negotiation produced.
141+func (c *clientConnection) publishLimits(next protoCeilings) {
142+ c.ceilingsMu.Lock()
143+ c.ceilings = next
144+ c.ceilingsMu.Unlock()
145+}
146+
125 func (c *clientConnection) SendClient(message protocol.ClientMessage) error {147 func (c *clientConnection) SendClient(message protocol.ClientMessage) error {
126 if err := c.ensurePreamble(); err != nil {148 if err := c.ensurePreamble(); err != nil {
127 return err149 return err
@@ -134,7 +156,7 @@ func (c *clientConnection) SendClient(message protocol.ClientMessage) error {
134 if err != nil {156 if err != nil {
135 return err157 return err
136 }158 }
137- if err := checkCategoryCeiling(raw, clientEnvelopeCategory(raw), c.ceilings.maxReceiveEnvelopeBytes); err != nil {159+ if err := checkCategoryCeiling(raw, clientEnvelopeCategory(raw), c.limits().maxReceiveEnvelopeBytes); err != nil {
138 return err160 return err
139 }161 }
140 return c.raw.Send(wire.Envelope{Payload: raw})162 return c.raw.Send(wire.Envelope{Payload: raw})
@@ -148,7 +170,8 @@ func (c *clientConnection) ReceiveServer() (protocol.ServerMessage, error) {
148 if err != nil {170 if err != nil {
149 return nil, err171 return nil, err
150 }172 }
151- message, decodeErr := decodeServerEnvelopeWithin(envelope.Payload, c.ceilings.maxReceiveEnvelopeBytes, c.ceilings.outputDataLimit)173+ limits := c.limits()
174+ message, decodeErr := decodeServerEnvelopeWithin(envelope.Payload, limits.maxReceiveEnvelopeBytes, limits.outputDataLimit)
152 if decodeErr == nil {175 if decodeErr == nil {
153 return message, nil176 return message, nil
154 }177 }
@@ -156,7 +179,7 @@ func (c *clientConnection) ReceiveServer() (protocol.ServerMessage, error) {
156 }179 }
157 180
158 func (c *clientConnection) Capabilities() protocol.ConnectionCapabilities {181 func (c *clientConnection) Capabilities() protocol.ConnectionCapabilities {
159- return rawCapabilities(c.raw, c.ceilings.outputDataLimit)182+ return rawCapabilities(c.raw, c.limits().outputDataLimit)
160 }183 }
161 184
162 func (c *clientConnection) LinkState() ports.LinkState { return rawLinkState(c.raw) }185 func (c *clientConnection) LinkState() ports.LinkState { return rawLinkState(c.raw) }
@@ -192,7 +215,9 @@ func (c *clientConnection) ensurePreambleWith(ctx context.Context) error {
192 c.preambleOnce.Do(func() {215 c.preambleOnce.Do(func() {
193 ctx, cancel := context.WithDeadline(ctx, c.deadline)216 ctx, cancel := context.WithDeadline(ctx, c.deadline)
194 defer cancel()217 defer cancel()
195- c.ceilings, c.preambleErr = runProtoClientPreamble(ctx, c.raw, c.ceilings)218+ next, err := runProtoClientPreamble(ctx, c.raw, c.limits())
219+ c.publishLimits(next)
220+ c.preambleErr = err
196 c.finishPreamble()221 c.finishPreamble()
197 })222 })
198 c.runHandshakeHooks()223 c.runHandshakeHooks()
A internal/adapters/sessionwire/preamble_race_test.go +70 -0
@@ -0,0 +1,70 @@
1+package sessionwire
2+
3+import (
4+ "io"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/vev/internal/protocol/wire"
10+)
11+
12+// failingTransport fails every receive, so the lazy preamble settles quickly
13+// while still writing the ceilings it negotiated.
14+type failingTransport struct{}
15+
16+func (failingTransport) Send(wire.Envelope) error { return nil }
17+func (failingTransport) Recv() (wire.Envelope, error) { return wire.Envelope{}, io.EOF }
18+func (failingTransport) Close() error { return nil }
19+
20+// TestCapabilitiesReadIsSafeDuringPreamble proves a caller may ask for
21+// capabilities while another goroutine is establishing the preamble.
22+//
23+// The preamble is lazy: whoever first uses the connection runs it, and it
24+// publishes the negotiated ceilings. A capabilities read does not go through
25+// that once, so it needs the same lock the publication takes; without it the
26+// read races with the write, which is exactly what a real offline composition
27+// reported under the race detector.
28+func TestCapabilitiesReadIsSafeDuringPreamble(t *testing.T) {
29+ connection := NewClientConnection(failingTransport{})
30+ require.NotNil(t, connection)
31+
32+ done := make(chan struct{})
33+ go func() {
34+ defer close(done)
35+ _, _ = connection.ReceiveServer()
36+ }()
37+
38+ // Read capabilities for the whole window in which the preamble publishes.
39+ for {
40+ select {
41+ case <-done:
42+ return
43+ default:
44+ _ = connection.Capabilities()
45+ }
46+ }
47+}
48+
49+// TestServerCapabilitiesReadIsSafeDuringPreamble is the server-side twin: the
50+// daemon-facing connection has the same lazy publication and the same unlocked
51+// capabilities read.
52+func TestServerCapabilitiesReadIsSafeDuringPreamble(t *testing.T) {
53+ connection, ok := NewServerConnection(failingTransport{}).(*serverConnection)
54+ require.True(t, ok)
55+
56+ done := make(chan struct{})
57+ go func() {
58+ defer close(done)
59+ _, _ = connection.ReceiveClient()
60+ }()
61+
62+ for {
63+ select {
64+ case <-done:
65+ return
66+ default:
67+ _ = connection.Capabilities()
68+ }
69+ }
70+}
@@ -0,0 +1,70 @@
1+package sessionwire
2+
3+import (
4+ "io"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/vev/internal/protocol/wire"
10+)
11+
12+// failingTransport fails every receive, so the lazy preamble settles quickly
13+// while still writing the ceilings it negotiated.
14+type failingTransport struct{}
15+
16+func (failingTransport) Send(wire.Envelope) error { return nil }
17+func (failingTransport) Recv() (wire.Envelope, error) { return wire.Envelope{}, io.EOF }
18+func (failingTransport) Close() error { return nil }
19+
20+// TestCapabilitiesReadIsSafeDuringPreamble proves a caller may ask for
21+// capabilities while another goroutine is establishing the preamble.
22+//
23+// The preamble is lazy: whoever first uses the connection runs it, and it
24+// publishes the negotiated ceilings. A capabilities read does not go through
25+// that once, so it needs the same lock the publication takes; without it the
26+// read races with the write, which is exactly what a real offline composition
27+// reported under the race detector.
28+func TestCapabilitiesReadIsSafeDuringPreamble(t *testing.T) {
29+ connection := NewClientConnection(failingTransport{})
30+ require.NotNil(t, connection)
31+
32+ done := make(chan struct{})
33+ go func() {
34+ defer close(done)
35+ _, _ = connection.ReceiveServer()
36+ }()
37+
38+ // Read capabilities for the whole window in which the preamble publishes.
39+ for {
40+ select {
41+ case <-done:
42+ return
43+ default:
44+ _ = connection.Capabilities()
45+ }
46+ }
47+}
48+
49+// TestServerCapabilitiesReadIsSafeDuringPreamble is the server-side twin: the
50+// daemon-facing connection has the same lazy publication and the same unlocked
51+// capabilities read.
52+func TestServerCapabilitiesReadIsSafeDuringPreamble(t *testing.T) {
53+ connection, ok := NewServerConnection(failingTransport{}).(*serverConnection)
54+ require.True(t, ok)
55+
56+ done := make(chan struct{})
57+ go func() {
58+ defer close(done)
59+ _, _ = connection.ReceiveClient()
60+ }()
61+
62+ for {
63+ select {
64+ case <-done:
65+ return
66+ default:
67+ _ = connection.Capabilities()
68+ }
69+ }
70+}
M internal/adapters/sessionwire/server.go +26 -3
@@ -24,6 +24,11 @@ var (
2424 type serverConnection struct {
2525 raw wire.Transport
2626 ceilings protoCeilings
27+ // ceilingsMu guards ceilings. The lazy preamble publishes the negotiated
28+ // values from whichever goroutine first uses the connection, while another
29+ // goroutine may already be asking for capabilities, so the once-guarded write
30+ // is not by itself a happens-before edge for that reader.
31+ ceilingsMu sync.Mutex
2732
2833 preambleOnce sync.Once
2934 preambleErr error
@@ -137,8 +142,10 @@ func (c *serverConnection) ensurePreamble() error {
137142 c.preambleOnce.Do(func() {
138143 ctx, cancel := context.WithDeadline(context.Background(), c.deadline)
139144 defer cancel()
140- c.ceilings, c.preambleErr = runProtoServerPreamble(ctx, c.raw, c.ceilings)
141- if c.preambleErr != nil {
145+ next, err := runProtoServerPreamble(ctx, c.raw, c.limits())
146+ c.publishLimits(next)
147+ c.preambleErr = err
148+ if err != nil {
142149 _ = c.raw.Close()
143150 }
144151 c.finishPreamble()
@@ -147,6 +154,22 @@ func (c *serverConnection) ensurePreamble() error {
147154 return c.preambleErr
148155 }
149156
157+// limits returns the negotiated ceilings under the lock the preamble publishes
158+// them through. Send paths may read them without the lock once ensurePreamble
159+// has returned, because that call orders the publish before the read.
160+func (c *serverConnection) limits() protoCeilings {
161+ c.ceilingsMu.Lock()
162+ defer c.ceilingsMu.Unlock()
163+ return c.ceilings
164+}
165+
166+// publishLimits records the ceilings one preamble negotiation produced.
167+func (c *serverConnection) publishLimits(next protoCeilings) {
168+ c.ceilingsMu.Lock()
169+ c.ceilings = next
170+ c.ceilingsMu.Unlock()
171+}
172+
150173 func (c *serverConnection) ReceiveClient() (protocol.ClientMessage, error) {
151174 if err := c.ensurePreamble(); err != nil {
152175 return nil, c.preambleDecodeFailure(err)
@@ -439,7 +462,7 @@ func clientFailureFor(envelope *wire.ClientEnvelope, err error) *protocol.Decode
439462 }
440463
441464 func (c *serverConnection) Capabilities() protocol.ConnectionCapabilities {
442- return rawCapabilities(c.raw, c.ceilings.outputDataLimit)
465+ return rawCapabilities(c.raw, c.limits().outputDataLimit)
443466 }
444467
445468 func (c *serverConnection) LinkState() ports.LinkState { return rawLinkState(c.raw) }
@@ -24,6 +24,11 @@ var (
24 type serverConnection struct {24 type serverConnection struct {
25 raw wire.Transport25 raw wire.Transport
26 ceilings protoCeilings26 ceilings protoCeilings
27+ // ceilingsMu guards ceilings. The lazy preamble publishes the negotiated
28+ // values from whichever goroutine first uses the connection, while another
29+ // goroutine may already be asking for capabilities, so the once-guarded write
30+ // is not by itself a happens-before edge for that reader.
31+ ceilingsMu sync.Mutex
27 32
28 preambleOnce sync.Once33 preambleOnce sync.Once
29 preambleErr error34 preambleErr error
@@ -137,8 +142,10 @@ func (c *serverConnection) ensurePreamble() error {
137 c.preambleOnce.Do(func() {142 c.preambleOnce.Do(func() {
138 ctx, cancel := context.WithDeadline(context.Background(), c.deadline)143 ctx, cancel := context.WithDeadline(context.Background(), c.deadline)
139 defer cancel()144 defer cancel()
140- c.ceilings, c.preambleErr = runProtoServerPreamble(ctx, c.raw, c.ceilings)145+ next, err := runProtoServerPreamble(ctx, c.raw, c.limits())
141- if c.preambleErr != nil {146+ c.publishLimits(next)
147+ c.preambleErr = err
148+ if err != nil {
142 _ = c.raw.Close()149 _ = c.raw.Close()
143 }150 }
144 c.finishPreamble()151 c.finishPreamble()
@@ -147,6 +154,22 @@ func (c *serverConnection) ensurePreamble() error {
147 return c.preambleErr154 return c.preambleErr
148 }155 }
149 156
157+// limits returns the negotiated ceilings under the lock the preamble publishes
158+// them through. Send paths may read them without the lock once ensurePreamble
159+// has returned, because that call orders the publish before the read.
160+func (c *serverConnection) limits() protoCeilings {
161+ c.ceilingsMu.Lock()
162+ defer c.ceilingsMu.Unlock()
163+ return c.ceilings
164+}
165+
166+// publishLimits records the ceilings one preamble negotiation produced.
167+func (c *serverConnection) publishLimits(next protoCeilings) {
168+ c.ceilingsMu.Lock()
169+ c.ceilings = next
170+ c.ceilingsMu.Unlock()
171+}
172+
150 func (c *serverConnection) ReceiveClient() (protocol.ClientMessage, error) {173 func (c *serverConnection) ReceiveClient() (protocol.ClientMessage, error) {
151 if err := c.ensurePreamble(); err != nil {174 if err := c.ensurePreamble(); err != nil {
152 return nil, c.preambleDecodeFailure(err)175 return nil, c.preambleDecodeFailure(err)
@@ -439,7 +462,7 @@ func clientFailureFor(envelope *wire.ClientEnvelope, err error) *protocol.Decode
439 }462 }
440 463
441 func (c *serverConnection) Capabilities() protocol.ConnectionCapabilities {464 func (c *serverConnection) Capabilities() protocol.ConnectionCapabilities {
442- return rawCapabilities(c.raw, c.ceilings.outputDataLimit)465+ return rawCapabilities(c.raw, c.limits().outputDataLimit)
443 }466 }
444 467
445 func (c *serverConnection) LinkState() ports.LinkState { return rawLinkState(c.raw) }468 func (c *serverConnection) LinkState() ports.LinkState { return rawLinkState(c.raw) }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close