Skip to content

Integrate Zenoh 1.8.0 - Part 2/2 - #868

Open
caguero wants to merge 11 commits into
caguero_zenoh_holder_shutdownfrom
caguero_zenoh_cold_start
Open

Integrate Zenoh 1.8.0 - Part 2/2#868
caguero wants to merge 11 commits into
caguero_zenoh_holder_shutdownfrom
caguero_zenoh_cold_start

Conversation

@caguero

@caguero caguero commented May 14, 2026

Copy link
Copy Markdown
Collaborator

Summary

This patch completes the Zenoh 1.8 integration started in #867:

  1. Cold start race in service requests. With interest driven routing (Zenoh > 1.6), the first Node::Request to a freshly spawned responder can time out silently. Requests now go through a per process cached zenoh::Querier whose interest declaration stays alive, so an in flight request reaches a queryable that appears late (INTEGRATION_twoProcsSrvCallLateResponder).

  2. Racy shutdown at exit. A new explicit NodeShared::Shutdown() closes the session in a deterministic order, removing the 1.8 close race by construction.

  3. No more teardown leaks. With the deterministic shutdown in place, cached Queriers and Publisher entities are undeclared normally instead of leaked, so phantom publishers disappear from discovery immediately (INTEGRATION_zenohPublisherLeak).

Also, CreateZenohGet no longer blocks while holding NodeShared::mutex (replies arrive through a weak_ptr, mirroring the ZeroMQ flow), and the minimum Zenoh version is relaxed to 1.7.2 after verifying the API surface is identical (drop 7176ddf if we prefer a 1.8.0 floor).

Checklist

  • Signed all commits for DCO
  • Added a screen capture or video to the PR description that demonstrates the fix (as needed)
  • Added tests
  • Updated documentation (as needed)
  • Updated migration guide (as needed)
  • Consider updating Python bindings (if the library has them)
  • codecheck passed (See contributing)
  • All tests passed (See test coverage)
  • Updated Bazel files (if adding new files). Created an issue otherwise.
  • While waiting for a review on your PR, please help review another open pull request to support the maintainers
  • Was GenAI used to generate this PR? If so, make sure to add "Generated-by" to your commits. (See this policy for more info.)

Generated-by: Claude Opus 4.7

Note to maintainers: Remember to use Squash-Merge and edit the commit message to match the pull request summary while retaining Signed-off-by and Generated-by messages.

Backports: If this is a backport, please use Rebase and Merge instead.

@caguero
caguero force-pushed the caguero_zenoh_holder_shutdown branch from b25d3db to 0be5d86 Compare May 15, 2026 10:43
@caguero
caguero force-pushed the caguero_zenoh_cold_start branch from 01c71af to ef695f6 Compare May 15, 2026 10:52
@azeey azeey linked an issue May 26, 2026 that may be closed by this pull request
4 tasks
caguero added 8 commits July 10, 2026 19:28
Generated-by: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Carlos Aguero <caguero@honurobotics.com>
…t NodeShared::Shutdown

Generated-by: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Carlos Aguero <caguero@honurobotics.com>
Generated-by: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Carlos Aguero <caguero@honurobotics.com>
Generated-by: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Carlos Aguero <caguero@honurobotics.com>
The key path 'transport/unicast/interests/timeout' does not exist in
Zenoh's config schema (the real key is 'routing/interests/timeout'),
so insert_json5 fails and the ignored ZResult hides it. The intended
value (10000 ms) is also already Zenoh's default, so the block was a
no-op twice over. GZ_TRANSPORT_ZENOH_CONFIG_OVERRIDE remains the way
to tune this.

Generated-by: Claude Fable 5
Signed-off-by: Carlos Aguero <caguero@honurobotics.com>
NodeShared::Shutdown() already guarantees the session is still open
when the querier cache is torn down, so the Querier destructors can
undeclare cleanly. This removes the release()-leak and the one-shot
shutdown machinery from ZenohQuerierEntry, addressing the review
feedback about leaking Zenoh wrappers.

Generated-by: Claude Fable 5
Signed-off-by: Carlos Aguero <caguero@honurobotics.com>
The previous implementation blocked inside CreateZenohGet for up to
timeout+500ms while the calling thread held NodeShared::mutex,
serializing every concurrent request and teardown in the process,
and then waited again in WaitUntil (double timeout on failure). It
also captured raw 'this' in the reply closure, which relied on the
handler never being removed from the requests storage (a per-request
leak) to avoid a use-after-free.

Fire the Querier get asynchronously instead and let Node::Request
wait on the handler's condition variable, exactly like the ZeroMQ
flow. IReqHandler now inherits enable_shared_from_this so the reply
closure holds a weak_ptr and drops late replies harmlessly, and
Node::Request removes the handler from the requests storage after
WaitUntil, fixing the storage leak. The SetTimeoutMs plumbing and
the heap-allocated wait state become unnecessary and are removed.

Generated-by: Claude Fable 5
Signed-off-by: Carlos Aguero <caguero@honurobotics.com>
Every zenoh-cpp API used by gz-transport (Querier, declare_querier,
liveliness_get, get_peers_z_id, Session::close, ...) is identical
between the 1.7.2 and 1.8.0 tags, and the teardown/cold-start fixes
are defensive patterns that do not depend on 1.8-only behavior, so
the hard 1.8.0 floor dropped 1.7.2 users unnecessarily. Also unify
the duplicated min-version variables and fix the found-version
message, which referenced an undefined variable.

Note: 1.8.0 is what CI validates; drop this commit if we prefer to
keep the stricter floor.

Generated-by: Claude Fable 5
Signed-off-by: Carlos Aguero <caguero@honurobotics.com>
@caguero
caguero force-pushed the caguero_zenoh_cold_start branch from ef695f6 to 7176ddf Compare July 10, 2026 19:56
Publisher teardown used to release() the Zenoh publisher and
liveliness token wrappers, leaking them and leaving a phantom
publisher visible to every other session until process exit. That
release existed to dodge an at-exit crash, but the explicit
NodeShared::Shutdown() introduced in the previous PR closes the
session deterministically, so dropping the wrappers is now safe at
any point: before close it is a quick undeclare (publishers run no
callbacks, so nothing can block or deadlock), after close it takes
the fast error path.

A cleanup queue with a background worker (as suggested in review)
was also considered. It remains a good fit if we later want to
retire the per-teardown detached threads used by subscribers and
queryables, whose undeclare can block on in-flight callbacks, but
for publishers there is nothing to wait on and the queue would only
add thread lifecycle complexity.

Adds INTEGRATION_zenohPublisherLeak, which fails against the old
code ('Topic is still listed after the remote publisher was
destroyed') and passes with this fix.

Generated-by: Claude Fable 5
Signed-off-by: Carlos Aguero <caguero@honurobotics.com>
@caguero caguero mentioned this pull request Jul 10, 2026
11 tasks
Comment thread src/NodeShared.cc Outdated
zenoh::ZResult result = Z_OK;
auto replies = this->Session()->liveliness_get(
zenoh::KeyExpr("@gz/**"),
zenoh::channels::FifoChannel(SIZE_MAX - 1),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I think this won't work, I tried to run this and traced a segfault to this line.
It seems what it tries to do is allocate a channel with a capacity of size_t-1 which is incredibly large, the allocation fails and the code segfaults.
This is wildly out of my comfort zone so I had Gemini suggest a fix that seems to simplify the code a fair bit using an API with a lambda instead of a channel that could grow indefinitely.
This seems to fix the segfault for me but again take it with a lot of salt since I am not familiar with Zenoh or gz-transport:

diff --git a/src/NodeShared.cc b/src/NodeShared.cc
index 2d174850..2b44b609 100644
--- a/src/NodeShared.cc
+++ b/src/NodeShared.cc
@@ -338,28 +338,20 @@ NodeShared::NodeShared()
       opts.timeout_ms = kZenohLivelinessGetTimeoutMs;
 
       zenoh::ZResult result = Z_OK;
-      auto replies = this->Session()->liveliness_get(
+      this->Session()->liveliness_get(
         zenoh::KeyExpr("@gz/**"),
-        zenoh::channels::FifoChannel(SIZE_MAX - 1),
+        [this](zenoh::Reply &reply)
+        {
+          if (reply.is_ok())
+          {
+            const auto &sample = reply.get_ok();
+            this->dataPtr->msgDiscovery->LivelinessMsgDataHandler(sample);
+            this->dataPtr->srvDiscovery->LivelinessSrvDataHandler(sample);
+          }
+        },
+        []() {},
         std::move(opts),
         &result);
-
-      if (result == Z_OK)
-      {
-        for (auto res = replies.recv();
-             std::holds_alternative<zenoh::Reply>(res);
-             res = replies.recv())
-        {
-          const auto &reply = std::get<zenoh::Reply>(res);
-          if (!reply.is_ok())
-            continue;
-          const auto &sample = reply.get_ok();
-          // Both handlers filter by entityType internally, so it is
-          // safe to dispatch every sample to both.
-          this->dataPtr->msgDiscovery->LivelinessMsgDataHandler(sample);
-          this->dataPtr->srvDiscovery->LivelinessSrvDataHandler(sample);
-        }
-      }
     }
     catch (const zenoh::ZException &e)
     {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I think this won't work, I tried to run this and traced a segfault to this line. It seems what it tries to do is allocate a channel with a capacity of size_t-1 which is incredibly large, the allocation fails and the code segfaults. This is wildly out of my comfort zone so I had Gemini suggest a fix that seems to simplify the code a fair bit using an API with a lambda instead of a channel that could grow indefinitely. This seems to fix the segfault for me but again take it with a lot of salt since I am not familiar with Zenoh or gz-transport:

Thanks!

I could not reproduce the segfault here. Did it happen in gz-transport or testing with gz-sim or something else? I thought that the FifoChannel was doing only as a logical bound, not an actual preallocation. And just to confirm, are you testing with Zenoh 1.8.0 or a different version?

That said, I'm testing the callback approach with one latch. Otherwise, your suggestion is fire and forget and reintroduces the cold start race this PR is trying to fix.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I'm on 1.7.2, I was testing with the CLI (i.e. gz topic -l).
Yea again can't really tell what is happening. I dug a bit deeper and indeed you are right and it doesn't preallocate and it was some sort of hallucination, now it's pointing to some sort of double free? Again not sure but the callback based method seems to work.
How have you been testing this? I have been building zenoh-c / zenoh-cpp / zenoh from source on the 1.7.2 tag in a colcon workspace

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I'm testing running all tests in gz-transport, and then, running gz-sim, and making sure that it doesn't crash when shutting down. I'm on Zenoh 1.8.0 right now but 1.7.2 should be fine as well.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actually hem apologies, I tried again to do a full clean build and actually it was working fine? I'll try to stress test this set of PRs a bit more but in general functionally it looks good

…completion latch

Assisted-by: Claude Fable 5
Signed-off-by: Carlos Aguero <caguero@honurobotics.com>
@cheriehu

Copy link
Copy Markdown

I’ve tried some functional tests on this branch with a few of the example worlds and things seem to work as expected. This is what I’ve tried.

  1. Sending cmd_vel via the command line on diff_drive.sdf. The robot moves.
  2. Pausing, resuming, and resetting the simulation via a gz service call from the command line works
  3. In sensors_demo.sdf, started the server and GUI in separate terminals. Echoed some camera topics, there was data being published.
  4. Started a world with server and GUI and separate terminals. Spawned a box from the GUI and saw a debug message on the server terminal reflecting the change. Calling the/world/world_name/create gz service to spawn a box also led to the box being spawned in the GUI
  5. Tried gz log record and gz log playback and verified that the playback looks right (at least for the /stats topic)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Inbox

Development

Successfully merging this pull request may close these issues.

Zenoh outstanding tasks

4 participants