Event Coalescing
Why Coalescing Exists
Section titled “Why Coalescing Exists”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 --> subscribersCoalescing Window
Section titled “Coalescing Window”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.
Priority Bypass
Section titled “Priority Bypass”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.crashedcapability.registered,capability.revoked,capability.diff,capability.invalidatedworkspace.changedpower.suspend_imminent,power.resumeddisplay.mode_changedpermission.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.
Coalescer Implementation
Section titled “Coalescer Implementation”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.
Interaction with Overflow
Section titled “Interaction with Overflow”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.