RPC Part 5: Sessions
Intro
In the previous post we learned about Sequences and Streams. We examined some of the issues that streaming solves including memory limitations, network latency, traffic minimization, flow control, and backpressure. In this post, the last in this series focused on the MSC RPC system, we will talk a little about Sessions, session lifetime and what impact session termination has on eventual values.
Back in the post RPC Part 3: Capability Exchange we introduced the concept of the Root Capability. The root was the first capability that we acquired. We observed that the root was received “when successfully initiating a new connected session”. But what does that mean? What is a session? How do we connect one? How long does it last? What happens when the connection is terminated? We will try to answer these questions in this post.
Transport vs. Session
Many message passing systems, including Promise RPC, are designed around two different layers of abstraction during point-to-point communication. Each is responsible for a different aspect of the overall function.
-
Transport Layer:
The transport layer defines the mechanics of physically moving data between two endpoints. It is usually also responsible for data ordering, reliability and integrity. -
Session Layer:
The session layer defines the logical protocol for communication. It is responsible for designating the structure of messages, the format of message serialization, and the semantics of message exchange. This layer is also responsible for authentication, versioning, and configuration negotiations.
Transports
Transports move groups of bytes (often called packets) from one endpoint to another.
A transport can be stateless, such as UDP, where each packet is sent fire-and-forget. In a stateless transport, any tracking (even determining if delivery was successful) must be implemented by a higher layer. Alternatively, a transport can be stateful, like TCP, where reliability and data integrity are part of the transport itself. Stateful transports may also keep track of the order in which multiple packets are sent to the same destination. If subsequent packets are delivered in the same order they were sent then the transport is called ordered (otherwise its unordered).
A transport can be connectionless (again, like UDP) where the destination address must be provided each time for each packet. Or, a transport can be connection-oriented (like TCP) where a unique label is first established between the two endpoints to represent a specific pairing. An appropriate label might be a name or a number. All subsequent packets sent on the connection are then automatically delivered to its destination with further reference to its address. Connection-oriented transports usually allow for multiple simultaneous independent connections between the same two endpoints (each having a different label), thus allowing multiple independent communication streams, each with an independent lifetime.
Transports can even be nested, where one transport is built on top of another. For example QUIC is a reliable, ordered, connection-oriented transport built over UDP which is an unreliable, unordered, connectionless transport. Steam Networking is a transport with multiple implementations over several different transports including UDP and TCP.
Not all transports use the network. For instance, a memory-based transport can be constructed from a pair of FIFO queues pointing in opposites directions. Memory-based transports are often used to communicate between threads in a multithreaded program. A shared-memory transport can be built from a block of memory mapped simultaneously into the memory address space of two different processes running on the same machine. Such a transport is commonly used for interprocess communication (IPC). A bidirectional pipe is another common transport used for IPC between a child process and its parent.
The benefit of separating the Transport Layer from the Session Layer is that we can choose different transports depending on the distance and communication medium between the endpoints. If the two endpoints are in the same process then a memory-based transport will be faster and far more efficient than a network one. If two endpoints are on different machines, then a network transport will be required, but we might choose TCP if the endpoints are both on a LAN or choose Steam Networking if the two endpoints are behind firewalls.
Promise RPC, like many message passing systems, implements a variety of transports out-of-the-box. This provides flexibility in performance and reachability that allows our games to achieve efficient communication between all their components wherever they may run (in-process, out-of-process, or across the network).
Sessions
A Session implements the logical protocol for communication between two parties. A session defines the shape and meaning of the messages that make up a message passing system. The session’s logic defines how these messages are encoded into bytes for transmission over the chosen transport. Messages fall into two broad categories:
-
Control Messages:
Control messages are exchanged privately by the session implementation at both endpoints. They are used to negotiate configurational and operational information used by the message passing system to maintain its internal state. Control messages may appear directly in the main application message ordering, or they may be sent and received using an alternate ordering that is independent of application interactions. When sent using an alternate ordering they are sometimes called out-of-band (OOB) messages. -
(Application) Messages:
All other messages are Application Messages (or simply messages). Application messages carry the data and methods that make up an RPC system. In all of our previous posts in this series we have discussed the messages in a message passing system. These are those messages. A message passing system often works in conjunction with a serialization library that provides an extensibility point that allows programs to define their own data contracts and methods for inclusion within an application message as defined by the session implementation. (We’ll talk more about the MSC Serialization library in a future post.)
Though there are many Transport implementations, there only needs to be a single Session implementation. The Session implementation is what separates one message passing system from another. In a program, the message passing system maintains a set of tables representing the state of communication between all of the connected parties including a collection of zero or more session instances. Each session instance represents an active communication between the current process and another party. Each instance consists of two endpoints (one representing the current process and the other its remote peer), a transport instance (which provides a mechanism for the two endpoints to communicate bidirectionally), and any additional state that is unique to the session.
Session Establishment
When a new session is created the two parties need to agree on a number of things before they can communicate further:
- Each endpoint MUST be reachable through the transport by their peer. If the transport is connection-oriented then a connection must first be established. If the endpoints must share resources, like in-memory queues or a shared memory block, then references to these resources must be exchanged. How these things happen is transport-dependent.
- The version of the session protocol to use (if there is more than one available) MUST be established. If the two parties are not using compatible versions of the session protocol then they will not understand each other’s messages and further communication will be impossible. Some session implementations may support multiple versions, in which case, the two parties will identify and agree on a mutually acceptable one to use for this session.
- The parties (may optionally) identity themselves through an agreed upon Authentication mechanism. In Promise RPC authentication is required (we’ll talk more about MSC Identity in a future post), but some message passing systems support unauthenticated sessions.
- Lastly, the parties may optionally exchange additional configuration information. In Promise RPC this includes the Root Capability.
We can say that a session is initiated when these four steps are begun. If any of these steps fails then the session is immediately terminated. For instance, if the transport fails to exchange packets between the parties (where failure is defined by the transport itself) then the session is terminated. If the two parties cannot agree on a protocol version then the session is terminated. If the parties fail to successfully authenticate each other, or if either party refuses the session with the remote peer based on their presented identity then the session is terminated. And lastly, if the two parties cannot agree on the appropriate configuration then the session is terminated.
If all of these steps succeed, then we say that the session is established. Once established, the parties are free to send messages as defined by the agreed upon version of the session protocol. All the messages we have talked about in previous post happen at this point.
Session Scopes
Though sessions can be created individually, in practice, this rarely happens. More commonly sessions are created and terminated automatically in the context of a larger semantic scope within a program. For instance, when another SIP is launched in the same process then a session between the launching SIP and the new SIP is established automatically and the corresponding root capability is returned to each. Similarly, if a child process is launched then a session between it and its parent is established automatically by the process creation library. A SIP represents a kind of scope of sessions, each session connecting that SIP to other SIPs. When a SIP terminates then, naturally, all of its sessions are also terminated at the same time, automatically.
Network sessions are also typically bounded by a larger scope. For example, a game might start a multiplayer mode in which it will connect through the network to the game clients of other players. The game might create a scope during which it will listen on a port for incoming network connections, accept those connections, and negotiate sessions with the connecting parties. The game will expose a root capability related to that multiplayer game instance for all connections established within that scope. If the local user ends or leaves the multiplayer game then the corresponding network scope in its entirety will also end. All of the sessions associated with other players in that multiplayer game instance will be terminated at the same time, automatically.
Because of this kind of scoping, we rarely create individual sessions directly. Instead we work with scoping
abstraction, like the TcpFactory
scope, which create sessions automatically in response to higher
level interactions like connecting to a game server or receiving an incoming connection from a game client.
Though ending a larger scope is the most common way to destroy sessions implicitly, most transports also provide an
explicit mechanism for external cancellation. For instance, the Promise RPC TCP transport accepts a
CancellationToken
that, when cancelled, aborts the underlying transport terminating the attached
session explicitly. The same token can be passed to a set of sessions to define an application-specific group all with
the same lifetime.
Session Termination
So, at some point a session is terminated. What happens then? To reason about what we think should happen let’s think about the kinds of values that are influenced by a session’s lifetime. We have called these values eventual values in previous posts, because their result is eventually resolved when the activity computing them completes. We have seen several types of values:
- Void Promises:
Track the completion of an activity. They have only avoid
value, but may resolve to an error if the computation failed. - Data Promises:
Track the completion of an activity and resolve to a data value that the computation produces as its return value. May resolve to an error if the computation failed to produce a result. - Proxy:
Track the completion of an activity producing a capability and provide access to the methods of that capability.
When holding a promise value, we can pipeline additional computations by using async
activities and the await
keyword (or the When
method). An async
activity returns another eventual value as its return value. Similarly,
proxies allow for pipeling method calls immediately upon their creation (even before the proxy itself has been resolved)
each producing additional eventual values as their return values. Conceptually, we can think of the composition of all
these speculative computations as forming directed graphs of eventual computation whose edges execute on-demand as the
eventual values forming its nodes resolve over time.
An eventual value is said to span a session when a consumer of that value lies on one side of a session and the producer of the result lies on the other. A capability will span a session (even after the activity that produced it has completed) if the target object it references lies on the other side (i.e. is remote). When an eventual value is consumed on the local side of a session and produced on the other, we call it an incoming value. When produced on the local side and consumed on the other, it is called an outgoing value.
When a session is terminated then incoming values that spans that session can never be resolved since there is no
longer any way to communicate their outcome. All such incoming eventual values are therefore immediately Aborted
locally when a session is terminated. An abort is an error outcome similar to a cancellation which resolves
outstanding eventual values to the well-known AbortedException
. Similarly, all incoming
capabilities that span a session are Revoked upon termination. A revoked capability will produce already aborted
results for all subsequent method calls.
In this way, any pending speculative computational graph will come to know of the aborts (and the session termination). Aborts can be explicitly handled, as with any error, identified by their unique exception type, but often all errors are allowed to automatically coalesce and collapse the computational graph until they can be handled (or written to logs for diagnostic purposes) higher up the stack. Coalescing aborts and errors generally simplifies programming and improves correctness by reducing the total number of states a program must handle.
Session aborts and revocations complete the lifecycle of an eventual value. All eventual values are guaranteed to resolve when either (a) the activity computing them completes, (b) the activity computing them fails with an error, or (c) the session connecting a consumer to its producer is terminated.
Conclusion
In this post we talked about transports and sessions. We examined how sessions are created and what kinds information are exchanged during session initiation. We looked at various forms of session grouping and lifetime scoping. Lastly, we looked at session termination and how that impacts eventual values that span sessions.
This is the last post in our series focused on the MSC RPC system. Until next time, code on!
Previous
Read the previous post in this series.
Feedback
Write us with feedback.