Sessions
A PluginSession is one isolated execution scope: its own service registry, its own event bus, its own context,
its own enabled-plugin set. The typical lifetime is one document, one chat, one tab, one workspace, one sandbox.
This page covers session isolation and the rules for talking across scopes. For the runtime that spawns sessions and the settings reconciliation that flows through them, see Runtime.
A session
Section titled “A session”final session = await runtime.createSession( contextFactory: (registry, sessionBus, globalBus) { return SessionPluginContext( registry: registry, bus: sessionBus, globalBus: globalBus, extras: {'test': 'value'}, ); },);Under the hood, createSession(...):
- Parses session-scoped service overrides.
- Determines enabled session plugins.
- Creates a fresh session registry.
- Runs
registeron each enabled session plugin. - Materializes wildcard overrides onto the winners.
- Builds the session context.
- Runs
attachon each enabled session plugin.
Disposal runs the reverse:
- Enabled session plugins run
detach. - The session bus is disposed.
- The session is removed from
runtime.sessions.
Two sessions sharing the same runtime never leak state into each other. They see the same global registry, but their service registries and event buses are independent instances. Opening a second document never disturbs the first.
Sessions stay sealed
Section titled “Sessions stay sealed”Each session’s registry is genuinely isolated from every other session’s.
A LazySingleton registered in a session’s register hook is built the first time it resolves
in that session. A different session asking for the same slot gets its own instance, lazily built
the first time it resolves there.
That isolation has practical consequences:
- Per-session caches do not bleed: one session can cache the previous query while another runs a fresh one.
- A misbehaving plugin that holds a
Streamopen or accumulates state cannot poison other sessions; close the session and that state goes with it. - Two implementations of the same slot can compete per session: settings can
give session A a
MockBackendfor testing and session B aRealBackend.
Closing a session disposes the session bus, runs every enabled session plugin’s
detach, and removes the session from runtime.sessions. Anything session-scoped
is cleaned up. Global plugins keep running.
Global bus and session buses are isolated
Section titled “Global bus and session buses are isolated”The global bus and each session bus are separate EventBus instances. A global
plugin’s context.bus IS the global bus; a session plugin’s context.bus is that session’s own bus.
They do not share handlers, and there is no automatic forwarding between them.
Cross-scope communication is explicit, in both directions.
Global plugin to all sessions
Section titled “Global plugin to all sessions”To broadcast to every active session, keep the broadcast logic in a StatefulPluginService<GlobalPluginContext>. The service has a bound context, so it can call sessions.emit safely while the global plugin remains the owner that registers it.
class ThemeService extends StatefulPluginService<GlobalPluginContext> { /// Broadcasts [theme] to every active session. Future<void> broadcast(Theme theme) async { await context.sessions.emit<AppThemeChanged>(AppThemeChanged(theme)); }}
class ThemePlugin extends GlobalPlugin<GlobalPluginContext> { @override PluginId get pluginId => const PluginId('theme');
@override void register(ScopedServiceRegistry registry) { registry.registerSingleton<ThemeService>( const ServiceId('theme_service'), () => ThemeService(), ); }}
// In your app shell, resolve and call:Future<void> applyTheme(PluginRuntime runtime, Theme currentTheme) async { final themeService = runtime.globalRegistry.resolve<ThemeService>( const ServiceId('theme_service'), ); await themeService.broadcast(currentTheme);}
class ThemeAwarePlugin extends SessionPlugin<SessionPluginContext> { @override PluginId get pluginId => const PluginId('theme_aware');
@override void attach(SessionPluginContext context) { on<AppThemeChanged>(context, (e) { // fires because ThemeService.broadcast used sessions.emit print('Theme changed to: ${e.event.theme.name}'); }); }}Emitting on context.bus from a global plugin only fires handlers registered on the global bus. Session plugins will not see it unless you go through sessions.emit (or some other explicit path).
Session plugin to global scope
Section titled “Session plugin to global scope”A session plugin that needs to reach global-scope handlers emits on context.globalBus:
/// Session plugin emits on globalBus to reach global-scope handlers.class DocumentSaved { /// The document identifier that was saved. final String documentId;
/// Creates a [DocumentSaved] event. const DocumentSaved({required this.documentId});}
/// Demonstrates emitting on globalBus from a session plugin context.Future<void> emitOnGlobalBus(SessionPluginContext context) async { await context.globalBus.emit<DocumentSaved>( event: const DocumentSaved(documentId: 'doc-42'), );}Global plugin to global plugin
Section titled “Global plugin to global plugin”Uses the global bus directly:
/// A simple system-ready marker event.class SystemReady { /// Creates a [SystemReady] event. const SystemReady();}
/// Global plugin emits on context.bus (the global bus).Future<void> emitOnGlobalPluginBus(GlobalPluginContext context) async { await context.bus.emit<SystemReady>(event: const SystemReady());}Session plugin to session plugin (same session)
Section titled “Session plugin to session plugin (same session)”Uses that session’s bus:
/// A user message passed through the session bus.class SessionUserMessage { /// The message text. final String text;
/// Creates a [SessionUserMessage] with [text]. const SessionUserMessage(this.text);}
/// Session plugin emits on context.bus (the session bus).Future<void> emitOnSessionBus(SessionPluginContext context, String text) async { await context.bus.emit<SessionUserMessage>(event: SessionUserMessage(text));}The separation is deliberate. Fire-and-forget cross-scope forwarding tends to produce surprising fan-out as the app grows. Making every cross-scope emit an explicit choice keeps the routing auditable.
Custom context types
Section titled “Custom context types”You can subclass GlobalPluginContext and SessionPluginContext to carry domain-specific fields: the current user, the active document, a tool registry, a telemetry client. When you do, you have to supply a factory the runtime can use to build your subclass.
- Global:
runtime.init(globalContextFactory: ...) - Session:
runtime.createSession(contextFactory: ...)
Forget the factory and the runtime throws StateError. That is on purpose: the default factories only build the base types, and silently downcasting would hide the misconfiguration until a plugin tried to read a field that did not exist. A loud failure at setup beats a quiet one at runtime.
See Custom Contexts for the full pattern.