Skip to content

fix: prevent transaction sends from racing teardown - #1629

Draft
justin-layerv wants to merge 1 commit into
OpenNHP:mainfrom
justin-layerv:justin/upstream-transaction-channel-race
Draft

fix: prevent transaction sends from racing teardown#1629
justin-layerv wants to merge 1 commit into
OpenNHP:mainfrom
justin-layerv:justin/upstream-transaction-channel-race

Conversation

@justin-layerv

Copy link
Copy Markdown
Contributor

What changed

  • Added lifecycle done signals and safe send helpers for local and remote transactions.
  • Removed message-channel closes from transaction teardown; transactions are removed from their lookup map and marked done under the same mutex.
  • Replaced every endpoint raw transaction-channel send with the safe helpers.
  • Released late response packets when a local transaction exits between lookup and delivery.
  • Added focused race-stress tests for remote messages, local packets, external messages, stale pointers, and blocked senders.

Why

FindLocalTransaction and FindRemoteTransaction could return a transaction immediately before its Run cleanup closed the destination channel. The caller then sent through that stale pointer and panicked with send on closed channel, terminating the process. A plain pre-send closed check cannot make the lookup/send sequence atomic.

The new helpers select between delivery and an immutable lifecycle signal. The data channels are left for garbage collection, while map removal and lifecycle closure share the lookup mutex. A stale caller now receives ErrTransactionClosed instead of panicking or remaining blocked.

This improves server and endpoint availability during timeout, disconnect, and reconnect races. It ports the repository-agnostic core of layervai/nhp#1096 to current OpenNHP.

Validation

  • go test -race -run 'Test(Remote|Local)Transaction' -count=10 ./core (nhp module)
  • go test -race -count=1 ./... (nhp module)
  • go test -race -count=1 ./ac ./agent ./db (endpoints module)
  • go test -race -c ./server (endpoints module)
  • go vet ./... in both modules

The full endpoint test sweep also reaches two unrelated current-main failures: relay expects a 301/400 redirect but receives 307, and server test initialization attempts to create /opt/confidential-containers. The changed endpoint packages pass and the server race build succeeds.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks Good - Code looks good

This is a well-engineered fix for a genuine send on closed channel panic (process-terminating) that occurred when FindLocalTransaction/FindRemoteTransaction returned a transaction pointer whose Run cleanup then closed the destination channel before the caller's send. The approach is sound and I found no security, correctness, or breaking-change issues.

Why the fix is correct

  • Lifecycle signal instead of channel close. Data channels (NextPacketCh, NextMsgCh, ExternalMsgCh) are no longer closed on teardown; instead a done channel is closed under the same mutex that removes the transaction from its lookup map (nhp/core/transaction.go:153-155 and 239-241). This makes the lookup→send sequence effectively atomic w.r.t. teardown: a stale caller either delivers or falls through to <-t.done and gets ErrTransactionClosed, never a panic and never a permanent block (both Run selects always terminate via timeout/stop, so done is always eventually closed).
  • No consumer relied on the close. I confirmed via grep that the only readers of these channels are the single-shot select receives in Run() — no range or , ok reads — so leaving them open for GC is safe.
  • Packet leak closed on the failure path. The local-packet forwarders now call ReleasePoolPacket(pkt) when SendPacket returns an error (previously the send panicked; a released packet on the error path is correct since it was never delivered — no double-free, as the success path still hands ownership to Run).

Bonus latent-bug fix

Moving ExternalMsgCh initialization into newLocalTransaction (rather than creating it inside Run) also closes a pre-existing nil-channel window: AddLocalTransaction starts Run in a goroutine, so a caller that found the transaction and sent to ExternalMsgCh before Run scheduled would previously block on a nil channel. Nice catch.

Minor / informational (no action required)

  • sendMessageRoutine (endpoints/server/udpserver.go:932) drops md on the ErrTransactionClosed path without falling through to SendMsgToPacket. This is correct — the transaction is already dead so the response is undeliverable — and it's not a resource leak because a response MsgData on sendMsgCh carries only Message bytes, not a pooled ExternalPacket.
  • The new transaction_test.go race-stress tests are thorough (post-done send, concurrent send/exit races with and without a receiver, blocked-sender liveness, and cleanup ordering) and cover exactly the panic scenario being fixed.

Verified: all transaction constructions now go through the constructors (only nhp/core/device.go builds them), so done is never nil in production; done is closed exactly once per Run; and IsClosed() on the bare ConnectionData in the cleanup-ordering test is safe (atomic bool zero value).

Thanks for the clear PR description and the linked upstream context.

@codecov

codecov Bot commented Jul 10, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 20.45455% with 70 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
endpoints/server/msghandler.go 0.00% 21 Missing ⚠️
nhp/core/transaction.go 46.15% 21 Missing ⚠️
endpoints/db/udpdevice.go 0.00% 6 Missing ⚠️
endpoints/server/udpserver.go 0.00% 5 Missing ⚠️
endpoints/ac/msghandler.go 0.00% 3 Missing ⚠️
endpoints/ac/udpac.go 0.00% 3 Missing ⚠️
endpoints/agent/msghandler.go 0.00% 3 Missing ⚠️
endpoints/agent/udpagent.go 0.00% 3 Missing ⚠️
endpoints/server/nhpauth.go 0.00% 3 Missing ⚠️
nhp/core/device.go 0.00% 2 Missing ⚠️

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #1629      +/-   ##
==========================================
+ Coverage   12.54%   12.68%   +0.13%     
==========================================
  Files          96       96              
  Lines       14526    14582      +56     
==========================================
+ Hits         1822     1849      +27     
- Misses      12526    12553      +27     
- Partials      178      180       +2     
Flag Coverage Δ
unittests 12.68% <20.45%> (+0.13%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
nhp/common/errors.go 19.44% <ø> (ø)
nhp/core/device.go 20.49% <0.00%> (+0.61%) ⬆️
endpoints/ac/msghandler.go 0.00% <0.00%> (ø)
endpoints/ac/udpac.go 0.00% <0.00%> (ø)
endpoints/agent/msghandler.go 0.00% <0.00%> (ø)
endpoints/agent/udpagent.go 5.44% <0.00%> (-0.02%) ⬇️
endpoints/server/nhpauth.go 0.00% <0.00%> (ø)
endpoints/server/udpserver.go 0.00% <0.00%> (ø)
endpoints/db/udpdevice.go 0.00% <0.00%> (ø)
endpoints/server/msghandler.go 7.98% <0.00%> (-0.20%) ⬇️
... and 1 more

... and 2 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

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