PANELITH DOCS Open the interactive docs ↗

Guide · Panelith 0.1.0 · plain HTML edition; the interactive edition has search and live examples

Sessions, state and push

On this page 4 sections
  1. Sessions
  2. State mirroring and restarts
  3. Push
  4. Background work from a handler

A public app cannot have one shared pane table. Pane ids are generated by the browser, so two visitors' first panes are both c1, and a shared table would hand one visitor's board to the other. Every browser therefore gets a session, and handlers never see it.

Sessions#

The first request, the manifest, sets an HttpOnly, SameSite=Lax cookie named panelith_<app id>, holding a random 256-bit id. The session owns the visitor's pane table and the queues of their open event streams. The server wraps every request in App.bind(session), so a handler's ctx.app.panes and app.push are that visitor's without the handler knowing sessions exist. A session also has a free data dict for per-visitor scratch that does not belong in any one pane's state.

Sessions are kept in memory and bounded two ways. A session with no open stream that has been idle for session_ttl seconds (two hours by default) is dropped on the next sweep, and at most max_sessions are kept, the idlest going first. When a visitor returns after their session was dropped, nothing is lost that matters: the browser kept the layout and the pane state, re-registers every pane with its saved copy, and the server adopts it.

class Docs(App):
    session_ttl = 60 * 60     # forget a visitor an hour after their last request
    max_sessions = 500

State mirroring and restarts#

Two rules keep the server's copy of the world honest.

Every set_state and patch_state a handler or a push produces is applied to the server's record before it is sent. That is why a handler can read ctx.pane.state and get what the user sees, and why ctx.state_of(other) works for a pane the handler has never touched. It is also where view() runs: the derived blocks are merged into the outgoing action and into the record at the same point.

On registration the rules decide who wins. A pane the server knows that re-registers as a restore keeps the server's state, because the server may have moved on. A pane the server does not know, whether because of a restart or an expired session, is adopted from the browser's copy and initial_state is skipped. And an event for a pane the server has never seen rebuilds the record from the tab list the shell sent, so a stale tab works again rather than erroring.

Push#

/api/events is a plain server-sent-events stream. The shell opens it after the manifest loads and reconnects by itself if it drops, which is the browser's built-in behaviour for EventSource and the reason SSE was chosen over a WebSocket. Every twenty seconds without traffic the server sends a comment line as a keepalive so proxies do not time the connection out.

From Python, app.push(*actions) sends actions to the current visitor's open tabs; app.broadcast(*actions) sends to every connected visitor. The Server pane is refreshed by a background thread the app starts in on_start:

class Server(Pane):
    kind = "server"
    label = "Server"
    glyph = "globe"
    widget = "keyvalue"
    tip = "Facts about the Python process, pushed to you every few seconds"

    pushes = 0  # class-level: shared by every visitor on purpose

    def items(self, ctx) -> dict[str, Any]:
        app = ctx.app
        return {
            "Python": platform.python_version(),
            "Visitors connected": sum(1 for s in app.sessions if s.connected),
            "Sessions kept": len(app.sessions),
            "Panes in your session": len(app.panes),
            "Pushes sent": Server.pushes,
            "Server time": time.strftime("%H:%M:%S"),
        }

    def initial_state(self, ctx):
        return widgets.keyvalue(self.items(ctx))

    @event("refresh")
    def refresh(self, ctx, payload):
        return a.set_state(ctx.cid, widgets.keyvalue(self.items(ctx)))
class Docs(App):
    PUSH_EVERY = 5.0

    def on_start(self):
        threading.Thread(target=self._ticker, daemon=True).start()

    def _ticker(self):
        server = self.kind("server")
        while True:
            time.sleep(self.PUSH_EVERY)
            for sess in self.sessions:
                if not sess.connected:
                    continue
                with self.bind(sess):
                    refs = [r for r in self.panes.values() if r.kind == "server"]
                    if refs:
                        ctx = self.context(s.ClientContext())
                        self.push(*[a.set_state(r.cid, widgets.keyvalue(server.items(ctx))) for r in refs])

bind(session) is how code outside a request says which visitor it is working for. Inside the block, self.panes is that visitor's pane table and self.push reaches that visitor only. Pushed state actions are mirrored into the server's records exactly like returned ones.

Background work from a handler#

A handler that starts a thread must not let the thread's first push race the reply's own state actions. app.after_reply(fn) runs fn once the reply has been applied to the session, so a worker started there sees, and builds on, the state the handler returned:

@event("run")
def run(self, ctx, payload):
    cid = ctx.cid
    def work():
        result = slow()
        ctx.app.push(a.patch_state(cid, status="done", stdout=result))
    ctx.app.after_reply(lambda: threading.Thread(target=work, daemon=True).start())
    return a.patch_state(cid, status="running")

Warning
Sessions are in memory, so one process serves an app. A deployment with several workers would need sticky sessions or a shared session store, neither of which exists yet.