Client Communication
In the previous chapter, we defined a hello provider and then run it
immediately using run_session_with_result. However in practical applications,
our Ferrite programs will typically have more complex session types, and
we would want to run multiple programs in parallel that communicate with
each others.
To demonstrate that, instead of running hello_provider directly, we can
define a hello_client that communicates with a provider of the Hello
protocol, and then link it with hello_provider.
First, we define hello_client to have the following type:
let hello_client: Session<ReceiveChannel<Hello, End>> = ...
Our hello_client has the Rust type Session<ReceiveChannel<Hello, End>>,
indicating that it is a Ferrite program offering the session type
ReceiveChannel<Hello, End>. Here we encounter a new session type in the form
ReceiveChannel<A, B>, which is used to receive a channel of session type
A offered by some provider, and then continue offering session type B.
Channels
Ferrite allows sending and receiving of channels, which represent the client
end point to communicate with a provider that offers the channel's session type.
At high level, we can think of receiving a channel using ReceiveChannel<A, B>
is similar to plain Rust functions with function traits like FnOnce(A) -> B.
In other words, ReceiveChannel is the equivalent of function types in Ferrite.
There is however one important distinction of ReceiveChannel from regular
Rust functions. That is channels received from ReceiveChannel must be
used linearly, i.e. they cannot be ignored or dropped.
We will revisit this property in later chapters to see why linearity is
important, and why bare Rust code is not sufficient to support linearity.
Now back to our hello_client example. The session type
ReceiveChannel<Hello, End> indicates that hello_client is receiving
a channel of session type Hello, and then terminates with End.
To implement such a session type, we can implement hello_client as follows:
let hello_client: Session<ReceiveChannel<Hello, End>> =
receive_channel(|a| {
receive_value_from(a, move |greeting| {
println!("Received greetings from provider: {}", greeting);
wait(a, terminate())
})
});
Our hello_client body looks slightly more complicated than hello_provider.
To understand what's going on, we will go through each line of
hello_client's body. Starting with receive_channel:
receive_channel(|provider| { ... })
To match the final session type ReceiveChannel<Hello, End> that is
offered by hello_client, we use receive_channel to receive a
channel of session type Hello, and then binds it to the channel variable
a. This is similar to a Rust function accepting an argument
and bind it to a variable.
Inside the continuation ..., since we have received the Hello channel
already, we will continue to offer the session type End. To do that,
we just need to eventually terminate hello_client. However we cannot
terminate hello_client just yet, because the channel variable a
is linear, and we must fully consume it before we can terminate.
Recall that Hello is a type alias, so the actual session type of
the channel variable a is SendValue<String, End>.
But instead of having to offer that, we are acting as the client
to consume the session type SendValue<String, End>. Since the provider
is expected to send a String value, as a client we are expected to
receive a String value from the provider. We can do that using
receive_value_from:
receive_value_from(provider, move |greeting| {
println!("Received greetings from provider: {}", greeting);
...
})
We use receive_value_from to receive a value sent from the a
channel, and then bind the received String value to the Rust variable
greeting. We then print out the value of greeting using println!.
Following that, in the continuation ..., the session type
of a changes from SendValue<String, End> to
become End.
Unlike regular Rust variables, each time we interacts with a channel variable, the session type of the channel variable is updated to its continuation session type. Since Ferrite channels are linear, we have to continuously interact with the channel until it is fully terminated.
After calling receive_value_from, we have the channel variable
a with session type End, and we need to offer the session
type End by terminating. But we can't terminate just yet, because
End simply indicates that the provider will eventually terminates,
but may not yet been terminated. Hence we would first have to wait
for a to terminate using wait:
wait(provider, terminate())
We use wait to wait for the provider on the other side of a
channel to terminate. After that, the a channel is discarded,
and we don't have anymore unused channel variable. With that, we
can finally terminate our program using terminate().
Linking Provider With Client
We have now defined a hello_client program that can accept channel
from any provider offering the session type Hello.
In theory, we can call hello_client with any provider that offers
Hello, not just hello_provider.
To link hello_client specifically with hello_provider, we have to
explicitly ask Ferrite to perform the linking. This can be done
using apply_channel:
let main: Session<End> = apply_channel(hello_client, hello_provider);
The apply_channel construct is provided by Ferrite to link a
client Ferrite program of session type ReceiveChannel<A, B>
with a provider Ferrite program of session type A,
resulting in a new Ferrite program of session type B
as the result.
We can think of the form apply_channel(f, x) as being similar similar to
regular function application in the form f(x). With f having the Rust type
FnOnce(A) -> B and x having the Rust type A, the result type of applying
x to func would be B.
Run Session
After applying hello_provider to hello_client, we now have a single main
program. When main is executed, it will run both hello_provider and
hello_client in parallel, and establish a communication channel between
the two processes.
Since main has the session type End, we can use the Ferrite construct
run_session to run the program:
run_session(main).await;
run_session accepts any Ferrite program offering the session type End,
executes the program, and wait for it to terminate. run_session is
similar to run_session_with_result, other than it does not expect
the Ferrite program to send back any Rust value as the result.
Full Program
Putting everything together, we now have our second hello world program
that is made of a hello_provider and a hello_client communicating
with each others.
use ferrite_session::prelude::*;
type Hello = SendValue<String, End>;
#[tokio::main]
async fn main()
{
let hello_provider: Session<Hello> =
send_value("Hello World!".to_string(), terminate());
let hello_client: Session<ReceiveChannel<Hello, End>> =
receive_channel(|a| {
receive_value_from(a, move |greeting| {
println!("Received greetings from provider: {}", greeting);
wait(a, terminate())
})
});
let hello_client_partial: PartialSession<
HList![SendValue<String, End>],
End,
> = receive_value_from(Z, move |greeting| {
println!("Received greetings from provider: {}", greeting);
wait(Z, terminate())
});
let main: Session<End> = apply_channel(hello_client, hello_provider);
run_session(main).await;
}