Sprint 1 — Service server and Service client components¶
Note
Status: DELIVERED — All implementation, tests, examples, and quality gates completed. See Advancements section below for a full delivery log.
Objective. Complete ROS 2 primitive support: introduce a shared abstract
ServiceComponent base (mirroring TopicComponent) and add concrete
LifecycleServiceServerComponent and LifecycleServiceClientComponent
with full lifecycle gating.
Deliverable. “lifecore supports all ROS 2 communication primitives with consistent activation semantics, behind a uniform two-layer (base + concrete) component design.”
Note
Naming. We do not use
ServiceComponent/ClientComponentas user-facing concrete classes. The pair is asymmetric and ambiguous (ClientComponentreads as “a generic client”, not “a ROS service client”). Instead, we follow the existing topic pattern:
Abstract base:
ServiceComponent[SrvT]— analogous toTopicComponent[MsgT]. Holds shared state (service name, srv type, QoS, callback group). No ROS object, no lifecycle hooks.Concrete server:
LifecycleServiceServerComponent[SrvT]— analogous toLifecyclePublisherComponent.Concrete client:
LifecycleServiceClientComponent[SrvT]— analogous toLifecycleSubscriberComponent.This keeps naming symmetric, intent-explicit, and aligned with the
- library naming convention
Lifecycle<Capability>Componentfor concrete managed entities (see
.github/instructions/naming-conventions).
—
Content¶
ServiceComponent (abstract base)¶
Abstract base class, mirrors
TopicComponent.Generic:
ServiceComponent[SrvT].Owns:
service name
srv_type(resolved via_resolve_iface_typewithinterface_kind="srv_type")QoS profile (defaulting to ROS 2 service QoS)
borrowed callback group (lifetime owned by caller)
Does not own:
the ROS service or client object — those belong to concrete subclasses
any
_on_*lifecycle hook implementation
Not intended to be subclassed directly outside the library. Application code subclasses
LifecycleServiceServerComponentorLifecycleServiceClientComponent.
LifecycleServiceServerComponent¶
Concrete subclass of
ServiceComponent.Generic:
LifecycleServiceServerComponent[SrvT].Lifecycle:
_on_configure: create ROS service, register internal dispatcher that routes requests toon_service_requestonly when active._on_activate: enable request handling._on_deactivate: reject new requests with the documented inactive-response policy (see Risks §1); in-flight handlers run to completion._on_cleanup: destroy the service, release the reference._on_shutdown: ensure cleanup is performed if not already done._on_error: best-effort cleanup, surface diagnostic.
Activation gating: requests received while inactive log a warning and return the default-constructed
Response(with a diagnostic field populated if the message defines one). See Risks §1.Handler hook:
on_service_request(request: RequestT) -> ResponseT(abstract).
LifecycleServiceClientComponent¶
Concrete subclass of
ServiceComponent.Generic:
LifecycleServiceClientComponent[SrvT].Lifecycle:
_on_configure: create ROS client (no calls issued yet)._on_activate: enablecall()andcall_async()._on_deactivate: newcall()raisesComponentNotActiveError; previously-issued futures are not cancelled (documented)._on_cleanup: destroy the client._on_shutdown: ensure cleanup._on_error: best-effort cleanup.
Activation gating:
call()andcall_async()raiseComponentNotActiveErrorwhen not active.Call methods:
call(request: RequestT, timeout_service: float | None = None, timeout_call: float | None = None) -> ResponseTcall_async(request: RequestT, timeout_service: float | None = None) -> Futuretimeout_service: if set, callswait_for_service(timeout_sec=...)before issuing the call; raisesTimeoutErrorif unavailable.timeout_call: forwarded astimeout_secto the underlyingrclpyclient (call()only).
wait_for_service(timeout)is available only when ACTIVE; calling it from any other state raisesComponentNotActiveError.
TopicComponent behaviors¶
Verify that existing LifecyclePublisherComponent and
LifecycleSubscriberComponent apply the same activation gating rules.
No breaking changes; this sprint verifies consistency between the four
primitives (publisher, subscriber, service-server, service-client).
—
Tests to write¶
ServiceComponent (base) unit tests¶
[x]
srv_typeinference from generic parameter (LifecycleServiceServerComponent[Empty]→Emptyresolved).[x] Explicit
srv_typeargument honored and validated against the generic parameter when both are supplied.[x]
TypeErrorwhensrv_typecannot be resolved.
LifecycleServiceServerComponent unit tests¶
[x] Service created in
_on_configure, destroyed in_on_cleanup.[x] Request rejected (inactive response) when component is INACTIVE.
[x] Request handled by
on_service_requestwhen component is ACTIVE.[x] Lifecycle:
configure→activate→deactivate→cleanup.[x] Double activate is idempotent (per adoption hardening §5 rule).
[x] Exception raised in
on_service_requestdoes not leave the component in an inconsistent state; the service remains bound.
LifecycleServiceClientComponent unit tests¶
[x] Client created in
_on_configure, destroyed in_on_cleanup.[x]
call()raisesComponentNotActiveErrorwhen not active.[x]
call()succeeds when active.[x]
call_async()raises (or returns failed future) when not active.[x] Lifecycle:
configure→activate→call→deactivate→ calls blocked.[x]
call(timeout_call=...)respects the timeout (forwarded to rclpy).[x]
call(timeout_service=...)waits for service; raisesTimeoutErrorwhen unavailable within the window.[x]
call_async(timeout_service=...)raisesTimeoutErrorwhen service unavailable.[x] In-flight futures are not cancelled on deactivate (documented contract).
Integration tests¶
[x]
LifecycleServiceServerComponent+LifecycleServiceClientComponentco-located in the same node perform a full request/response cycle.[x] Inactive server → client receives inactive-response.
[x] Client deactivated →
call()raises.[x] Activation-gating consistency test across all four primitives (publisher, subscriber, service-server, service-client).
—
Risks and mitigation¶
Risk 1: Server callback execution and inactive-response shape
Problem: A request arriving while INACTIVE must be handled deterministically. Returning the default-constructed
Responsesilently looks like success.Decision: the library applies a single inactive-response policy:
Log a warning identifying the component, service name, and current lifecycle state.
Return the default-constructed
Response. If the response message defines a diagnostic-style field (e.g.success,message), the library populates it to flag the inactive state; otherwise the default response is returned as-is.In-flight handlers triggered before deactivation run to completion; deactivation does not cancel them. Documented in the docstring.
Risk 2: Async client calls leak state
Problem:
call_async()returns a future; if the component deactivates, the future may dangle.Mitigation:
This sprint either ships
call_async()with the explicit contract “futures are not cancelled on deactivate; the application owns them”, or defers it entirely. Default position: ship with the explicit contract; revisit in Sprint 2.
Risk 3: Client timeout vs activation state
Problem: A long service call straddles a deactivation transition.
Mitigation:
The client-side timeout passed to
call()is independent of component state.Already-issued calls are not cancelled by
_on_deactivate.New calls issued after deactivate raise
ComponentNotActiveError.
Risk 4: Inconsistency with publisher/subscriber gating
Problem: Publisher gates on publication; subscriber gates on message handling. Service-server and service-client must match.
Mitigation:
Service-server: gate incoming requests (inactive-response when inactive).
Service-client: gate outgoing calls (raise when inactive).
Add a regression test that asserts the four primitives behave consistently.
Risk 5: Naming drift
Problem: Earlier drafts used
ClientComponent, which is ambiguous (reads as “any client”) and asymmetric withServiceComponent(which here doubles as both abstract base and “server” in casual reading).Mitigation:
Reserve
ServiceComponentfor the abstract base (mirrorsTopicComponent).Use
LifecycleServiceServerComponentandLifecycleServiceClientComponentfor the concrete classes.Naming is enforced by
.github/instructions/naming-conventions.
—
Dependencies¶
Requires:
LifecycleComponentbase (shipped).Requires:
TopicComponenttwo-layer pattern (shipped) — used as the blueprint forServiceComponent.Requires:
_resolve_iface_type(shipped) — used withinterface_kind="srv_type".Requires:
when_activedecorator (shipped) — used for callback gating.Requires: Error handling work (Sprint 2) — retroactively hardens error semantics.
Requires: Testing fixtures (Sprint 3) — shared utilities for testing this sprint.
—
Scope boundaries¶
In-scope for this sprint:
Abstract
ServiceComponent[SrvT]base (mirrorsTopicComponent).Concrete
LifecycleServiceServerComponent[SrvT].Concrete
LifecycleServiceClientComponent[SrvT].Single request/response semantics.
Lifecycle integration and activation gating.
Inactive-response policy (server) and
ComponentNotActiveError(client).
Out-of-scope:
Async request handlers on the server (deferred).
Service pooling or multiplexing.
Library-level timeout policies (timeout remains a parameter to
call()).Action components (different semantics, separate sprint).
Parameter components (separate sprint).
Cancelling in-flight
call_async()futures on deactivate.
—
Success signal¶
[x]
from lifecore_ros2 import ServiceComponent, LifecycleServiceServerComponent, LifecycleServiceClientComponentworks.[x] All tests pass (unit + integration): 42 service-related tests, 248 total, all green.
[x] Activation gating is enforced and tested across all four primitives.
[x] Ruff, Pyright, Pytest all green (
uv run ruff check ./uv run pyright/uv run pytest).[x] Examples:
examples/minimal_service_server.pyandexamples/minimal_service_client.py.[x] Design note: none required (primitives only).
[x] Docstrings complete (Google style, Napoleon-ready).
—
Example hooks¶
Server minimal implementation:
class MinimalServiceServer(LifecycleServiceServerComponent[std_srvs.srv.Empty]):
def on_service_request(
self,
request: std_srvs.srv.Empty.Request,
) -> std_srvs.srv.Empty.Response:
# Called by the library only when the component is ACTIVE.
return std_srvs.srv.Empty.Response()
Client minimal implementation:
class MinimalServiceClient(LifecycleServiceClientComponent[std_srvs.srv.Empty]):
def trigger(self) -> std_srvs.srv.Empty.Response:
# Raises ComponentNotActiveError if not ACTIVE.
return self.call(std_srvs.srv.Empty.Request(), timeout_service=1.0)
—
Advancements¶
All deliverables completed in one session on 2026-04-28.
Implementation¶
src/lifecore_ros2/components/service_component.py—ServiceComponent[SrvT]abstract base; mirrorsTopicComponent; no ROS objects, no lifecycle hooks.src/lifecore_ros2/components/lifecycle_service_server_component.py—LifecycleServiceServerComponent[SrvT]; creates service in_on_configure; inactive requests annotatesuccess/messagefields if present, return default response otherwise.src/lifecore_ros2/components/lifecycle_service_client_component.py—LifecycleServiceClientComponent[SrvT];call()andcall_async()gated by@when_active;timeout_serviceandtimeout_callparameters added.src/lifecore_ros2/components/__init__.pyandsrc/lifecore_ros2/__init__.py— all three names exported in__all__.
Examples¶
examples/minimal_service_server.py—TriggerServernode; demonstrates configure/activate viaros2 lifecycle set.examples/minimal_service_client.py— instantiatesLifecycleServiceClientComponent[Trigger]directly; calls withtimeout_service=2.0.
Tests¶
Test suite split into focused files backed by shared stubs:
tests/_service_stubs.py—_TriggerServer,_TriggerClient,_EmptyServer,_CrashingServer,_GatedPublisher,_GatedSubscriber,DUMMY_STATE,nodeandmock_svc_factoriesfixtures (loaded viapytest_plugins).tests/test_service_server.py— 12 tests: server lifecycle (Sections B) and activation gating (Section C).tests/test_service_client.py— 17 tests: client lifecycle (Section D), activation gating (Section E), and timeout parameters (Section E2).tests/test_service_components.py— 13 tests:srv_typeinference (Section A), integration (Section F), and four-primitive gating consistency (Section G).
Similarly, topic-component tests were split in the same session:
tests/_topic_stubs.py,tests/test_publisher_component.py,tests/test_subscriber_component.py,tests/test_timer_component.py(tests/test_components.pyretains transversal QoS/callback-group tests only).
Quality gates¶
Ruff lint: ✓ 0 errors
Ruff format: ✓ all files formatted
Pyright: ✓ 0 errors
Pytest: ✓ 248/248 passed