Event sequences — lifecycle transitions, side by side
The Phase-3 synthesis of the demo matrix: for each lifecycle transition, the ordered event sequence each platform actually delivered, distilled from the per-platform findings docs (every sequence below links to the run it came from). Platforms: Wayland (headless weston 15 / sway 1.11, Tier A), X11 (Xvfb ± icewm, Tier A), Win32 (A[wine] — Wine 10/11, headless; orderings are Wine's, queued for Windows CI re-confirmation), AppKit (A[ssh] — macOS 26.3 over SSH, console locked; geometry/callback orderings are sound, composited pixels are not).
Each section ends with Alignment notes: what a framework's unified event stream must reorder, synthesize, or suppress per platform.
Last reviewed: June 11, 2026
1. Creation → first pixel
What "presented" even means differs per platform — only Wayland has a server-driven confirmation; the other three confirm at best "my draw call returned".
Wayland [w-f01] X11 [x-f01]
connect → registry roundtrip (rt 1) XOpenDisplay (rt 1)
create wl_surface/xdg_surface/toplevel XCreateSimpleWindow (async, client mints XID)
commit(no buffer) XMapWindow (async)
← xdg_toplevel/xdg_surface.configure ← MapNotify → Expose (no WM)
(rt 2: wait for first configure) ← Configure(real) → Reparent →
ack_configure(serial) Configure(synth) → MapNotify → Expose (WM)
attach + damage + frame() + commit XShmPutImage(send_event)
← frame callback done ← ShmCompletionEvent
= "presented": commit consumed by = "presented": server done READING the
the compositor's repaint cycle buffer; glass needs the Present ext.
Win32 [n-f01] A[wine] AppKit [a-f01] A[ssh]
RegisterClassExW CGMainDisplayID (rt: WindowServer connect)
CreateWindowExW —— re-entrant inside: NSApplication.sharedApplication (rt)
WM_GETMINMAXINFO → WM_NCCREATE → NSWindow initWithContentRect (rt: server-
WM_NCCALCSIZE → WM_CREATE side window; scale already known)
ShowWindow —— re-entrant inside: setContentView —— synchronous inside:
WM_SHOWWINDOW → WM_WINDOWPOSCHANGING view setFrameSize: (first configure)
→ WM_ERASEBKGND → WM_WINDOWPOSCHANGED makeKeyAndOrderFront (paints nothing)
→ WM_SIZE (first configure) → WM_MOVE [NSApp run] → first drawRect: on the
UpdateWindow → WM_PAINT → BitBlt loop's first drawing pass (~26 ms in)
= "presented": BitBlt returned; DWM = "presented": drawRect: returned;
composites later, unreported CATransaction composites later, unreportedRound-trip/blocking shape: Wayland blocks exactly twice (registry sync, first configure) — everything else is buffered writes; X11 blocks on five reply-bearing calls (~0.65 ms of a ~1.4 ms total) and XCreateSimpleWindow itself is free; Win32 has zero observable round-trips but the first user32 call pays a one-time ~13 ms session-connection charge (A[wine]) and creation messages run re-entrantly inside CreateWindowExW; AppKit's init is a chain of synchronous WindowServer round-trips (sharedApplication ~27 ms, initWithContentRect: ~38 ms cold) totalling ~100–130 ms to first pixel.
With WS_VISIBLE, Win32's entire show cascade (through the first WM_SIZE and WM_MOVE) moves inside CreateWindowExW — same message order, different stack frame n-f01.
Alignment notes. A unified "window created" event must be deliverable before the platform's first resize: Win32 delivers the first WM_SIZE re-entrantly inside the creation/show call, and AppKit's first setFrameSize: fires inside setContentView: before the view's window link exists. A unified "first frame presented" event is honest only on Wayland; on the other three it must be synthesized from draw-call return (and documented as weaker). On X11 the framework must accept either MapNotify or ConfigureNotify as the on-screen signal — waiting for one specific kind deadlocks WM-less servers or misses WM size corrections.
2. Programmatic resize (one clean cycle)
Wayland — negotiation [w-f02] X11 — notification, maybe mediated [x-f02]
→ set_maximized / (request) → XResizeWindow (fire-and-forget)
← xdg_toplevel.configure(size, states) [1 stale frame at old size — structural;
← xdg_surface.configure(serial) ≤6 under a WM, which may batch grants]
→ ack_configure(serial) # MUST echo ← ConfigureNotify(real) send_event=0
→ realloc + attach + commit at the [+ ConfigureNotify(synth) send_event=1
configured size — wrong size in a in ROOT coords, under a WM]
maximized state = fatal protocol realloc → ← Expose (gate on count==0)
error, connection killed (EPROTO) → repaint at the authoritative size
Win32 — synchronous cascade [n-f02] AppKit — synchronous chain [a-f02]
→ SetWindowPos —— re-entrant inside: → setFrame:display:YES (or setContentSize:)
WM_WINDOWPOSCHANGING (proposal, —— synchronous inside the call:
mutable) → WM_NCCALCSIZE → view setFrameSize: (+0.4–0.7 ms)
[WM_ERASEBKGND — grows only] → → windowDidResize: delegate (+~60 µs)
WM_WINDOWPOSCHANGED (fact) → → drawRect: forced by display:YES (+~6 ms);
WM_SIZE (client size) → realloc without the flag, next run-loop pass
→ InvalidateRect + WM_PAINT — shrinks every request honoured bit-exact; no
self-invalidate NOTHING [n-f02] negotiation, no live-resize bracketWho picks the size: Wayland the compositor (the client must obey constrained states and carry its own floating-size memory); X11 the server — or the WM, which may delay, batch, or modify (XResizeWindow degrades to a ConfigureRequest suggestion); Win32 and AppKit the app, verbatim. X11's serial field cannot attribute events, and the server never coalesces a burst (three requests = three notifies) x-f02. Wayland's serial is an opaque per-compositor token to echo, never to interpret w-f02.
Alignment notes. The framework must present one "resized(w, h)" event per settled size, which means: on Wayland, fuse configure+ack+commit and enforce the serial discipline internally (a missed ack or wrong-sized maximized commit is connection-fatal); on X11, coalesce ConfigureNotify batches, drop synthetic-event coordinates (root vs parent space), and tolerate the structural stale frame; on Win32, survive the re-entrant cascade (the handler runs inside the app's own SetWindowPos) and self-invalidate on shrink; on AppKit, nothing — but note the event has already happened synchronously when the request returns.
3. Scale / DPI change under a live window
Wayland (sway, fractional) [w-f08] Win32 (documented) [n-f08] A[wine]
← wl_output.scale(2) # ceil, lie! ← WM_DPICHANGED(oldDpi, newDpi,
← wp_fractional_scale_v1 suggested rect)
.preferred_scale(180) # ÷120 = 1.5 → SetWindowPos(suggested rect)
← wl_surface.preferred_buffer_scale(2) ← WM_WINDOWPOSCHANGING/CHANGED → WM_SIZE
← xdg_toplevel/xdg_surface.configure → realloc at new physical size
(NEW logical size — retiled) …but under Wine 10.0 the message never
→ ack + realloc (buffer = logical × arrives: a live compositor scale change
scale120/120, half-away-from-zero) + lands as a plain WM_SIZE doubling the
viewport.set_destination + commit pixels at constant DPI 96 [n-f08]
AppKit [a-f08] X11 [x-f08]
← NSApplicationDidChangeScreenParams… ← PropertyNotify(RESOURCE_MANAGER) on the
(arrangement changed, app-level) ROOT window — opt-in convention only
← NSWindowDidChangeBackingProperties… → re-fetch Xft.dpi via XGetWindowProperty
(old scale in userInfo) (XResourceManagerString is a stale
← viewDidChangeBackingProperties connection-time snapshot)
→ reallocate at points × new scale → (nothing else, ever — the entire
[registration proven; firing is Tier C "rescale" is application policy)
— locked single-screen session]Wayland's first commit is always at scale 1 (the protocol default); the authoritative scale arrives post-map, so one rescale/realloc right after mapping is guaranteed on any scaled output. The legacy integer events (wl_output.scale, preferred_buffer_scale) fire alongside the fractional one and round up — a fractional-aware client must ignore them. AppKit fires viewDidChangeBackingProperties once at view installation (install ≠ scale change) and never hands out a fractional scale; X11 has no scale concept addressed to a window at all — the absence is the finding x-f08.
Alignment notes. A unified "scale changed" event needs heavy per-platform synthesis: on Wayland, suppress the rounded integer echoes and coalesce preferred_scale with the configure that almost always follows (a scale change is usually also a resize); on Win32, treat scale and size as independently arriving facts (Wine proves the size can change without the DPI message); on AppKit, distinguish the install-time view callback from a real window-level backing flip; on X11, the framework must invent the event from an opt-in root-property watch — or document that it never fires.
4. Maximize / restore
Wayland [w-f14] X11 + icewm [x-f14]
→ set_maximized → _NET_WM_STATE ClientMessage to ROOT
← configure size=1024x608 ← ConfigureNotify(real) + (synth) # resize FIRST
states=[maximized] ← PropertyNotify(_NET_WM_STATE) # state LAST
→ ack + commit at EXACTLY that size → re-fetch + diff the atom list:
restore: configure 0x0 states=[] — [MAXIMIZED_VERT, MAXIMIZED_HORZ]
"you pick"; client restores its OWN restore: symmetric (configure back, atoms drop)
remembered floating size no WM: the message is DISCARDED — silence
Win32 [n-f14] A[wine] AppKit (zoom:) [a-f14] A[ssh]
→ ShowWindow(SW_MAXIMIZE) — re-entrant: → zoom: —— BLOCKS ~356 ms (animated):
WM_GETMINMAXINFO ×2 (override point) windowWillUseStandardFrame (proposal)
→ WM_WINDOWPOSCHANGING → → windowShouldZoom:toFrame: (veto point)
WM_WINDOWPOSCHANGED → → windowWillStartLiveResize
WM_SIZE wParam=SIZE_MAXIMIZED (echo) → windowDidResize (ONE, at the end)
normal rect remembered by the SYSTEM → windowDidEndLiveResize
(GetWindowPlacement) zoomed frame = visibleFrame (minus menubar)
[winewayland only: a SECOND compositor- restore: AppKit restores ITS saved user
driven WM_WINDOWPOSCHANGED corrects frame; inserts windowWillResize:toSize:
the size ~2 ms later] isZoomed re-invokes willUseStandardFrame!Obedience varies: sway acks set_maximized on a tiled window with an unchanged states array — the echoed array, never the request, is the truth w-f14. Who remembers the pre-maximize geometry: the client (Wayland), the WM (X11), the system (GetWindowPlacement, Win32), AppKit (zoom).
Alignment notes. Emit "maximized" only from the platform's echo (states array / _NET_WM_STATE diff / SIZE_MAXIMIZED / isZoomed), never from the request returning. The framework owns floating-size memory on Wayland only. On Win32-on-Wayland, the first echo is provisional — debounce a possible second configure. On AppKit, the request blocks for an animation and delegate veto points fire mid-call; on X11 without a WM the request must time out.
5. Minimize — the asymmetry section
Who echoes, who goes dark:
Wayland [w-f14] X11 + icewm [x-f14]
→ set_minimized → WM_CHANGE_STATE (XIconifyWindow)
← (nothing — EVER; no minimized state, ← PropertyNotify _NET_WM_STATE → [HIDDEN]
no unset request, by protocol design) ← PropertyNotify WM_STATE → iconic
frame callbacks STOP — the only ← FocusOut
machine-readable signal (suspended ← UnmapNotify # properties FIRST,
needs xdg_wm_base v6; nobody ships it) structure LAST — inverse of maximize
NO ConfigureNotify: geometry is kept
Win32 [n-f14] A[wine] AppKit (miniaturize:) [a-f14] A[ssh]
→ ShowWindow(SW_MINIMIZE) → miniaturize: — returns IMMEDIATELY
← WM_KILLFOCUS next=null (readback still says mini=0)
← WM_WINDOWPOSCHANGING/CHANGED to ← windowWillMiniaturize + WillOrderOffScreen
(-32000,-32000) 160×31 # parked, (+4 ms)
still has geometry …1.55 s animation gap…
← WM_SIZE wParam=SIZE_MINIMIZED size=0x0 ← windowDidMiniaturize + DidOrderOffScreen
restore: SIZE_RESTORED then WM_SETFOCUS focus question unanswerable at A[ssh]
+ WM_ACTIVATE — focus returns unasked (locked session grants no key status)Four different temporal shapes: Wayland fire-and-forget (silence is the echo), X11 a five-event echo with the property/structure order inverted relative to maximize, Win32 a synchronous in-band echo with size 0×0, AppKit asynchronous fire-and-settle-later. Only Wayland never confirms; only Win32 reports a geometry (the parking lot); only X11 unmaps.
Alignment notes. "Minimized" must be synthesized on Wayland from frame-callback silence (which also means the event loop cannot be frame-callback-driven — it deadlocks at set_minimized w-f14). On X11, infer it from WM_STATE/_NET_WM_STATE_HIDDEN, not from UnmapNotify alone (that also means withdrawal). On Win32, suppress the 0×0 WM_SIZE from the resize stream (keep the old buffer). On AppKit, the unified event arrives seconds after the request — do not read state synchronously after the call.
6. Fullscreen — real state vs idiom vs Space transition
Wayland [w-f14] — first-class state X11 + icewm [x-f14] — real WM state
→ set_fullscreen(nil output) → _NET_WM_STATE_FULLSCREEN to root
← configure size=1024x640 (FULL output; ← ConfigureNotify pair (full screen, 0+0)
maximize had left panel room) ← PropertyNotify _NET_FRAME_EXTENTS
states=[fullscreen] (frame REMOVED)
→ ack + commit at exactly that size ← state_changed [FULLSCREEN] + layer raise
Win32 [n-f14] — a geometry IDIOM AppKit [a-f14] — a Space transition
→ save style + rect; strip → set collectionBehavior FullScreenPrimary
WS_OVERLAPPEDWINDOW; → toggleFullScreen: (asynchronous)
SetWindowPos(monitor rect, ← NSWindowWillSnapshotForFullScreen…
SWP_FRAMECHANGED) ← windowWillEnterFullScreen
← …POSCHANGING/CHANGED → ← live-resize bracket to full frame
WM_SIZE wParam=SIZE_RESTORED (!) ← [locked session: resized BACK, then
GetWindowPlacement says SW_SHOWNORMAL — windowDidFailToEnterFullScreen — a
no state exists; AND the idiom first-class failure callback; the one
DESTROYS rcNormalPosition: restore place the notification preceded the
must use the app's own saved rect delegate method]Alignment notes. A unified setFullscreen(bool) has four contracts: a state echo to await (Wayland, X11), a pure geometry trick whose echo claims "restored" and which corrupts the system's normal-rect memory (Win32 — the framework must own the saved rect), and an asynchronous transition that can fail and must surface that failure (AppKit). Only AppKit needs a prerequisite (collectionBehavior) and only AppKit can deny the request after starting it.
7. Close request + veto — the contract ladder
From first-class return-value veto to advisory-ignore:
AppKit [a-f14] performClose: → delegate windowShouldClose: returns NO = veto
(sticks: no windowWillClose, no notifications, window stays).
TRAPDOOR: close skips the ask entirely — route "request close"
through performClose: only.
Win32 [n-f14] ✕ → WM_SYSCOMMAND SC_CLOSE → WM_CLOSE; returning 0 WITHOUT
DefWindowProc = veto (DefWindowProc is what calls DestroyWindow).
Accepted close tears down hide → WA_INACTIVE → WM_ACTIVATEAPP 0
→ WM_KILLFOCUS → WM_DESTROY — focus loss is part of destruction.
X11 [x-f14] WM_PROTOCOLS/WM_DELETE_WINDOW ClientMessage; no reply channel —
veto = do nothing (indistinguishable from a hang; WMs escalate to
XKillClient → XIOError, non-returnable). Skipping the handshake
entirely converts every user close into SIGKILL semantics.
Wayland [w-f14] xdg_toplevel.close event; "the client may choose to ignore this
request" — veto = ignore, accept = tear down yourself. No
escalation ever; the only non-vetoable end is the socket dying
(EPIPE), which must be absorbed as a clean exit.Alignment notes. A unified onCloseRequested → bool maps natively onto AppKit/Win32 (synchronous return value) but on X11/Wayland the "veto" is the absence of action — the framework implements accept, not veto. It must also suppress the Win32 destruction-time focus events from the activation stream, and on X11 always install the WM_DELETE_WINDOW handshake plus an XIOError/EPIPE handler for the non-vetoable paths.
8. Window crosses to another output
Wayland (sway) [w-f09] — push, ordered AppKit [a-f09] — push, derived by AppKit
move to output B: ← NSWindowDidChangeScreenNotification
← configure (retiled to B's logical (window.screen re-answers; majority-
size) — the resize comes FIRST area rule; valid from init)
→ ack + commit at new size ← viewDidChangeBackingProperties /
← leave(A) / enter(B) ~200 ms LATER — window backing notification if the
occupancy derives from committed scale differs [a-f08]
buffers and TRAILS the resize [firing is Tier C — single locked screen;
occupied output dies: leave → registration + self-test proven]
global_remove → enter(survivor) →
configure(new size); spurious Win32 [n-f09] — pull + WM_DPICHANGED
same-output leave/enter pairs occur MonitorFromWindow on demand; topology
changes via WM_DISPLAYCHANGE; the DPI
X11 [x-f09] — nothing; derive it consequence is the §3 WM_DPICHANGED
no event addresses the window. On every sequence (unreachable under Wine).
ConfigureNotify: translate to root
coords, intersect with XRRGetMonitors
rects yourself. Fully off-screen =
still running, no event, no error.Alignment notes. A unified "window changed output" event is native push on Wayland and AppKit (but on Wayland it trails the resize — never gate "which output am I on" on enter having arrived, and handlers must be idempotent against spurious leave/enter), a poll Win32 must run on move/WM_DISPLAYCHANGE, and pure framework-side geometry intersection on X11. The scale consequence rides along only on Wayland (preferred_scale) and Win32 (WM_DPICHANGED); AppKit splits it into a separate backing notification; X11 has none (§3).
Sharpest divergences
Ranked by how little a 4-way unified event can reuse: (1) scale change — ordered multi-protocol negotiation vs a suggested-rect message vs a notification pair vs structural silence; (2) minimize — four temporal shapes, one of which is "no echo, ever"; (3) close/veto — return-value contract vs advisory-ignore, with X11's kill escalation; (4) resize is the deepest mechanical split (negotiate / notify / re-entrant cascade / synchronous chain) but at least every platform emits an authoritative size.
Sources
Every sequence above is quoted or condensed from the per-platform findings docs (which carry the verbatim instrument logs and primary-source citations): Wayland — F01, F02, F08, F09, F14; X11 — F01, F02, F08, F09, F14; Win32 — F01, F02, F08, F09, F14; AppKit — F01, F02, F08, F09, F14. Feature specs live in features/; the per-cell evidence grid is the feature matrix.