Skip to content

fix: serialize socket writes to prevent protocol corruption under concurrent goroutines - #2

Open
amathxbt wants to merge 1 commit into
canopy-network:mainfrom
amathxbt:fix/plugin-socket-write-race
Open

fix: serialize socket writes to prevent protocol corruption under concurrent goroutines#2
amathxbt wants to merge 1 commit into
canopy-network:mainfrom
amathxbt:fix/plugin-socket-write-race

Conversation

@amathxbt

@amathxbt amathxbt commented Jul 4, 2026

Copy link
Copy Markdown

Bug

ListenForInbound() spawns a new goroutine per inbound FSM message:

for {
    msg := new(FSMToPlugin)
    if err := p.receiveProtoMsg(msg); err != nil { log.Fatal(err.Error()) }
    go func() {
        ...
        return p.sendProtoMsg(&PluginToFSM{Id: msg.Id, Payload: response})
    }()
}

Each of these goroutines can independently reach sendProtoMsg() -> sendLengthPrefixed() -> p.conn.Write(...):

  • directly, as the response to an inbound Genesis/Begin/Check/Deliver/End call (line ~158), or
  • indirectly, via a nested StateRead/StateWrite call made from within a contract handler (e.g. DeliverMessageSend issuing a StateRead while another goroutine is still finishing a response for a prior message).

The pending/requestContract maps are correctly protected by p.l, but the underlying net.Conn write path has no equivalent protection. net.Conn.Write is not guaranteed to be safe for concurrent callers writing a single logical message; concurrent writers can interleave partial writes on the wire, corrupting the length-prefixed framing both sides of the connection rely on to delimit messages. Under any real concurrency this manifests as garbled/undecodable protobuf payloads or a wedged connection.

Fix

Added Plugin.writeMu sync.Mutex and hold it for the duration of the length-prefixed write in sendLengthPrefixed(), so at most one goroutine writes to the connection at a time. Reads are unaffected — they only ever happen sequentially inside the single ListenForInbound loop.

Testing

Read-through verification of all call sites of sendProtoMsg/sendLengthPrefixed (via Handshake, StateRead, StateWrite, and the inbound response path) to confirm all writers now go through writeMu. This repo has no existing test suite to extend.

…ed mutex

ListenForInbound() spawns a new goroutine per inbound FSM message. Each
goroutine can independently call sendProtoMsg() -> sendLengthPrefixed() ->
conn.Write(), either as a direct response to the FSM (Genesis/Begin/Check/
Deliver/End) or via a nested StateRead/StateWrite call made from within the
contract handler.

The pending/requestContract maps are correctly guarded by the existing
mutex (p.l), but the underlying net.Conn write path had no equivalent
protection. Concurrent writers can interleave mid-message on the wire,
corrupting the length-prefixed framing that both sides rely on to delimit
messages -- this would manifest as garbled/undecodable protobuf payloads
or a stuck connection under any real concurrency (e.g. a contract issuing
a StateRead while another goroutine sends a response for a prior message).

Fix: add Plugin.writeMu and hold it for the duration of the length-prefixed
write in sendLengthPrefixed(), so at most one goroutine writes to the
connection at a time.
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.

1 participant