Skip to content

fix(transport): don't let a closing connection evict its successor from the pool - #372

Closed
fridokus wants to merge 1 commit into
emiago:mainfrom
fridokus:fix/pool-delete-evicts-successor
Closed

fridokus wants to merge 1 commit into
emiago:mainfrom
fridokus:fix/pool-delete-evicts-successor

Conversation

@fridokus

Copy link
Copy Markdown

The bug

connectionPool.Delete and CloseAndDelete remove an entry by address alone, without
checking that the entry still names the connection being removed:

func (p *connectionPool) CloseAndDelete(c Connection, addr string) error {
	p.Lock()
	defer p.Unlock()
	delete(p.m, addr)   // <- deletes whatever is under addr, not necessarily c
	...
}

An address is only a name for a connection, and names get reused. A peer that reconnects
from the same source port produces a successor that Add files under a key its predecessor
still holds, and the successor wins it — which is the intended outcome, since the newer
connection is the reachable one. Add's own TODO how about multi connection support for same remote address is about this situation.

The predecessor's reader goroutine then exits, as it must, and runs its cleanup:

defer t.pool.Delete(laddr)
defer func() { t.pool.CloseAndDelete(conn, raddr) }()

Both names now belong to the successor, so the dying reader evicts a connection it has
nothing to do with. The successor stays open and stays read — only the pool forgets it, so
nothing re-adds it, because nothing knows it is gone. Every subsequent message routed to
that address either dials a second connection or fails outright.

It is easiest to hit with SIP over TCP, where a UA re-registering from the same source port
is routine, and it is worst there too: the orphaned connection is the one the far end is
still listening on, so requests sent the other way are lost. We found it in a P-CSCF, where
TS 33.203 makes the re-registration case unavoidable — the UE keeps port_us across a
re-registration, so every refresh produces exactly this predecessor/successor pair on a
shared remote key.

The fix

Delete an entry only while it still names the connection being removed.

connectionPool is package-private, so taking the connection as a parameter on Delete and
DeleteMultiple changes no exported API. Every caller is a reader-exit path that already
has the connection in hand (transport_tcp.go, transport_ws.go, transport_udp.go), so
each was a one-word change.

Tests

Two new tests in sip/transport_connection_pool_successor_test.go, verified by mutation
rather than by coverage:

mutation result
deleteIfCurrent deletes unconditionally (i.e. the bug restored) TestAPredecessorDoesNotEvictItsSuccessor FAIL, control PASS
deleteIfCurrent never deletes (i.e. a "fix" that leaks) TestASoleConnectionIsStillDeleted FAIL, other PASS
none both PASS

The second exists so the first cannot be satisfied by a change that simply stops deleting,
which would turn the pool into a leak.

go test ./sip/ is green. go test ./... fails to build the root package on
pattern testdata/certs/client.crt: no matching files found, which is identical before and
after this change.

gofmt -l reports sip/request.go, also pre-existing and untouched here.

…om the pool

An address is only a name for a connection, and names get reused. A peer that
reconnects from the same source port produces a successor that Add files under
a key its predecessor still holds, and the successor wins it -- which is the
intended outcome, since the newer connection is the reachable one.

The predecessor's reader then exits, as it must, and deletes that key by name.
The name now belongs to the successor, so the delete evicts a connection the
dying reader has nothing to do with. The successor stays open and stays read;
only the pool forgets it, so nothing re-adds it, because nothing knows it is
gone. Every subsequent message routed to that address dials a second connection
or fails outright.

This is easiest to hit with SIP over TCP, where a UA re-registering from the
same source port is routine, and it is worst there too: the orphaned connection
is the one the far end is still listening on, so requests sent the other way
are lost.

Delete an entry only while it still names the connection being removed. The
pool type is package-private, so taking the connection as a parameter on Delete
and DeleteMultiple changes no exported API; every caller is a reader-exit path
that already has it in hand.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
emiago added a commit that referenced this pull request Oct 1, 2026
Check connection identity under the pool lock before reader cleanup removes address entries. Cover stream and UDP successor preservation and ordinary cleanup with regression tests.

Refs #372
@emiago

emiago commented Oct 1, 2026

Copy link
Copy Markdown
Owner

thanks for reporting, fixed on main

@emiago emiago closed this Oct 1, 2026
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.

2 participants