Files
lerobot/tests/policy_server/test_session.py
T
2026-06-12 02:01:41 +02:00

299 lines
9.1 KiB
Python

# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Unit tests for Session (latest-only mailbox, episode reset, close,
processor-step introspection) and SessionRegistry (thread-safe map)."""
from __future__ import annotations
import threading
from lerobot.policy_server.schema import MsgHeader
from lerobot.policy_server.session import Session, SessionRegistry
from lerobot.processor import NormalizerProcessorStep, RelativeActionsProcessorStep
from tests.policy_server.conftest import TASK, MockPipeline, make_mock_processors
def make_session(
client_uuid: str = "client-a",
preprocessor: MockPipeline | None = None,
postprocessor: MockPipeline | None = None,
publisher=None,
) -> Session:
default_pre, default_post = make_mock_processors()
return Session(
session_id=f"sess-{client_uuid}",
client_uuid=client_uuid,
task=TASK,
robot_type="mock_robot",
rtc_enabled=False,
preprocessor=preprocessor if preprocessor is not None else default_pre,
postprocessor=postprocessor if postprocessor is not None else default_post,
action_publisher=publisher,
)
# ---------------------------------------------------------------------------
# Mailbox: latest-only deposit / take
# ---------------------------------------------------------------------------
def test_deposit_then_take_returns_item():
session = make_session()
header = MsgHeader(seq_id=7)
session.deposit(header, b"payload-7")
item = session.take()
assert item is not None
assert item.header.seq_id == 7
assert item.payload == b"payload-7"
assert item.recv_mono > 0
def test_second_deposit_supersedes_and_take_returns_newer():
session = make_session()
session.deposit(MsgHeader(seq_id=1), b"old")
session.deposit(MsgHeader(seq_id=2), b"new")
assert session.stats.superseded == 1
assert session.stats.superseded_since_reply == 1
item = session.take()
assert item is not None
assert item.header.seq_id == 2
assert item.payload == b"new"
def test_deposit_after_take_is_not_superseded():
session = make_session()
session.deposit(MsgHeader(seq_id=1), b"one")
session.take()
session.deposit(MsgHeader(seq_id=2), b"two")
assert session.stats.superseded == 0
assert session.stats.superseded_since_reply == 0
def test_take_clears_mailbox_second_take_is_none():
session = make_session()
session.deposit(MsgHeader(seq_id=1), b"one")
assert session.take() is not None
assert session.take() is None
def test_has_pending_transitions():
session = make_session()
assert not session.has_pending()
session.deposit(MsgHeader(seq_id=1), b"one")
assert session.has_pending()
session.take()
assert not session.has_pending()
def test_deposit_marks_alive_and_clears_token_drop():
session = make_session()
session.alive = False
session.token_dropped_mono = 123.4
before = session.last_seen_mono
session.deposit(MsgHeader(seq_id=1), b"one")
assert session.alive is True
assert session.token_dropped_mono is None
assert session.last_seen_mono >= before
# ---------------------------------------------------------------------------
# Episode boundary
# ---------------------------------------------------------------------------
def test_reset_episode_resets_pipelines_clears_mailbox_and_increments():
pre, post = make_mock_processors()
session = make_session(preprocessor=pre, postprocessor=post)
session.deposit(MsgHeader(seq_id=1), b"stale")
assert session.episode_id == 0
session.reset_episode()
assert not session.has_pending()
assert pre.reset_count == 1
assert post.reset_count == 1
assert session.episode_id == 1
session.reset_episode()
assert session.episode_id == 2
assert pre.reset_count == 2
assert post.reset_count == 2
def test_reset_episode_with_explicit_id():
session = make_session()
session.reset_episode(episode_id=7)
assert session.episode_id == 7
# ---------------------------------------------------------------------------
# close()
# ---------------------------------------------------------------------------
class FakePublisher:
def __init__(self, raise_on_undeclare: bool = False):
self.undeclare_calls = 0
self.raise_on_undeclare = raise_on_undeclare
def undeclare(self):
self.undeclare_calls += 1
if self.raise_on_undeclare:
raise RuntimeError("transport already closed")
def test_close_clears_mailbox_and_undeclares_publisher_exactly_once():
publisher = FakePublisher()
session = make_session(publisher=publisher)
session.deposit(MsgHeader(seq_id=1), b"stale")
session.close()
assert not session.has_pending()
assert publisher.undeclare_calls == 1
assert session.action_publisher is None
# Idempotent: a second close must not undeclare again.
session.close()
assert publisher.undeclare_calls == 1
def test_close_tolerates_undeclare_raising():
publisher = FakePublisher(raise_on_undeclare=True)
session = make_session(publisher=publisher)
session.deposit(MsgHeader(seq_id=1), b"stale")
session.close() # must not raise
assert publisher.undeclare_calls == 1
assert not session.has_pending()
assert session.action_publisher is None
def test_close_without_publisher_is_noop():
session = make_session(publisher=None)
session.close() # must not raise
assert session.action_publisher is None
# ---------------------------------------------------------------------------
# Processor-step introspection
# ---------------------------------------------------------------------------
def test_relative_and_normalizer_steps_detected():
relative = RelativeActionsProcessorStep(enabled=True)
normalizer = NormalizerProcessorStep(features={}, norm_map={})
pre = MockPipeline(steps=[relative, normalizer])
session = make_session(preprocessor=pre)
assert session.relative_step is relative
assert session.normalizer_step is normalizer
def test_disabled_relative_step_is_not_detected():
relative = RelativeActionsProcessorStep(enabled=False)
pre = MockPipeline(steps=[relative])
session = make_session(preprocessor=pre)
assert session.relative_step is None
assert session.normalizer_step is None
def test_empty_pipeline_yields_no_introspected_steps():
session = make_session()
assert session.relative_step is None
assert session.normalizer_step is None
# ---------------------------------------------------------------------------
# SessionRegistry
# ---------------------------------------------------------------------------
def test_registry_add_get_remove_len_snapshot():
registry = SessionRegistry()
assert len(registry) == 0
assert registry.get("missing") is None
assert registry.snapshot() == []
session_a = make_session("uuid-a")
session_b = make_session("uuid-b")
assert registry.add(session_a) is None
assert registry.add(session_b) is None
assert len(registry) == 2
assert registry.get("uuid-a") is session_a
assert registry.get("uuid-b") is session_b
assert set(registry.snapshot()) == {session_a, session_b}
removed = registry.remove("uuid-a")
assert removed is session_a
assert len(registry) == 1
assert registry.get("uuid-a") is None
def test_registry_remove_missing_returns_none():
registry = SessionRegistry()
assert registry.remove("never-added") is None
def test_registry_add_returns_displaced_same_uuid_session():
registry = SessionRegistry()
first = make_session("uuid-x")
second = make_session("uuid-x")
assert registry.add(first) is None
displaced = registry.add(second)
assert displaced is first
assert registry.get("uuid-x") is second
assert len(registry) == 1
def test_registry_thread_safety_smoke():
registry = SessionRegistry()
errors: list[Exception] = []
def worker(prefix: str) -> None:
try:
for i in range(200):
session = make_session(f"{prefix}-{i}")
registry.add(session)
assert registry.get(session.client_uuid) is session
assert registry.remove(session.client_uuid) is session
except Exception as exc: # noqa: BLE001 — surfaced to the main thread
errors.append(exc)
threads = [threading.Thread(target=worker, args=(p,)) for p in ("alpha", "beta")]
for t in threads:
t.start()
for t in threads:
t.join(timeout=10)
assert not t.is_alive()
assert errors == []
assert len(registry) == 0
assert registry.snapshot() == []