Skip to content

fix(asset/centrifuge): speak bidirectional Centrifuge protocol (#938)#944

Merged
icellan merged 4 commits into
bsv-blockchain:mainfrom
icellan:fix/asset-ws-bidirectional
May 27, 2026
Merged

fix(asset/centrifuge): speak bidirectional Centrifuge protocol (#938)#944
icellan merged 4 commits into
bsv-blockchain:mainfrom
icellan:fix/asset-ws-bidirectional

Conversation

@icellan

@icellan icellan commented May 26, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Fixes Asset Centrifuge WebSocket transport hardcoded Unidirectional()=true breaks bidirectional clients #938: the Asset Server's /connection/websocket advertised Unidirectional()=true on a WebSocket transport, so centrifuge emitted the connect reply as a Push envelope without the command id. centrifuge-go / centrifuge-js clients couldn't complete the handshake and timed out after ~5s. This has blocked the node-validation acceptance suite (CLIENT-1, CLIENT-3) since v0.15.0-beta-2.
  • The issue's suggested one-liner (just flip the boolean) would actually have panicked at runtime — c.Connect() always routes through unidirectionalConnect(req, nil), and the bidirectional branch in connectCmd dereferences cmd.Id with no nil check. The handler had to be rewritten to the upstream HandleReadFrame(c, reader) pattern.
  • Found and fixed a second latent bug while writing the test: r.Context() is cancelled the instant ServeHTTP returns (immediately after the goroutine launches). Centrifuge's HandleCommand checks <-c.ctx.Done() on every frame and aborted silently. The legacy c.Connect() path didn't go through HandleCommand, which masked it. Switched to context.WithoutCancel(r.Context()) so the goroutine keeps request-scoped credentials (set by authMiddleware) but isn't killed by handler return.

Changes

  • services/asset/centrifuge_impl/websocket.go: Unidirectional()false; replaced manual JSON-decode + c.Connect() + drain with centrifuge.HandleReadFrame(c, reader) loop over conn.NextReader(); deleted dead custom ConnectRequest/SubscribeRequest types and the no-op subs translation block; detached request-context cancellation.
  • services/asset/centrifuge_impl/centrifuge_test.go: flipped the Unidirectional() assertion; added TestWebsocketHandler_BidirectionalConnect that exercises the real handshake end-to-end against a centrifuge.Node (asserts the reply carries id=1, a non-empty connect.client, and the OnConnecting subscription).
  • ui/dashboard/src/internal/stores/p2pStore.ts: send {id:1,connect:{}} on open instead of {}; parse push.pub.data instead of unwrapped pub.data; ignore empty centrifuge ping replies; drop dead unwrapped-json.type fallback.
  • docs/topics/services/assetServer.md: documented the bidirectional command-reply and push wire-format envelopes.

Test plan

  • go test -race ./services/asset/centrifuge_impl/... → PASS, new test included.
  • go vet + golangci-lint run ./services/asset/centrifuge_impl/... → 0 issues.
  • go build ./... → clean.
  • npm run build --prefix ./ui/dashboard → clean build.
  • go test -tags integration -run TestAssetServiceWebSocketIntegration ./services/p2p/ against a live node (the existing integration test was already written for the bidirectional shape and was silently failing against the buggy server).
  • Dashboard against make dev: confirm node_status populates the network view and live block notifications flow through.
  • Smoke against centrifuge-go: the in-tree sample at services/asset/centrifuge_impl/client connects in <1s with a non-empty client id.

Notes for reviewers

  • The custom forked WS handler in this package is a thin wrapper around what upstream centrifuge.WebsocketHandler already does. Long-term we could replace it with the upstream handler outright; this PR keeps the scaffolding to minimise diff.
  • npm run check on main was already red with 30 pre-existing TS errors across 20 files (2 of them in this same p2pStore.ts, on Map.keys().next().value typing); none introduced by this PR. Fixing those is out of scope.

…n/websocket (bsv-blockchain#938)

The WS transport reported `Unidirectional()=true`, which made the centrifuge
core emit the connect reply as a Push envelope (no id, Connect at the wrong
nesting). Any bidirectional client (centrifuge-go, centrifuge-js) saw
`Reply.Id == 0` and `Reply.Push == nil`, treated it as a server ping, and
timed out after ~5s.

Fix the transport and the handler:

- `Unidirectional()` returns `false`.
- Replace the legacy manual `conn.ReadMessage` + custom JSON ConnectRequest +
  `c.Connect()` + drain loop with the upstream pattern: a
  `centrifuge.HandleReadFrame(c, reader)` loop over `conn.NextReader()`.
- Drop the dead custom `ConnectRequest` / `SubscribeRequest` types and the
  no-op subs-translation block that ranged over a never-populated map.
- Detach cancellation from `r.Context()` via `context.WithoutCancel`:
  `r.Context()` is cancelled the instant ServeHTTP returns (right after the
  goroutine launches), and centrifuge's `HandleCommand` checks
  `<-c.ctx.Done()` on every frame. The old `c.Connect()` path skipped that
  check, masking the bug. Keep request-scoped values (credentials set by
  `authMiddleware`) but strip the cancellation.

Wire-format consumers updated:

- Dashboard `p2pStore.ts`: send `{id:1,connect:{}}` on open instead of `{}`;
  parse `push.pub.data` (push envelope) instead of unwrapped `pub.data`;
  ignore empty centrifuge ping replies; drop the dead unwrapped-`json.type`
  fallback path.
- Docs: add bidirectional command-reply and push wire-format examples.

Tests:

- New `TestWebsocketHandler_BidirectionalConnect` exercises the full
  handshake against a real `centrifuge.Node`: sends `{id:1,connect:{}}`,
  asserts the reply carries `id=1`, a non-empty `connect.client`, and the
  `OnConnecting` subscription. Would have failed on every prior variant
  (original push-envelope bug, the issue's one-liner with nil-deref panic,
  and the bidirectional-without-WithoutCancel variant with silent abort).
- Flipped the `assert.True(t, transport.Unidirectional())` assertion.

The existing integration test at services/p2p/asset_websocket_integration_test.go
was already written for the bidirectional wire format and was silently
failing against the buggy server; it should turn green against the new one.

Refs: bsv-blockchain#938
@icellan icellan requested review from Copilot, freemans13 and ordishs and removed request for freemans13 and ordishs May 26, 2026 12:53
@icellan icellan requested review from freemans13 and ordishs May 26, 2026 12:53
@github-actions

github-actions Bot commented May 26, 2026

Copy link
Copy Markdown
Contributor

🤖 Claude Code Review

Status: Complete


Review Summary: No critical issues found. The PR correctly fixes the Centrifuge WebSocket transport to operate bidirectionally and includes comprehensive test coverage.

Strengths:

  • Root cause correctly identified: naive flip from Unidirectional()=true to false would have panicked at cmd.Id dereference
  • Proper fix: replaced manual c.Connect() with upstream centrifuge.HandleReadFrame(c, reader) pattern
  • Critical context handling bug fixed: context.WithoutCancel(r.Context()) prevents silent abort when ServeHTTP returns
  • Comprehensive test coverage: bidirectional handshake test validates protocol correctness, auth middleware test guards context handling regression
  • Documentation accurately describes wire format and matches implementation
  • Client code (dashboard, test page, sample HTML) all updated consistently

Verified:

  • Documentation claims match code: OnConnecting auto-subscribes to ping/block/subtree/mining_on/node_status (services/asset/centrifuge_impl/centrifuge.go:153-163, docs/topics/services/assetServer.md:559)
  • Connect reply envelope format documented correctly with id-matching
  • Push publication envelope structure (push.pub.data) matches implementation and client parsing
  • Dead code removed: unused ConnectRequest/SubscribeRequest types and translation block
  • All client implementations updated: p2pStore.ts, wstest/+page.svelte, client/index.html

Notes:

  • Prior reviewer (ordishs) approved with follow-up suggestions
  • Manual test items remain (integration tests, dashboard smoke test) - recommended before merge per AGENTS.md verification requirements

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR fixes the Asset Server’s /connection/websocket Centrifuge transport to correctly operate as bidirectional (protocol v2), restoring compatibility with standard Centrifuge clients (Go/JS) and unblocking affected acceptance tests. It updates the server-side WebSocket read handling to follow Centrifuge’s expected framing/command processing flow, and aligns the dashboard client + documentation with the proper envelope formats.

Changes:

  • Updated the Asset Server WebSocket transport to be bidirectional and to process incoming frames via centrifuge.HandleReadFrame (instead of the legacy unidirectional c.Connect() path).
  • Added an end-to-end unit test that validates a real bidirectional connect handshake (reply includes id, connect.client, and connect.subs).
  • Updated the dashboard WebSocket client parsing/sending to use Centrifuge command/reply + push envelope shapes, and documented the wire format.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated no comments.

File Description
services/asset/centrifuge_impl/websocket.go Switch transport to bidirectional and use Centrifuge frame handling loop; detach request context cancellation for connection lifetime correctness.
services/asset/centrifuge_impl/centrifuge_test.go Update transport assertion and add an end-to-end bidirectional connect handshake test.
ui/dashboard/src/internal/stores/p2pStore.ts Send a proper connect command on open; parse publications from push.pub.data; ignore empty ping replies.
docs/topics/services/assetServer.md Document bidirectional connect/reply and push publication envelope formats for clients.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@github-actions

github-actions Bot commented May 26, 2026

Copy link
Copy Markdown
Contributor

Benchmark Comparison Report

Baseline: main (unknown)

Current: PR-944 (150a0d8)

Summary

  • Regressions: 0
  • Improvements: 0
  • Unchanged: 144
  • Significance level: p < 0.05
All benchmark results (sec/op)
Benchmark Baseline Current Change p-value
_NewBlockFromBytes-4 1.633µ 1.624µ ~ 0.700
SplitSyncedParentMap_SetIfNotExists/256_buckets-4 71.58n 71.67n ~ 0.500
SplitSyncedParentMap_SetIfNotExists/16_buckets-4 71.16n 71.35n ~ 0.700
SplitSyncedParentMap_SetIfNotExists/1_bucket-4 71.14n 70.99n ~ 1.000
SplitSyncedParentMap_ConcurrentSetIfNotExists/256_buckets... 33.52n 32.97n ~ 0.100
SplitSyncedParentMap_ConcurrentSetIfNotExists/16_buckets_... 57.68n 55.48n ~ 0.200
SplitSyncedParentMap_ConcurrentSetIfNotExists/1_bucket_pa... 137.5n 140.4n ~ 0.100
MiningCandidate_Stringify_Short-4 231.0n 222.3n ~ 0.100
MiningCandidate_Stringify_Long-4 1.670µ 1.625µ ~ 0.100
MiningSolution_Stringify-4 877.6n 854.3n ~ 0.100
BlockInfo_MarshalJSON-4 1.749µ 1.802µ ~ 0.200
NewFromBytes-4 129.7n 129.3n ~ 0.600
AddTxBatchColumnar_Validation-4 2.512µ 2.585µ ~ 0.100
OffsetValidationLoop-4 638.8n 638.0n ~ 1.000
Mine_EasyDifficulty-4 52.05µ 51.87µ ~ 0.700
Mine_WithAddress-4 5.875µ 5.395µ ~ 0.100
BlockAssembler_AddTx-4 0.02553n 0.02740n ~ 0.700
AddNode-4 10.42 10.47 ~ 0.400
AddNodeWithMap-4 11.43 11.47 ~ 0.700
DirectSubtreeAdd/4_per_subtree-4 56.94n 56.85n ~ 1.000
DirectSubtreeAdd/64_per_subtree-4 28.97n 29.16n ~ 0.700
DirectSubtreeAdd/256_per_subtree-4 27.91n 27.84n ~ 0.700
DirectSubtreeAdd/1024_per_subtree-4 26.56n 26.53n ~ 1.000
DirectSubtreeAdd/2048_per_subtree-4 26.07n 26.07n ~ 1.000
SubtreeProcessorAdd/4_per_subtree-4 309.7n 302.4n ~ 0.700
SubtreeProcessorAdd/64_per_subtree-4 310.2n 286.9n ~ 0.400
SubtreeProcessorAdd/256_per_subtree-4 312.7n 289.3n ~ 0.100
SubtreeProcessorAdd/1024_per_subtree-4 300.2n 286.1n ~ 0.100
SubtreeProcessorAdd/2048_per_subtree-4 300.4n 319.7n ~ 0.700
SubtreeProcessorRotate/4_per_subtree-4 287.7n 286.4n ~ 1.000
SubtreeProcessorRotate/64_per_subtree-4 288.3n 281.5n ~ 0.700
SubtreeProcessorRotate/256_per_subtree-4 289.1n 281.6n ~ 0.700
SubtreeProcessorRotate/1024_per_subtree-4 289.9n 281.4n ~ 0.700
SubtreeNodeAddOnly/4_per_subtree-4 55.83n 55.24n ~ 0.400
SubtreeNodeAddOnly/64_per_subtree-4 36.21n 36.13n ~ 1.000
SubtreeNodeAddOnly/256_per_subtree-4 35.06n 35.09n ~ 1.000
SubtreeNodeAddOnly/1024_per_subtree-4 34.59n 34.42n ~ 0.100
SubtreeCreationOnly/4_per_subtree-4 110.7n 110.0n ~ 0.700
SubtreeCreationOnly/64_per_subtree-4 354.1n 347.2n ~ 0.100
SubtreeCreationOnly/256_per_subtree-4 1.243µ 1.228µ ~ 0.100
SubtreeCreationOnly/1024_per_subtree-4 3.850µ 3.833µ ~ 0.100
SubtreeCreationOnly/2048_per_subtree-4 6.974µ 6.839µ ~ 0.100
SubtreeProcessorOverheadBreakdown/64_per_subtree-4 310.1n 284.3n ~ 0.100
SubtreeProcessorOverheadBreakdown/1024_per_subtree-4 321.8n 277.9n ~ 0.100
ParallelGetAndSetIfNotExists/1k_nodes-4 2.042m 2.010m ~ 0.100
ParallelGetAndSetIfNotExists/10k_nodes-4 5.239m 5.110m ~ 0.100
ParallelGetAndSetIfNotExists/50k_nodes-4 7.726m 7.780m ~ 0.400
ParallelGetAndSetIfNotExists/100k_nodes-4 10.38m 11.07m ~ 0.700
SequentialGetAndSetIfNotExists/1k_nodes-4 1.818m 1.798m ~ 0.200
SequentialGetAndSetIfNotExists/10k_nodes-4 5.068m 4.774m ~ 0.100
SequentialGetAndSetIfNotExists/50k_nodes-4 14.15m 14.24m ~ 0.700
SequentialGetAndSetIfNotExists/100k_nodes-4 25.63m 28.09m ~ 0.100
ProcessOwnBlockSubtreeNodesParallel/1k_nodes-4 2.064m 2.080m ~ 0.700
ProcessOwnBlockSubtreeNodesParallel/10k_nodes-4 8.442m 8.523m ~ 0.700
ProcessOwnBlockSubtreeNodesParallel/100k_nodes-4 13.83m 14.39m ~ 0.700
ProcessOwnBlockSubtreeNodesSequential/1k_nodes-4 1.851m 1.834m ~ 0.700
ProcessOwnBlockSubtreeNodesSequential/10k_nodes-4 8.634m 9.109m ~ 1.000
ProcessOwnBlockSubtreeNodesSequential/100k_nodes-4 44.95m 53.12m ~ 0.100
DiskTxMap_SetIfNotExists-4 3.856µ 3.964µ ~ 1.000
DiskTxMap_SetIfNotExists_Parallel-4 3.607µ 3.669µ ~ 0.200
DiskTxMap_ExistenceOnly-4 495.8n 379.0n ~ 1.000
Queue-4 186.1n 189.3n ~ 0.200
AtomicPointer-4 3.680n 3.643n ~ 0.700
ReorgOptimizations/DedupFilterPipeline/Old/10K-4 837.9µ 843.9µ ~ 0.700
ReorgOptimizations/DedupFilterPipeline/New/10K-4 757.9µ 773.1µ ~ 0.100
ReorgOptimizations/AllMarkFalse/Old/10K-4 127.8µ 129.1µ ~ 0.700
ReorgOptimizations/AllMarkFalse/New/10K-4 64.71µ 64.37µ ~ 0.100
ReorgOptimizations/HashSlicePool/Old/10K-4 63.10µ 61.65µ ~ 1.000
ReorgOptimizations/HashSlicePool/New/10K-4 11.55µ 11.02µ ~ 0.100
ReorgOptimizations/NodeFlags/Old/10K-4 4.469µ 4.825µ ~ 0.100
ReorgOptimizations/NodeFlags/New/10K-4 1.687µ 1.560µ ~ 0.200
ReorgOptimizations/DedupFilterPipeline/Old/100K-4 9.850m 9.307m ~ 0.400
ReorgOptimizations/DedupFilterPipeline/New/100K-4 10.92m 10.93m ~ 1.000
ReorgOptimizations/AllMarkFalse/Old/100K-4 1.179m 1.125m ~ 0.200
ReorgOptimizations/AllMarkFalse/New/100K-4 706.2µ 705.5µ ~ 1.000
ReorgOptimizations/HashSlicePool/Old/100K-4 549.8µ 639.9µ ~ 0.100
ReorgOptimizations/HashSlicePool/New/100K-4 223.8µ 205.7µ ~ 0.700
ReorgOptimizations/NodeFlags/Old/100K-4 45.45µ 50.33µ ~ 0.100
ReorgOptimizations/NodeFlags/New/100K-4 16.34µ 17.42µ ~ 0.100
TxMapSetIfNotExists-4 49.61n 49.51n ~ 0.400
TxMapSetIfNotExistsDuplicate-4 42.58n 41.81n ~ 0.400
ChannelSendReceive-4 632.2n 648.7n ~ 0.100
CalcBlockWork-4 515.6n 514.9n ~ 1.000
CalculateWork-4 703.3n 702.5n ~ 0.400
BuildBlockLocatorString_Helpers/Size_10-4 1.357µ 1.359µ ~ 1.000
BuildBlockLocatorString_Helpers/Size_100-4 13.42µ 12.99µ ~ 0.400
BuildBlockLocatorString_Helpers/Size_1000-4 129.5µ 142.5µ ~ 1.000
CatchupWithHeaderCache-4 104.6m 104.4m ~ 0.100
_BufferPoolAllocation/16KB-4 5.773µ 4.027µ ~ 0.100
_BufferPoolAllocation/32KB-4 8.709µ 8.383µ ~ 1.000
_BufferPoolAllocation/64KB-4 19.74µ 20.51µ ~ 0.700
_BufferPoolAllocation/128KB-4 32.11µ 33.55µ ~ 0.700
_BufferPoolAllocation/512KB-4 126.3µ 123.5µ ~ 1.000
_BufferPoolConcurrent/32KB-4 18.68µ 19.17µ ~ 0.700
_BufferPoolConcurrent/64KB-4 29.63µ 30.58µ ~ 0.400
_BufferPoolConcurrent/512KB-4 144.6µ 150.4µ ~ 0.100
_SubtreeDeserializationWithBufferSizes/16KB-4 636.2µ 698.0µ ~ 0.100
_SubtreeDeserializationWithBufferSizes/32KB-4 654.8µ 750.5µ ~ 0.100
_SubtreeDeserializationWithBufferSizes/64KB-4 644.6µ 707.8µ ~ 0.100
_SubtreeDeserializationWithBufferSizes/128KB-4 655.3µ 720.0µ ~ 0.100
_SubtreeDeserializationWithBufferSizes/512KB-4 640.4µ 619.6µ ~ 0.700
_SubtreeDataDeserializationWithBufferSizes/16KB-4 36.63m 37.80m ~ 0.100
_SubtreeDataDeserializationWithBufferSizes/32KB-4 36.58m 36.82m ~ 1.000
_SubtreeDataDeserializationWithBufferSizes/64KB-4 36.59m 36.79m ~ 0.400
_SubtreeDataDeserializationWithBufferSizes/128KB-4 36.81m 36.71m ~ 0.700
_SubtreeDataDeserializationWithBufferSizes/512KB-4 36.37m 36.27m ~ 0.700
_PooledVsNonPooled/Pooled-4 833.5n 831.7n ~ 1.000
_PooledVsNonPooled/NonPooled-4 7.761µ 8.330µ ~ 0.100
_MemoryFootprint/Current_512KB_32concurrent-4 7.277µ 7.563µ ~ 0.100
_MemoryFootprint/Proposed_32KB_32concurrent-4 9.483µ 10.216µ ~ 0.100
_MemoryFootprint/Alternative_64KB_32concurrent-4 9.395µ 9.246µ ~ 1.000
_prepareTxsPerLevel-4 397.7m 402.6m ~ 0.700
_prepareTxsPerLevelOrdered-4 3.640m 3.674m ~ 1.000
_prepareTxsPerLevel_Comparison/Original-4 400.2m 397.4m ~ 0.100
_prepareTxsPerLevel_Comparison/Optimized-4 3.571m 3.655m ~ 0.100
SubtreeSizes/10k_tx_4_per_subtree-4 1.257m 1.255m ~ 1.000
SubtreeSizes/10k_tx_16_per_subtree-4 294.9µ 292.8µ ~ 1.000
SubtreeSizes/10k_tx_64_per_subtree-4 69.99µ 70.09µ ~ 1.000
SubtreeSizes/10k_tx_256_per_subtree-4 17.41µ 17.46µ ~ 1.000
SubtreeSizes/10k_tx_512_per_subtree-4 8.757µ 8.671µ ~ 1.000
SubtreeSizes/10k_tx_1024_per_subtree-4 4.266µ 4.280µ ~ 0.400
SubtreeSizes/10k_tx_2k_per_subtree-4 2.158µ 2.150µ ~ 0.700
BlockSizeScaling/10k_tx_64_per_subtree-4 68.31µ 68.76µ ~ 0.400
BlockSizeScaling/10k_tx_256_per_subtree-4 17.77µ 17.45µ ~ 1.000
BlockSizeScaling/10k_tx_1024_per_subtree-4 4.292µ 4.345µ ~ 0.700
BlockSizeScaling/50k_tx_64_per_subtree-4 359.6µ 357.0µ ~ 0.700
BlockSizeScaling/50k_tx_256_per_subtree-4 86.67µ 89.87µ ~ 0.700
BlockSizeScaling/50k_tx_1024_per_subtree-4 21.47µ 21.09µ ~ 0.100
SubtreeAllocations/small_subtrees_exists_check-4 147.3µ 148.8µ ~ 0.700
SubtreeAllocations/small_subtrees_data_fetch-4 155.6µ 157.4µ ~ 0.200
SubtreeAllocations/small_subtrees_full_validation-4 306.5µ 305.8µ ~ 0.400
SubtreeAllocations/medium_subtrees_exists_check-4 8.814µ 8.686µ ~ 0.200
SubtreeAllocations/medium_subtrees_data_fetch-4 9.313µ 9.180µ ~ 0.700
SubtreeAllocations/medium_subtrees_full_validation-4 17.53µ 17.18µ ~ 0.100
SubtreeAllocations/large_subtrees_exists_check-4 2.068µ 2.086µ ~ 0.400
SubtreeAllocations/large_subtrees_data_fetch-4 2.199µ 2.205µ ~ 1.000
SubtreeAllocations/large_subtrees_full_validation-4 4.327µ 4.280µ ~ 0.100
StoreBlock_Sequential/BelowCSVHeight-4 327.3µ 330.1µ ~ 0.100
StoreBlock_Sequential/AboveCSVHeight-4 331.0µ 329.6µ ~ 1.000
GetUtxoHashes-4 265.2n 264.4n ~ 0.400
GetUtxoHashes_ManyOutputs-4 42.88µ 43.03µ ~ 1.000
_NewMetaDataFromBytes-4 228.2n 229.6n ~ 0.400
_Bytes-4 398.6n 405.3n ~ 0.100
_MetaBytes-4 137.1n 138.0n ~ 0.400

Threshold: >10% with p < 0.05 | Generated: 2026-05-27 10:53 UTC

@ordishs ordishs left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approving. Root-cause analysis is correct and the fix adopts upstream's pattern rather than reinventing it.

Verified during review:

  • Unidirectional()=false matches the centrifuge package's own WebSocket transport default — the fork was a divergence, not intentional.
  • The "just flip the boolean" rebuttal holds: the old c.Connect() path routes through unidirectionalConnect(req, nil) and the bidirectional connectCmd branch dereferences cmd.Id with no nil check, so a naive flip would have panicked. HandleReadFrame(c, reader) is the right call.
  • context.WithoutCancel(r.Context()) is the correct fix for the silent-abort: net/http cancels r.Context() the moment ServeHTTP returns (right after the goroutine launches), and centrifuge's HandleCommand checks ctx.Done() per frame. WithoutCancel keeps the authMiddleware Credentials while detaching cancellation.
  • Removed ConnectRequest/SubscribeRequest types confirmed unused outside the file; the subs translation block was dead (built, never read).
  • go test -race ./services/asset/centrifuge_impl/... passes locally.

Please address before/after merge:

  1. (near-blocking) services/asset/centrifuge_impl/client/index.html:28-30 still sends JSON.stringify({}) on open. It's served live via the /client/ file server and your own test plan references it as the smoke target. The server now treats {} as an empty command — update it to { id: 1, connect: {} } to match the dashboard, otherwise the canonical in-tree sample is broken.

  2. (follow-up) ui/dashboard/src/routes/wstest/+page.svelte:52 still inspects the unwrapped data.type / pub.data shapes the server no longer emits. Harmless dead code, worth a cleanup.

  3. (follow-up) The new test stubs Credentials via OnConnecting; production sets them through authMiddleware — the exact path the WithoutCancel fix protects. A test driving the handler through authMiddleware and asserting the reply still arrives after ServeHTTP returns would guard against regressing the context handling.

  4. The three unchecked manual items in the test plan (live integration test, make dev dashboard, centrifuge-go sample) are the scenarios that matter most for an external-protocol fix — worth ticking at least the dashboard run before merge.

Not introduced here, but flagging for a separate ticket: sameHostOriginCheck returns nil unconditionally (websocket.go:486-505).

- services/asset/centrifuge_impl/client/index.html: the in-tree smoke
  client was still sending `JSON.stringify({})` on open and parsing the
  legacy unwrapped centrifuge fields (`data.pub`, `data.disconnect`,
  `data.subscribe`, `data.unsubscribe`). Update the send to a real
  centrifuge connect command `{id:1,connect:{}}` and parse push frames
  out of `data.push.*` so the canonical smoke target matches the new
  protocol.

- ui/dashboard/src/routes/wstest/+page.svelte: clean up the debug page
  to recognise both shapes — raw payloads (when pointed at /p2p-ws) and
  centrifuge push envelopes at `data.push.pub.data` (when pointed at the
  asset endpoint). Same connect command on open as the production client.

- services/asset/centrifuge_impl/centrifuge_test.go: add
  TestWebsocketHandler_BidirectionalConnectThroughAuthMiddleware. The
  existing test stubbed Credentials via OnConnecting; production wires
  them through authMiddleware, and authMiddleware is the exact path that
  depends on `context.WithoutCancel`. The new test drives the handler
  through authMiddleware, completes the handshake, and asserts the
  reply still reaches the client after ServeHTTP returns — verified to
  fail when the WithoutCancel detach is removed.

Refs: review feedback on bsv-blockchain#944
@icellan

icellan commented May 27, 2026

Copy link
Copy Markdown
Contributor Author

Addressed the followups in 4a31c5c.

  1. (near-blocking) client/index.html smoke client — updated. Sends {id:1,connect:{}} on open and parses the centrifuge push envelope (data.push.pub, data.push.disconnect, data.push.subscribe, data.push.unsubscribe) so the canonical in-tree smoke target matches the new protocol.

  2. (follow-up) wstest debug page — cleaned up. Sends the same connect command on open and now recognises both shapes: raw payloads (when pointed at /p2p-ws) and data.push.pub.data (when pointed at the asset endpoint). The wrapped flag in the UI still distinguishes the two for debugging.

  3. (follow-up) authMiddleware regression test — added TestWebsocketHandler_BidirectionalConnectThroughAuthMiddleware. It builds a real Centrifuge, calls Init(), populates cachedCurrentNodeStatus so authMiddleware permits the connection, wraps NewWebsocketHandler in c.authMiddleware, and asserts the connect reply arrives after ServeHTTP returns. TDD-verified: temporarily reverting context.WithoutCancel makes both this test and the original one fail with read tcp ...: i/o timeout; restoring brings them green under -race.

  4. Manual items — happy to leave that to whoever clicks merge; the in-tree smoke client is now self-consistent and should be a clean target.

Separate ticket for the sameHostOriginCheck inversion noted but not addressed here.

@sonarqubecloud

Copy link
Copy Markdown

@oskarszoon oskarszoon self-requested a review May 27, 2026 14:24

@oskarszoon oskarszoon left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approve. Core fix correct, regression test pins it (TestWebsocketHandler_BidirectionalConnectThroughAuthMiddleware fails without WithoutCancel). HandleReadFrame loop terminates cleanly on conn close + deadline + centrifuge !proceed; defer closeFn() handles client cleanup; authMiddleware credentials propagate through context.WithoutCancel correctly; no goroutine leaks; both new tests use require. Frontend has one real bug worth fixing in the same PR.

Worth fixing in this PR

ui/dashboard/src/internal/stores/p2pStore.ts:89-91, 329-344 — reconnect exponential backoff is broken. connectToP2PServer() resets connectionAttempts = 0 at entry, so every reconnect iteration restarts from attempt 1. Backoff never accumulates — it's always a flat 2-second delay. Production outages hammer the server. Fix: move the reset out of connectToP2PServer() to the explicit "user opened connection" path; let reconnect callers increment the existing counter.

Plus two smaller TS items in p2pStore.ts:

  • :134-168 centrifuge message shapes (connect reply / push frame / ping) all typed any; tsconfig.json:8 sets noImplicitAny: false which overrides strict: true and hides the gap. Either add typed interfaces or drop the override.
  • :138-140 ping detection should match the raw string before JSON.parse (matches client/index.html:43), not after parse to empty object.

Nits (non-blocking)

  • services/asset/centrifuge_impl/websocket.go:183-184ctxCh is created and deferred-closed inside the goroutine but never read anywhere. Leftover from the refactor, delete both lines.
  • ui/dashboard/src/routes/wstest/+page.svelte route is not in protectedRoutes — accessible unauthenticated in production. Add a dev-only guard or comment that it's intentionally public.
  • wstest/+page.svelte:185 messages keyed by msg.time ISO string — sub-millisecond collisions possible, use a counter/UUID.
  • client/index.html:43-46 reconnect uses linear delay (numFailures * 1000ms), dashboard uses (currently broken) exponential. Inconsistent.

@icellan icellan merged commit 77cdbe5 into bsv-blockchain:main May 27, 2026
25 checks passed
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.

Asset Centrifuge WebSocket transport hardcoded Unidirectional()=true breaks bidirectional clients

5 participants