ntk 8.12.3 → 8.12.4

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/fontconfig.js CHANGED
@@ -255,6 +255,9 @@ function cacheMatches(fc, out) {
255
255
  return list;
256
256
  }
257
257
 
258
+ // fc -> { promise, base }: one child per pattern however many callers ask.
259
+ // `base` is where a prewarm writes its answer (`spawnToFiles`), null for a
260
+ // child whose answer only reaches the event loop.
258
261
  const inflight = new Map();
259
262
 
260
263
  /**
@@ -270,13 +273,14 @@ const inflight = new Map();
270
273
  */
271
274
  function runFcMatch(fc) {
272
275
  const pending = inflight.get(fc);
273
- if (pending) return pending;
276
+ if (pending) return pending.promise;
274
277
  const cp = childProcess();
275
278
  if (!cp) return Promise.reject(noFontsError(NOT_NODE));
276
279
 
277
- const promise = new Promise((resolve, reject) => {
280
+ const entry = { promise: null, base: null };
281
+ entry.promise = new Promise((resolve, reject) => {
278
282
  cp.execFile('fc-match', [...fcMatchArgs, fc], fcMatchOpts, (err, out, stderr) => {
279
- inflight.delete(fc);
283
+ if (inflight.get(fc) === entry) inflight.delete(fc);
280
284
  if (!err) return resolve(out);
281
285
  // execFile hands stderr to the callback; execFileSync hangs it on the
282
286
  // error, which is where the shared diagnosis reads it from
@@ -284,8 +288,152 @@ function runFcMatch(fc) {
284
288
  reject(err);
285
289
  });
286
290
  });
287
- inflight.set(fc, promise);
288
- return promise;
291
+ inflight.set(fc, entry);
292
+ return entry.promise;
293
+ }
294
+
295
+ // What a prewarm's child runs: fc-match with its answer in a file, then —
296
+ // once that file is complete — the exit status in another, which is what a
297
+ // synchronous caller waits for (`answerSync`). Nothing but shell builtins
298
+ // runs after fc-match, so a PATH that holds fc-match alone is enough.
299
+ const PREWARM_SCRIPT = 'fc-match "$@" > "$0.out" 2> "$0.err"; echo $? > "$0.done"';
300
+
301
+ // The directory prewarms write into, made on first use: undefined until
302
+ // then, null where it cannot be (no shell, no writable tmpdir), which leaves
303
+ // prewarms on the event loop the way they always were.
304
+ let prewarmDir;
305
+ let prewarmCount = 0;
306
+
307
+ function prewarmBase() {
308
+ const fs = builtin('node:fs');
309
+ const path = builtin('node:path');
310
+ if (prewarmDir === undefined) {
311
+ prewarmDir = null;
312
+ const p = globalThis.process;
313
+ const os = builtin('node:os');
314
+ if (p && p.platform !== 'win32' && fs && os && path && fs.existsSync('/bin/sh')) {
315
+ try {
316
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ntk-fc-'));
317
+ p.once('exit', () => {
318
+ try {
319
+ fs.rmSync(dir, { recursive: true, force: true });
320
+ } catch {
321
+ // a tmpdir cleaned up under us is already clean
322
+ }
323
+ });
324
+ prewarmDir = dir;
325
+ } catch {
326
+ // a read-only or missing tmpdir: prewarm the old way
327
+ }
328
+ }
329
+ }
330
+ return prewarmDir === null ? null : path.join(prewarmDir, String(prewarmCount++));
331
+ }
332
+
333
+ /** The status and answer a prewarm left, once it has left them; null while
334
+ * it is still running. */
335
+ function readPrewarm(base) {
336
+ const fs = builtin('node:fs');
337
+ let done;
338
+ try {
339
+ done = fs.readFileSync(`${base}.done`, 'utf8');
340
+ } catch {
341
+ return null;
342
+ }
343
+ // `echo` writes the status and the newline in one go, but a read can land
344
+ // between the file appearing and its bytes arriving
345
+ if (!done.endsWith('\n')) return null;
346
+ const status = Number(done);
347
+ let out = '';
348
+ if (status === 0) {
349
+ try {
350
+ out = fs.readFileSync(`${base}.out`, 'utf8');
351
+ } catch {
352
+ return { status: -1, out: '' };
353
+ }
354
+ }
355
+ return { status, out };
356
+ }
357
+
358
+ function removePrewarm(base) {
359
+ const fs = builtin('node:fs');
360
+ for (const ext of ['.out', '.err', '.done']) {
361
+ try {
362
+ fs.unlinkSync(base + ext);
363
+ } catch {
364
+ // never written, or already gone
365
+ }
366
+ }
367
+ }
368
+
369
+ /**
370
+ * A prewarm's child, answering through files so that the synchronous path
371
+ * can take its answer without the event loop (`answerSync`) — which the
372
+ * first text layout is holding, from inside a render, for as long as the
373
+ * first frame takes. A prewarm answered through `execFile` alone lands after
374
+ * that, so the layout it was started for spawned fc-match a second time and
375
+ * waited for that one instead.
376
+ *
377
+ * Null where files cannot be used; the caller prewarms the old way.
378
+ */
379
+ function spawnToFiles(fc) {
380
+ const cp = childProcess();
381
+ const base = cp ? prewarmBase() : null;
382
+ if (base === null) return null;
383
+ let child;
384
+ try {
385
+ child = cp.spawn('/bin/sh', ['-c', PREWARM_SCRIPT, base, ...fcMatchArgs, fc], {
386
+ stdio: 'ignore'
387
+ });
388
+ } catch {
389
+ return null;
390
+ }
391
+ const entry = { promise: null, base, answer: undefined };
392
+ entry.promise = new Promise((resolve, reject) => {
393
+ const settle = () => {
394
+ if (inflight.get(fc) === entry) inflight.delete(fc);
395
+ // answered already, by a synchronous caller that could not wait
396
+ if (entry.answer === undefined) {
397
+ const left = readPrewarm(base);
398
+ removePrewarm(base);
399
+ entry.answer = left && left.status === 0 ? left.out : null;
400
+ }
401
+ if (entry.answer !== null) resolve(entry.answer);
402
+ else reject(new Error(`fc-match for "${fc}" did not answer`));
403
+ };
404
+ child.once('error', settle);
405
+ child.once('exit', settle);
406
+ });
407
+ inflight.set(fc, entry);
408
+ return entry;
409
+ }
410
+
411
+ // the synchronous path's nap while a prewarm finishes
412
+ const nap = new Int32Array(new SharedArrayBuffer(4));
413
+
414
+ // How long the synchronous path waits on a prewarm before asking fc-match
415
+ // itself. fc-match answers in tens of milliseconds, and a prewarm that is
416
+ // running has a head start on any spawn that could replace it.
417
+ const PREWARM_WAIT_MS = 3000;
418
+
419
+ /**
420
+ * An in-flight prewarm's answer, waited for synchronously: its stdout, or
421
+ * null if it failed or took too long — the caller then spawns fc-match
422
+ * itself, which is also what reports why.
423
+ */
424
+ function answerSync(fc, entry) {
425
+ const deadline = performance.now() + PREWARM_WAIT_MS;
426
+ let left = readPrewarm(entry.base);
427
+ while (left === null && performance.now() < deadline) {
428
+ Atomics.wait(nap, 0, 0, 1);
429
+ left = readPrewarm(entry.base);
430
+ }
431
+ if (inflight.get(fc) === entry) inflight.delete(fc);
432
+ if (entry.answer === undefined) {
433
+ entry.answer = left && left.status === 0 ? left.out : null;
434
+ if (left !== null) removePrewarm(entry.base);
435
+ }
436
+ return entry.answer;
289
437
  }
290
438
 
291
439
  /**
@@ -294,8 +442,10 @@ function runFcMatch(fc) {
294
442
  * matchSortedSync is deliberately synchronous — it answers from inside text
295
443
  * layout — so the first layout for a pattern pays the fc-match spawn (~50ms)
296
444
  * as a first-paint stall. Starting the same command here with a non-blocking
297
- * execFile, while the X connection is still being set up, moves that cost off
298
- * the critical path (issue #182).
445
+ * spawn, while the X connection is still being set up, moves that cost off
446
+ * the critical path (issue #182). The child answers through files as well as
447
+ * through the event loop, so a layout that asks for the pattern before the
448
+ * loop has run takes the prewarm's answer rather than spawning its own.
299
449
  *
300
450
  * Never rejects and never reports: a prewarm is an optimization, and an app
301
451
  * that never renders text must not crash — or even warn — over a missing
@@ -304,9 +454,6 @@ function runFcMatch(fc) {
304
454
  * `unavailable`: the first sync throw keeps its original spawn error as
305
455
  * `cause`.
306
456
  *
307
- * A sync call racing this one wins — the child's result is discarded
308
- * whenever the pattern is already cached by the time it exits.
309
- *
310
457
  * `matchSorted` is the reporting variant: same spawn, same cache, but it
311
458
  * awaits an answer and so has somewhere to put a failure.
312
459
  *
@@ -316,7 +463,8 @@ function runFcMatch(fc) {
316
463
  export function prewarm(pattern = {}) {
317
464
  const fc = patternFor(pattern);
318
465
  if (sortedCache.has(fc) || unavailable) return Promise.resolve();
319
- return runFcMatch(fc).then(
466
+ const pending = inflight.get(fc)?.promise ?? spawnToFiles(fc)?.promise ?? runFcMatch(fc);
467
+ return pending.then(
320
468
  (out) => {
321
469
  if (!sortedCache.has(fc)) {
322
470
  const list = parseMatches(out);
@@ -327,6 +475,31 @@ export function prewarm(pattern = {}) {
327
475
  );
328
476
  }
329
477
 
478
+ // the faces a family is asked for in: regular, bold, and both in italic
479
+ const FACES = [
480
+ [400, 'normal'],
481
+ [700, 'normal'],
482
+ [400, 'italic'],
483
+ [700, 'italic']
484
+ ];
485
+ const facesWarmed = new Set();
486
+
487
+ /**
488
+ * Prewarm a family in the four faces text is set in — once per family.
489
+ *
490
+ * A document asks for its family's faces one at a time, as layout reaches
491
+ * the first bold word, the first emphasis, and each ask that misses is a
492
+ * synchronous fc-match on the way to the first frame: a Markdown document
493
+ * waited on five of them, 600 ms on XQuartz. Started together they run side
494
+ * by side, and each later ask finds its answer there.
495
+ */
496
+ export function prewarmFaces(pattern = {}) {
497
+ const family = pattern.family || 'sans-serif';
498
+ if (facesWarmed.has(family) || unavailable) return;
499
+ facesWarmed.add(family);
500
+ for (const [weight, style] of FACES) prewarm({ family, weight, style });
501
+ }
502
+
330
503
  /**
331
504
  * The same match list as `matchSortedSync`, without blocking for it.
332
505
  *
@@ -352,10 +525,18 @@ export async function matchSorted(pattern = {}) {
352
525
  if (unavailable) throw noFontsError(unavailable);
353
526
 
354
527
  let out;
355
- try {
356
- out = await runFcMatch(fc);
357
- } catch (err) {
358
- throw fcMatchError(err);
528
+ const pending = inflight.get(fc);
529
+ if (pending?.base) {
530
+ // a prewarm running for the pattern: its answer, or — if it has none —
531
+ // a spawn of our own, which is what reports why
532
+ out = await pending.promise.catch(() => undefined);
533
+ }
534
+ if (out === undefined) {
535
+ try {
536
+ out = await runFcMatch(fc);
537
+ } catch (err) {
538
+ throw fcMatchError(err);
539
+ }
359
540
  }
360
541
  // A sync call may have answered this pattern while the child ran. Its list
361
542
  // is the cached one, and candidates memoize their parsed charset, so hand
@@ -385,6 +566,14 @@ export function matchSortedSync(pattern) {
385
566
  if (cached) return cached;
386
567
  if (unavailable) throw noFontsError(unavailable);
387
568
 
569
+ // The family's other faces are the next asks (`prewarmFaces`): started
570
+ // now, they run beside this one instead of after it.
571
+ prewarmFaces(pattern);
572
+ const pending = inflight.get(fc);
573
+ if (pending?.base) {
574
+ const out = answerSync(fc, pending);
575
+ if (out !== null) return sortedCache.get(fc) ?? cacheMatches(fc, out);
576
+ }
388
577
  let out;
389
578
  try {
390
579
  out = execFileSync('fc-match', [...fcMatchArgs, fc], fcMatchOpts);
@@ -51,7 +51,7 @@ import {
51
51
  matchSorted,
52
52
  matchSortedSync,
53
53
  noFontsError,
54
- prewarm,
54
+ prewarmFaces,
55
55
  supported
56
56
  } from '../fontconfig.js';
57
57
  import Font from './font.js';
@@ -116,13 +116,13 @@ export function parseFamilies(family) {
116
116
  export class FontconfigFontSource {
117
117
  constructor() {
118
118
  // Construction is the moment fontconfig is chosen as the lookup path, so
119
- // the fc-match spawn for the pattern every widget default resolves to —
119
+ // the fc-match spawns for the pattern every widget default resolves to —
120
120
  // sans-serif at regular weight, exactly as FontManager.match normalizes
121
- // it — starts here, asynchronously, instead of stalling the first text
122
- // layout for ~50ms. Fire-and-forget: prewarm never rejects, and a source
123
- // constructed where fc-match is missing stays silent until (unless) text
124
- // is actually rendered.
125
- prewarm({ family: 'sans-serif', weight: 400, style: 'normal' });
121
+ // it — and for its bold and italic start here, asynchronously, instead of
122
+ // stalling the first text layout for ~50ms each. Fire-and-forget:
123
+ // prewarm never rejects, and a source constructed where fc-match is
124
+ // missing stays silent until (unless) text is actually rendered.
125
+ prewarmFaces({ family: 'sans-serif' });
126
126
  }
127
127
 
128
128
  matchSorted(pattern) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ntk",
3
- "version": "8.12.3",
3
+ "version": "8.12.4",
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",