# Managing Clients of Perspectives

## Overview

As we've watched several applications build on top of `twisted.cred` and Perspective Broker, we've seen several models of interaction between `twisted.cred.perspective.Perspective`s and the clients which connect to them. In the future, these patterns may be codified by support classes in the framework, but for now we shall just document them here. They are:

Clientless Perspective

  in which the Perspective remains oblivious of the fact that clients attach to it.

Single Client

  in which no more than one client is attached to a Perspective. There are two sub-categories, based on how this limit is enforced:

  - excess connections from clients are refused (seen in CVSToys).
  - new connections replace the old (seen in `twisted.words`).
Multiple Client

  in which the Perspective keeps a list of clients instead of a single one. The clients all share this Perspective, the actions of any may effect the perspective for all. (Seen in `twisted.manhole`.)

Anonymous Clients

  where any number of clients may connect to the service using a particular perspective name, but clients may not effect one another and any changes to the perspective do not persist.

explain or point to how clients get attached to perspectives (pb.connect, guard(?)).

## Clientless Perspective

    from twisted.cred import perspective

    class ClientlessPerspective(perspective.Perspective):
        """I have no notion of 'client' whatsoever.

        I may still have methods which carry out actions and/or return
        objects (perspective_ methods and Referenceable objects, in the
        case of PB), but I take no notice when clients attach or detach
        from me.  Nor do I push data to clients; the only data they
        receive from me is the return values of any methods they choose to
        call.
        """
        pass

Source listing - listings/pclients/clientless.py

Needless to say, the `ClientlessPerspective` is not ideal for all applications. A common model for network applications is to have the client functioning as an observer of messages distributed by the server (i.e. chat services, build failure notification, etc.). For this purpose, the server needs maintain a list of observers on reachable clients. The Perspective class provides facilities for this, offering `twisted.cred.perspective.Perspective.attached`() and `twisted.cred.perspective.Perspective.detached`() methods which are called with references to clients connecting or disconnecting from the service.

## Single Client

    from twisted.cred import error, perspective

    class PerspectiveInUse(error.Unauthorized):
        """Raised when a client requests a perspective already connected to another.
        """
        # XXX: Is there any information this exception should carry, i.e.
        #   the Perspective in question.
        #   the client it's currently attached to.
        #   the Identity which attached it.

    class SingleClientPerspective(perspective.Perspective):
        """One client may attach to me at a time.

        If another client tries to attach while a previous one is still connected,
        it will encounter a PerspectiveInUse exception.

        @ivar client: The client attached to me, if any.  (Passed by the
            client as the I{client} argument to L{pb.connect}.
        @type client: L{RemoteReference}
        """

        client = None

        def attached(self, ref, identity):
            if self.client is not None:
                raise PerspectiveInUse
            self.client = ref

            # Perspective.attached methods must return a Perspective to tell the
            # caller what they actually ended up being attached to.
            return self

        def detached(self, ref, identity):
            assert ref is self.client, "Detaching something that isn't attached."
            del self.client

        def sendMessage(self, message):
            """Send a message to my client.

            (This isn't a defined Perspective method, just an example of something
            you would define in your sub-class to use to talk to your client.)
            """
            # Using 'assert' in this case is probably not a good idea for real
            # code.  Define an exception, or choose to let it pass without comment,
            # as your needs see fit.
            assert self.client is not None, "No client to send a message to!"
            # Nor is the 'message' method defined by twisted.cred -- your client
            # can have any interface you desire, any type of object may be passed
            # to 'attached'.
            self.client.message(message)

        def __getstate__(self):
            state = styles.Versioned.__getstate__(self)
            # References to clients generally aren't persistable.
            try:
                del state['client']
            except KeyError:
                pass
            return state

Source listing - listings/pclients/single.py

Here's a more complex example, using the more specialized `twisted.spread.pb.Perspective.brokerAttached` method of Perspective Broker.

    from twisted.cred import error
    from twisted.spread import pb

    class PerspectiveInUse(error.Unauthorized):
        """Raised when a client requests a perspective already connected to another.
        """
        # XXX: Is there any information this exception should carry, i.e.
        #   the Perspective in question.
        #   the client it's currently attached to.
        #   the Identity which attached it.

    class SingleClientPerspective_PB(pb.Perspective):
        """One client may attach to me at a time.

        With verbose logging and some detection for lost connections.
        """

        client = None

        # This example is from cvstoys.actions.pb.Notifiee.

        def brokerAttached(self, ref, identity, broker):
            log.msg("%(identityName)s<auth%(authID)X>@"
                    "%(peer)s<broker%(brokerID)X> "
                    "requests attaching client %(client)s to "
                    "%(perspectiveName)s@%(serviceName)s" %
                    {'identityName': identity.name,
                     'authID': id(identity.authorizer),
                     'peer': broker.transport.getPeer(),
                     'brokerID': id(broker),
                     'client': ref,
                     'perspectiveName': self.getPerspectiveName(),
                     'serviceName': self.service.getServiceName(),
                     })

            if self.client:
                oldclient, oldid, oldbroker = self.client
                if oldbroker:
                    brokerstr = "<broker%X>" % (id(oldbroker),)
                else:
                    brokerstr = "<no broker>"
                log.msg(
                    "%(pn)s@%(sn)s already has client %(client)s "
                    "from %(id)s@%(broker)s" %
                    {'pn': self.getPerspectiveName(),
                     'sn': self.service.getServiceName(),
                     'id': oldid.name,
                     'client': oldclient,
                     'broker': brokerstr})

                # Here's the part that checks is the currently connected client
                # has a stale connection.  It *shouldn't* happen, but if it did,
                # it would suck to not be able to sign back on because the system
                # wouldn't believe you were logged off.
                if (not oldbroker) or (oldbroker.connected and oldbroker.transport):
                    if oldbroker:
                        brokerstr = "%s%s" % (oldbroker.transport.getPeer(),
                                              brokerstr)
                    log.msg("%s@%s refusing new client %s from broker %s." %
                            (self.getPerspectiveName(),
                             self.service.getServiceName(),
                             ref, broker))
                    raise PerspectiveInUse("This perspective %r already has a "
                                           "client.  (Connected by %r from %s.)"
                                           % (self, oldid.name, brokerstr))
                elif oldbroker:
                    log.msg("BUG: Broker %s disconnected but client %s never"
                            "detached.\n(I'm dropping the old client and "
                            "allowing a new one to attach.)" %
                            (oldbroker, self.client,))
                    self.brokerDetached(self, self.client, identity, oldbroker)
                    # proceed with normal attach
            #endif self.client
            self.client = (ref, identity, broker)
            return self

        def detached(self, ref, identity):
            del self.client

        def sendMessage(self, message):
            """Send a message to my client.

            (This isn't a defined Perspective method, just an example of something
            you would define in your sub-class to use to talk to your client.)
            """
            # Using 'assert' in this case is probably not a good idea for real
            # code.  Define an exception, or choose to let it pass without comment,
            # as your needs see fit.
            assert self.client is not None, "No client to send a message to!"
            # This invokes remote_message(message) on the client object.
            self.client.callRemote("message", message)

        def __getstate__(self):
            state = styles.Versioned.__getstate__(self)
            try:
                del state['client']
            except KeyError:
                pass
            return state

Source listing - listings/pclients/single-pb.py

Here's an example which attempts to enforce the single-client limit in a different manner:

    from twisted.spread import pb

    class SingleClientWithAKickPerspective(pb.Perspective):
        """One client may attach to me at a time.

        If a new client requests to be attached to me, any currently
        connected perspective will be disconnected.
        """

        # This example is from twisted.words.service.Participant.

        client = None

        def __getstate__(self):
            state = styles.Versioned.__getstate__(self)
            try:
                del state['client']
            except KeyError:
                pass
            return state

        def attached(self, client, identity):
            if self.client is not None:
                self.detached(client, identity)
            self.client = client
            return self

        def detached(self, client, identity):
            self.client = None

            # For the case where 'detached' was called by 'attached' wanting to
            # kick someone off, is this all we need to do?  I'm afraid not --
            # no-one ever told the client that it had been detached!  So the
            # client, which will still have a reference to this perspective until
            # its broker dies, will continue to execute methods on it, will
            # continue to get results returned by those methods.  It just won't get
            # events sent to self.client.  Meanwhile, the newly attached client
            # will probably be confused, because its Perspective is doing things
            # the new client did not ask it to do and it thinks it is the only
            # thing connected.

Source listing - listings/pclients/single-kick.py

What do we learn?

- `twisted.words`, the only system that ships with Twisted which uses Perspectives in Perspective Broker, is a lousy example. ;)
- `detached` is not sufficient to kick a client off.

In fact, it turns out to be hard to kick a client off a Perspective, because there's no way you can force them to lose the reference they hold. The best you can do is define a goodbye method on the client interface and hope they honor and implement it correctly. If the client is talking to you over a transport (as is the case with Perspective Broker), you can kick them off somewhat forcibly by closing the transport, but this is bad practice for several reasons. First, it requires knowing how to access and shut down the transport, which breaks some abstractions. Second, it's a damned inconsiderate thing to do if that transport may have been also carrying traffic for other services.

I can think of several lines along which you could develop from here:

- Add a way to mark references as invalid to Perspective Broker. Then when the client tried to call a method on the perspective, it would get an exception saying it wasn't allowed to talk to that object any more. Obviously, this only works for Perspective Broker, and not for other systems which might access your Service.
- Forget about the whole idea of kicking clients off. Anyone who has gained a reference to the Perspective through the Authorizer has a right to keep it. If another client wants to attach before the first one is done, you'll just have to accommodate them both. Which leads us to...

## Multiple Client

    from twisted.spread import pb

    class MultipleClientPerspective(pb.Perspective):
        """Many clients may use this Perspective at once."""

        # This example is from twisted.manhole.service.Perspective.

        def __init__(self, perspectiveName, identityName="Nobody"):
            pb.Perspective.__init__(self, perspectiveName, identityName)
            self.clients = {}

        def attached(self, client, identity):
            # The clients dictionary is really only used as a set and not as a
            # mapping, but we go ahead and throw the Identity into the value slot
            # because hey, it's there.
            self.clients[client] = identity
            return self

        def detached(self, client, identity):
            try:
                del self.clients[client]
            except KeyError:
                # This is probably something as benign as the client being removed
                # by a DeadReferenceError in sendMessage and again when the broker
                # formally closes down.  No big deal.
                pass

        def sendMessage(self, message):
            """Pass a message to my clients' console.
            """
            for client in self.clients.keys():
                try:
                    client.callRemote('message', message)
                except pb.DeadReferenceError:
                    # Stale broker.  This is the error you get if in the process
                    # of doing the callRemote, the broker finds out the transport
                    # just died, or something along those lines.  So remove that
                    # client from our list.
                    self.detached(client, None)

        def __getstate__(self):
            state = styles.Versioned.__getstate__(self)
            state['client'] = {}
            return state

Source listing - listings/pclients/multiple.py

## Anonymous Clients

Last item on the list: Anonymous perspectives. One way to do it would be to use a `ClientlessPerspective` or `MultipleClientPerspective` and promise to not have any methods that stored state on or otherwise modified the perspective instance so no client can interfere with any other. Another way to do it, without that restriction, would be to use disposable Perspectives:

    from twisted.cred import perspective

    class AnonymousPerspective(perspective.Perspective):
        """Define this as a perspective with whatever capabilities you feel safe
        to give to anonymous users.
        """

    class UnattachablePerspective(perspective.Perspective):
        """I am a special class of Perspective that can never be attached to.

        I can be stored in a Service's collection of perspectives and
        placed on an Identity's keyring, but the client will never obtain
        a reference to me.  Instead, they'll get back a single-use
        perspective, of the class named by L{disposablePerspectiveClass}.
        """

        # The code in this example is ALL NEW (and thus not been field-tested or
        # mother-approved), but might be used in CVSToys or BuildBot, where we're
        # publishing public read-only messages and aren't interested in maintaining
        # accounts for the people who sign on to read them.

        disposablePerspectiveClass = AnonymousPerspective
        _counter = 0

        def attached(self, client, identity):
            name = "%s#%d" % (self.name, self._counter)
            self._counter = self._counter + 1
            p = self.disposablePerspectiveClass(name)
            p.setService(self.service)
            # You might add p to the Service's cache of perspectives at this point,
            # so s.getPerspectiveNamed(p.getPerspectiveName()) would work.  Just as
            # long as you remember to remove it from the cache when it's through,
            # so you don't leak memory.
            return p.attached(client, identity)

        def detached(self, client, identity):
            assert 0, "How can this be?  Nothing should have ever attached to me."

Source listing - listings/pclients/unattachable.py

If you wanted to have all access to a Service be anonymous, you could make a service like this:

    class AnonymousService(service.Service):
        class perspectiveClass(UnattachablePerspective):
            disposablePerspectiveClass = MyAnonymousPerspective
            # XXX: does this lazy subclassing work, or do you end up with a class
            # that isn't persistable because it's not module-level or something?

But to make only certain log-ins anonymous:

    theService = Service(serviceName)
    anonymousPerspective = UnattachablePerspective("anonymous")
    anonymousPerspective.disposablePerspectiveClass = MyAnonymousPerspective
    theService.addPerspective(anonymousPerspective)
    # Set anonymous's password to the empty string:
    anonymousPerspective.makeIdentity('')

## Feedback

That's all for today, thanks for playing. We'd like to hear about how you're using this code! Questions, comments, reservations? Please send them to twisted-python@twistedmatrix.com.

Index