Skip to content

io_object lifecycle through handles#144

Merged
sgerbino merged 9 commits intocppalliance:developfrom
sgerbino:pr/handle-1
Feb 15, 2026
Merged

io_object lifecycle through handles#144
sgerbino merged 9 commits intocppalliance:developfrom
sgerbino:pr/handle-1

Conversation

@sgerbino
Copy link
Collaborator

@sgerbino sgerbino commented Feb 15, 2026

Summary by CodeRabbit

  • New Features

    • I/O objects are now move-only and expose a handle-based ownership model for safer lifetime management across platforms.
  • Refactor

    • Service lifecycle rewritten from per-object release to construct/destroy/close semantics with unified implementation types.
    • Public APIs simplified: implementations and services use consistent handle-based access and clearer open/close semantics.

Remove impl_ and ctx_ from io_object, making h_ (handle) the sole
data member. All I/O objects (timer, signal_set, resolver, tcp_socket,
tcp_acceptor) now use handles on every platform. Each service implements
io_service::construct()/destroy()/open()/close() for lifecycle management.
Eliminate all create_impl() indirection — bodies inlined into construct().
The handle-based refactor uses find_service (not use_service), so
services must be pre-registered. The IOCP context was missing this,
causing "service not installed" on Windows.
handle::~handle() and operator= now call svc_->close() before
destroy(), preventing fd leaks if a derived class forgets to
close explicitly. The context-only handle/io_object/io_stream
constructors were unused and removed.
Without CancelIoEx(), closesocket() does not reliably complete
pending overlapped operations with ERROR_OPERATION_ABORTED,
causing close-while-reading tests to get the wrong error code.
open() was only meaningful for socket services — timers, signals,
resolvers, and acceptors all had empty bodies. tcp_socket::open()
now casts directly to the concrete service and calls open_socket(),
matching the pattern tcp_acceptor::listen() already uses.

close() gets a default empty body since most services (timers,
signals, resolvers) don't need it, eliminating boilerplate.
Consistent with kqueue which already uses dispatch_coro for
inline completions.
Unify naming: io_object_impl, timer_impl, signal_set_impl,
resolver_impl, io_stream_impl, acceptor_impl, and socket_impl
all become nested implementation structs.
@coderabbitai
Copy link

coderabbitai bot commented Feb 15, 2026

📝 Walkthrough

Walkthrough

Refactors io_object ownership to a move-only, handle-centric model: adds nested implementation and io_service types, a handle class, and create_handle(). Replaces create/destroy/release per-backend APIs with construct()/destroy()/close(handle), and updates all platform backends and consumers accordingly.

Changes

Cohort / File(s) Summary
Core I/O object
include/boost/corosio/io_object.hpp
Adds nested implementation and io_service, introduces handle (owns ctx, svc, impl), create_handle<Service>(), move-only semantics, h_ member, bool operator, get() returns pointer, and handle-based lifecycle (construct/destroy/close).
Public I/O types
include/boost/corosio/io_stream.hpp, include/boost/corosio/resolver.hpp, include/boost/corosio/signal_set.hpp, include/boost/corosio/tcp_acceptor.hpp, include/boost/corosio/tcp_socket.hpp, include/boost/corosio/timer.hpp
Rename nested *_implimplementation; constructors now take/move handle; accessors cast from h_.get(); replace direct ctx_/impl_ usage with handle-based access and move-only semantics.
Socket & acceptor backends (epoll/kqueue/select/iocp)
src/corosio/src/detail/epoll/sockets.*, src/corosio/src/detail/epoll/acceptors.*, src/corosio/src/detail/kqueue/sockets.*, src/corosio/src/detail/kqueue/acceptors.*, src/corosio/src/detail/select/sockets.*, src/corosio/src/detail/select/acceptors.*, src/corosio/src/detail/iocp/sockets.*, src/corosio/src/detail/iocp/acceptors.*
Replace per-backend create_impl/destroy_impl/release with construct()io_object::implementation*, destroy(io_object::implementation*), and close(io_object::handle&). Remove release() overrides; backend impl classes now derive from public ...::implementation. Update accept/connect ops to use io_object::implementation** and use dispatch_coro() inlined resume paths.
Timer / Resolver / Signals services
src/corosio/src/detail/timer_service.*, src/corosio/src/detail/posix/*, src/corosio/src/detail/iocp/*, src/corosio/src/detail/posix/*, src/corosio/src/detail/select/op.hpp
Rename timer timer_implimplementation, change internal containers/heap to hold implementation*. Services now inherit io_object::io_service, remove old virtual create_impl factory, add construct()/destroy() overrides and service-level close(handle&).
Service abstraction & headers
src/corosio/src/detail/socket_service.hpp
Remove old factory virtuals (create_impl/destroy_impl), update open_socket/open_acceptor signatures to accept implementation&, add io_object::io_service inheritance for acceptor_service, default destructor.
Contexts & consumers
src/corosio/src/resolver.cpp, src/corosio/src/tcp_acceptor.cpp, src/corosio/src/tcp_socket.cpp, src/corosio/src/timer.cpp, src/corosio/src/*_context.cpp
Construct objects via create_handle<Service>(ctx); replace impl_ checks with h_/is_open() guards; delegate open/close/cancel to h_.service() and service close(handle). Minor comment cleanup in context files and added IOCP service registrations.
Platform op/accept types
src/corosio/src/detail/epoll/op.hpp, src/corosio/src/detail/kqueue/op.hpp, src/corosio/src/detail/select/op.hpp
Change op/accept member types from io_object::io_object_impl* / io_object::io_object_impl** to io_object::implementation* / io_object::implementation**. Reset/init logic adjusted accordingly.
Benchmarks
perf/profile/*.cpp
Removed include of boost/corosio/detail/platform.hpp; no behavior changes.

Sequence Diagram(s)

mermaid
sequenceDiagram
participant User
participant Context
participant Service
participant Impl
participant Handle

User->>Context: create_handle(ctx)
Context->>Service: locate service instance
Service->>Service: construct() -> io_object::implementation*
Service-->>Context: return impl pointer
Context->>Handle: create(handle {ctx, svc, impl})
Handle-->>User: return handle
User->>User: io_object(handle) (move-only)
User->>Service: h_.service().open/close(h_)
Service->>Impl: operate on impl / close and destroy
Service->>Service: destroy(io_object::implementation*)

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 I held a handle, tight and neat,
Impl and ctx now nicely meet.
Construct, then close — the services sing,
Move-only rabbity leap of spring. 🥕

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main architectural change: introducing a handle-based lifecycle system for io_object across the entire codebase.
Merge Conflict Detection ✅ Passed ✅ No merge conflicts detected when merging into develop

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

No actionable comments were generated in the recent review. 🎉

🧹 Recent nitpick comments
src/corosio/src/detail/iocp/sockets.cpp (1)

925-925: Unchecked static_cast from io_object::implementation* to win_socket_impl&.

This downcast is safe today because svc_.construct() always returns a win_socket_impl*, but it's fragile — a future change to construct() or a wrong service reference would silently produce UB. Consider adding a debug assertion or using dynamic_cast in debug builds to catch mismatches early.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@cppalliance-bot
Copy link

cppalliance-bot commented Feb 15, 2026

An automated preview of the documentation is available at https://144.corosio.prtest3.cppalliance.org/index.html

If more commits are pushed to the pull request, the docs will rebuild at the same URL.

2026-02-15 04:05:36 UTC

@codecov
Copy link

codecov bot commented Feb 15, 2026

Codecov Report

❌ Patch coverage is 94.97207% with 9 lines in your changes missing coverage. Please review.
✅ Project coverage is 81.02%. Comparing base (9dc652d) to head (5c79da7).
⚠️ Report is 9 commits behind head on develop.

Files with missing lines Patch % Lines
src/corosio/src/detail/select/acceptors.cpp 63.63% 4 Missing ⚠️
src/corosio/src/detail/epoll/acceptors.cpp 77.77% 2 Missing ⚠️
include/boost/corosio/io_object.hpp 97.14% 1 Missing ⚠️
src/corosio/src/detail/epoll/sockets.cpp 88.88% 1 Missing ⚠️
src/corosio/src/tcp_socket.cpp 95.45% 1 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@             Coverage Diff             @@
##           develop     #144      +/-   ##
===========================================
- Coverage    81.18%   81.02%   -0.17%     
===========================================
  Files           64       64              
  Lines         5724     5696      -28     
===========================================
- Hits          4647     4615      -32     
- Misses        1077     1081       +4     
Files with missing lines Coverage Δ
include/boost/corosio/io_stream.hpp 100.00% <100.00%> (ø)
include/boost/corosio/resolver.hpp 97.26% <100.00%> (-0.15%) ⬇️
include/boost/corosio/signal_set.hpp 93.33% <100.00%> (ø)
include/boost/corosio/tcp_acceptor.hpp 93.93% <100.00%> (-0.66%) ⬇️
include/boost/corosio/tcp_socket.hpp 90.32% <100.00%> (-0.86%) ⬇️
include/boost/corosio/timer.hpp 98.18% <100.00%> (ø)
src/corosio/src/detail/epoll/acceptors.hpp 100.00% <100.00%> (ø)
src/corosio/src/detail/epoll/op.hpp 81.44% <ø> (ø)
src/corosio/src/detail/epoll/sockets.hpp 100.00% <ø> (+8.33%) ⬆️
src/corosio/src/detail/posix/resolver_service.cpp 79.93% <100.00%> (-0.61%) ⬇️
... and 21 more

... and 3 files with indirect coverage changes


Continue to review full report in Codecov by Sentry.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 9dc652d...5c79da7. Read the comment docs.

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

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (11)
src/corosio/src/iocp_context.cpp (1)

28-38: 🛠️ Refactor suggestion | 🟠 Major

Add implementation comment explaining the Windows-specific service registration pattern.

The service initialization order (scheduler → sockets → acceptor → signals) is correct for the IOCP context. However, this file requires an implementation-level overview comment per the coding guidelines. Unlike the POSIX contexts (epoll, kqueue, select), the Windows IOCP implementation registers a signals service and passes the sockets reference to the acceptor service due to Windows-specific architecture. Add a brief /* */ block comment after the includes explaining this pattern.

src/corosio/src/tcp_socket.cpp (1)

85-98: ⚠️ Potential issue | 🟡 Minor

Consider using INVALID_SOCKET constant or a platform-abstracted sentinel instead of magic value ~0ull.

While ~0ull cast to std::uintptr_t produces the correct sentinel value (equivalent to INVALID_SOCKET), the codebase already defines and uses INVALID_SOCKET extensively throughout the IOCP implementation. Using the actual constant or defining a platform-abstracted sentinel (e.g., platform::invalid_socket()) would improve clarity and consistency with the rest of the Windows socket handling code. This would also align with how is_open() uses ~native_handle_type(0) for comparisons.

src/corosio/src/detail/kqueue/sockets.hpp (1)

66-72: ⚠️ Potential issue | 🟡 Minor

Stale block comment: destroy_impl() should be destroy().

Line 68 reads "destroy_impl() removes the shared_ptr from the map" but this method has been renamed to destroy() in this PR. The select backend's equivalent comment (select/sockets.hpp, line 63) was correctly updated.

📝 Suggested fix
-    kqueue_socket_service owns all socket impls. destroy_impl() removes the
+    kqueue_socket_service owns all socket impls. destroy() removes the
src/corosio/src/detail/epoll/sockets.hpp (1)

73-79: ⚠️ Potential issue | 🟡 Minor

Stale block comment: destroy_impl() should be destroy().

Same issue as in the kqueue backend — line 75 still references destroy_impl(). The select backend was correctly updated.

📝 Suggested fix
-    epoll_socket_service owns all socket impls. destroy_impl() removes the
+    epoll_socket_service owns all socket impls. destroy() removes the
src/corosio/src/detail/select/sockets.hpp (1)

68-68: ⚠️ Potential issue | 🟡 Minor

Fix stale comment in kqueue backend: "destroy_impl()" should be "destroy()".

The block comment in src/corosio/src/detail/kqueue/sockets.hpp at line 68 incorrectly references destroy_impl() instead of destroy() in the Service Ownership description. The select backend's equivalent comment was correctly updated to use destroy().

include/boost/corosio/resolver.hpp (1)

341-351: ⚠️ Potential issue | 🟡 Minor

Move assignment differs from tcp_socket in how it handles cleanup.

The docstring states "Cancels any existing operations and transfers ownership," but unlike tcp_socket::operator= which calls close() before moving, resolver relies on the implicit destruction of the replaced handle to trigger cleanup. While the handle destructor does call svc_->close() and svc_->destroy(), making this implicit cleanup functionally correct, the pattern is inconsistent between similar I/O object types. For clarity and consistency with tcp_socket, consider explicitly calling close() or documenting why the implicit pattern is preferred here.

src/corosio/src/detail/epoll/acceptors.cpp (1)

63-83: ⚠️ Potential issue | 🟠 Major

Accepted fd leaks if construct() throws std::bad_alloc.

socket_svc->construct() calls std::make_shared which can throw. If it does, accepted_fd is never closed — the cleanup at lines 93–102 is not reached because the exception propagates out of operator()(). The same issue exists at line 155 in the inline accept path.

Consider wrapping the construct + setup block in a try/catch, or use a scope guard on accepted_fd to ensure it's closed on any exit path.

🐛 Proposed fix sketch
     if (success && accepted_fd >= 0 && acceptor_impl_)
     {
         auto* socket_svc = static_cast<epoll_acceptor_impl*>(acceptor_impl_)
             ->service().socket_service();
         if (socket_svc)
         {
-            auto& impl = static_cast<epoll_socket_impl&>(*socket_svc->construct());
+            io_object::implementation* raw = nullptr;
+            try {
+                raw = socket_svc->construct();
+            } catch (...) {
+                ::close(accepted_fd);
+                accepted_fd = -1;
+                *ec_out = make_err(ENOMEM);
+                success = false;
+            }
+            if (!raw) goto cleanup;
+            auto& impl = static_cast<epoll_socket_impl&>(*raw);
src/corosio/src/timer.cpp (1)

49-61: ⚠️ Potential issue | 🟡 Minor

Move-assign from a moved-from timer will dereference null in other.context().

Line 55 calls other.context() which ultimately dereferences other.h_.ctx_. If other was previously moved-from, ctx_ is null. While move-assigning from a moved-from object is arguably a precondition violation, the same pattern in signal_set::operator= (line 888 of signals.cpp) has the same issue. A defensive if (!other.h_) early-return (or assert) would prevent the crash.

src/corosio/src/detail/posix/signals.cpp (1)

882-893: ⚠️ Potential issue | 🟡 Minor

Same moved-from null dereference risk as timer::operator=.

Line 888 calls other.context() which dereferences other.h_.ctx_. If other is moved-from, this is a null dereference. See the same comment on timer::operator=.

src/corosio/src/detail/select/acceptors.cpp (1)

56-101: ⚠️ Potential issue | 🟡 Minor

Accept success path doesn't handle construct() failure (out of memory).

Line 64: socket_svc->construct() allocates via std::make_shared. If this throws std::bad_alloc, the exception propagates out of operator()() with accepted_fd still open, leaking the file descriptor.

The kqueue backend (line 112 of kqueue/acceptors.cpp) has the same issue. Consider wrapping in a try/catch to close accepted_fd on allocation failure.

src/corosio/src/detail/kqueue/acceptors.cpp (1)

104-136: ⚠️ Potential issue | 🔴 Critical

Remove the explicit ::close(accepted_fd) call; let destroy() handle fd closure.

Line 131 closes accepted_fd, but impl.fd_ still references the same (now-closed) descriptor. When destroy(&impl) is called on line 133, it invokes close_socket() which attempts to close impl.fd_ again, resulting in a double-close.

Fix
                if (::setsockopt(accepted_fd, SOL_SOCKET, SO_NOSIGPIPE, &one, sizeof(one)) == -1)
                {
                    if (ec_out)
                        *ec_out = make_err(errno);
-                   ::close(accepted_fd);
-                   accepted_fd = -1;
                    socket_svc->destroy(&impl);
+                   accepted_fd = -1;
                    if (impl_out)
                        *impl_out = nullptr;
                }
🧹 Nitpick comments (9)
src/corosio/src/tcp_socket.cpp (1)

39-57: IOCP open() path has a deep cast chain — consider a comment explaining the layering.

The IOCP path (lines 46–49) performs three static_casts to navigate from the abstract handle through tcp_socket::implementation down to win_socket_impl::get_internal(). This is correct given the type hierarchy, but the indirection is non-obvious.

A brief // win_sockets creates win_socket_impl which wraps the IOCP-specific internal style comment would help future maintainers reason about the cast chain.

src/corosio/src/detail/posix/signals.hpp (1)

52-53: Empty public: access specifier left behind after removing create_impl().

The public: on line 52 has no declarations before protected: on line 53. This is likely a leftover from removing the create_impl() pure virtual.

🧹 Remove the empty access specifier
 {
-public:
 protected:
     posix_signals() = default;
 };
src/corosio/src/detail/iocp/signals.hpp (1)

22-24: Duplicate #include <system_error>.

Lines 22 and 24 both include <system_error>.

🧹 Remove duplicate include
 `#include` "src/detail/intrusive.hpp"
 `#include` <system_error>
 
-#include <system_error>
-
 `#include` "src/detail/iocp/mutex.hpp"
include/boost/corosio/timer.hpp (1)

100-114: Consider adding a brief docstring for the public implementation struct.

implementation is a public nested type in a public header (include/boost/corosio/timer.hpp), in a non-detail namespace. While it's clearly an internal extension point, a one-line summary would satisfy the documentation guidelines for public declarations.

As per coding guidelines: "Docstrings are required for all classes and functions in public headers in non-detail namespaces."

📝 Suggested docstring
+    /** Internal timer implementation base with heap-indexed expiry state. */
     struct implementation : io_object::implementation
     {
include/boost/corosio/io_object.hpp (1)

158-170: reset() has no safety net if called on a moved-from handle with a non-null p.

If reset(p) is called on a default-constructed or moved-from handle (where svc_ is nullptr) with p != nullptr, the handle will store a non-null impl_ with a null svc_, leading to a null-pointer dereference in the destructor or a subsequent reset().

Consider adding an assertion or a precondition check:

🛡️ Proposed defensive assertion
 void reset(implementation* p) noexcept
 {
     if (impl_)
     {
         svc_->close(*this);
         svc_->destroy(impl_);
     }
+    // Precondition: if p is non-null, this handle must own a service.
+    BOOST_ASSERT(!p || svc_);
     impl_ = p;
 }
src/corosio/src/detail/timer_service.cpp (1)

167-187: implementation name shadows the enclosing timer::implementation in this TU.

The local struct implementation : timer::implementation in the detail namespace works correctly since it's only used internally, but the name collision could confuse maintainers reading the file. The block comment at line 33 and the struct declaration make this clear enough, though a brief // detail::implementation — concrete timer impl comment at the declaration would help.

src/corosio/src/detail/iocp/sockets.cpp (2)

645-664: Two separate lock acquisitions in construct() are intentional but could be consolidated.

The lock is released between adding to socket_list_ (line 653) and socket_wrapper_list_ (line 660), keeping new win_socket_impl(...) outside the critical section. This is a reasonable tradeoff, but since the wrapper allocation is typically fast and holding the lock would simplify reasoning about invariants, consider a single lock scope.


784-803: win_acceptor_service::construct() takes locks on svc_.mutex_ — document the friend relationship dependency.

This method accesses svc_.mutex_, svc_.acceptor_list_, and svc_.acceptor_wrapper_list_ directly, relying on the friend class win_acceptor_service declaration in win_sockets (line 620 of sockets.hpp). This tight coupling is acceptable for a platform-specific implementation, but a brief comment noting the friend dependency would help maintainers.

src/corosio/src/detail/iocp/sockets.hpp (1)

642-687: win_acceptor_service::shutdown() is empty — relies on win_sockets::shutdown() for acceptor cleanup.

This works because win_sockets directly iterates acceptor_list_ in its own shutdown(). However, the execution_context shuts down services in reverse creation order, and win_acceptor_service is created after win_sockets (since its constructor receives a win_sockets&). So win_acceptor_service::shutdown() runs first — which is fine because it's a no-op. Then win_sockets::shutdown() cleans up everything. Correct ordering, but worth a brief comment in the empty shutdown() explaining why.

📝 Suggested comment
-    void shutdown() override {}
+    // No-op: win_sockets::shutdown() handles acceptor cleanup.
+    // This service shuts down first (reverse creation order), so
+    // win_sockets is still alive to perform the actual teardown.
+    void shutdown() override {}

Comment on lines +378 to 381
implementation& get() const noexcept
{
return *static_cast<signal_set_impl*>(impl_);
return *static_cast<implementation*>(h_.get());
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

get() dereferences h_.get() without a null check.

If the signal_set has been moved-from, h_.get() may return null, making this UB. Other io_objects (e.g., tcp_acceptor) guard public methods with is_open() before calling get(). The wait_awaitable::await_suspend at line 197 calls s_.get().wait(...) without such a guard. This is likely a pre-existing concern, but worth noting now that the accessor has changed.

@cppalliance-bot
Copy link

GCOVR code coverage report https://144.corosio.prtest3.cppalliance.org/gcovr/index.html
LCOV code coverage report https://144.corosio.prtest3.cppalliance.org/genhtml/index.html
Coverage Diff Report https://144.corosio.prtest3.cppalliance.org/diff-report/index.html

Build time: 2026-02-15 04:09:03 UTC

@cppalliance-bot
Copy link

GCOVR code coverage report https://144.corosio.prtest3.cppalliance.org/gcovr/index.html
LCOV code coverage report https://144.corosio.prtest3.cppalliance.org/genhtml/index.html
Coverage Diff Report https://144.corosio.prtest3.cppalliance.org/diff-report/index.html

Build time: 2026-02-15 04:14:01 UTC

@sgerbino sgerbino merged commit 0ce691f into cppalliance:develop Feb 15, 2026
17 checks passed
@sgerbino sgerbino deleted the pr/handle-1 branch February 15, 2026 15:50
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.

2 participants