ntk 8.2.0 → 8.3.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.
@@ -35,6 +35,7 @@
35
35
  import GlyphSet from './glyphset.js';
36
36
  import { ellipseSegments, flattenPath } from './path.js';
37
37
  import { rasterizePolys } from './rasterize.js';
38
+ import { SHAPE_PAGE_TOKEN, sharedGlyphsFor } from './sharedglyphs.js';
38
39
 
39
40
  /**
40
41
  * Shape rendering policy, overridable per app via `app.shapePolicy`
@@ -164,6 +165,28 @@ export function orientCorner(master, corner) {
164
165
  return out;
165
166
  }
166
167
 
168
+ /** a fresh AddGlyphs object per call — node-x11 mutates its input; the
169
+ * oriented bitmap buffer itself is never written to and can be retained */
170
+ function uploadCorner(lid, image, stride, height) {
171
+ return {
172
+ id: lid,
173
+ // width is the padded stride, like the text page: node-x11 then
174
+ // ships the buffer as-is instead of re-padding a copy, and the spare
175
+ // zero columns composite as no-ops under Over
176
+ width: stride,
177
+ height,
178
+ // x/y 0: the glyph's top-left lands exactly on its elt position
179
+ x: 0,
180
+ y: 0,
181
+ // corners do not advance a pen. node-x11 expects offX/offY in 26.6
182
+ // fixed point and mutates the objects it is given, which is why
183
+ // these are fresh one-shot objects (see AGENTS.md)
184
+ offX: 0,
185
+ offY: 0,
186
+ image
187
+ };
188
+ }
189
+
167
190
  /**
168
191
  * Server-side cache of corner glyphs — one page per app, all radii and
169
192
  * border widths together, mirroring `GlyphPage` for text (text/glyphs.js).
@@ -171,19 +194,28 @@ export function orientCorner(master, corner) {
171
194
  * Local ids are compact and sequential so CompositeGlyphs uses the 8-bit
172
195
  * encoding for the first 256 distinct corners (a shape population never
173
196
  * leaves it in practice); new corners upload lazily in one AddGlyphs batch
174
- * per draw that introduces any.
197
+ * per draw that introduces any — and, when the display's shared glyph
198
+ * directory is live (docs/shared-glyphs.md), re-bind to the display-wide
199
+ * corner page so every ntk app shares four glyphs per (radius, band width).
175
200
  */
176
201
  export class ShapeGlyphPage {
177
202
  constructor(app) {
178
203
  this.app = app;
179
- this.glyphset = new GlyphSet(app);
180
- this.entries = new Map(); // cornerKey -> { lid }
181
- this.bytes = 0; // uploaded bitmap bytes, for the cache budget
204
+ // private from birth when shared glyphs are off; minted lazily when on
205
+ // (a page resolving entirely from the directory never creates one) —
206
+ // exactly the arrangement GlyphPage has (text/glyphs.js)
207
+ this.glyphset = sharedGlyphsFor(app) ? null : new GlyphSet(app);
208
+ this.entries = new Map(); // cornerKey -> { lid, gs }
209
+ this.bytes = 0; // privately uploaded bitmap bytes, for the cache budget
210
+ this._lids = 0; // next private lid
211
+ this._maxLid = -1; // widest id this page composites, private or shared
212
+ this._privateCount = 0; // entries still bound to the private set
213
+ this._shared = undefined; // SharedPage binding, resolved on first ensure
182
214
  }
183
215
 
184
216
  /** bits per glyph id needed to address this page */
185
217
  get bits() {
186
- return this.entries.size <= 256 ? 8 : 16;
218
+ return this._maxLid > 255 ? 16 : 8;
187
219
  }
188
220
 
189
221
  /**
@@ -192,13 +224,21 @@ export class ShapeGlyphPage {
192
224
  * a Map hit and nothing else. Within one batch the top-left master of each
193
225
  * (kind, rx, ry, bw) family rasterizes once and the other corners mirror
194
226
  * it (see `orientCorner`).
227
+ *
228
+ * With the shared directory live, new corners are also asked about — the
229
+ * `cornerKey()` string goes on the wire verbatim as the member key
230
+ * (docs/shared-glyphs.md) — and their entries re-bind to the display-wide
231
+ * page on the reply, so every ntk app's boxes of one radius share four
232
+ * glyphs per (r, bw) across the whole display.
195
233
  */
196
234
  ensure(specs) {
197
235
  let batch = null;
198
236
  let masters = null;
237
+ let ask = null;
238
+ const shared = this._sharedBinding();
199
239
  for (const spec of specs) {
200
240
  if (this.entries.has(spec.key)) continue;
201
- const lid = this.entries.size;
241
+ const lid = this._lids++;
202
242
  if (lid >= 65536) {
203
243
  // 2^16 corners on one connection — unreachable (the cache budget
204
244
  // resets the page long before), but fail loudly rather than corrupt
@@ -212,39 +252,67 @@ export class ShapeGlyphPage {
212
252
  masters.set(mkey, master);
213
253
  }
214
254
  const image = orientCorner(master, spec.corner);
215
- this.entries.set(spec.key, { lid });
255
+ if (!this.glyphset) this.glyphset = new GlyphSet(this.app);
256
+ if (lid > this._maxLid) this._maxLid = lid;
257
+ this.entries.set(spec.key, { lid, gs: this.glyphset.id, private: true });
258
+ this._privateCount++;
216
259
  if (!batch) batch = [];
217
- batch.push({
218
- id: lid,
219
- // width is the padded stride, like the text page: node-x11 then
220
- // ships the buffer as-is instead of re-padding a copy, and the spare
221
- // zero columns composite as no-ops under Over
222
- width: master.stride,
223
- height: master.h,
224
- // x/y 0: the glyph's top-left lands exactly on its elt position
225
- x: 0,
226
- y: 0,
227
- // corners do not advance a pen. node-x11 expects offX/offY in 26.6
228
- // fixed point and mutates the objects it is given, which is why
229
- // these are fresh one-shot objects (see AGENTS.md)
230
- offX: 0,
231
- offY: 0,
232
- image
233
- });
260
+ batch.push(uploadCorner(lid, image, master.stride, master.h));
234
261
  this.bytes += image.length;
262
+ if (shared && shared.open) {
263
+ if (!ask) ask = [];
264
+ ask.push({ key: spec.key, payload: { image, stride: master.stride, height: master.h } });
265
+ }
235
266
  }
236
267
  if (batch) this.glyphset.addGlyphs(batch);
268
+ if (ask) shared.ask(ask);
237
269
  }
238
270
 
239
271
  entry(key) {
240
272
  return this.entries.get(key);
241
273
  }
242
274
 
243
- /** free the server-side glyphset (budget reset / shutdown) */
275
+ /** the shared side of this page; null when the feature is off */
276
+ _sharedBinding() {
277
+ if (this._shared !== undefined) return this._shared;
278
+ const client = sharedGlyphsFor(this.app);
279
+ this._shared = client
280
+ ? client.bindPage({
281
+ token: SHAPE_PAGE_TOKEN,
282
+ indices: false, // member keys are cornerKey() strings, verbatim
283
+ makeGlyph: (key, payload, lid) =>
284
+ uploadCorner(lid, payload.image, payload.stride, payload.height),
285
+ adopt: (key, lid, gsid) => this._adoptShared(key, lid, gsid)
286
+ })
287
+ : null;
288
+ return this._shared;
289
+ }
290
+
291
+ /** re-bind one corner to the shared set; drop the private set once
292
+ * nothing composites from it any more */
293
+ _adoptShared(key, lid, gsid) {
294
+ const prev = this.entries.get(key);
295
+ this.entries.set(key, { lid, gs: gsid });
296
+ if (lid > this._maxLid) this._maxLid = lid;
297
+ if (prev && prev.private && --this._privateCount === 0 && this.glyphset) {
298
+ this.glyphset.destroy();
299
+ this.glyphset = null;
300
+ this.bytes = 0;
301
+ this._lids = 0;
302
+ }
303
+ }
304
+
305
+ /** free the server-side glyphset and shared alias (budget reset / shutdown) */
244
306
  destroy() {
245
- this.glyphset.destroy();
307
+ if (this.glyphset) this.glyphset.destroy();
308
+ this.glyphset = null;
309
+ if (this._shared) this._shared.destroy();
310
+ this._shared = undefined;
246
311
  this.entries.clear();
247
312
  this.bytes = 0;
313
+ this._lids = 0;
314
+ this._maxLid = -1;
315
+ this._privateCount = 0;
248
316
  }
249
317
  }
250
318
 
@@ -0,0 +1,433 @@
1
+ // Shared glyphs across processes — the client half (docs/shared-glyphs.md).
2
+ //
3
+ // A display-wide "glyph directory" (lib/glyphdirectory.js) owns the
4
+ // `_NTK_GLYPHD` manager selection and is the single allocator of compact
5
+ // local ids inside shared RENDER glyphsets. This module is everything an app
6
+ // needs to *use* it: discovery (GetSelectionOwner; self-election when nobody
7
+ // owns it yet), the ensure/added property RPC, the XID-reuse fence around
8
+ // ReferenceGlyphSet, and the per-page state machine `GlyphPage` and
9
+ // `ShapeGlyphPage` bind to.
10
+ //
11
+ // The feature is a pure overlay: any failure — no owner, a dead owner, a
12
+ // refused request, a hardened server — closes the shared side of the page it
13
+ // happened to, and glyphs keep rendering through today's private per-process
14
+ // path. Entries already bound to a shared set stay bound: aliases survive
15
+ // the directory's death (fenced by test/shared-glyphs-assumptions.test.js),
16
+ // so pages merely freeze rather than break.
17
+ //
18
+ // The one rule everything here enforces: a client never composites a glyph
19
+ // id it has not confirmed present — Xorg silently skips unknown ids without
20
+ // advancing the pen, which corrupts layout rather than erroring.
21
+
22
+ import x11 from 'x11';
23
+
24
+ import { variationKey } from './text/font.js';
25
+ import { builtin } from './builtin.js';
26
+ import GlyphSet from './glyphset.js';
27
+ import GlyphDirectory from './glyphdirectory.js';
28
+ import { GLYPHD, encodeGlyphdRequest, parseGlyphdReply } from './glyphdwire.js';
29
+
30
+ export { GLYPHD, RPC_VERSION, encodeGlyphdRequest, parseGlyphdRequest, encodeGlyphdReply, parseGlyphdReply } from './glyphdwire.js';
31
+
32
+ /** shape pages: one display-wide page for every corner glyph, as today's
33
+ * per-process page is one page for all corners (lib/shapeglyphs.js) */
34
+ export const SHAPE_PAGE_TOKEN = 'ntks1:a8';
35
+
36
+ // How long to wait for the directory's DONE before declaring it dead. The
37
+ // answer is one property read and one write away, so a healthy directory
38
+ // answers in a round trip or two; this is a liveness fence, not a pace.
39
+ const RPC_TIMEOUT = 2000;
40
+
41
+ /**
42
+ * The token naming one shared font page: content-addressed, because the
43
+ * generator's input is a font file far too big to name literally.
44
+ *
45
+ * ntkg1:<sha1 of font file bytes>:<variation coords>:<px size>:<format>
46
+ *
47
+ * The `ntkg1` version prefix is load-bearing, not hygiene: advances are
48
+ * baked into glyphs at AddGlyphs time and idempotent duplicate uploads
49
+ * assume byte-identical rasterization, both of which hold only among
50
+ * processes running the same rasterizer and rounding. Change either — bump
51
+ * the version, and two ntk versions on one display simply share nothing.
52
+ *
53
+ * The sha1 is computed from the loaded font buffer, lazily on first shared
54
+ * use, and cached on the base face (a variation instance hashes the same
55
+ * file its base was cut from). `null` — no buffer to hash, or no crypto in
56
+ * this environment — means this page stays private, which is the correct
57
+ * degradation everywhere it can happen.
58
+ *
59
+ * @param {Font} font
60
+ * @param {number} size pixel size
61
+ * @returns {string|null}
62
+ */
63
+ export function fontPageToken(font, size) {
64
+ const base = font.variationOf ?? font;
65
+ if (base._ntkSha1 === undefined) {
66
+ const buffer = base.fk?.stream?.buffer;
67
+ const crypto = buffer ? builtin('node:crypto') : undefined;
68
+ base._ntkSha1 = crypto ? crypto.createHash('sha1').update(buffer).digest('hex') : null;
69
+ }
70
+ if (!base._ntkSha1) return null;
71
+ return `ntkg1:${base._ntkSha1}:${variationKey(font.variationCoords)}:${size}:a8`;
72
+ }
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // The per-app client
76
+ // ---------------------------------------------------------------------------
77
+
78
+ /**
79
+ * The shared-glyph client of one connection — `app.sharedGlyphs`, `null`
80
+ * when the feature is off (`NTK_NO_SHARED_GLYPHS`, or
81
+ * `createClient({ sharedGlyphs: false })`).
82
+ *
83
+ * Everything here is driven lazily by the glyph pages: nothing touches the
84
+ * server until the first page binds. Discovery self-elects when no directory
85
+ * owns the selection — the first ntk app on a display becomes the directory,
86
+ * embedded behind this same code path.
87
+ */
88
+ export class SharedGlyphs {
89
+ constructor(app) {
90
+ this.app = app;
91
+ this.X = app.X;
92
+ /** 'idle' | 'resolving' | 'active' | 'dead' (dead = waiting for MANAGER) */
93
+ this.state = 'idle';
94
+ /** bumps whenever the owner changes or dies; bindings pin the epoch a
95
+ * reply was fenced under, so a stale await cannot adopt across owners */
96
+ this.epoch = 0;
97
+ this.ownerWid = 0;
98
+ this._atoms = null;
99
+ this._mailbox = null;
100
+ this._directory = null; // GlyphDirectory when this app self-elected
101
+ this._resolving = null;
102
+ this._serial = 0;
103
+ this._waiters = new Map(); // serial -> resolve(status)
104
+ this._chain = Promise.resolve(); // RPCs are serialized: one property pair
105
+ }
106
+
107
+ /** Bind one glyph page to the shared cache; see SharedPage. */
108
+ bindPage(adapter) {
109
+ return new SharedPage(this, adapter);
110
+ }
111
+
112
+ /**
113
+ * Resolve discovery once: 'idle' -> 'active' | 'dead'. After death only a
114
+ * MANAGER announcement (a new owner) re-activates; pages ask again then.
115
+ * @returns {Promise<boolean>} whether the directory is usable
116
+ */
117
+ _ready() {
118
+ if (this.state === 'active') return Promise.resolve(true);
119
+ if (this.state === 'dead') return Promise.resolve(false);
120
+ if (!this._resolving) this._resolving = this._resolve();
121
+ return this._resolving;
122
+ }
123
+
124
+ async _resolve() {
125
+ this.state = 'resolving';
126
+ try {
127
+ const names = [GLYPHD.selection, GLYPHD.ensure, GLYPHD.added, GLYPHD.done, GLYPHD.property, GLYPHD.manager];
128
+ const ids = await Promise.all(names.map((n) => this._atom(n)));
129
+ this._atoms = {
130
+ selection: ids[0],
131
+ ensure: ids[1],
132
+ added: ids[2],
133
+ done: ids[3],
134
+ property: ids[4],
135
+ manager: ids[5]
136
+ };
137
+ // the mailbox: selection-style helper window — request/reply properties
138
+ // live on it and DONE ClientMessages are addressed to it
139
+ this._mailbox = this.app.createWindow({
140
+ width: 1,
141
+ height: 1,
142
+ eventMask: x11.eventMask.PropertyChange
143
+ });
144
+ this._mailbox.on('message', (ev) => this._onDone(ev));
145
+ // handover/new-owner announcements arrive as MANAGER ClientMessages on
146
+ // the root; StructureNotify is the mask ICCCM says they are sent with.
147
+ // A server that refuses the selection just costs us handover detection.
148
+ const root = this.app.rootWindow();
149
+ root.on('message', (ev) => this._onRootMessage(ev));
150
+ await root.selectInput(x11.eventMask.StructureNotify).catch(() => {});
151
+
152
+ let owner = await this._selectionOwner();
153
+ if (!owner) {
154
+ // nobody home: self-elect. claim() resolves the race for us — it
155
+ // returns whoever actually owns the selection afterwards.
156
+ this._directory = new GlyphDirectory(this.app, this.app.options.sharedGlyphs || undefined);
157
+ owner = await this._directory.claim();
158
+ if (owner !== this._directory.wid) this._directory = null;
159
+ }
160
+ // a MANAGER announcement racing this discovery may have adopted a
161
+ // newer owner already — never clobber it with what we found first
162
+ if (this.state === 'resolving') {
163
+ if (!owner) {
164
+ this.state = 'dead';
165
+ return false;
166
+ }
167
+ this.ownerWid = owner;
168
+ this.state = 'active';
169
+ }
170
+ return this.state === 'active';
171
+ } catch {
172
+ if (this.state === 'resolving') this.state = 'dead';
173
+ return this.state === 'active';
174
+ }
175
+ }
176
+
177
+ /** the directory stopped answering (or its window is gone): freeze. New
178
+ * pages go private until a MANAGER announcement names a successor. */
179
+ _die() {
180
+ if (this.state === 'dead') return;
181
+ this.state = 'dead';
182
+ this.epoch++;
183
+ for (const resolve of this._waiters.values()) resolve(0);
184
+ this._waiters.clear();
185
+ }
186
+
187
+ _onRootMessage(ev) {
188
+ if (!this._atoms || ev.message_type !== this._atoms.manager) return;
189
+ if ((ev.data[1] >>> 0) !== this._atoms.selection) return;
190
+ const wid = ev.data[2] >>> 0;
191
+ if (!wid || (this.state === 'active' && wid === this.ownerWid)) return;
192
+ // a new owner (first announcement after a death, or a live handover):
193
+ // adopt it for pages bound from here on; already-bound pages notice the
194
+ // epoch change and freeze instead of mixing generations.
195
+ this.epoch++;
196
+ this.ownerWid = wid;
197
+ this.state = 'active';
198
+ }
199
+
200
+ _onDone(ev) {
201
+ if (!this._atoms || ev.message_type !== this._atoms.done) return;
202
+ const resolve = this._waiters.get(ev.data[0]);
203
+ if (!resolve) return;
204
+ this._waiters.delete(ev.data[0]);
205
+ resolve(ev.data[1]);
206
+ }
207
+
208
+ /**
209
+ * One RPC: write the request property on the mailbox, poke the directory,
210
+ * await DONE, read back the reply property (ensure only). Serialized so a
211
+ * single property pair serves every page; batching keeps this rare (one
212
+ * ensure per draw that met new glyphs, mirroring today's one AddGlyphs).
213
+ * Resolves `null` on any failure — a dead directory kills the client, a
214
+ * refusal only this request.
215
+ */
216
+ _rpc(kindAtom, payload, wantReply) {
217
+ const run = () => this._rpcNow(kindAtom, payload, wantReply);
218
+ const result = this._chain.then(run, run);
219
+ this._chain = result.catch(() => {});
220
+ return result;
221
+ }
222
+
223
+ async _rpcNow(kindAtom, payload, wantReply) {
224
+ if (this.state !== 'active') return null;
225
+ const epoch = this.epoch;
226
+ const a = this._atoms;
227
+ const serial = ++this._serial;
228
+ const X = this.X;
229
+ X.ChangeProperty(0, this._mailbox.id, a.property, a.property, 8, payload);
230
+ const status = await new Promise((resolve) => {
231
+ const timer = setTimeout(() => {
232
+ // no answer: the owner is gone or wedged — same consequence
233
+ this._waiters.delete(serial);
234
+ this._die();
235
+ resolve(0);
236
+ }, RPC_TIMEOUT);
237
+ timer.unref?.();
238
+ this._waiters.set(serial, (st) => {
239
+ clearTimeout(timer);
240
+ resolve(st);
241
+ });
242
+ // wid = the directory's own window: ClientMessages route to a Window
243
+ // wrapper by their wid field, and the mailbox rides in data[0]
244
+ X.SendClientMessage(this.ownerWid, this.ownerWid, kindAtom, 32, [this._mailbox.id, serial, a.property], 0, (err) => {
245
+ // BadWindow: the owner's window is gone — fail fast, not by timeout
246
+ if (err) this._die();
247
+ return true;
248
+ });
249
+ });
250
+ if (!status || this.epoch !== epoch) return null;
251
+ if (!wantReply) return { status };
252
+ const prop = await new Promise((resolve) =>
253
+ X.GetProperty(1, this._mailbox.id, a.property, 0, 0, 0x1fffffff, (err, res) => resolve(err ? null : res))
254
+ );
255
+ if (!prop || !prop.data || this.epoch !== epoch) return null;
256
+ const reply = parseGlyphdReply(prop.data);
257
+ return reply && reply.serial === serial ? reply : null;
258
+ }
259
+
260
+ _ensure(token, indices, keys) {
261
+ return this._rpc(this._atoms.ensure, encodeGlyphdRequest({ token, indices, keys }), true);
262
+ }
263
+
264
+ _added(token, indices, keys, bytes) {
265
+ return this._rpc(this._atoms.added, encodeGlyphdRequest({ token, indices, keys, bytes }), false);
266
+ }
267
+
268
+ _selectionOwner() {
269
+ return new Promise((resolve) =>
270
+ this.X.GetSelectionOwner(this._atoms.selection, (err, wid) => resolve(err ? 0 : wid))
271
+ );
272
+ }
273
+
274
+ _atom(name) {
275
+ return new Promise((resolve, reject) =>
276
+ this.X.InternAtom(false, name, (err, atom) => (err ? reject(err) : resolve(atom)))
277
+ );
278
+ }
279
+ }
280
+
281
+ /**
282
+ * One page's shared side, driven by `GlyphPage`/`ShapeGlyphPage` through an
283
+ * adapter:
284
+ *
285
+ * token the page's name on the wire (fontPageToken / SHAPE_PAGE_TOKEN)
286
+ * indices member keys are u32 font glyph indices rather than strings
287
+ * makeGlyph(key, payload, lid) a fresh AddGlyphs object for a glyph the
288
+ * directory reported absent (node-x11 mutates its input, so
289
+ * fresh per call); may rasterize when the payload carries no
290
+ * bitmap (the warm path)
291
+ * adopt(key, lid, gsid, payload) bind the page entry to the shared set —
292
+ * called only for ids confirmed present or just uploaded by us
293
+ *
294
+ * A binding binds to exactly one (owner epoch, gsid, generation). Any
295
+ * disagreement afterwards — new generation, new owner, a failed RPC — closes
296
+ * it: entries already adopted keep compositing from the alias (frozen), new
297
+ * glyphs stay on the page's private path, and a fresh page binds fresh.
298
+ */
299
+ export class SharedPage {
300
+ constructor(client, adapter) {
301
+ this.client = client;
302
+ this.adapter = adapter;
303
+ this.open = true;
304
+ this.bound = false;
305
+ this.alias = null; // GlyphSet referencing the directory's set
306
+ this.gsid = 0;
307
+ this.generation = 0;
308
+ this.pending = new Map(); // key -> payload, in flight
309
+ this._chain = Promise.resolve(); // asks are serialized per page
310
+ }
311
+
312
+ /**
313
+ * Ask the directory about keys this page has not confirmed yet.
314
+ * `items` is [{ key, payload }]; keys already in flight are skipped.
315
+ * Never rejects; resolves once the batch is adopted, uploaded, or dropped.
316
+ */
317
+ ask(items) {
318
+ if (!this.open) return Promise.resolve();
319
+ const keys = [];
320
+ for (const { key, payload } of items) {
321
+ if (this.pending.has(key)) continue;
322
+ this.pending.set(key, payload);
323
+ keys.push(key);
324
+ }
325
+ if (keys.length === 0) return this._chain;
326
+ const run = () => this._ask(keys).catch(() => this._drop(keys));
327
+ this._chain = this._chain.then(run, run);
328
+ return this._chain;
329
+ }
330
+
331
+ async _ask(keys) {
332
+ if (!(await this.client._ready()) || !this.open) return this._drop(keys);
333
+ const epoch = this.client.epoch;
334
+ const reply = await this.client._ensure(this.adapter.token, this.adapter.indices, keys);
335
+ if (!this.open) return this._drop(keys);
336
+ if (!reply || reply.entries.length !== keys.length) {
337
+ this._drop(keys);
338
+ this._close();
339
+ return;
340
+ }
341
+ if (!this.bound) {
342
+ // The XID-reuse fence. The reply's gsid must not be referenced into a
343
+ // void: the directory could be gone and the id recycled. Reference and
344
+ // GetSelectionOwner go out back to back; replies are processed in
345
+ // order, and the directory's contract is that it frees nothing while
346
+ // it owns the selection — so if the owner still is the window that
347
+ // advertised this gsid *after* the reference executed, the reference
348
+ // bound the intended set. Anything else: drop the alias and close (a
349
+ // reference that raced the death itself fails with a GLYPHSET error —
350
+ // an error, never wrong pixels — surfacing as one benign warning).
351
+ const alias = GlyphSet.referenceTo(this.client.app, reply.gsid);
352
+ const owner = await this.client._selectionOwner();
353
+ if (owner !== this.client.ownerWid || this.client.epoch !== epoch || !this.open) {
354
+ alias.destroy();
355
+ this._drop(keys);
356
+ this._close();
357
+ return;
358
+ }
359
+ this.alias = alias;
360
+ this.gsid = reply.gsid;
361
+ this.generation = reply.generation;
362
+ this.bound = true;
363
+ } else if (reply.gsid !== this.gsid || reply.generation !== this.generation || this.client.epoch !== epoch) {
364
+ // the directory opened a new generation (or changed hands): this page
365
+ // freezes on the set it has; a future page binds to the new one
366
+ this._drop(keys);
367
+ this._close();
368
+ return;
369
+ }
370
+ const uploads = [];
371
+ const uploadedKeys = [];
372
+ let bytes = 0;
373
+ for (let i = 0; i < keys.length; i++) {
374
+ const key = keys[i];
375
+ const payload = this.pending.get(key);
376
+ this.pending.delete(key);
377
+ if (payload === undefined) continue;
378
+ const { lid, present } = reply.entries[i];
379
+ if (!present) {
380
+ // ours to upload: the directory told everyone racing on this glyph
381
+ // the same, and duplicate uploads are idempotent (identical bytes,
382
+ // AddGlyphs replaces by id, Xorg dedupes storage by content hash)
383
+ const glyph = this.adapter.makeGlyph(key, payload, lid);
384
+ if (!glyph) continue; // cannot produce the bitmap: leave it private
385
+ uploads.push(glyph);
386
+ uploadedKeys.push(key);
387
+ bytes += glyph.image.length;
388
+ }
389
+ this.adapter.adopt(key, lid, this.alias.id, payload);
390
+ }
391
+ if (uploads.length) {
392
+ // upload through our own alias, then flip the directory's presence
393
+ // bits. Our own composites are ordered after the upload on this
394
+ // connection, so adopting before the AddGlyphs flushes is safe.
395
+ this.alias.addGlyphs(uploads);
396
+ await this.client._added(this.adapter.token, this.adapter.indices, uploadedKeys, bytes);
397
+ }
398
+ }
399
+
400
+ _drop(keys) {
401
+ for (const key of keys) this.pending.delete(key);
402
+ }
403
+
404
+ _close() {
405
+ this.open = false;
406
+ this.pending.clear();
407
+ }
408
+
409
+ /** the page is going away: drop the alias (a server-side refcount) */
410
+ destroy() {
411
+ this._close();
412
+ if (this.alias) {
413
+ this.alias.destroy();
414
+ this.alias = null;
415
+ }
416
+ }
417
+ }
418
+
419
+ /**
420
+ * The app's shared-glyph client, or `null` when the feature is off. Decided
421
+ * once per app: `createClient({ sharedGlyphs: false })`, or the
422
+ * `NTK_NO_SHARED_GLYPHS` environment kill switch. Off means not a byte of
423
+ * this machinery runs — pages behave exactly as they did before it existed.
424
+ */
425
+ export function sharedGlyphsFor(app) {
426
+ if (app._sharedGlyphs === undefined) {
427
+ const disabled =
428
+ app.options.sharedGlyphs === false ||
429
+ (typeof process !== 'undefined' && process.env && process.env.NTK_NO_SHARED_GLYPHS);
430
+ app._sharedGlyphs = disabled ? null : new SharedGlyphs(app);
431
+ }
432
+ return app._sharedGlyphs;
433
+ }