Skip to content

Phase D1: streamrNode* C API — the full network node in the shared library#90

Merged
ptesavol merged 1 commit into
mainfrom
claude/phase-d1
Jul 13, 2026
Merged

Phase D1: streamrNode* C API — the full network node in the shared library#90
ptesavol merged 1 commit into
mainfrom
claude/phase-d1

Conversation

@ptesavol

Copy link
Copy Markdown
Collaborator

Summary

Milestone D, phase D1 of trackerless-network-completion-plan.md: the shared library gains a handle-based streamrNode* C API (include/streamrnode.h) — a full network node next to the existing proxyClient* API, which stays unchanged for compatibility.

API surface

  • streamrNodeNew / streamrNodeDelete with StreamrNodeConfig: layer-0 entry points as (websocketUrl, ethereumAddress) pairs (StreamrEntryPoint, same layout as Proxy), websocket server port and host (port 0 = client-only mode, the mobile configuration), acceptProxyConnections.
  • streamrNodeStart / streamrNodeStop (stop is terminal, like the underlying NetworkStack).
  • streamrNodeJoinStreamPart / streamrNodeLeaveStreamPart with optional per-stream-part entry points.
  • streamrNodePublish with optional ECDSA signing (same signature payload as proxyClientPublish).
  • streamrNodeSubscribe / streamrNodeUnsubscribe with a message callback (invoked on an internal network thread; documented constraints in the header).
  • streamrNodeGetNeighborCount for observing topology formation.
  • streamrNodeSetProxies — the proxy mode folded into the node handle (direction enum matches the proto values).

The implementation lives in the same translation unit as the proxy API so the two share the result registry, the ProxyResult** out-parameter convention, and the library lifecycle. createStreamMessage was generalized to buildStreamMessage and is shared by both publish paths (behavior-preserving).

⚠️ Naming decision for review

The node API deliberately reuses the proxy API's shared infrastructure by its existing names: ProxyResult, proxyClientResultDelete, proxyClientInitLibrary / proxyClientCleanupLibrary, and struct Proxy (aliased as StreamrEntryPoint). Renaming these to a streamr* prefix would be cleaner for node-API users but breaks every existing proxy-API consumer. Also up for review: whether the node API should live in its own streamrnode.h (as here) or be merged into streamrproxyclient.h.

Notable implementation points

  • Stream part id canonicalization (real bug found by the new exchange test): the delivery layer keys stream parts by the canonical form derived from the message's integer partition, so a raw "…#01" key would put the subscription and the publishes of one stream part into two different, empty overlays — the published message expired unseen in the propagation buffer. The C API canonicalizes at the boundary; the callback documents that it reports the canonical form.
  • Entry-point-less start: a node with a websocket server becomes its own entry point (the connectivity self-check against its own server works); a fully isolated node keeps an empty entry point list — an entry point without a websocket would make the connectivity check dial ws://:0 and fail — and streamrNodeStart uses start(false) plus an explicit joinDht([own]), the TS-driver pattern.
  • The new test fixtures call the idempotent proxyClientInitLibrary() in their constructors: the existing proxy integration suite segfaults when run as a single process (per-test proxyClientCleanupLibrary() nulls the singleton, which only the dylib-load constructor re-creates). This is invisible under ctest, which runs each test in its own process; the pre-existing suite is left untouched.

Tests

  • test/integration/StreamrNodeTest.cpp (registered in ctest, 9 tests): input validation and lifecycle error codes; a two-node publish/subscribe exchange over real websockets on 127.0.0.1 (signed and unsigned messages, both directions); proxy-mode publish from a client-only node into an accepting full node, including the accepting-node-cannot-set-proxies error.
  • test/ts-interop/StreamrNodeTsInteropTest.cpp (manual-run target, matching the C8 interop test's registration decision): a client-only C API node joins a pinned-TypeScript (@streamr/trackerless-network 103.8.0-rc.3 from npm) entry point whose node id is derived from its ethereum address via the pinned DhtNodeOptions.nodeId, and messages flow both ways through the public C API only — no C++ modules in the test.

Verification

  • Package ctest: 24/24 pass (9 new node tests, 10 proxy-client tests, 5 wrapper tests).
  • TS interop end-to-end: passes locally in ~7 s (node v22).
  • lint.sh (clangd-tidy + clang-format) green across all packages (pre-push hook run); the library package's lint file sweep now excludes node_modules (same fix the trackerless-network package needed for its C8 harness).

🤖 Generated with Claude Code

…brary

Adds the handle-based streamrNode* C API (include/streamrnode.h) next to
the existing proxyClient* API, implemented over NetworkNode/NetworkStack
in the same translation unit so the two APIs share the result registry
and library lifecycle:

- streamrNodeNew/Delete with StreamrNodeConfig (layer-0 entry points as
  (websocketUrl, ethereumAddress) pairs, websocket server port/host,
  acceptProxyConnections), streamrNodeStart/Stop, JoinStreamPart/
  LeaveStreamPart (optional per-stream-part entry points), Publish
  (optional ECDSA signing, same payload as proxyClientPublish),
  Subscribe/Unsubscribe with a message callback, GetNeighborCount,
  SetProxies (the proxy mode folded into the node handle).
- Entry-point-less configurations: a node with a websocket server
  becomes its own entry point (connectivity self-check against its own
  server); a fully isolated node keeps an empty entry point list and
  streamrNodeStart joins its own DHT explicitly (the doJoin path would
  wait for connectivity that never comes).
- Stream part ids are canonicalized at the API boundary ("#1" -> "#1"):
  the delivery layer keys stream parts by the canonical form derived
  from the message's integer partition, so a raw non-canonical key put
  the subscription and the publishes of one stream part into two
  different empty overlays (found via the two-node exchange test: the
  A->B message expired unseen in the propagation buffer while B->A
  arrived through the cross-part topology that formed meanwhile).
- createStreamMessage generalized to buildStreamMessage, shared by both
  publish paths (behavior-preserving).

Tests:
- test/integration/StreamrNodeTest.cpp (in ctest): input validation and
  lifecycle errors, a two-node publish/subscribe exchange over real
  websockets, and proxy-mode publish into an accepting full node.
- test/ts-interop/StreamrNodeTsInteropTest.cpp (manual-run target like
  the C8 interop test): a client-only C API node — the mobile
  configuration — joins a pinned-TS entry point (nodeId derived from its
  ethereum address via the DhtNode nodeId option) and messages flow both
  ways through the public C API only.

NAMING for review: the node API reuses ProxyResult /
proxyClientResultDelete / proxyClientInitLibrary / proxyClientCleanupLibrary
and the Proxy struct (as StreamrEntryPoint typedef); renaming the shared
infrastructure to a streamr* prefix would break existing proxy-API users,
so it is left as a PR decision.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ptesavol
ptesavol merged commit f940df9 into main Jul 13, 2026
8 of 9 checks passed
ptesavol added a commit that referenced this pull request Jul 17, 2026
Resolves the naming question deferred from the D1 review (PR #90) as its
own phase: the proxy-prefixed shared infrastructure (ProxyResult, Error,
proxyClientResultDelete, the library lifecycle functions, struct Proxy)
gets neutral streamr* names in the same breaking release that extends
the C++/Swift/Kotlin/Python wrappers with the streamrNode* full-node
functionality, so the wrappers are written against the final names once.
Old proxyClient* names stay as deprecated aliases for one release; the
definition of done is amended accordingly.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
ptesavol added a commit that referenced this pull request Jul 17, 2026
WebrtcFullNodeNetworkTest.HappyPath hit the 1200 s ctest timeout twice
on the ubuntu-latest leg of PR #90: after one concurrent start threw
"Timed out" (waitForNetworkConnectivity, 10 s), the process went silent
for ~20 minutes until ctest killed it. Runtime-evidence diagnosis (logs
+ a worker-pool-saturation reproduction) confirmed the mechanism:

- Every node's start() had already detached a fire-and-forget joinDht
  into NetworkStack's GuardedAsyncScope. TearDown drives all nine
  stop() coroutines on ONE blockingWait thread; the first stop()'s
  joinScope.close() blocked that thread AND waited for its join to
  complete NATURALLY (nothing cancelled it first, violating the scope's
  own cancel-before-close contract - the DhtNode abort only happens
  later in the stop sequence and cannot be reordered past the
  ContentDeliveryManager teardown-ordering constraints).
- A join only completes with shared-worker-pool capacity and network
  progress: every coroutine resumption, including folly::coro::timeout
  resumptions, needs a pool slot. The 8-node start storm starved the
  4-thread CI pool, so the drain never returned. Locally, saturating
  SharedExecutors::worker() reproduced the identical silent hang; the
  queued joinDht started the same millisecond a pool thread freed.

The fix removes teardown's dependence on join completion:

- NetworkStack: a CancellationSource wraps the detached joinDht; stop()
  requests cancellation BEFORE draining, and drains via the new
  suspending GuardedAsyncScope::closeAsync() so concurrent sibling
  stops sharing the drive thread proceed meanwhile.
- DiscoverySession/RingDiscoverySession: MERGE the ambient cancellation
  token with the node's abort signal instead of replacing it -
  co_withCancellation replaces the ambient token, which made stop()-time
  cancellation invisible inside the sessions' RPC awaits.
- PeerDiscovery::runSessions: a join cancelled by its owner's stop() no
  longer schedules a detached rejoin.
- CoroutineHelper: export co_current_cancellation_token and
  cancellation_token_merge shims (CPOs, same pattern as blockingWait).

Verified with the instrumented reproduction: post-fix, all nine
joinScope drains enter in the same millisecond, cancelled joins collapse
in ~1.5 s, and the failed-SetUp teardown takes 2.5 s (was: wedged for
the whole pool-starvation window, 19+ minutes on CI). Under a 20 s
artificial pool saturation, teardown completes ~5 s after the first
pool liveness instead of never. GuardedAsyncScopeTest adds the
deadlock-shape regression test; the dht end-to-end join suite (7 tests)
passes under heavy load with the cancellation plumbing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ptesavol added a commit that referenced this pull request Jul 18, 2026
…in (#92)

* Fix the full-node teardown hang: cancel and suspend the joinScope drain

WebrtcFullNodeNetworkTest.HappyPath hit the 1200 s ctest timeout twice
on the ubuntu-latest leg of PR #90: after one concurrent start threw
"Timed out" (waitForNetworkConnectivity, 10 s), the process went silent
for ~20 minutes until ctest killed it. Runtime-evidence diagnosis (logs
+ a worker-pool-saturation reproduction) confirmed the mechanism:

- Every node's start() had already detached a fire-and-forget joinDht
  into NetworkStack's GuardedAsyncScope. TearDown drives all nine
  stop() coroutines on ONE blockingWait thread; the first stop()'s
  joinScope.close() blocked that thread AND waited for its join to
  complete NATURALLY (nothing cancelled it first, violating the scope's
  own cancel-before-close contract - the DhtNode abort only happens
  later in the stop sequence and cannot be reordered past the
  ContentDeliveryManager teardown-ordering constraints).
- A join only completes with shared-worker-pool capacity and network
  progress: every coroutine resumption, including folly::coro::timeout
  resumptions, needs a pool slot. The 8-node start storm starved the
  4-thread CI pool, so the drain never returned. Locally, saturating
  SharedExecutors::worker() reproduced the identical silent hang; the
  queued joinDht started the same millisecond a pool thread freed.

The fix removes teardown's dependence on join completion:

- NetworkStack: a CancellationSource wraps the detached joinDht; stop()
  requests cancellation BEFORE draining, and drains via the new
  suspending GuardedAsyncScope::closeAsync() so concurrent sibling
  stops sharing the drive thread proceed meanwhile.
- DiscoverySession/RingDiscoverySession: MERGE the ambient cancellation
  token with the node's abort signal instead of replacing it -
  co_withCancellation replaces the ambient token, which made stop()-time
  cancellation invisible inside the sessions' RPC awaits.
- PeerDiscovery::runSessions: a join cancelled by its owner's stop() no
  longer schedules a detached rejoin.
- CoroutineHelper: export co_current_cancellation_token and
  cancellation_token_merge shims (CPOs, same pattern as blockingWait).

Verified with the instrumented reproduction: post-fix, all nine
joinScope drains enter in the same millisecond, cancelled joins collapse
in ~1.5 s, and the failed-SetUp teardown takes 2.5 s (was: wedged for
the whole pool-starvation window, 19+ minutes on CI). Under a 20 s
artificial pool saturation, teardown completes ~5 s after the first
pool liveness instead of never. GuardedAsyncScopeTest adds the
deadlock-shape regression test; the dht end-to-end join suite (7 tests)
passes under heavy load with the cancellation plumbing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ContentDeliveryManager: cancel and suspend the joinScope drain in destroy()

Same mechanism and fix as NetworkStack's joinScope in the parent commit,
one layer down: destroy()'s blocking joinScope.close() drained
un-cancelled startLayersAndJoinDht/handleEntryPointLeave tasks on the
shared blockingWait drive thread — unbounded under a starved worker pool
(sampled as ContentDeliveryManager.cppm:209 -> GuardedAsyncScope::close()
wedging the WebrtcFullNodeNetworkTest teardown on a CI-sized 4-thread
pool). A joinCancellation source now wraps all four detached joinScope
tasks; destroy() requests cancellation first and drains via the
suspending closeAsync().

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* StoreManager: scope and drain the detached onContactAdded replications

The fire-and-forget replication coroutines were started with a bare
.start(): they pin the StoreManager via sharedFromThis, but their RPC
remotes send through the owning DhtNode's communicator, which nothing
kept alive — a replication resuming after DhtNode::stop() dereferenced
the freed communicator (SIGSEGV in
RpcCommunicatorClientApi::notify<ReplicateDataRequest> resume,
reproduced 13/16 on a CI-sized 4-thread pool once the teardown drain no
longer serialized everything). Both launch sites now go through a
cancellable GuardedAsyncScope that destroy() cancels and drains while
the communicator and transport are still alive; the awaited final
leave-replication is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ConnectionLocker: make lockConnection/unlockConnection coroutines

Both wrapped their RPC round-trip in a thread-blocking blockingWait().
On the shared worker pool that is a self-deadlock: once poolsize
concurrent joins sit inside lockConnection at the same time, every pool
thread waits for a lock-RPC response that only those same threads could
process (sampled: all four StreamrWorkers of a CI-sized pool blocked in
PeerDiscovery::joinThroughEntryPoint -> lockConnection -> blockingWait,
test wedged). The interface and the ConnectionManager implementation now
return Task<void>; PeerDiscovery co_awaits them. ProxyClient's
synchronous connection routine wraps the calls in blockingWait at its
own call sites for now — it already blocks on its other RPCs, and its
callers block at the C-API boundary; async-ifying that path is a
follow-up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* DhtNode: drain the communicator's detached RPC coroutines in stop()

The server-dispatch scope only drained in ~RoutingRpcCommunicator during
member destruction — after later-declared members were already gone — so
a straggler getClosestPeers dispatch resumed into freed memory (crash
reports on the CI-sized pool; the teardown-drain-ordering rule). stop()
now removes the transport listeners with offAndWait() (a Message handler
mid-invocation can otherwise schedule one more dispatch past the drain)
and calls rpcCommunicator->drainAsyncTasks() as its last step, after the
leave notices and final replications that still send through the
communicator. The RPC client/server APIs gate their scope-add paths on
the drained flag: folly forbids add-after-join, so a late request now
fails cleanly (client) or is dropped like any request to a stopped node
(server).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Websocket connectors: close pending connections outside the mutex

Both destroy() methods held the connector mutex across the
pendingConnection->close(true) call-outs. The Disconnected fan-out
re-enters connection and connector code, so this deadlocks ABBA against
any thread that holds a connection's mutex and needs the connector's in
its handler chain (sampled: main thread in
WebsocketClientConnector::destroy -> PendingConnection::close ->
Disconnected handler blocked on a connection mutex, an rtc close
callback holding it and blocked on the connector mutex, a pool worker in
connect() queued behind — the never-hold-locks-across-call-outs rule).
Both now snapshot and clear their maps under the lock and make every
call-out after releasing it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* clang-format: converge on the checker's clang-format (llvm 22)

The previous commits were formatted with the system clang-format; this
re-runs run-clang-format.py -i with the same binary lint.sh checks
against, mostly reverting cosmetic reflow the wrong version introduced.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* CoroutineHelper: comment touch to invalidate the stale macOS CI cache

The restored incremental-build cache on the macos-26 leg carried a
pre-#92 BMI of this module and under-rebuilt: DiscoverySession and
RingDiscoverySession failed to compile against the cancellation shims
this branch added, while identical code built and passed on both Linux
legs and locally. Touching the module interface forces the BMI (and its
dependents) to rebuild — the documented remedy for this cache trap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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