ntk 8.3.0 → 8.3.1

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/app.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { connectionGone } from './cleanup.js';
1
2
  import Clipboard from './clipboard.js';
2
3
  import { CursorCache } from './cursor.js';
3
4
  import { GLError, backendFor, glCapabilities, glError, resolveGLPolicy } from './gl.js';
@@ -253,6 +254,12 @@ export default class App {
253
254
  _probeRefreshRate() {
254
255
  this._refreshProbe = null; // in progress; only ever started once
255
256
  const X = this.X;
257
+ // A connection on its way out is one more "no rate to be had". The probe
258
+ // is started lazily by the first window built on this connection, which
259
+ // may be one adopted from an event still arriving as it closes — and
260
+ // node-x11 throws synchronously at a request from then on, where the
261
+ // event dispatch has no caller to catch it (issue #321).
262
+ if (connectionGone(X)) return;
256
263
  X.require('randr', (err, R) => {
257
264
  if (err || !R) return;
258
265
  const root = this.display.screen[0].root;
package/lib/cleanup.js CHANGED
@@ -1,11 +1,27 @@
1
- // Release a server-side resource, tolerating a connection that is closing or
2
- // already gone — the X server frees all of a client's resources on disconnect,
3
- // so there is nothing left to do and nothing worth throwing about. This
4
- // matters for FinalizationRegistry callbacks, which run after app.close() if
5
- // wrappers get garbage collected late and have no user code around them to
6
- // catch.
1
+ /**
2
+ * Whether a request issued now would throw rather than reach the server.
3
+ * node-x11 throws *synchronously* ("client is in closing state") once
4
+ * `close()` has begun, and a destroyed or ended stream is the same state one
5
+ * step further along.
6
+ *
7
+ * The check matters wherever a request is issued from somewhere with no
8
+ * caller to catch: a FinalizationRegistry callback, a paced frame's timer, or
9
+ * the X event dispatch itself — events already in the read buffer keep being
10
+ * delivered after `close()` (issue #321).
11
+ */
12
+ export function connectionGone(X) {
13
+ return !!(X._closing || !X.stream || X.stream.destroyed || X.stream.writableEnded);
14
+ }
15
+
16
+ // Issue requests — typically releasing a server-side resource — tolerating a
17
+ // connection that is closing or already gone. The X server frees all of a
18
+ // client's resources on disconnect, so there is nothing left to do and
19
+ // nothing worth throwing about. This matters wherever the requests have no
20
+ // caller around them to catch: FinalizationRegistry callbacks, which run
21
+ // after app.close() if wrappers get garbage collected late, a paced frame's
22
+ // timer, and the X event dispatch (issue #321).
7
23
  export function safeRelease(X, fn) {
8
- if (X._closing || !X.stream || X.stream.destroyed || X.stream.writableEnded) return;
24
+ if (connectionGone(X)) return;
9
25
  try {
10
26
  fn();
11
27
  } catch {
package/lib/window.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import x11 from 'x11';
2
2
 
3
3
  import { builtin } from './builtin.js';
4
- import { safeRelease } from './cleanup.js';
4
+ import { connectionGone, safeRelease } from './cleanup.js';
5
5
  import Drawable from './drawable.js';
6
6
  import { packIcons, unpackIcons } from './imagedata.js';
7
7
  import Pixmap from './pixmap.js';
@@ -606,22 +606,34 @@ export default class Window extends Drawable {
606
606
  // client chose the visual for (issue #295). They go out in the same
607
607
  // batch, so the pair costs one round trip, not two, and `ready` is the
608
608
  // wait for both.
609
- this._readyPending = 2;
610
- X.GetGeometry(this.id, (err, res) => {
611
- // A window that was destroyed between the id reaching us and this
612
- // request reaching the server answers BadWindow, and there is
613
- // nothing to record. `ready` still resolves: adopting a window that
614
- // has since gone is ordinary for a window manager, and a wait that
615
- // never ends is worse than one that ends with width undefined.
616
- this._readyPending--;
617
- if (err) this._settleReady();
618
- else this._applyGeometry(unpackGeometry(res));
619
- });
620
- X.GetWindowAttributes(this.id, (err, attrs) => {
621
- this._readyPending--;
622
- if (!err) this.visualId = attrs.visual;
609
+ //
610
+ // Nothing is asked of a connection that is going away: node-x11 throws
611
+ // synchronously at a request once close() has begun, and adoption
612
+ // happens from places with no caller to catch — a routed child-event,
613
+ // XEmbed, the shared glyph cache all build windows from inside the X
614
+ // event dispatch (issue #321). There is nothing to learn there either,
615
+ // so `ready` settles with the geometry unknown, the same outcome as a
616
+ // window destroyed before its reply landed (see the getter).
617
+ this._readyPending = connectionGone(X) ? 0 : 2;
618
+ if (this._readyPending) {
619
+ X.GetGeometry(this.id, (err, res) => {
620
+ // A window that was destroyed between the id reaching us and this
621
+ // request reaching the server answers BadWindow, and there is
622
+ // nothing to record. `ready` still resolves: adopting a window that
623
+ // has since gone is ordinary for a window manager, and a wait that
624
+ // never ends is worse than one that ends with width undefined.
625
+ this._readyPending--;
626
+ if (err) this._settleReady();
627
+ else this._applyGeometry(unpackGeometry(res));
628
+ });
629
+ X.GetWindowAttributes(this.id, (err, attrs) => {
630
+ this._readyPending--;
631
+ if (!err) this.visualId = attrs.visual;
632
+ this._settleReady();
633
+ });
634
+ } else {
623
635
  this._settleReady();
624
- });
636
+ }
625
637
  }
626
638
 
627
639
  Window._cacheFor(app).set(this.id, this);
@@ -779,6 +791,12 @@ export default class Window extends Drawable {
779
791
  this.on('child-event', (ev) => {
780
792
  const eventName = xevents.eventName[ev.type];
781
793
  if (!eventName) return;
794
+ // Events buffered before `close()` keep being dispatched after it, and
795
+ // a window this client already saw destroyed has no substructure left
796
+ // to report. Neither is worth building a child for — and the questions
797
+ // the constructor would ask on the way out are what used to throw into
798
+ // the dispatcher (issue #321).
799
+ if (this._destroyed || connectionGone(X)) return;
782
800
  const child = new Window(app, { id: ev.wid });
783
801
  const ntkev = { ...ev, parent: this, window: child, target: child };
784
802
  // wait until we know that we track correct x,y,w,h values
@@ -788,6 +806,9 @@ export default class Window extends Drawable {
788
806
  });
789
807
 
790
808
  this.on('newListener', (name) => {
809
+ // one of ntk's own handlers going on, not a caller asking for an
810
+ // event: nothing to arm and nothing to select — see _listenInternal
811
+ if (this._internalListen) return;
791
812
  // Listening for 'close' is the opt-in. WM_DELETE_WINDOW only reaches a
792
813
  // client that advertised it in WM_PROTOCOLS — a window manager kills
793
814
  // anyone else outright — and having to know that, on top of decoding a
@@ -814,6 +835,13 @@ export default class Window extends Drawable {
814
835
  const eventMask = xevents.mask[name];
815
836
  if (!eventMask) return;
816
837
  if ((eventMask & this.eventMask) === 0) {
838
+ // A connection on its way out can select nothing, and node-x11 throws
839
+ // synchronously at a request from then on. This hook runs from
840
+ // `.on()` — including the calls made while adopting a window from
841
+ // inside the X event dispatch, where there is no caller to catch it
842
+ // (issue #321). Before the mask is touched, so it goes on saying what
843
+ // this connection holds.
844
+ if (connectionGone(X)) return;
817
845
  this.eventMask |= eventMask;
818
846
  // the selection can legitimately fail — SubstructureRedirect is
819
847
  // one-client-only, so `on('map_request')` is how you find out
@@ -821,14 +849,29 @@ export default class Window extends Drawable {
821
849
  // error hook rather than dropping it; selectInput() is the
822
850
  // explicit form that hands the error straight back.
823
851
  X.ChangeWindowAttributes(this.id, { eventMask: this.eventMask }, (err) => {
824
- if (err) this.app.options.onXError?.(err);
852
+ if (!err) return;
853
+ // the request failed as a whole, so the server kept the mask it
854
+ // had: drop the bits again rather than leave `eventMask` claiming
855
+ // a selection this connection does not hold. selectInput() trusts
856
+ // it to decide whether a write can be skipped.
857
+ this.eventMask &= ~eventMask;
858
+ this.app.options.onXError?.(err);
825
859
  });
826
860
  }
827
861
  });
828
862
 
829
- this.on('map', () => (this._mapped = true));
830
- this.on('unmap', () => (this._mapped = false));
831
- this.on('destroy', () => {
863
+ // ntk's own bookkeeping listens for the same events an app does, and the
864
+ // hook above reads a listener as a caller expressing interest and selects
865
+ // for it. For a window ntk created that is free — StructureNotify is in
866
+ // the CreateWindow value list already — but for an adopted one the
867
+ // tracked mask starts at zero and the write is absolute, so it would
868
+ // replace whatever this connection had selected on a window another
869
+ // client owns (issue #322). Subscribe past the hook: adopting a window
870
+ // costs no requests and changes no server-side state, and a caller that
871
+ // wants these events still asks for them, with on('map') or selectInput.
872
+ this._listenInternal('map', () => (this._mapped = true));
873
+ this._listenInternal('unmap', () => (this._mapped = false));
874
+ this._listenInternal('destroy', () => {
832
875
  this._destroyed = true;
833
876
  this._forget();
834
877
  this._teardownFrame(false); // the server has already taken the window
@@ -842,6 +885,24 @@ export default class Window extends Drawable {
842
885
  }
843
886
  }
844
887
 
888
+ /**
889
+ * Attach one of ntk's own handlers without the `newListener` hook reading
890
+ * it as a caller asking for the event. The hook's job is to turn expressed
891
+ * interest into a server-side selection; ntk's internal bookkeeping
892
+ * expresses none — it only wants the events that arrive anyway — and on an
893
+ * adopted window a selection nobody asked for overwrites another
894
+ * subsystem's (issue #322). The flag is read synchronously by the hook,
895
+ * which EventEmitter emits before the listener is stored.
896
+ */
897
+ _listenInternal(name, fn) {
898
+ this._internalListen = true;
899
+ try {
900
+ this.on(name, fn);
901
+ } finally {
902
+ this._internalListen = false;
903
+ }
904
+ }
905
+
845
906
  /**
846
907
  * Resolves with this window once its geometry is known — immediately for a
847
908
  * window ntk created (it already knows what it asked for), when the
@@ -1098,7 +1159,11 @@ export default class Window extends Drawable {
1098
1159
 
1099
1160
  _handleExpose(ev) {
1100
1161
  if (this._backingValid) {
1101
- this.X.CopyArea(this._backing.id, this.id, this._presentGc, ev.x, ev.y, ev.x, ev.y, ev.width, ev.height);
1162
+ // an expose can arrive after the connection started closing, and this
1163
+ // runs inside the event dispatch where a throw has no caller (#321)
1164
+ safeRelease(this.X, () => {
1165
+ this.X.CopyArea(this._backing.id, this.id, this._presentGc, ev.x, ev.y, ev.x, ev.y, ev.width, ev.height);
1166
+ });
1102
1167
  } else {
1103
1168
  this._requestRedraw();
1104
1169
  }
@@ -3517,13 +3582,26 @@ export default class Window extends Drawable {
3517
3582
  * either makes you the window manager or rejects with BadAccess because
3518
3583
  * something else already is. The mask is OR-ed into whatever handlers
3519
3584
  * have already asked for.
3585
+ *
3586
+ * A mask this connection already holds is a request that would change
3587
+ * nothing, so it resolves without one — `eventMask` is what this
3588
+ * connection selected and nothing else adds to it behind the caller's
3589
+ * back. It resolves rather than returning undefined because the promise is
3590
+ * the whole interface: an awaited no-op still has to mean "you have it".
3520
3591
  */
3521
3592
  selectInput(mask) {
3522
- this.eventMask |= mask;
3593
+ const added = mask & ~this.eventMask;
3594
+ if (added === 0) return Promise.resolve(this);
3595
+ this.eventMask |= added;
3523
3596
  return new Promise((resolve, reject) => {
3524
- this.X.ChangeWindowAttributes(this.id, { eventMask: this.eventMask }, (err) =>
3525
- err ? reject(err) : resolve(this)
3526
- );
3597
+ this.X.ChangeWindowAttributes(this.id, { eventMask: this.eventMask }, (err) => {
3598
+ if (!err) return resolve(this);
3599
+ // a refused write left the server's mask as it was — give back the
3600
+ // bits, or the next call would find them held and skip a request
3601
+ // that has not happened yet
3602
+ this.eventMask &= ~added;
3603
+ reject(err);
3604
+ });
3527
3605
  });
3528
3606
  }
3529
3607
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ntk",
3
- "version": "8.3.0",
3
+ "version": "8.3.1",
4
4
  "description": "Desktop UI toolkit for X11 with canvas-like 2d and OpenGL rendering",
5
5
  "author": "Andrey Sidorov <sidorares@yandex.ru>",
6
6
  "license": "MIT",