Repository navigation
Conversation
|
Review against SWIP-74 rev 3 (ethersphere/SWIPs #111), which now specifies the challenge–response claim this PR started from. The findings were checked adversarially against the code, the spec and bee's p2p wrapper on master. (Edited: cursor semantics, test flow and counter list aligned with SWIP-74 rev 3 as pushed.) State of the PR. Seven commits 2026-09-12..24; 424 lines of non-generated Go and HeadlineThe scaffold follows SWIP-74 in three places and diverges from it in one that matters; Follows: one create-or-attach join ( Diverges — and the divergence is the right one. The publisher role is claimed by a
Lifecycle: the broker handler is fire-and-forget — it spawns the per-stream goroutine Wire, message by message
The takeover semantics in the Streams and state
Not started, and expected not to beValidation on the publish path (id substitution, SOC check with the address, owner == Tests
Small things
What to ask for
Open
|
Round 2, against the six commits since the review (d28ced3..14b89a5) and the seven "design decisions" from our chat, checked against SWIP-74 rev 4 (#111), which since yesterday carries the claim as a SOC — your idea, with the spec's fields inside it. Where a point was already in the review above and the code has not changed, it is only listed as still open. What improved
The scheme, decision by decision
The arguments in chat
The code
Where the two designs meet — a construction for the SWIPacud's best idea is to carry the claim as a SOC body so that one existing call verifies The broker (or any receiver of a claim) computes the expected address from the id it A point for the specThe same reasoning shows the What to ask acud
|
(cherry picked from commit 2d7adcf)
zelig
left a comment
There was a problem hiding this comment.
Overall, this is not what we settled on.
Please read the spec and keep to it, lets get rid of all other complications.
(cherry picked from commit 0adf704)
|
Round 3, against The code follows the spec now — frames and numbers, the id derivation, the five checks and their outcomes, pending streams, the six bounds, the counters — and I have gone through every open thread: 45 are resolved above as addressed or withdrawn (the cohort-derived challenge among them: @acud was right, a challenge derived from a cohort value is the same on every stream of the admin and every subscriber sees it, so anyone could replay one captured frame and hold the publisher identity), 8 stay open with a note. What a review of
On the spec side, rev 9 takes the implementation's names and settles what the code exposed: |
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> (cherry picked from commit 790d490)
The cursor is set to index+1 on acceptance; the last index would wrap it to 0 and reopen the whole feed. SWIP-74 rev 9 makes that index invalid on every feed; verify now treats it as an invalid SOC, so the broker, the subscriber and the WebSocket bridge all refuse it before the signature check. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> (cherry picked from commit 9583d4e)
A pending stream accepts exactly one frame: its AUTH, the empty chunk at index 0 of the session's AUTH feed. Anything else from it — an update, an unknown kind — is wrong_stream, before any signature is checked. DATA is accepted on a publisher stream only, so the retransmit check no longer depends on the role and nothing upgrades a stream from the data path. An AUTH on a publisher stream is a heartbeat: it counts as activity and does nothing else. An AUTH at another index is an invalid SOC. The WebSocket bridge closes a connection that sends a DATA frame before its AUTH (4002 "expected auth") instead of letting the broker reset and blocklist the node. Tests: pending() and publisher() helpers, a frame before the AUTH, an AUTH at index 1, the heartbeat. SWIP-74 rev 10. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> (cherry picked from commit 90dd563)
…s the owner as a subscriber Review of the AUTH-only change found three places where the bridge could still get its own node reset or blocklisted by the broker, and one stale string: - a frame before the AUTH is refused (4002 "expected auth") before its signature is checked, as the broker's step 1 precedes its step 5; a bad DATA before the AUTH no longer reports 4001; - the bridge's 30 s auth deadline is armed before the join, so it always falls before the broker's and the broker never blocklists this node for a slow dApp; - /bps/subscribe refuses identity == owner with 400: such a stream is a pending publisher stream at the broker, receives nothing, and would be blocklisted at the auth deadline; - errWrongStream reads "frame not what the stream may send", since it now also covers a pending stream's premature frame. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> (cherry picked from commit 51f9682)
2873d26 to
e6803b7
Compare
There was a problem hiding this comment.
🔵 Needs a closer look
It introduces a large new p2p protocol, broker concurrency model, and public WebSocket API whose safety depends on subtle concurrency and crypto-validation invariants that warrant human review.
1 open finding
What changed in this PR
This PR adds BPS-lite (SWIP-74), a single-publisher live pub/sub stream over a feed brokered through one node, one hop. It introduces a new pkg/bps protocol package (p2p broker + client session, wire protobufs, metrics, SOC id/verification), and a new WebSocket HTTP API (/bps/subscribe/..., /bps/publish/...) in pkg/api, wired into node bootstrap and documented in the OpenAPI spec. Full nodes act as brokers (protocol registered only in FullNodeMode); any node can join a remote broker as a subscriber or publisher. The broker enforces capacity bounds, blocklists protocol violators, and exposes per-cohort/Prometheus metrics.
Changes:
- New
pkg/bpspackage: broker/session state machine, cohort/role management, DoS bounds, SOC-based challenge/verify, metrics, and protobuf wire format. - New
pkg/api/bps.goWebSocket bridge (subscribe/publish) with frame parsing, close codes (4001/4002/4003), auth timeout, and request validation; wired viaapi.go,router.go,export_test.go. - Node wiring in
pkg/node/node.go, OpenAPI docs, and comprehensive unit tests for both the protocol and the API.
| File | Description |
|---|---|
| pkg/bps/bps.go | Core broker/session logic, cohorts, roles, bounds, handshake, frame handling |
| pkg/bps/id.go | Session-topic/id derivation and SOC verify for untrusted frames |
| pkg/bps/metrics.go | Per-cohort counters and Prometheus collectors |
| pkg/bps/pb/* | Protobuf definitions and generated wire code |
| pkg/bps/{bps,export,main}_test.go | Protocol unit tests + goleak + exported test hooks |
| pkg/api/bps.go | WebSocket subscribe/publish handlers, validation, close codes |
| pkg/api/bps_test.go | API handler tests with fake bps service/session |
| pkg/api/{api,router,export_test,api_test}.go | Service wiring, routes, test hooks/options |
| pkg/api/status_test.go | Removes a stray blank line (formatting) |
| pkg/node/node.go | Constructs bps service, registers protocol (full node), metrics, closer |
| openapi/Swarm.yaml | Documents the two new WebSocket endpoints |
One convention issue was filed: the new package validates untrusted peer input but lacks the fuzz target the repo consistently ships for such packages. The core concurrency (per-iteration frame allocation avoiding pointer aliasing, ctx cancellation ordering around the writer goroutine, bounds/cleanup paths) and SOC validation (bounds-safe via soc.FromChunk) otherwise appear sound.
Files not reviewed (1)
- pkg/bps/pb/bps.pb.go: Generated file
🧠 Review effort: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| readErr = fmt.Errorf("%w: %w", errInvalidDelivery, err) | ||
| return | ||
| } | ||
| s.cursor.Store(f.Index + 1) |
There was a problem hiding this comment.
so cursor stores the index that will come next? or the last index?
There was a problem hiding this comment.
the next one is expected to be >= cursor
| return nil, fmt.Errorf("challenge length %d: %w", len(ack.Challenge), errProtocolError) | ||
| } | ||
|
|
||
| ctx, cancel := context.WithCancel(context.Background()) |
There was a problem hiding this comment.
might be better to also have a root context on the component level which can be cancelled and that the cancels all the downstream child contexts
There was a problem hiding this comment.
Lets give the service a root context that Close cancels and derive every session's context from it.
Or?
| s.finish(readErr) | ||
| if readErr != nil && s.ctx.Err() == nil { | ||
| _ = stream.Reset() | ||
| } else { |
There was a problem hiding this comment.
It carries behaviour: a Reset when the read failed, so the broker sees a reset; a FullClose when the session was cancelled or the broker ended it.
| return errSessionClosed | ||
| default: | ||
| } | ||
| s.wmtx.Lock() |
There was a problem hiding this comment.
not sure whether a mutex is needed here, but also - this is why channels work better for tx/rx - you spawn two goroutines - one reads and the other writes, and the inherent channel properties circumvent the need for mutexes.
on the flip side you lose the synchronous write that you have here which returns after writing to the stream. though it is not a full guarantee that the other peer actually gets/reads the message iirc
There was a problem hiding this comment.
To get the potential write error synchronously: Publish may be called from several goroutines on one stream, and a writer goroutine fed by a channel would lose the synchronous error at the call site.
|
|
||
| // refuse answers a Join with a non-OK Ack, which ends the stream. | ||
| func (s *Service) refuse(ctx context.Context, w protobuf.Writer, stream p2p.Stream, status pb.Status, cause error) error { | ||
| if err := w.WriteMsgWithContext(ctx, &pb.Ack{Status: status}); err != nil { |
There was a problem hiding this comment.
odd that refuse can take any input including OK
There was a problem hiding this comment.
Right. Let's derive it from the err
| case sub.queue <- f: | ||
| default: | ||
| s.count(&co.counters.queueReset, "queue_reset") | ||
| overflow = append(overflow, sub) |
There was a problem hiding this comment.
this is really odd. can we have a comment explaining?
There was a problem hiding this comment.
Resetting under the lock would deadlock: reset ends the stream, whose handler then calls leave, which takes s.mtx and co.mtx. Let's explain that in a comment above the loop.
| co.mtx.Lock() | ||
| if idle := time.Since(co.inactiveSince); idle < s.opts.InactiveTimeout { | ||
| co.inactivity.Reset(s.opts.InactiveTimeout - idle) | ||
| co.mtx.Unlock() |
There was a problem hiding this comment.
why not use defer statements?
There was a problem hiding this comment.
Shutdown an resets must run after both locks are released (a reset ends the stream, whose handler calls leave, which takes s.mtx and co.mtx), and with defer the unlocks would run in the opposite order and we would lose the two early returns. Overall: lock, decide, unlock, then act.
| counters counters | ||
|
|
||
| mtx sync.Mutex | ||
| cursor uint64 // the lowest DATA index accepted next |
There was a problem hiding this comment.
why index a theoretical value?
There was a problem hiding this comment.
"Next" is clean because of the zero state: cursor == 0 means nothing accepted yet, and index 0 is accepted like any other. It is also why index 2⁶⁴−1 is refused: the cursor must never wrap.
The frame carries the index-th update of the session feed: a single-owner chunk whose id is derived from kind, challenge and index. The generated doc comment follows.
Seeded with valid AUTH and DATA chunks and with malformed frames, so that verify never panics on hostile peer input. Co-authored-by: zelig <769725+zelig@users.noreply.github.com>
refuse took any Status, OK included. It now takes the refusal's cause and answers FULL for a capacity bound and REJECTED for anything else, which is what both callers did by hand. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Checklist
Description
Open API Spec Version Changes (if applicable)
Motivation and Context (Optional)
Related Issue (Optional)
Screenshots (if appropriate):
AI Disclosure