Skip to content
Portal Control Protocol

Event Coalescing

The compositor emits events at the speed of user interaction and system state changes. During a window resize, the compositor can emit dozens of surface.geometry_changed events per second. When a user drags a brightness slider, display.brightness_changed fires on every input tick. Rapid focus changes (pressing Tab through several surfaces) generate a burst of focus_gained and focus_lost pairs.

Delivering every one of these events to subscribers wastes CPU on processing intermediate states that no consumer needs. The coalescer sits between the event bus’s publish() call and the subscriber channels. It absorbs high-frequency bursts and emits a single event representing the final state.

Adapter publish()
|
v
COALESCER
|
| Buffer:
| evt A (t=0ms)
| evt B (t=30ms)
| evt C (t=80ms)
| |
| +-- window expires (100ms)
| |
v v
Single coalesced event --> subscribers

The coalescer groups events by (source, event_type). A time window governs how long the coalescer holds events before flushing them. While the window is open, incoming events of the same type from the same source replace the buffered event rather than creating additional deliveries.

The default window is 100 milliseconds, but each event type can define its own:

Event Pattern Window Rule
surface.geometry_changed (same surface) 100ms Deliver final geometry only
surface.state_changed (same surface) 50ms Batch all state changes
audio.volume_changed 200ms Deliver final volume
power.battery_changed 5s Deliver latest level and charging state
network.state_changed 2s Deliver final state
display.brightness_changed 200ms Deliver final brightness
Focus thrash (focus_gained / focus_lost alternating) 100ms Deliver final focus state
Rapid surface.created (multi-window launch) 200ms Batch into single event with all surfaces

The window values reflect how fast each state changes in practice and how quickly a consumer needs to react. Battery level drifts slowly, so a 5-second window avoids flooding subscribers with 1% level changes. Surface geometry changes during a resize are rapid, so the 100ms window still delivers often enough to keep UI responsive.

Not all events are eligible for coalescing. Events marked as never-coalesced bypass the coalescer entirely and are delivered immediately, unmodified. This covers all Critical and High priority events, plus specific medium-priority events where dropping intermediate states would be dangerous.

Events that always bypass the coalescer:

  • app.started, app.closed, app.crashed
  • capability.registered, capability.revoked, capability.diff, capability.invalidated
  • workspace.changed
  • power.suspend_imminent, power.resumed
  • display.mode_changed
  • permission.granted, permission.revoked, permission.escalation_attempted

The rationale is straightforward. A crash event must reach subscribers the instant it occurs. Capability revocations are security-sensitive and cannot be merged with earlier state. Permission escalation attempts are audit-critical. These events carry information that loses its meaning if delayed or merged.

The coalescer maintains a per-(source, event_type) buffer. Each buffer tracks its window start time and accumulated events:

pub struct Coalescer {
/// Per-(source, event_type) buffer of pending events.
buffers: DashMap<(String, String), CoalesceBuffer>,
/// Default coalescing window per event type.
windows: HashMap<String, Duration>,
/// Event types that are never coalesced.
passthrough: HashSet<String>,
}
struct CoalesceBuffer {
events: Vec<Arc<PcpPushEvent>>,
window_start: Instant,
window_duration: Duration,
}
impl Coalescer {
pub fn coalesce(&self, event: Arc<PcpPushEvent>) -> CoalesceResult {
let key = (event.source.clone(), event.event_type.clone());
// Never-coalesced events pass through immediately
if self.passthrough.contains(&event.event_type) {
return CoalesceResult::Deliver(vec![event]);
}
let window = self.windows
.get(&event.event_type)
.copied()
.unwrap_or(Duration::from_millis(100));
// Buffer logic: if window is still open, replace the buffered
// event with the new one. If window expired, flush the buffer
// and start a new window with the incoming event.
// ...
}
}
pub enum CoalesceResult {
/// Event(s) ready for delivery now.
Deliver(Vec<Arc<PcpPushEvent>>),
/// Event buffered, not yet ready for delivery.
Buffered,
}

The DashMap allows concurrent access from multiple adapter threads without a global lock. Each (source, type) pair gets its own buffer, so a burst of geometry events from one surface does not interfere with events from another surface.

When the window expires, the coalescer extracts the final state from the buffer. For geometry changes, that means the last payload in the buffer. For focus thrash, the coalescer looks at the sequence of gained/lost events and delivers a single event reflecting the final focus holder.

Coalescing reduces the load on subscriber channels, but it does not eliminate overflow entirely. A sustained high-frequency stream (for example, a video playing with constant frame updates) can still fill a channel faster than the consumer drains it. Coalescing makes this less likely by collapsing bursts into single deliveries, but the overflow strategy on the subscription still governs what happens when capacity is reached. See Channels & Overflow for the full overflow behavior.

Last updated: