Services

Services are Toit's way of communicating between different containers. They use an RPC mechanism that allows containers to call predefined methods across container boundaries. As such they are a natural way of separating out complex drivers (like the ones for cellular modems), so they can run in the own address spaces.

Architecture

When working with services, there are four main concepts:

  • a "service interface"
  • a "service provider"
  • a "service client"
  • a "service manager"

The service interface describes the API the service provides.

A provider class then implements the service interface and registers itself as a service provider.

A client class implements the actual RPC calls so that users of the service can interact with the provider as if it was a local object.

The service manager is responsible for connecting clients and providers, and for dispatching requests between them. It is part of the system.

Service interfaces

A service interface describes the functionality that a provider offers to clients. The interface is identified by a UUID, together with a major and minor version number. Each method furthermore gets a unique integer.

For example:

// service.toit.
import system.services show ServiceSelector

interface RandomGeneratorService:
  static SELECTOR ::= ServiceSelector
      --uuid="dd9e5fd1-a5e9-464e-b2ef-92bf15ea02ca"
      --major=0
      --minor=1

  generate limit/int -> int
  static GENERATE-INDEX ::= 1

The RandomGeneratorService.SELECTOR constant will bind a client of the service and the service implementation together. We typically just generate random UUIDs, because all we need is some notion of identity. The dd9e5fd1-a5e9-464e-b2ef-92bf15ea02ca constant was generated via https://www.uuidgenerator.net/.

The RandomGeneratorService.SELECTOR.major and RandomGeneratorService.SELECTOR.minor values represent the current version of the interface. The version is used during service discovery, because it is possible that a client will be trying to access a newer or older implementation of the service due to the fact that clients and implementations are decoupled and are likely to be updated independently of each other.

The interface furthermore adds information on what the method for index 1 (GENERATE-INDEX) should look like. The underlying RPC mechanism doesn't take advantage of this information. Provider and client implementations, on the other hand, should use this information to implement the interface, so they are compatible.

We currently manually assign indexes to methods, but you could imagine generating the interface definition from something like a protocol buffer service and have the indices automatically assigned. If you change the index of any existing method you should bump the major version, but if you only add new methods with previously unused indices, you can get away with just bumping the minor version.

In a similar vein, we are investigating ways to automatically generate the service provider and client code from the interface definition.

Service providers

A service provider is an object that provides the functionality that the service interface promises.

It registers itself with the service manager, and then listens for incoming requests. The service manager connects clients and providers, and dispatches requests between them.

For example, the aforementioned RandomGeneratorService could be implemented as follows:

import system.services show ServiceProvider ServiceHandler
import .service

class RandomGeneratorServiceProvider extends ServiceProvider
    implements ServiceHandler:

  constructor:
    super "test/random-generator" --major=7 --minor=9
    provides RandomGeneratorService.SELECTOR --handler=this

  handle index/int arguments/any --gid/int --client/int -> any:
    if index == RandomGeneratorService.GENERATE-INDEX:
      return generate arguments
    unreachable

  generate limit/int -> int:
    print "got request to generate a random number with limit $limit"
    return random limit

The RandomGeneratorServiceProvider class extends the ServiceProvider base clase which implements the communication with the service manager.

The arguments to the super call are the name of the service, and the version of the service interface. These are not used by the service manager, and are there mostly for debugging purposes. They are fundamentally a description of the provider.

The provides call (invoking a method on the super class) declares the selector this provider implements. There can be multiple calls to provides if the provider implements multiple service interfaces.

The provides invocation takes a ServiceHandler object as --handler argument. This object is responsible for handling incoming requests. In most cases, the provider itself will implement the ServiceHandler interface. However, especially for providers that implement multiple service interfaces, it can be useful, or even necessary, to have a separate handlers for each interface. For example, a temperature, humidity, and pressure sensor could implement three different service interfaces, and have a separate handler for each.

Note that the ServiceHandler interface is just describing a callback:

interface ServiceHandler:
  handle index/int arguments/any --gid/int --client/int -> any

The handle method implements the actual service. It switches on the index of the incoming request, and calls the appropriate method.

In this example the provider class actually implements the RandomGeneratorService interface, and we could explicitly state that by adding implements RandomGeneratorService to the class. However, to give the developer more flexibility in implementing the service, we generally don't add the implements clause.

Before a provider can be used, it must be registered with the service manager by calling install on the provider object.

Using a service provider

Typically, a service provider is installed in its own container where the main function simply installs the provider:

...

main:
  provider := RandomGeneratorServiceProvider
  provider.install

For testing (or on a desktop machine), it can also be useful to spawn the provider process from the client process:

...

main:
  spawn::
    provider := RandomGeneratorServiceProvider
    provider.install

  // Do client stuff here.

Service provider lifecycle

A service provider is a long-lived object. It is usually installed when the container starts, and stays alive until the container is forcefully stopped. In Artemis containers that run providers should be marked as background so they don't prevent the system to go to deep sleep.

For each client that connects to a provider, the on-opened method is called. Similarly, when a client disconnects, the on-closed method is called. These methods can be used to keep track of the number of connected clients, and to perform setup actions or cleanups.

For example, a driver that requires an external peripheral to be powered on could use the on-opened method to power on the peripheral, and the on-closed method to power it off again.

Service clients

A service client is an object that provides the service functionality to the client. It transparently forwards calls to the service provider.

Once the client has connected to a provider (with the help of the service manager), its main job consists of serializing the arguments and return values, and to map the method calls to the appropriate indexes.

For example, the aforementioned RandomGeneratorService could be implemented as follows:

import system.services show ServiceClient ServiceSelector
import .service

class RandomGeneratorServiceClient extends ServiceClient:
  static SELECTOR ::= RandomGeneratorService.SELECTOR

  constructor selector=SELECTOR:
    assert: selector.matches SELECTOR
    super selector

  generate limit/int -> int:
    return invoke_ RandomGeneratorService.GENERATE-INDEX limit

The RandomGeneratorServiceClient class extends the ServiceClient base clase which implements the communication with the service manager.

The super call takes a ServiceSelector object as argument. This object describes the service that the client wants to connect to. By default the selector from the RandomGeneratorService interface is used, but users can also provide their own selector as long as its compatible (the assert call checks this).

The generate method then implements the actual service. It calls the invoke_ method on the super class, passing the index of the method to call, and the arguments to the method. The invoke_ method then serializes the arguments, sends them to the service provider, and waits for the response. It then deserializes the response and returns it.

Using a service client

A client needs the a provider to be available. See how to use providers for more information.

The common way to use a service is to construct it and to open it. The open call contacts the service manager and finds a matching provider.

main:
  client := RandomGeneratorServiceClient
  client.open
  10.repeat:
    print "random = $(client.generate 100)"

Often, the service can also be in a lazy-initialized constant:

service_/RandomGeneratorServiceClient? ::= (RandomGeneratorServiceClient).open
    --if-absent=: null

The --if-absent block is invoked when we cannot find the requested service. You can provide a timeout if you're willing to wait a bit for the service to appear:

service_/RandomGeneratorServiceClient? ::= (RandomGeneratorServiceClient).open
    --timeout=(Duration --s=2)
    --if-absent=: null

Serialization

As part of the communication between clients and providers, messages are serialized in an efficient way. Messages must be either null, integers, booleans, floats, strings, byte arrays, lists or maps. Any type that is not one of these must be converted before calling invoke_. In that case, the provider and client must agree on how to encode the type.

For example, if we want to send more than one argument as an RPC message we must encode them first. By convention, we encode them as a list of the individual arguments.

For a more efficient message passing, byte arrays may be transferred directly, without copying. In that case, the original byte array is "neutered" and reset to an empty byte array. This means that the byte array cannot be used anymore after it has been sent.

Service resources and proxies

Sometimes it is useful for a service to let clients refer to resources allocated on their behalf. The resource lives with the service provider, and the client receives a handle to it. The system will then take care of cleaning up the resource when the client goes away.

Say, the provider can allocate a Die resource that the client can interact with, by calling roll on it. Let's assume, that the object must live on the provider. The provider gives the client a way to create the object, and then sends a "handle" to the client. Handles are simply integers, and the service is thus changed as follows:

interface RandomGeneratorService:
  ...
  create-die -> int
  static CREATE-DIE-INDEX /int ::= 2

  roll-die handle/int -> int
  static ROLL-DIE-INDEX /int ::= 3

The Die's roll method is integrated into the service interface, but the provider and client should abstract them away and move them onto the resource objects.

Resources on the provider

On the provider side a resource object should extend the ServiceResource base class:

import system.services show ServiceResource ServiceProvider

class DieResource extends ServiceResource:
  sides_/int ::= ?

  constructor .sides_ provider/ServiceProvider client/int:
    super provider client

  roll -> int:
    return 1 + (random sides_)

  on-closed -> none:
    // Handle cleanup.

The on-closed method is automatically called when the client closes the resource or if the client happens to go away. Instances of ServiceResource are automatically serializable, so it is possible to return them from the handle method in the service provider and the ServiceResource constructor takes care of registering them correctly, so they can be found later on future client method calls.

Let's add the new methods to the RandomGeneratorServiceProvider:

  handle index/int arguments/any --gid/int --client/int -> any:
    ...
    if index == RandomGeneratorService.CREATE-DIE-INDEX:
      // The 'arguments' parameter is equal to the number of sides.
      return DieResource arguments this client

    if index == RandomGeneratorService.ROLL-DIE-INDEX:
      // The 'arguments' parameter is set to the handle of the resource.
      die := (resource client arguments) as DieResource
      return die.roll

Note that we implement the create-die and roll-die functionality directly in the handle method. That's not necessary, but often convenient if it's as simple as in this case.

The provider also handles the ROLL-DIE-INDEX method by forwarding it to the corresponding DieResource instance.

Resources on the client

Clients receive resources as integers, representing the handle to the resource. They need to convert the handle to a resource proxy that can be used to call methods on the resource.

There is currently nothing in the service interface that defines how the proxy should look like. It's up to the implementation to create a good API for it.

A proxy should always extend the ServiceResourceProxy base class, which automatically closes the resources if it is garbage-collected:

import system.services show ServiceResourceProxy

class DieProxy extends ServiceResourceProxy:
  constructor client/ServiceClient handle/int:
    super client handle

  roll -> int:
    return (client_ as RandomGeneratorServiceClient).roll-die_ handle_

The proxy takes the corresponding client and handle as arguments. As can be seen, a handle is serialized as an int, but for the most part this can be ignored.

The proxy simply forwards its 'roll' method to the client which needs to add create-die and roll-die_ methods:

class RandomGeneratorServiceClient extends ServiceClient:
  ...
  create-die sides/int -> DieProxy:
    handle := invoke_ RandomGeneratorService.CREATE-DIE-INDEX sides
    proxy := DieProxy this handle
    return proxy

  roll-die_ handle/int -> int:
    return invoke_ RandomGeneratorService.ROLL-DIE-INDEX handle

Using a resource

With all that machinery in place, we can now use create resources through our client and call methods on them:

main:
  spawn::
    service := RandomGeneratorServiceProvider
    service.install

  client := RandomGeneratorServiceClient
  client.open
  die := client.create-die 6
  10.repeat:
    print "die roll = $(die.roll)"
  die.close

Resource notifications

While most interactions with resources follow a simple request-response pattern, it can be useful to be able to asynchronously notify users of a resource of certain events. The ServiceResource and ServiceResourceProxy classes have built-in support for this through notifications. A notification is any kind of serializable object sent from the resource to the proxy. The resources that take part in this must be marked notifiable at construction time:

class DieResource extends ServiceResource:
  ...
  constructor .sides_ provider/ServiceProvider client/int:
    super provider client --notifiable
  ...

Even though you probably wouldn't expect dice to ping you periodically, we can now experiment with the behavior by adding periodic notifications like this:

class DieResource extends ServiceResource:
  sides_/int ::= ?
  task_ := null

  constructor .sides_ provider/ServiceProvider client/int:
    super provider client --notifiable
    task_ = task:: notify-periodically

  roll -> int:
    return random sides_

  notify-periodically -> none:
    while not Task.current.is-canceled:
      sleep --ms=(random 500) + 500
      notify_ 87

  on-closed -> none:
    task_.cancel

The notifications will show up on the proxy side through calls to on-notified_:

class DieProxy extends ServiceResourceProxy:
  ...
  on-notified_ notification/any -> none:
    print "got notified: $notification"
  ...

You'll need to wait a bit in main for the notifications to start showing up:

main:
  ...
  die := client.create-die 6
  10.repeat:
    print "die roll = $(die.roll)"
  sleep (Duration --s=10)
  die.close

Example: Network by proxy

We have used the service framework to allow providing a full network implementation from a separate container. The core of this is the NetworkService interface and the associated NetworkServiceClient:

https://github.com/toitlang/toit/blob/master/lib/system/api/network.toit

We use them to build proxying sockets like SocketResourceProxy_ that forward reads and writes to the network service:

class SocketResourceProxy_ extends ServiceResourceProxy:
  static WRITE-DATA-SIZE-MAX_ /int ::= 2048
  ...
  write data from=0 to=data.size -> int:
    to = min to (from + WRITE-DATA-SIZE-MAX_)
    return (client_ as NetworkServiceClient).socket-write handle_ data[from..to]
  ...

You can find all the helpers in the main repository:

https://github.com/toitlang/toit/blob/master/lib/system/base/network.toit

All in all, this allows a cellular driver to provide a network to all other apps that a blissfully unaware that their data flows through a good old-fashioned sequence of AT commands.