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 contract — Session 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.
internal sealed class AuthenticatePlaySessionHandler(
IZLinkActorManager actors,
IZLinkRouteClient channels,
ILogger<AuthenticatePlaySessionHandler> logger)
: IZLinkSessionPacketHandler<IZLinkSessionContext, AuthenticateReq>
{
public async ValueTask HandleAsync(
IZLinkSessionContext context,
ZLinkSessionDispatchContext dispatch,
AuthenticateReq authenticate,
CancellationToken cancellationToken)
{
logger.LogInformation(
"play stream: authenticate requested. sessionId={SessionId}",
context.SessionId);
var accessToken = authenticate.AccessToken.Trim();
if (string.IsNullOrWhiteSpace(accessToken))
throw new InvalidOperationException("Authentication token is empty.");
var authenticated = await channels.RequestToChannel(
SampleChannels.Api,
new AuthenticatePlayerReq(accessToken))
.Async<AuthenticatePlayerRes>(cancellationToken);
logger.LogInformation(
"play stream: authenticate accepted. sessionId={SessionId}, player={ActorId}",
context.SessionId,
authenticated.Player.ActorId);
await EnsureActorBoundAsync(
context,
authenticated.Player,
cancellationToken);
await context.Client.Reply(new AuthenticateRes(authenticated.Player))
.Async(cancellationToken);
}
private async ValueTask EnsureActorBoundAsync(
IZLinkSessionContext context,
PlayerInfo player,
CancellationToken cancellationToken)
{
logger.LogInformation(
"play stream: creating actor before dispatch. sessionId={SessionId}, actor={ActorId}",
context.SessionId,
player.ActorId);
var result = await actors.GetOrCreate(player.ActorId, SampleTypes.PlayerActor)
.Request(new PlayerActorCreateReq(player)).Async(cancellationToken);
var playerActor = result switch
{
ZLinkActorCreateResult.Existing value => value.Actor,
ZLinkActorCreateResult.Created value => value.Actor,
_ => throw new InvalidOperationException("Player Actor creation was rejected.")
};
logger.LogInformation(
"play stream: binding actor to session. sessionId={SessionId}, actor={ActorId}",
context.SessionId,
player.ActorId);
var boundActor = await context.Actors.BindOrGetAsync(
playerActor,
cancellationToken);
// ActorRef equality covers the actor id, object generation, mesh and owner
// route. Keep that exact-identity check on the server; AuthenticateRes only
// returns the public PlayerInfo payload.
if (boundActor.Ref != playerActor)
throw new InvalidOperationException(
$"Bound ActorRef does not match the resolved ActorRef for '{player.ActorId}'.");
logger.LogInformation(
"play stream: actor bound to session. sessionId={SessionId}, actor={ActorId}",
context.SessionId,
boundActor.ActorId);
if (result is ZLinkActorCreateResult.Existing)
logger.LogInformation(
"tictactoe-lifecycle actor-bound actor={ActorId}",
boundActor.ActorId);
}
}
A minimal version looks like this.
public sealed class AuthenticateHandler(IZLinkActorManager actors)
: IZLinkSessionPacketHandler<IZLinkSessionContext, Authenticate>
{
public async ValueTask HandleAsync(
IZLinkSessionContext context,
ZLinkSessionDispatchContext dispatch,
Authenticate request,
CancellationToken cancellationToken)
{
ActorRef actor = (await actors
.GetOrCreate(request.PlayerId, "player")
.Request(new CreatePlayer(request.DisplayName))
.Async(cancellationToken)) switch
{
ZLinkActorCreateResult.Existing value => value.Actor,
ZLinkActorCreateResult.Created value => value.Actor,
_ => throw new InvalidOperationException("Player creation was rejected.")
};
await context.Actors.BindOrGetAsync(
actor,
// Returns the existing route if the same exact incarnation is already bound.
cancellationToken);
await context.Client
.Reply(new Authenticated(actor.ActorId))
// Submits the current request's one-shot reply.
.Async(cancellationToken);
}
}
Bind treats a duplicate bind as an error. For a flow that might already be bound, like a
retried authentication, use BindOrGetAsync.
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.
public sealed class PlaySession(IZLinkSessionContext context) : IZLinkSession
{
public IZLinkSessionContext Context { get; } = context;
public void Configure()
{
Context.Handlers
// Registers the packet to handle before Actor binding.
.AddHandler<AuthenticateHandler>();
}
public async ValueTask OnDispatchAsync(
ZLinkSessionDispatchContext dispatch,
ZLinkMessage payload,
CancellationToken cancellationToken)
{
if (await Context.Handlers.TryHandleAsync(
dispatch,
payload,
cancellationToken))
{
return;
}
IZLinkSessionActor actor = Context.Actors.Bound.Count == 1
? Context.Actors.Bound.Single()
: throw new InvalidOperationException(
"Exactly one actor must be bound before relay.");
await actor.RelayAsync(
payload,
// Hands the Framework-owned payload to the Actor handler without decoding it.
cancellationToken);
}
public ValueTask OnConnectedAsync(CancellationToken cancellationToken)
=> ValueTask.CompletedTask;
public ValueTask OnErrorAsync(
ZLinkStreamError error,
CancellationToken cancellationToken)
=> ValueTask.CompletedTask;
public ValueTask OnDisconnectedAsync(CancellationToken cancellationToken)
=> ValueTask.CompletedTask;
}
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.
IZLinkSessionActor? actor = Context.Actors.Find(playerId);
if (actor is not null)
{
await actor.NotifyDisconnectedAsync(cancellationToken);
// Waits for the OnDisconnectActorAsync callback on the Actor's Spot to complete.
}
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.
public sealed class StateChangedHandler
: IZLinkSpotActorSendHandler<GameRoom, PlayerActor, StateChanged>
{
public async ValueTask HandleAsync(
GameRoom spot,
PlayerActor actor,
IZLinkMessageContext messageContext,
StateChanged message,
CancellationToken cancellationToken)
{
await actor.Context.BoundSession
.Send(new GameStateNotify(message.State))
.Metadata("revision", message.Revision.ToString())
// Waits for local admission on the current bound session.
.Async(cancellationToken);
}
}
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.
6. Related Documents¶
- Runnable verification examples for this chapter's contract:
13. Interface Catalogchapter §5 — the verification classStreamContracts - The STREAM node and session lifecycle: STREAM
- Actor creation and Spot join: Actor And Spot