Session and Actor¶
Guide Home | Previous: STREAM | Next: Location
View in another language — C++ · C#/.NET · Java · Kotlin · Node/TypeScript
What you get from this chapter
You can bind one external client's connection to one Actor, and have that Actor push a notification back over the same connection. The code comes from the tutorial README in the examples repository; follow its Download, Build, and Run sections to reproduce the results below.
A STREAM session ends when its connection drops. A player's state has to outlive that, and what holds it is an Actor. This chapter binds the two — once bound, packets the session does not handle reach that Actor, and the Actor can push over that connection.
1. The Role of Binding an Actor and a Session¶
Binding connects a connection and an entity whose lifetimes differ. When the same player drops and returns, the connection is a new one and the entity is the one that was already there. So the connection is made to point at its entity, and packets after that are received by the entity.
▶ Try it in the interactive tour — Lobby and dungeon
The session does not disappear once bound. Packets the session knows are handled by the session first, and only the ones it does not know go to the Actor.
2. Deciding the Packets¶
An authentication request, its answer, and the notification the Actor pushes. The last one answers no request.
struct authenticate_t
{
static constexpr const char *packet_name = "Authenticate";
std::string player_id;
};
struct authenticated_t
{
static constexpr const char *packet_name = "Authenticated";
std::string player_id;
};
// Pushed by the player to its own connection, with no request to answer.
struct nickname_changed_t
{
static constexpr const char *packet_name = "NicknameChanged";
std::string nickname;
};
3. The Receiving Side — the Node That Binds the Connection¶
The receiving process
The code in this section goes into the process that registered the stream node in STREAM. Actor dispatch has to be turned on at that registration for the relay in this chapter to work.
3.1 Binding¶
When the client names its id, the Actor for that id is found or created and bound to this connection.
// Each authenticate packet binds its player to this connection.
if (packet == authenticate_t::packet_name) {
const auto request = payload.parse_json<authenticate_t> ();
// A returning client finds its existing player rather than a new one.
auto &actors = stream.actors ();
auto located = actors.get_or_create (
"player", request.player_id, create_player_t{request.player_id});
if (!located)
throw fw::framework_exception_t (located.error_kind (),
"Player creation was rejected.");
auto bound = co_await actors.bind_or_get (located.value ().ref ()).async ();
stream
.reply_packet (
zlink::message_t::from_json (authenticated_t{std::string (bound.actor_id ())}))
.async ();
co_return;
}
A client that returns with the same id binds to the Actor that is already there. That is why creating and finding are one call.
3.2 Forwarding What Is Left¶
Packets the session did not handle are sent to the bound Actor. The dispatch context's Actor names the target for each packet — a packet the client sent through an Actor handle points at that Actor. A packet sent without an Actor handle has no Actor, so when one Actor is bound to this connection it goes to that Actor. With no bound Actor there is nowhere to send it, which is where authentication first is enforced. The client side of telling Actors apart is covered in Receiving Packets §8.
// The packet's Actor slot selects one binding on this connection.
if (dispatch.actor) {
co_await dispatch.actor->relay (packet, payload);
co_return;
}
auto bound = stream.actors ().bound ();
if (bound.size () != 1)
throw fw::framework_exception_t (
fw::framework_error_kind_t::invalid_operation,
"Specify an Actor when the session has no unique binding.");
co_await bound.front ().relay (packet, payload);
A forwarded packet is received by that Actor's handler — the same handler written in Actor. One handler is called both by a mesh call and by a STREAM relay.
3.3 The Actor Pushes Over That Connection¶
An Actor knows the connection bound to it. This is not a reply but a notification it sends on its own.
// Pushes over the connection bound to this player. The same handler also runs
// on an HTTP path with no bound connection, where push ends with InvalidOperation.
// Rename is already complete, so only that failure is discarded.
try {
co_await player.context ()
.bound_session ()
.send (nickname_changed_t{player.nickname})
.async ();
}
catch (const fw::framework_exception_t &error) {
if (error.kind () != fw::framework_error_kind_t::invalid_operation)
throw;
}
A push without a bound connection ends with InvalidOperation
The same handler is also called by a mesh call, where no connection is bound. In that case, the
push ends with InvalidOperation at the call's terminal, and the failure is observed through each
language's asynchronous completion mechanism. The code above has already completed the rename
before pushing, so it discards only that failure.
4. The Attaching Side — a Client Outside the Mesh¶
The attaching process
The code in this section goes into the process that opened the connection in STREAM. It pairs with the section above.
It authenticates, then receives a notification. The second call is one-way and waits for no answer, yet a value arrives after it — that is the notification the Actor pushed.
auto bound_notice = connector.on_actor_bound ([] (const auto &actor) {
std::cout << "actor bound: " << actor->actor_id () << std::endl;
});
auto unbound_notice = connector.on_actor_unbound ([] (const auto &actor) {
std::cout << "actor unbound: " << actor->actor_id () << std::endl;
});
// With one Actor bound, the connector can send without an Actor handle.
const auto authenticated = value_of (
connector.request (authenticate_t{"p1"}).submit<authenticated_t> (), "authenticate");
std::cout << "bound player: " << authenticated.player_id << std::endl;
std::promise<sc::message_t<nickname_changed_t>> single_changed;
auto single_received = single_changed.get_future ();
{
auto single_notice = connector.on<nickname_changed_t> (
[&] (const auto &changed) { single_changed.set_value (changed); });
connector.send (change_nickname_t{"speedy"}).submit ();
if (single_received.wait_for (options.wait_timeout) != std::future_status::ready)
throw std::runtime_error ("NicknameChanged was not received for p1");
const auto pushed = single_received.get ();
std::cout << "pushed: " << pushed.payload.nickname
<< ", actor: " << pushed.actor_id.value_or ("none") << std::endl;
}
// A second Actor on the same connection calls for explicit handles.
const auto authenticated2 = value_of (
connector.request (authenticate_t{"p2"}).submit<authenticated_t> (), "authenticate");
std::cout << "bound player: " << authenticated2.player_id << std::endl;
auto player = connector.actor (authenticated.player_id);
auto player2 = connector.actor (authenticated2.player_id);
if (!player || !player2)
throw std::runtime_error ("Player Actor was not bound");
std::cout << "actor handle: " << player->actor_id () << std::endl;
std::cout << "actor handle: " << player2->actor_id () << std::endl;
std::promise<sc::message_t<nickname_changed_t>> changed1;
std::promise<sc::message_t<nickname_changed_t>> changed2;
auto received1 = changed1.get_future ();
auto received2 = changed2.get_future ();
auto player_notice = player->on<nickname_changed_t> (
[&] (const auto &changed) { changed1.set_value (changed); });
auto player2_notice = player2->on<nickname_changed_t> (
[&] (const auto &changed) { changed2.set_value (changed); });
// Connector-level callbacks can distinguish the same pushes by ActorId.
auto actor_id_notice = connector.on<nickname_changed_t> ([] (const auto &message) {
std::cout << "received actor id: " << message.actor_id.value_or ("none") << std::endl;
});
// Each handle addresses its own player on the shared connection.
player->send (change_nickname_t{"speedy-p1"}).submit ();
player2->send (change_nickname_t{"speedy-p2"}).submit ();
if (received1.wait_for (options.wait_timeout) != std::future_status::ready)
throw std::runtime_error ("NicknameChanged was not received for p1");
if (received2.wait_for (options.wait_timeout) != std::future_status::ready)
throw std::runtime_error ("NicknameChanged was not received for p2");
const auto pushed1 = received1.get ();
const auto pushed2 = received2.get ();
std::cout << "pushed: " << pushed1.payload.nickname
<< ", actor: " << pushed1.actor_id.value_or ("none") << std::endl;
std::cout << "pushed: " << pushed2.payload.nickname
<< ", actor: " << pushed2.actor_id.value_or ("none") << std::endl;
5. What You See When You Run It¶
With the tutorial Server running as the README's Run section specifies, start StreamClient with the command below: its bind request and received Actor notification appear on the StreamClient process's stdout, while session and Actor handler records appear on the Server process's stdout or in server.log.
dotnet run --project StreamClient/StreamClient.csproj
# connected: True
# round trip: 56ms
# bound player: p1
# pushed: speedy
pushed is the point. The client sent a rename and asked for no answer, and what came back is
a notification the player pushed on its own. In between, the packet passed the session,
reached the Actor, and the Actor sent back over the same connection.
6. After the Connection Drops — the Session Ends and the Actor Stays¶
The session ends and the Actor stays. A client that returns with the same id is bound to that Actor again.
The callback that tells the Actor about the drop, the rules for a binding moving to another connection, and what happens while the Actor moves to another node are covered by Session and Actor.
7. Related Documents¶
- Where the connection is accepted — STREAM
- The entity that gets bound — Actor
- Disconnect notices and relocation — Session and Actor
- A running version of this chapter's code — the tutorial README in the examples repository