Metadata-Version: 2.4
Name: loop-node
Version: 0.3.0
Summary: Shared Node protocol and runtime for Loop and external integrations.
Requires-Python: >=3.10
Description-Content-Type: text/markdown
Requires-Dist: eclipse-zenoh==1.10.1
Requires-Dist: protobuf>=7.36.0
Requires-Dist: pydantic>=2.12.0
Provides-Extra: numpy
Requires-Dist: numpy>=1.26; extra == "numpy"

# loop-node

`loop-node` is the low-level runtime and wire protocol shared by Loop and
`loop-sdk`. Most integrations should use `loop-sdk` directly; use `loop-node`
when building a lower-level Node API or integration layer.

## Installation

```bash
pip install loop-node
```

Published wheels support CPython 3.10–3.14 on Linux x86_64 (glibc 2.28+).
They contain compiled implementation modules and type stubs; installation
does not require a compiler.

## Minimal example

```python
import time

from loop_node import (
    EmptyConfig,
    FieldContract,
    LifecycleState,
    Node,
    NodeConnectionConfig,
    PayloadContract,
    ValueKind,
)

VALUE = PayloadContract(fields={"value": FieldContract(kind=ValueKind.SCALAR)})

node = Node(config_type=EmptyConfig)
output = node.declare_stream_output("value", payload_contract=VALUE)
node.start(
    node_id="example_node",
    connection=NodeConnectionConfig(loop_endpoint="tcp/127.0.0.1:7448"),
)

try:
    while node.is_running:
        node.process_control()
        if node.status.lifecycle is LifecycleState.ACTIVE:
            output.publish(timestamp_ns=time.time_ns(), payload={"value": 1.0})
        time.sleep(0.1)
finally:
    if node.status.lifecycle is not LifecycleState.FINALIZED:
        node.shutdown()
    node.close()
```

A Node declares its Config and Ports before connecting. Loop supplies the
validated Config, Port bindings, and lifecycle operations.
Call `process_control()` regularly to handle lifecycle requests from Loop.
Use the Node lifecycle state to decide when your application should exchange
Graph data. In most cases, only exchange data while the Node is `ACTIVE`.

## Lifecycle

All Nodes use `IDLE → Configure → CONFIGURED → Start → ACTIVE`.
Configure validates Config and calls optional `on_configure(config)` before
Port binding. Start takes only bindings and calls optional `on_start()`.
Stop and ResetFault return to IDLE; each restart requires Configure again.
Configure may update existing payload contracts. The Orchestrator validates
the resulting graph before starting Nodes. Port names and kinds stay fixed.

Describe returns the Config schema and current Port contracts for inspection.
Loop uses Configure results for payload compatibility checks; contracts read
through Describe are not treated as finalized for a run.

## Port transport policy

Declare a transport policy on each Port, independently of the Node connection:

| Policy | Binding behavior |
| --- | --- |
| `auto` (default) | Image contracts use SHM when every peer passes an actual SHM round trip; otherwise use ordinary Zenoh network transmission. Contracts without images use network transmission. |
| `zenoh_network` | Require ordinary Zenoh transmission, including for local peers. |
| `zenoh_shared_memory` | Require SHM for all payload types. An inaccessible peer or conflicting policy rejects binding before application Start. |

```python
from loop_node import TransportMode

# Optional policy; omitting transport is equivalent to AUTO.
image_output = node.declare_stream_output(
    "image", payload_contract=IMAGE_CONTRACT,
    transport=TransportMode.AUTO,
)
policy = node.declare_request_client(
    "action", request=OBSERVATION_CONTRACT, response=ACTION_CONTRACT,
    request_transport=TransportMode.AUTO,
    response_transport=TransportMode.ZENOH_NETWORK,
)
```

The examples assume the payload contracts have been declared. Both endpoints
advertise policies and supported transports in their Port contracts. An explicit
policy constrains an `auto` peer; contradictory policies fail. Request and reply
are negotiated independently, so an observation with images can use SHM while
its small action response uses the network path. TCP/UDP endpoint selection is
still Zenoh session configuration, independent of these Port policies.

One provider Port currently uses one common mode for all its receivers. For
image fanout, every receiver must pass the SHM probe; a remote receiver makes
an automatic provider use network transmission for all receivers. SHM-only
Ports instead reject an incompatible connection. Probes use dedicated control
endpoints and never invoke a user handler or robot action.

A binding keeps its selected mode until it is released. Partial Node restarts
retain the graph's common mode while any Node remains started. Stop all Nodes
before renegotiating, changing contracts, or reconnecting peers. Configuration
changes that preserve Port contracts and graph connections are allowed. Selected
modes and reasons are logged when bindings are created. Probes check memory
accessibility; later allocation exhaustion or transport loss is still an error,
not a reason to resend a request or silently change mode.

Cell Config bindings now contain only `from`; they do not store transfer modes.
The appended `collect_cell_config_2_0_0_auto_transport` migration removes old
binding profile fields from all saved revisions while preserving Node Config
and other graph content. The low-level Graph Config has a
`shared_memory_pool_size_bytes` limit (default 64 MiB per sending Port).

This protocol requires `loop-node` 0.3.x on Loop and external Nodes. Control
protocol version 2 rejects older Node registrations. Existing `publish`, `poll`,
`request`, `Payload`, `ImageValue`, and step APIs keep the same application data
interface. Use the matching SDK update when importing through `loop-sdk`.

## Image framing and SHM lifetime

Loop uses Zenoh 1.10.1. Image bytes are outside the Protobuf body for **both**
network and SHM transmission. Protobuf metadata in the attachment describes
one contiguous binary payload by field name, offset, and length. When a payload
contains images, its tensor and audio fields share that buffer. Network payloads
without images use ordinary Protobuf. Explicit SHM packs every payload, including
scalar-only and empty payloads (one padding byte for an empty binary buffer).

Zenoh's implicit transport SHM optimization is disabled in Loop sessions so
`zenoh_network` remains network transmission. Loop's `auto` policy negotiates
explicit SHM instead. SHM support remains enabled. Physical transport changes
are rejected by graph input and RPC bindings. Passive recorder previews are
observers: they can also decode ordinary copies delivered outside the graph's
negotiated SHM peers.

Each SHM sending Port owns a bounded pool. The limit must cover in-flight data,
values retained by receivers, and allocation overhead. The receiver validates
buffer ranges, layout, and sizes. Image/audio values retain their Zenoh payload
and remain readable after the binding or session closes. Retaining request
images does not keep their Query open. Releasing values allows the sender to
reclaim allocations; retaining them can exhaust its pool. Request timeouts and
client closure do not interrupt a handler already running on the server.

Zenoh Python 1.10.1 does not expose received SHM through the Python buffer
protocol. Packed ranges share a lazy Python snapshot, and tensor decoding
materializes it to preserve `TensorValue.data` as a read-only `memoryview`.
This is not end-to-end zero-copy NumPy access.

On Linux, Zenoh locks mapped SHM into memory. Each process needs a sufficient
`RLIMIT_MEMLOCK` limit for the pools it creates or maps, and containers need
enough `/dev/shm` capacity for all participating processes. Wheel tests configure
Docker with `--shm-size=256m --ulimit=memlock=268435456:268435456` for their
multi-process image requests and replies. Size these limits for the pools used
by your application; a small limit can reject pool creation before any data is sent.
