fontist 3.0.21 → 3.0.23

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6e1f8b3f4a8dfaf67dc6c776c7364c21a1241218946f1f7557d51c96667a1c44
4
- data.tar.gz: 685fbcfad963b38344972e9e8b56bb9a2daf4863ff14a829def85ebb805e2890
3
+ metadata.gz: 437b60367c3601897b3f22af4c3e00a2ffec30d74120df9fff64ad76291b7624
4
+ data.tar.gz: 49206f8ffe89f3110c8d97513b4b263d6cfc8c47566bf39283464a46b34b94cd
5
5
  SHA512:
6
- metadata.gz: affe57c0973ee61cc2b314cd3c8332b3d068fedb59b316b9a352eb387d71a4191f1a44b4e89534cde03091fe0ce3cd806a520f7804299c92140e97c2efb500f4
7
- data.tar.gz: 8714f8daa816bc0c32b09cf6c68f35ae190ccf87933a10bf4bc96281bdfcb8eec76191fec5f25e1748c9d592c702bdea87def55f72d20b2b4a58e81946dc119c
6
+ metadata.gz: b95402fae1199140efe5a00a54ce59207bd15268ee28803a3e830942d812ba3d0d8bedddf800a7ebcea6c736666d8f183f68b1df98a74714137da2ebd1fcae86
7
+ data.tar.gz: 42d80c546ae290865441aa1ebaf9ac04278ee99dfaad92863dd61b92fb3594511434957d950fbb6551344708ec06da5cf3812fe06e3009c0a36a92cb473c6cef
@@ -105,8 +105,10 @@ module Fontist
105
105
 
106
106
  # Parsing thousands of formula files dominates index rebuilds;
107
107
  # worker threads overlap the libyaml C parse, which releases
108
- # the GVL
109
- formulas = Parallel.map(paths) do |path|
108
+ # the GVL. The pool is capped like every other fontist pool, so
109
+ # worker counts never scale with host core counts
110
+ workers = [Parallel.processor_count, 8].min
111
+ formulas = Parallel.map(paths, in_threads: workers) do |path|
110
112
  Formula.from_file(path)
111
113
  end.compact
112
114
 
@@ -1,52 +1,78 @@
1
1
  module Fontist
2
2
  # Thread-safe, mtime-validated memoization for parsed YAML artifacts
3
- # (formula index, formula files), which cost hundreds of milliseconds to
4
- # parse and are re-read on every font lookup.
3
+ # (formula index, formula files), which cost hundreds of milliseconds
4
+ # to parse and are re-read on every font lookup.
5
5
  #
6
- # Returned instances are shared between all callers and must be treated
7
- # as read-only. Entries re-resolve automatically when the file's mtime
8
- # changes (e.g. after `fontist update` rewrites the formulas repo).
6
+ # Retention is bounded: fontist runs embedded in long-lived host
7
+ # processes (metanorma compiles) under memory caps, so the cache is
8
+ # an LRU holding at most max_entries parsed documents instead of
9
+ # every file the process has ever touched (~350MB for a full
10
+ # formulas corpus). Returned instances are shared between callers
11
+ # and must be treated as read-only. Entries re-resolve automatically
12
+ # when the file's mtime changes (e.g. after `fontist update`
13
+ # rewrites the formulas repo).
9
14
  module FromFileCache
10
- def self.memoize(path)
11
- mtime = File.mtime(path)
15
+ DEFAULT_MAX_ENTRIES = 64
12
16
 
13
- hit = current_value(path, mtime)
14
- return hit if hit
17
+ class << self
18
+ attr_writer :max_entries
15
19
 
16
- # Parse without holding the lock so parallel readers (e.g.
17
- # Formula.all over worker threads) overlap; concurrent misses on
18
- # the same path may duplicate work but write identical values
19
- value = yield
20
+ def max_entries
21
+ @max_entries ||= DEFAULT_MAX_ENTRIES
22
+ end
20
23
 
21
- store(path, mtime, value)
24
+ def memoize(path)
25
+ mtime = File.mtime(path)
22
26
 
23
- value
24
- end
27
+ hit = mutex.synchronize { lookup(path, mtime) }
28
+ return hit if hit
25
29
 
26
- def self.clear
27
- mutex.synchronize { cache.clear }
28
- end
30
+ # Parse without holding the lock so parallel readers (e.g.
31
+ # Formula.all over worker threads) overlap; concurrent misses on
32
+ # the same path may duplicate work but write identical values
33
+ value = yield
29
34
 
30
- def self.current_value(path, mtime)
31
- hit = mutex.synchronize { cache[path] }
32
- hit&.fetch(:mtime, nil) == mtime ? hit[:value] : nil
33
- end
35
+ store(path, mtime, value)
34
36
 
35
- def self.store(path, mtime, value)
36
- mutex.synchronize do
37
- existing = cache[path]
38
- if existing.nil? || existing[:mtime] != mtime
39
- cache[path] = { mtime: mtime, value: value }
40
- end
37
+ value
41
38
  end
42
- end
43
39
 
44
- def self.mutex
45
- @mutex ||= Mutex.new
46
- end
40
+ def clear
41
+ mutex.synchronize { cache.clear }
42
+ end
47
43
 
48
- def self.cache
49
- @cache ||= {}
44
+ private
45
+
46
+ attr_reader :mutex
47
+
48
+ def mutex
49
+ @mutex ||= Mutex.new
50
+ end
51
+
52
+ def cache
53
+ @cache ||= {}
54
+ end
55
+
56
+ def lookup(path, mtime)
57
+ entry = cache[path]
58
+ return nil unless entry&.fetch(:mtime, nil) == mtime
59
+
60
+ cache.delete(path)
61
+ cache[path] = entry
62
+
63
+ entry[:value]
64
+ end
65
+
66
+ def store(path, mtime, value)
67
+ mutex.synchronize do
68
+ existing = cache[path]
69
+ return if existing&.fetch(:mtime, nil) == mtime
70
+
71
+ cache.delete(path)
72
+ cache.delete(cache.each_key.first) if cache.size >= max_entries
73
+ cache[path] = { mtime: mtime, value: value }
74
+ end
75
+ end
50
76
  end
51
77
  end
52
78
  end
@@ -100,39 +100,6 @@ module Fontist
100
100
  end
101
101
  end
102
102
 
103
- # Thread-safe progress display for parallel index scans
104
- class ScanProgress
105
- SPINNER_CHARS = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"].freeze
106
-
107
- def initialize(total, verbose:, &on_step)
108
- @total = total
109
- @verbose = verbose
110
- @on_step = on_step
111
- @mutex = Mutex.new
112
- @processed = 0
113
- # Show minimal progress for large collections even in non-verbose mode
114
- @minimal = !verbose && total > 100
115
- @interval = total > 1000 ? 100 : 50
116
- end
117
-
118
- def step(path)
119
- @mutex.synchronize do
120
- @processed += 1
121
- if @verbose
122
- @on_step&.call(@processed, path)
123
- elsif @minimal && report?
124
- Fontist.ui.say("Scanning fonts: #{@processed}/#{@total}...")
125
- end
126
- end
127
- end
128
-
129
- private
130
-
131
- def report?
132
- (@processed % @interval).zero? || @processed == @total
133
- end
134
- end
135
-
136
103
  # {:path=>"/Library/Fonts/Arial Unicode.ttf",
137
104
  # :full_name=>"Arial Unicode MS",
138
105
  # :family_name=>"Arial Unicode MS",
@@ -278,6 +245,15 @@ module Fontist
278
245
  build
279
246
  check_index
280
247
 
248
+ # A completed rebuild is the scan verification for this session,
249
+ # and it must also land the scan metadata: index files written by
250
+ # older fontist versions predate last_scan_time and
251
+ # directory_mtimes, and without them recently_scanned? and
252
+ # directories_unchanged? stay false forever, so every lookup
253
+ # rescans and re-parses the whole index
254
+ save_metadata
255
+ mark_verified!
256
+
281
257
  fonts
282
258
  end
283
259
 
@@ -514,7 +490,7 @@ module Fontist
514
490
  to_file(@path)
515
491
  end
516
492
 
517
- def detect_paths(paths, verbose: false, stats: nil, parallel: true)
493
+ def detect_paths(paths, verbose: false, stats: nil)
518
494
  existing_fonts_by_path = fonts&.group_by(&:path) || {}
519
495
 
520
496
  # Initialize stats if not provided
@@ -523,100 +499,18 @@ module Fontist
523
499
 
524
500
  sorted_paths = paths.sort.uniq
525
501
 
526
- # Disable parallel processing on Windows due to fontisan's internal tempfile GC issues
527
- # Windows file locking prevents deletion of recently-accessed files, causing
528
- # EACCES errors when fontisan's checksum calculator creates tempfiles that get GC'd
529
- # See: https://github.com/fontist/fontist/issues/xxx
530
- is_windows = Fontist::Utils::System.user_os == :windows
531
-
532
- # Decide whether to use parallel processing
533
- use_parallel = parallel && sorted_paths.size > 100 && !is_windows
534
-
535
- if use_parallel
536
- process_paths_parallel(sorted_paths, existing_fonts_by_path,
502
+ # The scan runs sequentially. Fontisan parsing is GVL-bound Ruby,
503
+ # so measured on 600 cache-miss fonts a single thread (2.3s) is
504
+ # faster than eight threads (3.3s), and fontisan memoizes parses
505
+ # per process, which bounds rescan costs anyway. Concurrency here
506
+ # previously meant forks, whose inherited heaps transiently
507
+ # multiplied host RSS up to 9x inside memory-capped metanorma
508
+ # compiles (fontist#510), and Marshal crashes on cached entries
509
+ # (fontist#508); Windows additionally suffered fontisan tempfile
510
+ # races under concurrent scans. Sequential serves every case
511
+ # Sequential serves every case
512
+ process_paths_sequential(sorted_paths, existing_fonts_by_path,
537
513
  verbose: verbose, stats: stats)
538
- else
539
- process_paths_sequential(sorted_paths, existing_fonts_by_path,
540
- verbose: verbose, stats: stats)
541
- end
542
- end
543
-
544
- def process_paths_parallel(sorted_paths, existing_fonts_by_path, verbose:,
545
- stats:)
546
- require "parallel"
547
-
548
- # Auto-detect cores, cap at 8 for optimal I/O performance
549
- num_cores = [Parallel.processor_count, 8].min
550
-
551
- if verbose
552
- puts Paint["Using parallel processing with #{num_cores} cores",
553
- :cyan]
554
-
555
- # Verbose runs keep threads so live progress and per-font stats
556
- # stay visible; fontisan parsing is GVL-bound, so this mode is
557
- # slow, but it is the human-attended path
558
- process_paths_in_threads(sorted_paths, existing_fonts_by_path,
559
- verbose: verbose, stats: stats,
560
- num_workers: num_cores)
561
- else
562
- # Threads, not forked processes: a silent scan runs from inside
563
- # long-lived host processes (e.g. metanorma, whose heap reaches
564
- # hundreds of MB by the time fonts are looked up). Each fork
565
- # inherits that heap, and its RSS is charged to the process group
566
- # while it lives, so an 8-process scan transiently multiplies the
567
- # host's footprint by up to 9x — measured 3.6GB against a 0.9GB
568
- # parent during a metanorma compile (OOMs under cgroup caps).
569
- # The ~1s-per-600-fonts scan penalty is bounded and paid only on
570
- # cache-miss rebuilds. split_paths_by_reuse keeps cached entries
571
- # in the parent: see its comment for the marshal constraint.
572
- to_scan, cached_results = split_paths_by_reuse(
573
- sorted_paths,
574
- existing_fonts_by_path)
575
-
576
- results = Parallel.map(to_scan, in_threads: num_cores) do |path|
577
- detect_fonts(path, stats: nil)
578
- end.flatten.compact
579
-
580
- stats&.total_fonts = cached_results.size + results.size
581
- cached_results + results
582
- end
583
- end
584
-
585
- # The stat-only reuse check runs in the parent: cached entries can
586
- # carry backrefs to their SystemIndexFontCollection (whose
587
- # paths_loader is a Proc), and worker results are Marshal.dump'ed
588
- # back across the fork boundary — crossing it with cached
589
- # collections raises "no _dump_data is defined for class Proc".
590
- # Workers therefore receive only to-scan paths, and the block
591
- # handed to Parallel closes over nothing but its argument.
592
- def split_paths_by_reuse(sorted_paths, existing_fonts_by_path)
593
- unchanged, to_scan = sorted_paths.partition do |path|
594
- cached = existing_fonts_by_path[path]
595
- cached&.any? && file_unchanged?(path, cached.first)
596
- end
597
- cached_results = unchanged.flat_map do |path|
598
- existing_fonts_by_path[path].to_a
599
- end
600
-
601
- [to_scan, cached_results]
602
- end
603
-
604
- def process_paths_in_threads(sorted_paths, existing_fonts_by_path,
605
- verbose:, stats:, num_workers:)
606
- progress = ScanProgress.new(sorted_paths.size, verbose: verbose) do |count, path|
607
- display_progress(count, sorted_paths.size, path,
608
- ScanProgress::SPINNER_CHARS)
609
- end
610
-
611
- results = Parallel.map(sorted_paths, in_threads: num_workers) do |path|
612
- result = detect_font_with_cache(path, existing_fonts_by_path[path],
613
- stats: stats)
614
- progress.step(path)
615
- result
616
- end.flatten.compact
617
-
618
- clear_progress_line if verbose
619
- results
620
514
  end
621
515
 
622
516
  def process_paths_sequential(sorted_paths, existing_fonts_by_path,
@@ -1,3 +1,3 @@
1
1
  module Fontist
2
- VERSION = "3.0.21".freeze
2
+ VERSION = "3.0.23".freeze
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fontist
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.0.21
4
+ version: 3.0.23
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-11-01 00:00:00.000000000 Z
11
+ date: 2026-11-02 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: down