Skip to content

8. Session and Actor Binding

Guide Home | Previous: 7. Actor and Spot | Next: 9. STREAM

View in another language — C#/.NET · C++ · Java · Kotlin · Node/TypeScript

The documents that own this chapter's contractSession Actor dispatch owns the behavior, and the per-language STREAM session / bound session public contract owns the exact signatures.

Session binding connects a client STREAM session to an exact Actor incarnation. After binding, the session can relay a client packet to the Actor, and the Actor can push through the same session.

Binding is independent of the Actor's Spot membership. Even when an Actor relocates to another Spot or node, ActorId and ObjectGeneration are preserved and the Framework updates the binding route.

The cardinality is open in only one direction. One Session can bind several Actors at once — one connection can use both a player Actor and a party Actor. Conversely, one Actor is bound to only one session at a time. Once a new binding is confirmed, the previous binding becomes invalid, and a late message sent to it is rejected.

Relay doesn't re-query the Location Store. The session keeps, per Actor, the route it confirmed at bind time and uses it to send. When an Actor moves, the Framework updates that stored route after the relocation commits — the application doesn't rebind.

1. Binding an Actor After Authentication

Create or find the Actor in the Session handler, then bind the ActorRef. Don't pass a local Actor instance or a target NodeRid directly.

See it in a sample — TicTacToe. This is where the authentication request is received, the player Actor is created and bound to the session, and the reply is sent. The excerpt is actual code from the repository.

class authenticate_play_session_handler_t
{
  public:

    explicit authenticate_play_session_handler_t (channel_client_t &client) :
        _client (client)
    {
    }

    bool can_handle (const session_message_context_t &dispatch) const
    {
        return dispatch.packet_name == authenticate_req_t::packet_name;
    }

    task_t<session_actor_t>
    handle (session_actor_manager_t &actors, stream_t &stream, const zlink::message_t &payload)
    {
        auto request = payload.parse_json<authenticate_req_t> ();
        const auto authenticate_request = authenticate_player_req_t{request.access_token};
        auto authenticated =
          co_await _client.request (sample_names_t::api_channel, authenticate_request)
            .async<authenticate_player_res_t> ();
        if (authenticated.player.actor_id.empty ()) {
            co_return result_t<session_actor_t>::failure (framework_error_kind_t::internal_failure,
                                                          "Player authentication failed.");
        }

        /* 공통 sample spec §13: 인증 응답의 PlayerInfo.ActorId로 actor를 만들고, 같은
         * PlayerInfo를 actor 생성 payload로 실어 보낸다(별도 EnsurePlayerActor 계약 없음). */
        const auto &player = authenticated.player;
        auto located = actors.get_or_create (
          sample_names_t::actor_type, player.actor_id,
          player_actor_create_req_t{player});
        if (!located) {
            co_return result_t<session_actor_t>::failure (framework_error_kind_t::internal_failure,
                                                          "Player actor could not be located.");
        }
        auto actor = co_await actors.bind_or_get (located.value ().ref ()).async ();
        bool first_player_x_binding = false;
        {
            std::lock_guard lock (bound_actors_mutex);
            first_player_x_binding = actor.actor_id () == "player-x"
              && bound_actors.insert (std::string (actor.actor_id ())).second;
        }
        if (first_player_x_binding) {
            std::cout << "tictactoe-lifecycle actor-bound actor=" << actor.actor_id ()
                      << std::endl;
        }

        const auto reply_payload = authenticate_res_t{player};
        const auto reply_message = zlink::message_t::from_json (reply_payload);
        stream.reply_packet (reply_message).async ();

        co_return actor;
    }

  private:
    channel_client_t &_client;
    inline static std::mutex bound_actors_mutex;
    inline static std::unordered_set<std::string> bound_actors;
};

A minimal version looks like this.

// A C++ session branches directly in on_packet instead of registering handlers.
auto located = actors.get_or_create (sample_names_t::actor_type, request.player_id,
                                     create_player_t{request.display_name});
if (!located)
    co_return result_t<session_actor_t>::failure (framework_error_kind_t::request_failed,
                                                 "Player actor could not be located.");

// Returns the existing route if the same exact incarnation is already bound.
auto actor = co_await actors.bind_or_get (located.value ().ref ()).async ();

// Submits the current request's one-shot reply.
stream.reply_packet (zlink::message_t::from_json (authenticated_t{actor.actor_id ()})).async ();

bind treats a duplicate bind as an error. For a flow that might already be bound, like a retried authentication, use bind_or_get.

2. Relaying a Session Packet to an Actor

Register session-only handlers, such as authentication, in the Session's configure(). An unhandled packet is handed to the bound Actor.

// A C++ session inherits the interface and branches inside one on_packet.
// Instead of using a handler registry, it directly checks whether this is the authenticate packet.
class play_session_t : public packet_stream_session_t
{
  public:
    task_t<void> on_packet (stream_t &stream,
                            const stream_dispatch_context_t &dispatch,
                            const zlink::message_t &payload) override
    {
        // First, filter out the packet to handle before Actor binding.
        if (_authenticate.can_handle (dispatch)) {
            auto authenticated = co_await _authenticate.handle (_actors, stream, payload);
            _bound_actor_id = std::string (authenticated.actor_id ());
            co_return;
        }

        auto actor = require_bound_actor ();
        if (!actor)
            co_return;

        // Hands the Framework-owned payload to the Actor handler without decoding it.
        co_await actor.value ().relay (payload);
    }

    task_t<void> on_connected (stream_t &) override { co_return; }
    task_t<void> on_disconnected (stream_t &) override { co_return; }
    task_t<void> on_error (stream_t &, const stream_error_t &) override { co_return; }
};

One session can bind several Actors. In that case, the application protocol passes the selected ActorId to Context.Actors.Find(actorId). The Framework never picks an arbitrary Actor on its own.

3. Disconnect Notification

The Framework automatically notifies every current binding on a physical STREAM disconnect. Call it explicitly only to signal a logical disconnect while the connection stays up.

if (auto actor = _actors.find (player_id)) {
    // Waits for the on_disconnect_actor callback on the Actor's Spot to complete.
    co_await actor->notify_disconnected ();
}

A disconnect doesn't delete the Actor or move it to the Entry Spot. A reconnecting session can look up the same ActorRef again and bind it.

If notifying one Actor fails, the rest continue. The Framework takes a snapshot of the bindings at the moment the connection drops and notifies each Actor; if one of them fails or a callback exceeds its deadline, it doesn't stop notifying the remaining Actors or stop session cleanup.

Even if the automatic notification and an explicit call overlap, the callback runs only once. The Framework merges two notifications for the same binding, so if the connection drops right after an explicit call, the Spot's disconnect callback doesn't run twice.

4. Pushing from an Actor to the Client

An Actor handler sends a message to the currently bound client through Context.BoundSession.

// A C++ actor handler is a Spot member function. The Spot arrives as `this`, so there are 3 arguments.
task_t<void> game_room_t::state_changed (player_actor_t &actor,
                                         const message_context_t &,
                                         const state_changed_t &message)
{
    // Waits for local admission on the current bound session.
    co_await actor.context ().bound_session ()
      .send (game_state_notify_t{message.state})
      .metadata ("revision", std::to_string (message.revision))
      .async ();
}

A bound session supports only push and disconnect. An Actor's reply to a client request is handled through the request handler's return value.

5. Error-Handling Standard

Situation Result
The Actor doesn't exist or isn't Ready The bind ends with a typed framework error.
ObjectGeneration differs A stale ActorRef is never bound to a different incarnation.
An Actor relocation seal is in progress Ends with ActorMoving, with no hidden retry.
An Actor relocates after binding The Framework updates the route without rebinding the session.
Session disconnect The Actor and its Spot membership are preserved.
A reply arrives after the session has closed Discarded. Never used as the reply for a new session or a new binding.
A timeout/route failure after a relay Never auto-resent to a different Actor, new owner, or different node.

ActorRef.MeshName and NodeRid are a snapshot of the initial control route. The application doesn't assemble a stale route on its own — it re-obtains the current ref through the actor manager's lookup call.

  • Runnable verification examples for this chapter's contract: 13. Interface Catalog chapter §5 — the verification class StreamContracts
  • The STREAM node and session lifecycle: STREAM
  • Actor creation and Spot join: Actor And Spot