ntk 6.6.1 → 7.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/window.js CHANGED
@@ -41,6 +41,63 @@ const SPLIT_SAVING = 0.75;
41
41
  */
42
42
  const BACKING_GRANULARITY = 128;
43
43
 
44
+ /**
45
+ * Frame period assumed before anything better is known, in ms.
46
+ *
47
+ * The fence clock's rate limit, and the vblank clock's placeholder until the
48
+ * display has reported its own period (see _sampleRefresh).
49
+ */
50
+ const DEFAULT_FRAME_INTERVAL = 16;
51
+
52
+ /**
53
+ * The range a measured vblank period has to fall in to be believed, in ms.
54
+ *
55
+ * Completion timestamps come from the server's clock and its idea of a frame
56
+ * counter, neither of which is guaranteed to mean what it does on a physical
57
+ * output — a window with no CRTC behind it gets a synthesized msc, and an idle
58
+ * gap between two presents inflates the period derived from them. A sample
59
+ * outside 1000Hz..10Hz is not a refresh rate and is dropped.
60
+ */
61
+ const MIN_REFRESH_INTERVAL = 1;
62
+ const MAX_REFRESH_INTERVAL = 100;
63
+
64
+ /**
65
+ * How many recent completion intervals the refresh estimate is drawn from,
66
+ * and how far up their sorted order it reads.
67
+ *
68
+ * Near the bottom rather than the middle, because what the msc counts is not
69
+ * the same everywhere. Where it counts vertical blanks a one-blank gap is
70
+ * exactly one period and every sample agrees; where it counts *presents* —
71
+ * Xwayland advances it once per frame the compositor takes — a slow frame
72
+ * reports its own duration as if it were the display's, and only the quick
73
+ * frames are telling the truth. Not the outright minimum, because a
74
+ * timer-driven fake vblank (Xvfb) jitters in both directions and a single
75
+ * short gap should not become the answer. A history, rather than a running
76
+ * value, is what lets the estimate rise again after a mode change to a slower
77
+ * rate.
78
+ */
79
+ const REFRESH_SAMPLES = 16;
80
+ const REFRESH_QUANTILE = 0.25;
81
+
82
+ /**
83
+ * How long a frame may stay outstanding before the vblank clock is abandoned
84
+ * for the fence, in ms.
85
+ *
86
+ * Deliberately far above any frame rate: this is a deadlock breaker, not a
87
+ * pacer, and the rate a window is *legitimately* given can be very low. A
88
+ * Wayland compositor throttles a window nobody can see — measured at ~1Hz for
89
+ * an occluded window under Mutter — by simply not sending the frame callback
90
+ * that completes its present, and that window is right to stop rendering.
91
+ * Waking it up to run frames at fence rate would burn CPU on pixels no one
92
+ * sees, so the timeout has to sit above the slowest honest answer.
93
+ *
94
+ * A server that never completes anything is a different case and worth
95
+ * catching quickly, so the first present a window ever sends is given the
96
+ * shorter deadline — nothing has proved it will be answered yet.
97
+ */
98
+ const STALL_TIMEOUT = 2000;
99
+ const STALL_TIMEOUT_FIRST = 250;
100
+
44
101
  function rectArea(r) {
45
102
  return Math.max(0, r.w) * Math.max(0, r.h);
46
103
  }
@@ -359,6 +416,10 @@ export default class Window extends Drawable {
359
416
  // (see docs/window.md "Frames, coalescing and slow connections")
360
417
  this._coalesce = args.coalesceEvents !== false;
361
418
  this._frameSyncEnabled = args.frameSync !== false;
419
+ // which signal ends a frame (see _clockOnPresent): 'auto' takes the
420
+ // display's own vblank where the Present path can supply it and the fence
421
+ // everywhere else, 'fence' pins the round-trip clock
422
+ this._frameClockPref = args.frameClock ?? 'auto';
362
423
  // _NET_WM_SYNC_REQUEST (see enableSyncRequest): the counter the window
363
424
  // manager watches, the value it last asked for, and the watchdog that
364
425
  // guarantees an answer even when nothing repaints
@@ -373,13 +434,34 @@ export default class Window extends Drawable {
373
434
  this._fixesExt = null;
374
435
  this._updateRegion = 0;
375
436
  this._presentSerial = 0;
376
- this.frameInterval = args.frameInterval ?? 16;
437
+ this._presentEid = 0;
438
+ // The vblank clock (see _onPresentComplete): the period learnt from
439
+ // completion events, the samples it is drawn from, and the latch the
440
+ // watchdog sets when completions stop arriving.
441
+ this.refreshInterval = null;
442
+ this.droppedFrames = 0;
443
+ this._refreshSamples = [];
444
+ this._lastComplete = null;
445
+ this._clockStalled = false;
446
+ // `frameInterval` is a plain number, but whether the caller set it is what
447
+ // decides its meaning under the vblank clock — an explicit value caps the
448
+ // frame rate, an unasked-for default must not (see _armTimer). Assigning
449
+ // the property later is the caller setting it, hence the accessor.
450
+ //
451
+ // The default is the display's own period where the connection has found
452
+ // it out, and DEFAULT_FRAME_INTERVAL until it has: a window built before
453
+ // that probe answers adopts the result when it lands.
454
+ this._frameInterval = args.frameInterval ?? app.frameInterval ?? DEFAULT_FRAME_INTERVAL;
455
+ this._frameIntervalExplicit = args.frameInterval !== undefined;
377
456
  this.frameLatency = null;
378
457
  this._frame = {
379
458
  pending: new Map(), // coalescible event name -> merged event
380
459
  rafCbs: [],
381
460
  rafId: 0,
382
461
  inFlight: false, // fence round-trip awaiting the server's reply
462
+ presentInFlight: false, // present awaiting the display's CompleteNotify
463
+ presentAt: 0, // when that present was sent, for frameLatency
464
+ stallTimer: null,
383
465
  timer: null,
384
466
  scheduled: false,
385
467
  needsRedraw: false,
@@ -511,14 +593,30 @@ export default class Window extends Drawable {
511
593
  // the earliest. `await wnd.enableSyncRequest()` when that is too close.
512
594
  this.enableSyncRequest().catch((err) => this.app.options.onXError?.(err));
513
595
  }
596
+ // Presenting is what a double-buffered window does unless it says
597
+ // otherwise, so the decision is deferred to the point one gets a backing
598
+ // store (see _enableBackingStore): a window drawing through 'opengl' or
599
+ // 'x11' has no pixmap to present and would only be paying for an update
600
+ // region and an event selection it never uses. `present: true` is still
601
+ // honoured eagerly, for a caller who wants it up before the first paint.
602
+ this._presentOptOut = args.present === false;
514
603
  if (args.present && !args.id) {
515
604
  // fire and forget: until the extensions answer, blits use CopyArea,
516
605
  // which is what they would have done anyway
517
- this.enablePresent().catch((err) => this.app.options.onXError?.(err));
606
+ this.enablePresent().catch((err) => this.app.options?.onXError?.(err));
518
607
  }
519
608
 
520
609
  X.event_consumers[this.id] = this;
521
610
  this.on('event', (ev) => {
611
+ // Present speaks in X Generic Events, which carry an extension opcode
612
+ // and a sub-type where a core event carries its code — the name table
613
+ // below cannot see them, and they are not events user code asked for
614
+ if (ev.type === 35) {
615
+ if (this._presentExt && ev.extension === this._presentExt.majorOpcode) {
616
+ this._handlePresentEvent(ev);
617
+ }
618
+ return;
619
+ }
522
620
  const ntkev = ev; // todo: clone
523
621
  ntkev.window = this;
524
622
  ntkev.target = this;
@@ -640,7 +738,7 @@ export default class Window extends Drawable {
640
738
  this.on('destroy', () => {
641
739
  this._destroyed = true;
642
740
  this._forget();
643
- this._teardownFrame();
741
+ this._teardownFrame(false); // the server has already taken the window
644
742
  // the server frees window-backed pictures with the window — let
645
743
  // contexts drop their wrappers without issuing FreePicture
646
744
  this.emit('_destroyed');
@@ -676,6 +774,12 @@ export default class Window extends Drawable {
676
774
  this._dirty = false;
677
775
  this._backingValid = false;
678
776
  this._presentScheduled = false;
777
+ // Now there is a pixmap to present. Inert where the extensions are
778
+ // missing, so this costs a window on such a server two extension queries
779
+ // that node-x11 answers from its cache after the first.
780
+ if (!this._presentOptOut && !this._foreign) {
781
+ this.enablePresent().catch((err) => this.app.options?.onXError?.(err));
782
+ }
679
783
 
680
784
  this._presentGc = this.X.AllocID();
681
785
  this.X.CreateGC(this._presentGc, this.id, { graphicsExposures: 0 });
@@ -897,39 +1001,120 @@ export default class Window extends Drawable {
897
1001
 
898
1002
  /*
899
1003
  * Frame clock. Noisy events (see events_map coalesce), synthetic redraws
900
- * and requestAnimationFrame callbacks are delivered in "frames", paced by
901
- * two independent gates:
902
- *
903
- * - a fence: a cheap request with a reply (GetInputFocus) sent after each
904
- * frame; X processes requests in order, so its reply confirms the
905
- * server consumed everything the frame drew. At most one fence is in
906
- * flight — on a slow connection frames degrade to one per round-trip
907
- * instead of queueing a trail of stale updates.
908
- * - a timer: at most one paced frame per `frameInterval` ms, so a fast
909
- * local server doesn't get redraws at input-device rate.
1004
+ * and requestAnimationFrame callbacks are delivered in "frames". What ends
1005
+ * a frame — and so what paces the next one — is one of two signals:
1006
+ *
1007
+ * - the display, on a window presenting through the Present extension: the
1008
+ * CompleteNotify for the frame's PresentPixmap, which the server sends
1009
+ * when it has executed the copy at a vertical blank. Frames then run at
1010
+ * the output's own rate, phase-locked to it, whatever that rate is.
1011
+ * - a fence, everywhere else: a cheap request with a reply
1012
+ * (GetInputFocus) sent after each frame; X processes requests in order,
1013
+ * so its reply confirms the server consumed everything the frame drew.
1014
+ *
1015
+ * Either way at most one is outstanding, which is what bounds the work in
1016
+ * flight: on a slow connection frames degrade to one per round-trip instead
1017
+ * of queueing a trail of stale updates.
1018
+ *
1019
+ * A timer backs both up. Under the fence it is the rate limit — at most one
1020
+ * frame per `frameInterval` ms, so a fast local server isn't asked to redraw
1021
+ * at input-device rate. Under the vblank clock the display is the rate
1022
+ * limit, and the timer only covers frames that drew nothing: those produce
1023
+ * no present, so no completion is coming and something else has to run the
1024
+ * next frame (`refreshInterval` is the period there, so a compute-only
1025
+ * animation loop still runs at display rate).
910
1026
  *
911
1027
  * Discrete events (mousedown, keydown, ...) bypass the timer for latency:
912
1028
  * the first blit after a quiet moment goes out with the handler's own
913
- * requests. Their blits are still bounded, by the fence and by a minimum
914
- * inter-blit interval (also `frameInterval`) — see _present(). Without that
915
- * interval a stream of discrete events blits at round-trip rate, which on a
916
- * local server is several hundred per second, and under a compositor every
917
- * blit is a recomposite.
1029
+ * requests. Their blits are still bounded, by whichever clock is running and
1030
+ * by a minimum inter-blit interval (`frameInterval` again) — see _present().
1031
+ * Without that interval a stream of discrete events blits at round-trip
1032
+ * rate, which on a local server is several hundred per second, and under a
1033
+ * compositor every blit is a recomposite. The vblank clock has no need of
1034
+ * it: a present outstanding already means the display has not caught up.
918
1035
  */
919
1036
  _scheduleFrame() {
920
1037
  const f = this._frame;
921
- if (f.scheduled || f.inFlight || f.timer) return;
1038
+ if (f.scheduled || f.inFlight || f.presentInFlight || f.timer) return;
922
1039
  f.scheduled = true;
923
1040
  setImmediate(() => {
924
1041
  f.scheduled = false;
925
1042
  // gates may have been armed after this got scheduled (work queued
926
1043
  // from inside a running frame, e.g. a rAF loop re-registering) —
927
- // the fence reply / timer expiry will reschedule then
928
- if (f.inFlight || f.timer) return;
1044
+ // the completion / fence reply / timer expiry will reschedule then
1045
+ if (f.inFlight || f.presentInFlight || f.timer) return;
929
1046
  this._runFrame();
930
1047
  });
931
1048
  }
932
1049
 
1050
+ /**
1051
+ * Minimum ms between paced frames, and between blits.
1052
+ *
1053
+ * Under the vblank clock an *explicit* value is a cap on top of the
1054
+ * display's rate — `frameInterval: 33` renders at 30fps on any output,
1055
+ * which is a real ask on battery — while the default must not cap anything,
1056
+ * or a 165Hz display would be held to the 62.5fps that 16ms implies.
1057
+ * Assigning the property counts as explicit, which is why this is an
1058
+ * accessor: `wnd.frameInterval = 33` has to mean the same as passing it.
1059
+ */
1060
+ get frameInterval() {
1061
+ return this._frameInterval;
1062
+ }
1063
+
1064
+ set frameInterval(ms) {
1065
+ this._frameInterval = ms;
1066
+ this._frameIntervalExplicit = true;
1067
+ }
1068
+
1069
+ /**
1070
+ * Take the connection's measured default, if this window is still on the
1071
+ * guess. Called when the RandR probe answers, which is normally a few
1072
+ * round trips after the first window was built — see App#_probeRefreshRate.
1073
+ */
1074
+ _adoptDefaultFrameInterval(ms) {
1075
+ if (this._frameIntervalExplicit || this._destroyed) return;
1076
+ this._frameInterval = ms;
1077
+ }
1078
+
1079
+ /**
1080
+ * Which clock is ending this window's frames right now: 'present' when the
1081
+ * display is, 'fence' when the server round-trip is. Assignable with
1082
+ * 'auto' (prefer the display) or 'fence' (never use it).
1083
+ */
1084
+ get frameClock() {
1085
+ return this._clockOnPresent() ? 'present' : 'fence';
1086
+ }
1087
+
1088
+ set frameClock(mode) {
1089
+ this._frameClockPref = mode === 'present' ? 'auto' : mode;
1090
+ if (this._frameClockPref !== 'fence') this._selectPresentInput();
1091
+ }
1092
+
1093
+ /**
1094
+ * Is the display ending frames on this window, rather than the socket?
1095
+ *
1096
+ * Needs the Present path up (there is no completion event without it) and
1097
+ * the frame sync the caller may have turned off — `frameSync: false` asks
1098
+ * for pacing that never waits on the server, and a completion is the
1099
+ * strongest wait there is. `_clockStalled` is the watchdog's latch: a
1100
+ * completion that never came drops the window back to the fence until one
1101
+ * does, so a clock that stops cannot stop the window with it.
1102
+ *
1103
+ * `_presentFlipped` is the one-way door — a server that has flipped a copy
1104
+ * present is off this path for good, and its blits no longer produce the
1105
+ * completions the clock is made of.
1106
+ */
1107
+ _clockOnPresent() {
1108
+ return (
1109
+ this._frameClockPref !== 'fence' &&
1110
+ this._frameSyncEnabled &&
1111
+ !this._clockStalled &&
1112
+ !this._presentFlipped &&
1113
+ !!this._presentExt &&
1114
+ !!this._presentEid
1115
+ );
1116
+ }
1117
+
933
1118
  _runFrame() {
934
1119
  const f = this._frame;
935
1120
  if (
@@ -966,7 +1151,10 @@ export default class Window extends Drawable {
966
1151
  // covers a window with no backing store, and a resize that changed
967
1152
  // nothing: _presentNow did not run, but the frame is still "handled"
968
1153
  this._ackSyncRequest();
969
- this._armFence();
1154
+ // A frame that presented is already clocked — its completion ends it. One
1155
+ // that drew nothing has to fall back to the timer, or nothing would ever
1156
+ // run the next frame.
1157
+ if (!this._clockOnPresent()) this._armFence();
970
1158
  this._armTimer();
971
1159
  }
972
1160
 
@@ -1063,29 +1251,209 @@ export default class Window extends Drawable {
1063
1251
  this.X.GetInputFocus(() => {
1064
1252
  f.inFlight = false;
1065
1253
  this.frameLatency = performance.now() - start;
1066
- if (this._presentPending) {
1067
- // a blit deferred while the fence was in flight: show it as soon as
1068
- // the minimum inter-blit interval allows (immediately when the last
1069
- // blit is already older than that, which is the common case)
1070
- this._present();
1071
- }
1072
- if ((f.pending.size || f.rafCbs.length || f.needsRedraw) && !f.timer) this._scheduleFrame();
1254
+ this._frameEnded();
1073
1255
  });
1074
1256
  });
1075
1257
  }
1076
1258
 
1259
+ /**
1260
+ * Present's events, which are the ones the core event table cannot name:
1261
+ * they arrive as X Generic Events (type 35), carrying an extension opcode
1262
+ * and a sub-type instead of an event code.
1263
+ */
1264
+ _handlePresentEvent(ev) {
1265
+ const P = this._presentExt;
1266
+ if (!P || ev.evtype !== P.events.CompleteNotify) return;
1267
+ if (ev.kind !== P.CompleteKind.Pixmap) return; // not a frame of ours
1268
+ if (ev.mode === P.CompleteMode.Flip) {
1269
+ // The server took the pixmap for scanout instead of copying it, and
1270
+ // keeps it until some later present — but ntk has one grow-only backing
1271
+ // pixmap and is about to draw into it, which would now paint the screen
1272
+ // directly. Option.Copy is passed on every present precisely so this
1273
+ // cannot happen (presentproto: "'pixmap' will be idle ... as soon as the
1274
+ // operation occurs"), so a server doing it anyway is one whose Present
1275
+ // path we cannot use at all.
1276
+ this._presentFlipped = true;
1277
+ this.app.options.onXError?.(
1278
+ new Error(
1279
+ 'ntk: the X server flipped a present sent with Option.Copy, which leaves it ' +
1280
+ "owning the window's backing pixmap — falling back to CopyArea blits"
1281
+ )
1282
+ );
1283
+ this._stallClock();
1284
+ return;
1285
+ }
1286
+ this._onPresentComplete(ev);
1287
+ }
1288
+
1289
+ /**
1290
+ * A frame reached the display: the server executed the copy, at a vblank.
1291
+ *
1292
+ * This is `_armFence`'s reply callback with a better trigger behind it. The
1293
+ * fence proves the server *read* the frame's requests; this proves it *ran*
1294
+ * them, on the output, which is both a stronger flow-control signal and the
1295
+ * moment the backing pixmap is ours to draw into again.
1296
+ */
1297
+ _onPresentComplete(ev) {
1298
+ const f = this._frame;
1299
+ const now = performance.now();
1300
+ this._clearStallWatchdog();
1301
+ // completions are arriving again, whatever the watchdog concluded before
1302
+ this._clockStalled = false;
1303
+ if (!f.presentInFlight) return; // a completion we are not clocking on
1304
+ f.presentInFlight = false;
1305
+ this.frameLatency = now - f.presentAt;
1306
+ this._sampleRefresh(ev, now);
1307
+ this._frameEnded();
1308
+ }
1309
+
1310
+ /**
1311
+ * What both clocks do when a frame ends: send the blit that was held back
1312
+ * behind it, then run the next frame if anything is waiting for one.
1313
+ */
1314
+ _frameEnded() {
1315
+ const f = this._frame;
1316
+ if (this._presentPending) {
1317
+ // a blit deferred while the frame was outstanding: show it as soon as
1318
+ // the pacing allows, which under the vblank clock is now
1319
+ this._present();
1320
+ }
1321
+ if ((f.pending.size || f.rafCbs.length || f.needsRedraw) && !f.timer) this._scheduleFrame();
1322
+ }
1323
+
1324
+ /**
1325
+ * Learn the display's refresh period from the frame that just landed.
1326
+ *
1327
+ * Two completions give it without asking anyone: `ust` is when the
1328
+ * presentation happened (microseconds, on the server's clock) and `msc`
1329
+ * counts the frames between them, so their ratio is one vblank. Nothing is
1330
+ * configured, nothing is queried, and it follows the window to another
1331
+ * monitor or through a mode change on its own — where a RandR lookup would
1332
+ * need change-tracking to keep up.
1333
+ *
1334
+ * Only differences are read from the server clock, never an absolute time,
1335
+ * and a sample is believed only if it lands in the range a refresh rate can
1336
+ * be in — which is also what keeps a window the compositor has throttled to
1337
+ * a frame a second from being mistaken for a 1Hz display.
1338
+ */
1339
+ _sampleRefresh(ev, now) {
1340
+ const prev = this._lastComplete;
1341
+ this._lastComplete = { msc: ev.msc, ust: ev.ust, at: now };
1342
+ if (!prev || !(ev.msc > prev.msc)) return;
1343
+ const frames = ev.msc - prev.msc;
1344
+ // Frames the display went through without one of ours. Counted from the
1345
+ // msc, which is the only place the information exists: where the counter
1346
+ // tracks presents rather than vblanks the gap is always 1 and a miss is
1347
+ // simply not observable, and reporting nothing beats inferring it from
1348
+ // elapsed time on a server whose timing is synthetic to begin with.
1349
+ //
1350
+ // Only across a gap short enough to have been a frame at all. A window
1351
+ // that sat idle between two clicks also let the counter run, and that is
1352
+ // not a dropped frame; past a tenth of a second the two are anyway
1353
+ // indistinguishable, and under-reporting is the right way for a
1354
+ // diagnostic to be wrong.
1355
+ if (now - prev.at <= MAX_REFRESH_INTERVAL) this.droppedFrames += frames - 1;
1356
+ // Only a single-frame gap measures a frame's length. A longer one spans a
1357
+ // miss, and dividing it out assumes the msc advanced for the same reason
1358
+ // in each — true of a hardware counter, false of one driven off a timer,
1359
+ // where it makes a late frame look like a *faster* display.
1360
+ if (frames !== 1) return;
1361
+ // ust is the server's clock and local receipt is ours; they measure the
1362
+ // same interval, so the local one stands in when ust is not usable (a
1363
+ // synthesized msc can carry a ust that does not move with it)
1364
+ const byUst = (ev.ust - prev.ust) / 1000;
1365
+ const sample = byUst >= MIN_REFRESH_INTERVAL ? byUst : now - prev.at;
1366
+ if (!(sample >= MIN_REFRESH_INTERVAL && sample <= MAX_REFRESH_INTERVAL)) return;
1367
+ const samples = this._refreshSamples;
1368
+ samples.push(sample);
1369
+ if (samples.length > REFRESH_SAMPLES) samples.shift();
1370
+ const sorted = [...samples].sort((a, b) => a - b);
1371
+ this.refreshInterval = sorted[Math.floor(REFRESH_QUANTILE * (sorted.length - 1))];
1372
+ }
1373
+
1374
+ /**
1375
+ * Guarantee that a frame ends even if its completion does not arrive.
1376
+ *
1377
+ * A fence is a request with a reply and the server always sends one. A
1378
+ * completion is an event about work the server may never do: an unmapped
1379
+ * window, a compositor changing its mind about redirection, or simply a
1380
+ * server whose Present is a stub. `_presentPending` has exactly one wakeup,
1381
+ * so a completion that never comes would be a window that never updates
1382
+ * again — a permanently stale window, which is the trap #195 hit.
1383
+ *
1384
+ * See STALL_TIMEOUT for why the deadline is measured in seconds rather than
1385
+ * in frames: a slow answer is usually the display being honest about how
1386
+ * often this window is worth drawing.
1387
+ */
1388
+ _armStallWatchdog() {
1389
+ const f = this._frame;
1390
+ if (f.stallTimer) return;
1391
+ const timeout = this._lastComplete ? STALL_TIMEOUT : STALL_TIMEOUT_FIRST;
1392
+ f.stallTimer = setTimeout(() => {
1393
+ f.stallTimer = null;
1394
+ if (f.presentInFlight) this._stallClock();
1395
+ }, timeout);
1396
+ if (typeof f.stallTimer.unref === 'function') f.stallTimer.unref();
1397
+ }
1398
+
1399
+ _clearStallWatchdog() {
1400
+ const f = this._frame;
1401
+ if (!f.stallTimer) return;
1402
+ clearTimeout(f.stallTimer);
1403
+ f.stallTimer = null;
1404
+ }
1405
+
1406
+ /**
1407
+ * Give up on the display as a clock, for now. The fence takes over, and the
1408
+ * next completion to arrive hands it back (see _onPresentComplete) — a
1409
+ * window that is merely unmapped for a while must not be left on the slower
1410
+ * clock forever.
1411
+ */
1412
+ _stallClock() {
1413
+ const f = this._frame;
1414
+ this._clockStalled = true;
1415
+ f.presentInFlight = false;
1416
+ this._clearStallWatchdog();
1417
+ // nothing else needs re-arming: an idle window wants no clock, and the
1418
+ // frame _frameEnded schedules will arm the fence on its way out
1419
+ this._frameEnded();
1420
+ }
1421
+
1422
+ /**
1423
+ * The timer gate, whose period depends on what it is standing in for.
1424
+ *
1425
+ * Under the fence it is the rate limit and `frameInterval` is the period.
1426
+ * Under the vblank clock it is the fallback for a frame that presented
1427
+ * nothing — there is no completion coming for a frame that drew nothing —
1428
+ * so the period is the display's, and a frame that *did* present arms no
1429
+ * timer at all: the completion is closer and more accurate than any timer,
1430
+ * and arming one would only cap the rate the display just set. An explicit
1431
+ * `frameInterval` is honoured in both, since a cap the caller asked for
1432
+ * applies whatever ends the frame.
1433
+ */
1077
1434
  _armTimer() {
1078
- if (!(this.frameInterval > 0)) return;
1079
1435
  const f = this._frame;
1080
1436
  if (f.timer) return;
1437
+ let period = this.frameInterval;
1438
+ if (this._clockOnPresent() && !this._frameIntervalExplicit) {
1439
+ if (f.presentInFlight) return;
1440
+ period = this.refreshInterval ?? DEFAULT_FRAME_INTERVAL;
1441
+ }
1442
+ if (!(period > 0)) return;
1081
1443
  f.timer = setTimeout(() => {
1082
1444
  f.timer = null;
1083
1445
  if (f.pending.size || f.rafCbs.length || f.needsRedraw) this._scheduleFrame();
1084
- }, this.frameInterval);
1446
+ }, period);
1085
1447
  if (typeof f.timer.unref === 'function') f.timer.unref();
1086
1448
  }
1087
1449
 
1088
- _teardownFrame() {
1450
+ /**
1451
+ * @param {boolean} [windowAlive] false when the server has already destroyed
1452
+ * the window (a DestroyNotify), which takes its Present event context with
1453
+ * it — deleting one explicitly then is a request against a window id that
1454
+ * no longer exists, i.e. a BadWindow for the app's error hook to explain.
1455
+ */
1456
+ _teardownFrame(windowAlive = true) {
1089
1457
  const f = this._frame;
1090
1458
  if (f.timer) {
1091
1459
  clearTimeout(f.timer);
@@ -1095,6 +1463,18 @@ export default class Window extends Drawable {
1095
1463
  clearTimeout(f.presentTimer);
1096
1464
  f.presentTimer = null;
1097
1465
  }
1466
+ this._clearStallWatchdog();
1467
+ if (this._presentEid) {
1468
+ const eid = this._presentEid;
1469
+ const P = this._presentExt;
1470
+ this._presentEid = 0;
1471
+ f.presentInFlight = false;
1472
+ safeRelease(this.X, () => {
1473
+ // an empty mask deletes the event context (presentproto)
1474
+ if (windowAlive && P) P.SelectInput(eid, this.id, P.EventMask.NoEvent);
1475
+ this.X.ReleaseID(eid);
1476
+ });
1477
+ }
1098
1478
  if (this._syncWatchdog) {
1099
1479
  clearTimeout(this._syncWatchdog);
1100
1480
  this._syncWatchdog = null;
@@ -1129,9 +1509,10 @@ export default class Window extends Drawable {
1129
1509
 
1130
1510
  /**
1131
1511
  * DOM-style requestAnimationFrame: `cb(now)` runs on this window's next
1132
- * paced frame — at most once per `frameInterval` ms and with at most one
1133
- * frame's requests unacknowledged by the server, so animation loops adapt
1134
- * to connection latency instead of flooding a slow link.
1512
+ * paced frame — one per vertical blank where the display is the clock (see
1513
+ * _clockOnPresent), otherwise at most once per `frameInterval` ms — and
1514
+ * always with at most one frame outstanding, so animation loops adapt to
1515
+ * the output's rate and to connection latency instead of flooding either.
1135
1516
  * Returns an id for cancelAnimationFrame().
1136
1517
  */
1137
1518
  requestAnimationFrame(cb) {
@@ -1150,8 +1531,9 @@ export default class Window extends Drawable {
1150
1531
 
1151
1532
  /**
1152
1533
  * Whether a blit this window already owes is still waiting to go out —
1153
- * because the fence for the last frame is unanswered, or because a present
1154
- * is deferred behind the minimum inter-blit interval.
1534
+ * because the last frame is unanswered (its fence unreplied, or its present
1535
+ * not yet on the display), or because a present is deferred behind the
1536
+ * minimum inter-blit interval.
1155
1537
  *
1156
1538
  * The one bit of frame-clock state worth publishing, because it is the
1157
1539
  * difference between the two ways a toolkit can answer a discrete input.
@@ -1173,24 +1555,29 @@ export default class Window extends Drawable {
1173
1555
  * "draw it now" to every notch — the precise work this gate exists to skip.
1174
1556
  *
1175
1557
  * `false` whenever nothing gates blits at all: `frameSync: false` sends no
1176
- * fence and `frameInterval: 0` keeps no interval, so a caller who asked for
1177
- * both gets the unpaced behaviour those options ask for.
1558
+ * fence and takes the vblank clock with it (a completion is a wait on the
1559
+ * server too), and `frameInterval: 0` keeps no interval, so a caller who
1560
+ * asked for both gets the unpaced behaviour those options ask for.
1178
1561
  */
1179
1562
  frameInFlight() {
1180
- return this._frame.inFlight || this._presentPending;
1563
+ return this._frame.inFlight || this._frame.presentInFlight || this._presentPending;
1181
1564
  }
1182
1565
 
1183
1566
  _present() {
1184
1567
  if (!this._backing) return;
1568
+ const f = this._frame;
1185
1569
  const wait = this._presentWait();
1186
- if (this._frame.inFlight || wait > 0) {
1187
- // the server hasn't confirmed the previous frame yet, or the last blit
1188
- // was too recent — defer, and leave a wakeup behind
1570
+ if (f.inFlight || f.presentInFlight || wait > 0) {
1571
+ // the server hasn't confirmed the previous frame yet, the display hasn't
1572
+ // shown it, or the last blit was too recent — defer, leaving a wakeup
1189
1573
  this._deferPresent(wait);
1190
1574
  return;
1191
1575
  }
1192
1576
  this._presentNow();
1193
- this._armFence();
1577
+ // the fence is what ends this blit's frame unless the display is doing
1578
+ // it — which is decided by _presentNow, since a blit that fell back to
1579
+ // CopyArea gets no completion however the clock is configured
1580
+ if (!f.presentInFlight) this._armFence();
1194
1581
  }
1195
1582
 
1196
1583
  /**
@@ -1199,8 +1586,16 @@ export default class Window extends Drawable {
1199
1586
  * The gate is `frameInterval`, the same knob that paces frames, so
1200
1587
  * `frameInterval: 0` keeps the old fence-only behaviour — a caller who asked
1201
1588
  * for no timer gate gets none here either.
1589
+ *
1590
+ * The vblank clock needs no such gate and must not inherit the default one:
1591
+ * an outstanding present already means the display has not caught up, and
1592
+ * that is a truer statement of the same thing than any interval. Holding a
1593
+ * click's repaint for a 16ms default on a 165Hz output is exactly the
1594
+ * latency this gate was careful not to add. An explicit interval still
1595
+ * applies — a cap the caller asked for is a cap.
1202
1596
  */
1203
1597
  _presentWait() {
1598
+ if (this._clockOnPresent() && !this._frameIntervalExplicit) return 0;
1204
1599
  const min = this.frameInterval;
1205
1600
  if (!(min > 0)) return 0;
1206
1601
  const since = performance.now() - this._frame.lastPresentAt;
@@ -1211,16 +1606,17 @@ export default class Window extends Drawable {
1211
1606
  * Hold a blit back, and guarantee it still happens.
1212
1607
  *
1213
1608
  * This is the only place `_presentPending` is set, because every deferral has
1214
- * to leave a wakeup behind: the fence reply when the fence is what we are
1215
- * waiting on, this timer when the interval is. Nothing else reschedules a
1216
- * present — `_scheduleFrame()` bails while a frame timer is armed, and
1217
- * neither reschedule condition (in `_armFence`'s reply or `_armTimer`'s
1218
- * callback) looks at the present. Without the timer the last blit of a burst
1219
- * would simply never go out, leaving stale pixels on screen.
1609
+ * to leave a wakeup behind: the end of the frame we are waiting on (its
1610
+ * fence reply, or its completion), this timer when the interval is what we
1611
+ * are waiting on. Nothing else reschedules a present — `_scheduleFrame()`
1612
+ * bails while a frame timer is armed, and no reschedule condition
1613
+ * (`_frameEnded`, `_armTimer`'s callback) looks at the present. Without the
1614
+ * timer the last blit of a burst would simply never go out, leaving stale
1615
+ * pixels on screen.
1220
1616
  */
1221
1617
  _deferPresent(wait) {
1222
1618
  this._presentPending = true;
1223
- if (wait <= 0) return; // waiting on the fence: its reply blits this
1619
+ if (wait <= 0) return; // waiting on the frame: its end blits this
1224
1620
  const f = this._frame;
1225
1621
  if (f.presentTimer) return; // already armed
1226
1622
  f.presentTimer = setTimeout(() => {
@@ -1228,9 +1624,9 @@ export default class Window extends Drawable {
1228
1624
  if (!this._presentPending && !this._dirty) return; // someone blitted it
1229
1625
  const again = this._presentWait();
1230
1626
  if (again > 0) return this._deferPresent(again); // a paced frame re-stamped
1231
- if (f.inFlight) return; // the fence ack is closer; it will blit
1627
+ if (f.inFlight || f.presentInFlight) return; // the frame's end is closer
1232
1628
  this._presentNow();
1233
- this._armFence();
1629
+ if (!f.presentInFlight) this._armFence();
1234
1630
  }, wait);
1235
1631
  if (typeof f.presentTimer.unref === 'function') f.presentTimer.unref();
1236
1632
  }
@@ -1263,17 +1659,49 @@ export default class Window extends Drawable {
1263
1659
  */
1264
1660
  enablePresent() {
1265
1661
  if (this._presentExt || this._destroyed) return Promise.resolve(this);
1662
+ // Memoized because `createWindow({ present: true })` starts this and
1663
+ // `await wnd.enablePresent()` is the documented way to find out whether it
1664
+ // took — doing both is the normal thing to write, and two runs in flight
1665
+ // at once would each allocate an update region, orphaning one on the
1666
+ // server for the life of the window.
1667
+ if (this._presentPromise) return this._presentPromise;
1266
1668
  const X = this.X;
1267
1669
  const need = (name) =>
1268
1670
  new Promise((resolve) => X.require(name, (err, ext) => resolve(err ? null : ext)));
1269
- return Promise.all([need('present'), need('fixes')]).then(([present, fixes]) => {
1671
+ this._presentPromise = Promise.all([need('present'), need('fixes')]).then(([present, fixes]) => {
1270
1672
  if (!present || !fixes || this._destroyed) return this; // stay on CopyArea
1271
1673
  this._updateRegion = X.AllocID();
1272
1674
  safeRelease(X, () => fixes.CreateRegion(this._updateRegion, []));
1273
1675
  this._presentExt = present;
1274
1676
  this._fixesExt = fixes;
1677
+ this._selectPresentInput();
1275
1678
  return this;
1276
1679
  });
1680
+ return this._presentPromise;
1681
+ }
1682
+
1683
+ /**
1684
+ * Ask for the completion events the vblank clock runs on.
1685
+ *
1686
+ * CompleteNotify only; IdleNotify would say the same thing twice, since
1687
+ * every present ntk sends carries Option.Copy and the spec makes such a
1688
+ * pixmap idle "as soon as the operation occurs" — the moment the completion
1689
+ * reports. Nothing is selected for a window pinned to the fence, so a
1690
+ * caller who opted out is not sent events it will not read.
1691
+ */
1692
+ _selectPresentInput() {
1693
+ const P = this._presentExt;
1694
+ if (!P || this._presentEid || this._destroyed) return;
1695
+ if (this._frameClockPref === 'fence' || !this._frameSyncEnabled) return;
1696
+ const eid = this.X.AllocID();
1697
+ // the id is only recorded once the request is really on its way: the
1698
+ // clock's whole contract is that a completion will arrive, and a
1699
+ // selection that never left (a connection closing under us) promises
1700
+ // nothing. Failing this way leaves the window on the fence, which works.
1701
+ safeRelease(this.X, () => {
1702
+ P.SelectInput(eid, this.id, P.EventMask.CompleteNotify);
1703
+ this._presentEid = eid;
1704
+ });
1277
1705
  }
1278
1706
 
1279
1707
  /**
@@ -1284,6 +1712,7 @@ export default class Window extends Drawable {
1284
1712
  _presentWithExtension(rects) {
1285
1713
  const P = this._presentExt;
1286
1714
  if (!P || !this._fixesExt || !this._updateRegion) return false;
1715
+ if (this._presentFlipped) return false; // see _handlePresentEvent
1287
1716
  safeRelease(this.X, () => {
1288
1717
  this._fixesExt.SetRegion(
1289
1718
  this._updateRegion,
@@ -1329,10 +1758,20 @@ export default class Window extends Drawable {
1329
1758
  // Stamped here rather than at the call sites so it is a true inter-blit
1330
1759
  // interval across all three of them, and only when pixels really move — a
1331
1760
  // present that copies nothing must not push the next one out.
1332
- this._frame.lastPresentAt = performance.now();
1761
+ const f = this._frame;
1762
+ f.lastPresentAt = performance.now();
1333
1763
  // Present sends the exact rectangles in one request, so the bounding-box
1334
1764
  // collapse — which trades pixels for fewer requests — has nothing to buy
1335
- if (!this._presentWithExtension(clamped)) {
1765
+ if (this._presentWithExtension(clamped)) {
1766
+ // This frame is now the display's to end. Only a present through the
1767
+ // extension can be: a CopyArea reports nothing back, so a window that
1768
+ // fell back to it must keep the fence even with the clock configured.
1769
+ if (this._clockOnPresent()) {
1770
+ f.presentInFlight = true;
1771
+ f.presentAt = f.lastPresentAt;
1772
+ this._armStallWatchdog();
1773
+ }
1774
+ } else {
1336
1775
  const rects = this._blitList(clamped);
1337
1776
  // a paced frame can fire after the connection started closing
1338
1777
  safeRelease(this.X, () => {