findxpand 0.1.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.
@@ -0,0 +1,657 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The rule set, held in memory and persisted to one file.
4
+ #
5
+ # **Nothing here ever calls out.** Findxpand pushes to this process; this process
6
+ # never fetches from Findxpand. A middleware that blocks on a network call to a
7
+ # vendor has made that vendor's uptime the site's uptime for the sake of a title
8
+ # tag, and polling is the same coupling with a staleness window attached. The
9
+ # consequence worth holding in mind: if Findxpand is down, this keeps serving
10
+ # whatever it last stored, indefinitely.
11
+ #
12
+ # The cache file exists so a restart is not a regression. Without it every deploy
13
+ # would drop every rule until the next push, and the site would quietly revert to
14
+ # the titles the client is paying to change.
15
+ #
16
+ # **One store per process, shared by every attach point.** Ruby has more ways to
17
+ # get into the request path than Node or Python do — a Railtie, a builder
18
+ # prepend, and a hand-written `use` are all reachable in the same process — and
19
+ # two of them can be live at once. Two `Store` objects would mean two manifests,
20
+ # two sets of counters and a `/status` answering for whichever instance happened
21
+ # to own the admin path, so `Findxpand.store` is memoised and every instance uses
22
+ # it unless a test passes its own (§20.1 rule 2).
23
+ #
24
+ # Frozen string literals: on. Nothing here appends to a literal.
25
+
26
+ require 'json'
27
+ require 'fileutils'
28
+
29
+ module Findxpand
30
+ # The one degraded verdict, worded by the engine and shared with the Node and
31
+ # Python implementations.
32
+ #
33
+ # Mirrors `engine.fix.origin.DEGRADED_REASONS` and
34
+ # `middleware/node/src/manifest.ts`'s `degradedReason` branch for branch. Two
35
+ # independently-worded health checks are two that disagree the first time
36
+ # either is edited, and this one is the sentence a customer pastes into a
37
+ # support thread — `middleware/conformance/cases.json` carries the three
38
+ # templates and `test/conformance_test.rb` asserts these against them.
39
+ #
40
+ # The order is the order of usefulness: `skip_encoded` names the actual
41
+ # mistake, so it is reported ahead of the "nothing landed" symptom it causes.
42
+ # A middleware mounted behind compression matches both branches, and reporting
43
+ # the symptom would send somebody hunting through their manifest for a
44
+ # mounting bug.
45
+ module Degraded
46
+ def self.reason(counters, has_rules)
47
+ skip_encoded = count(counters, :skip_encoded)
48
+ if skip_encoded.positive?
49
+ return "#{skip_encoded} response(s) arrived already compressed and could not be " \
50
+ 'rewritten - the middleware is mounted after compression, and needs to be ' \
51
+ 'mounted before it'
52
+ end
53
+
54
+ errors = count(counters, :error)
55
+ if errors.positive?
56
+ # Not gated on `has_rules`, and the fixture says so in its own row: that
57
+ # gate guards the third branch only. A rewrite that raised is read from
58
+ # what happened to a response; `has_rules` is read from the manifest,
59
+ # and the two are not the same claim.
60
+ return "#{errors} rewrite(s) raised and the original page was served unchanged"
61
+ end
62
+
63
+ considered = count(counters, :considered)
64
+ if has_rules && considered.positive? && count(counters, :applied).zero?
65
+ # `applied == 0`, never `applied < considered`. A page whose rule matched
66
+ # and whose output was identical is a `pass` rather than a failure, and
67
+ # reporting 39 of 40 as degraded would flag every site holding a rule for
68
+ # a page that already carries the right title.
69
+ return "#{considered} response(s) matched a rule and none were rewritten"
70
+ end
71
+
72
+ ''
73
+ end
74
+
75
+ # One counter, whether it is keyed by Symbol or by String.
76
+ #
77
+ # The `Store` keeps Symbols and the shared fixture carries Strings, because
78
+ # it arrives through `JSON.parse`. Converting inside the test would mean the
79
+ # suite asserting against a shape it made up rather than the one the fixture
80
+ # ships (§20.1 rule 1), and converting inside `Store` would leave this
81
+ # function with a second caller shape it had never been run against. One
82
+ # lookup that answers both, at the only place that reads a counter by name.
83
+ def self.count(counters, name)
84
+ value = counters[name]
85
+ value = counters[name.to_s] if value.nil?
86
+ value.to_i
87
+ end
88
+ end
89
+
90
+ class Store
91
+ EMPTY = { 'version' => '', 'pages' => {} }.freeze
92
+
93
+ # What this process has actually done since it started.
94
+ #
95
+ # These exist because the worst failure on this route is invisible. Mounted
96
+ # behind compression, or mounted on a path the manifest never names, the
97
+ # middleware serves every page untouched and a status endpoint reporting
98
+ # only "42 rules, pushed 3 minutes ago" looks perfectly healthy. Answering
99
+ # that with README prose telling the customer to be careful pushes the
100
+ # detection onto them; a counter and a `degraded` flag let a monitor find it.
101
+ #
102
+ # Raised 19 Aug 2026 by a customer's developer reviewing the install docs,
103
+ # who was right: "they documented the silent-failure mode instead of fixing
104
+ # it."
105
+ #
106
+ # Ten keys: the eight every implementation reports, plus the two this
107
+ # runtime can actually reach.
108
+ #
109
+ # **The comment that used to sit here said these were "one shape of status
110
+ # JSON whichever package answers". They are not, and never were.** Node
111
+ # ships `unmatched` and cannot ship `skip_undecodable` —
112
+ # `Buffer.toString('utf8')` substitutes replacement characters rather than
113
+ # failing, so it has nothing to count; Python ships `skip_undecodable` and
114
+ # no `unmatched`; PHP ships `skip_undecodable` plus `armed` and
115
+ # `skip_streamed`, which only an `ob_start` attach can fail at. The honest
116
+ # rule, and the one PHP writes out at `middleware/php/src/Store.php:100-165`,
117
+ # is: **every key is present at zero rather than absent**, so no reader ever
118
+ # writes a branch for a key that appears only on the stacks that hit it.
119
+ #
120
+ # `unmatched` was the missing one here, and its absence was not cosmetic. It
121
+ # counts requests that reached this middleware on a path the manifest does
122
+ # not name, and it is the third value that makes `considered: 0` readable: a
123
+ # manifest whose keys match nothing looks exactly like an install nobody has
124
+ # sent a request to. `engine.fix.origin.degraded_reason`'s docstring names
125
+ # it as the honest fix for that hole, Node implemented it
126
+ # (`middleware/node/src/manifest.ts:60-89`), and an operator alerting on
127
+ # `counters.unmatched > 0 && counters.considered == 0` was evaluating
128
+ # `nil > 0` against a Ruby origin — so the alert never fired, for exactly the
129
+ # failure it was written for. Ported 2 Sep 2026, counter and semantics
130
+ # unchanged, rather than reworded here (§20.1 rule 2).
131
+ #
132
+ # Still outstanding, and named rather than hidden: Python and PHP do not
133
+ # have `unmatched` yet, and `Degraded.reason` — which is pinned by the
134
+ # shared fixture — still does not read it in any language. What reads it
135
+ # here is `reach_reason`, this package's own layer over the shared verdict.
136
+ NO_COUNTERS = {
137
+ considered: 0, applied: 0, passed: 0, skip_encoded: 0,
138
+ skip_large: 0, skip_undecodable: 0, error: 0, redirect: 0, status: 0,
139
+ unmatched: 0
140
+ }.freeze
141
+
142
+ # Header marker to counter name. `1` is the applied case; `redirect` and
143
+ # `status` are served straight from the manifest and never reach a rewrite.
144
+ COUNTER_KEYS = {
145
+ '1' => :applied, 'pass' => :passed, 'error' => :error,
146
+ 'skip-encoded' => :skip_encoded, 'skip-large' => :skip_large,
147
+ 'skip-encoding' => :skip_undecodable,
148
+ 'redirect' => :redirect, 'status' => :status
149
+ }.freeze
150
+
151
+ # The markers that mean a rule matched and a rewrite decision was reached.
152
+ # `redirect` and `status` are excluded: they are answers, not rewrites, and
153
+ # counting them would make `applied == 0` look explicable on a site whose
154
+ # manifest happens to be all redirects.
155
+ CONSIDERED = %w[1 pass error skip-encoded skip-large skip-encoding].freeze
156
+
157
+ # How often the cache file is checked for somebody else's push, in seconds.
158
+ #
159
+ # **This is the fork problem, and Ruby has it worse than the other two
160
+ # packages.** Puma in cluster mode, Unicorn and Passenger all serve from
161
+ # forked workers, and a manifest pushed to `/__findxpand/manifest` arrives at
162
+ # exactly one of them: the others go on serving the previous rules until the
163
+ # next deploy. Nothing reports it, and it is worse than a plain staleness bug
164
+ # — `verify` re-fetches the page through the same load balancer, so a fix
165
+ # lands or fails to land depending on which worker answers, and the same
166
+ # change verifies on one request and not the next.
167
+ #
168
+ # The worker that took the push has already written the cache file, so every
169
+ # other worker can see the change with a `stat`. Throttled to once a second
170
+ # because it is on the read path of every request; the rest of the time this
171
+ # costs one clock reading and one float comparison.
172
+ #
173
+ # Two limits, stated rather than hidden: `mtime` has one-second granularity
174
+ # on some filesystems, so two pushes inside the same second whose payloads
175
+ # are also the same length would look identical and the second would be
176
+ # missed until the one after it; and a deployment with no shared cache file
177
+ # gets none of this, which is why `FINDXPAND_CACHE_FILE` is documented as
178
+ # required rather than optional on a forking server.
179
+ #
180
+ # **No outbound request is involved.** This reads a local file that this
181
+ # process's own sibling wrote. The gem never calls Findxpand.
182
+ RELOAD_INTERVAL_S = 1.0
183
+
184
+ # How long after boot the "never in the request path" verdict is withheld.
185
+ #
186
+ # A process that has served no page and answers `/status` is either
187
+ # misattached or thirty seconds old, and those are the same picture. Naming
188
+ # the mistake immediately would page somebody on every deploy of a
189
+ # low-traffic site; never naming it is the §20.1 rule 3 failure this whole
190
+ # counter exists to end. A minute is long enough that a running site has
191
+ # served something and short enough that a monitor notices within its first
192
+ # few polls.
193
+ NEVER_SEEN_AFTER_S = 60
194
+
195
+ # How many unmatched requests are evidence, rather than an accident of what
196
+ # happened to arrive first.
197
+ #
198
+ # `unmatched` moves on every request the manifest does not name - an image, a
199
+ # stylesheet, a favicon, an API call - so one of them is not evidence of
200
+ # anything, and a verdict that fired at the first would accuse a perfectly
201
+ # healthy site whose first request was its own logo. §9 rule 3: a threshold
202
+ # has to be defensible, and "one" is not.
203
+ #
204
+ # Twenty, held together with the same one-minute window as the verdict below,
205
+ # because that pair is the cheapest thing that cannot be reached by accident:
206
+ # a working site clears it the moment a single page matches a rule
207
+ # (`considered` becomes positive and the branch stops applying), and a
208
+ # middleware mounted where pages never go never clears it, because nothing
209
+ # there will ever match. The review case this exists for reported
210
+ # `requests_seen: 847, considered: 0` - three floors above this one.
211
+ #
212
+ # Node counts the same thing and deliberately does not vote on it yet
213
+ # (`middleware/node/src/manifest.ts:60-89`: "Reported, so an operator and a
214
+ # monitor can see it; not yet voted on"), because `degradedReason` is pinned
215
+ # by the shared fixture and a branch only one package has is drift by
216
+ # construction. This vote is cast in `reach_reason`, which is this package's
217
+ # own layer and is not pinned by the fixture - the same place PHP casts its
218
+ # equivalent.
219
+ KEYS_MATCH_NOTHING_AFTER = 20
220
+
221
+ # The verdicts this package adds after the shared three, and why they are
222
+ # not *in* the shared three.
223
+ #
224
+ # `Degraded.reason` is pinned by `middleware/conformance/cases.json` and has
225
+ # to answer identically in four languages, so a branch only one of us has
226
+ # cannot live there. It lives in `reach_reason` instead, asked only when the
227
+ # shared verdict has nothing to say — the same layering PHP uses
228
+ # (`middleware/php/src/Store.php:580-633`, `reach_reason`, merged into
229
+ # `degraded_reason` only when that one is empty). This is not a second
230
+ # wording of one rule; it is a second rule, about whether the middleware is
231
+ # anywhere useful at all.
232
+ #
233
+ # Ruby's attach has more ways to silently not happen than Node's or
234
+ # Python's: `RUBYOPT` may not be set on the process that actually serves (a
235
+ # `bin/rails` wrapper, a Procfile line edited in one place of two), the
236
+ # Railtie only registers when the gem is required after Rails, a hand-written
237
+ # `use` can land inside a `map` block that pages never reach, and the server
238
+ # may parse `config.ru` with its own vendored builder (see `auto.rb`). Every
239
+ # one of those ends the same way — the admin endpoints answer, the manifest
240
+ # is present and current, and not one page has ever passed through us — and
241
+ # the shared verdicts cannot see it, because all three start from
242
+ # `considered`, which only a request that matched a rule ever moves.
243
+ #
244
+ # Each of these three reports something this process **observed**, never
245
+ # something it arranged (§20.1 rule 3). A push that was not written, requests
246
+ # that matched nothing, an uptime with no requests at all: all three are
247
+ # facts about what happened here, and all three are silent until there is
248
+ # one.
249
+
250
+ # A push arrived and could not be stored. Reported first, because it is the
251
+ # only one of the three that has already lost work: the engine reads the
252
+ # deployed manifest back before it merges the next fix, so a store that
253
+ # forgets is a store that discards every previously approved fix at the next
254
+ # restart, with no error anywhere. That was the shipped behaviour of every
255
+ # non-`RUBYOPT` install until 2 Sep 2026 — see `Findxpand.store`.
256
+ PUSH_NOT_PERSISTED =
257
+ 'a manifest was pushed and could not be written to disk - this process serves it now, ' \
258
+ 'a restart drops it, and on a forking server the other workers never saw it. Set ' \
259
+ 'FINDXPAND_CACHE_FILE to a path every worker can write'
260
+
261
+ # Rules loaded, traffic arriving, and not one request has matched. The exact
262
+ # signature of the `normalise_path` disagreement that shipped, and of a
263
+ # middleware mounted inside a `map` block that pages never reach. `{count}`
264
+ # is `unmatched` rather than `requests_seen`, because the sentence is about
265
+ # requests that were *offered* to the manifest and refused by it.
266
+ KEYS_MATCH_NOTHING =
267
+ '%<count>d request(s) reached this middleware and not one matched a rule - the ' \
268
+ 'manifest keys do not match the paths this site serves, or this middleware is ' \
269
+ 'mounted where the pages do not go'
270
+
271
+ # Nothing has arrived at all, for longer than a boot takes.
272
+ NEVER_IN_REQUEST_PATH =
273
+ 'the middleware is loaded and answering here, but no page request has ever reached ' \
274
+ 'it - it is attached somewhere requests do not go'
275
+
276
+ def initialize(cache_file: nil, manifest_file: nil, clock: nil)
277
+ @cache_file = cache_file
278
+ @lock = Mutex.new
279
+ @current = EMPTY
280
+ @updated_at = nil
281
+ @source = 'empty'
282
+ @counters = NO_COUNTERS.dup
283
+ @last_applied_at = nil
284
+ # Every request that reached `Middleware#call` on a path that is not the
285
+ # admin surface, whether or not a rule matched. Counted separately from
286
+ # `considered` on purpose: `considered` answers "did a rule match", and
287
+ # the question here is the one before it, "was I in the path at all".
288
+ @requests = 0
289
+ # `nil` until a push has proved it one way or the other by being written
290
+ # or failing to be. Three states rather than two, and the third one is the
291
+ # point: an install that has been pushed nothing has not *failed* to
292
+ # persist, and reporting it as a fault would fire on every correct install
293
+ # on its first day. §20.1 rule 3 cuts both ways — nothing examined is not
294
+ # health, and it is not a failure either. Modelled on
295
+ # `middleware/php/src/Store.php`'s `$persisted`, which is `?bool` for the
296
+ # same reason.
297
+ @persisted = nil
298
+ # Injected so the time-dependent half of `status` is testable without
299
+ # sleeping. Every reading of the clock in this class goes through it.
300
+ @clock = clock || -> { Time.now.to_f }
301
+ @started_at = @clock.call
302
+ @checked_at = nil
303
+ @cache_fingerprint = nil
304
+
305
+ # A manifest committed to the repository wins at boot and is then
306
+ # overwritten by the first push. It is the mode for a client whose
307
+ # security review forbids an inbound endpoint — honestly the worse
308
+ # product, since it needs a deploy per change, but it must not be worse
309
+ # than nothing.
310
+ [[manifest_file, 'file'], [cache_file, 'push']].each do |path, source|
311
+ next if path.nil? || path.to_s.empty?
312
+
313
+ begin
314
+ loaded = JSON.parse(File.read(path, encoding: 'UTF-8'))
315
+ rescue SystemCallError, IOError, JSON::ParserError, ArgumentError
316
+ # A corrupt cache is not a reason to fail a boot. Serving the site
317
+ # unmodified is always safe; refusing to start never is. `ArgumentError`
318
+ # is in the list because `File.read` raises it, not a JSON error, when
319
+ # the file is not valid UTF-8.
320
+ next
321
+ end
322
+ next unless self.class.manifest?(loaded)
323
+
324
+ @current = loaded
325
+ @source = source
326
+ # **The file's own mtime, not `now`.** `age` is the field an operator
327
+ # reads to decide whether last night's push landed, and setting it to
328
+ # the moment this process *looked at* a file makes it answer "how long
329
+ # since I booted" — so a Rails app redeployed after four weeks with no
330
+ # push reports `age: 0` for rules that last changed 28 days ago, and
331
+ # somebody checking whether a push arrived concludes it did. Named and
332
+ # fixed first in `middleware/php/src/Store.php:294-304` ("Both shipped
333
+ # stores set `updated_at` to the moment they loaded the file"); Node and
334
+ # Python still have it. `nil` when the file's mtime cannot be read,
335
+ # which `status` already renders as `age: null` — unknown, rather than
336
+ # zero.
337
+ @updated_at = mtime_of(path)
338
+ end
339
+
340
+ # The boot read counts as a check: the file was just looked at, so
341
+ # `reload_if_stale` has nothing to learn from it for another interval.
342
+ # Without this the first request of every process re-reads and re-parses a
343
+ # file it has already read, and — worse for the suite — the throttle would
344
+ # be untestable, because its first call would always be the one that acts.
345
+ @checked_at = @started_at
346
+ @cache_fingerprint = fingerprint_of(@cache_file)
347
+ end
348
+
349
+ def self.manifest?(value)
350
+ value.is_a?(Hash) && value['version'].is_a?(String) && value['pages'].is_a?(Hash)
351
+ end
352
+
353
+ def get
354
+ @current
355
+ end
356
+
357
+ def rule_for(url)
358
+ rule = @current['pages']
359
+ return nil unless rule.is_a?(Hash)
360
+
361
+ found = rule[Rewrite.normalise_path(url)]
362
+ found.is_a?(Hash) ? found : nil
363
+ end
364
+
365
+ def redirect_for(url)
366
+ path = Rewrite.normalise_path(url)
367
+ list = @current['redirects']
368
+ return nil unless list.is_a?(Array)
369
+
370
+ list.find do |rule|
371
+ # Exact match only — no prefixes and no patterns. A rule that
372
+ # accidentally matched a subtree would send a whole section to one URL,
373
+ # and there is no undo for visitors who already followed it.
374
+ #
375
+ # **Both ends must be Strings, and `to` must not be empty.** This was
376
+ # `Rewrite.normalise_path(rule['from'].to_s)` alone, and the `.to_s` did
377
+ # two things silently: `{"from":null,"to":"/x"}` normalised to `"/"` and
378
+ # 301'd the *homepage*, and `{"from":"/old","to":null}` reached the
379
+ # middleware and was served as a 301 with an empty `location` — a dead
380
+ # page where the application would have rendered a working one, counted
381
+ # as a healthy `redirect`. Python raises on `str(redirect["to"])` and
382
+ # Node throws `ERR_HTTP_INVALID_HEADER_VALUE`; both fail loudly, and
383
+ # this failed silently in the one direction §3 rule 7 forbids. Matched
384
+ # to `middleware/php/src/Store.php:333-356`, which drops the entry and
385
+ # serves the page — the closed direction here is "serve the page
386
+ # unchanged", never "redirect to nowhere". Dropping the entry rather
387
+ # than the list is the same shape the engine's own fixture pins for a
388
+ # malformed `hreflang` row.
389
+ next false unless rule.is_a?(Hash)
390
+
391
+ from = rule['from']
392
+ to = rule['to']
393
+ next false unless from.is_a?(String) && to.is_a?(String) && !to.empty?
394
+
395
+ Rewrite.normalise_path(from) == path
396
+ end
397
+ end
398
+
399
+ # Store a pushed manifest and return its version. Raises on rubbish.
400
+ def replace(payload)
401
+ unless self.class.manifest?(payload)
402
+ raise ArgumentError, 'not a manifest: expected {version, pages}'
403
+ end
404
+
405
+ @lock.synchronize do
406
+ @current = payload
407
+ @updated_at = @clock.call
408
+ @source = 'push'
409
+ write_cache(payload)
410
+ end
411
+ payload['version'].to_s
412
+ end
413
+
414
+ # Count one response, by the marker it was served with.
415
+ #
416
+ # Takes the header value rather than a symbol so exactly one place decides
417
+ # what a response was: the middleware sets the header and passes the same
418
+ # string here. A second classification would eventually disagree with the
419
+ # header, and the header is what a customer greps for.
420
+ def record(marker)
421
+ @lock.synchronize do
422
+ @counters[:considered] += 1 if CONSIDERED.include?(marker)
423
+ key = COUNTER_KEYS[marker]
424
+ next if key.nil?
425
+
426
+ @counters[key] += 1
427
+ @last_applied_at = @clock.call if marker == '1'
428
+ end
429
+ end
430
+
431
+ # One request arrived on a path that is not the admin surface.
432
+ #
433
+ # **Not called for `/__findxpand/status`**, and that is the whole point: a
434
+ # probe that satisfies its own question is an auditor that cannot fail
435
+ # (§20.1 rule 3). If asking whether we have ever been in the request path
436
+ # counted as having been in it, the answer would be yes from the first poll
437
+ # on a middleware mounted where no page ever goes.
438
+ #
439
+ # **It is every non-admin request, not every page request**, and the README
440
+ # said the latter until 2 Sep 2026. An asset, a favicon, an API call or a
441
+ # load-balancer probe moves this counter, so one `GET /api/health` clears
442
+ # `NEVER_IN_REQUEST_PATH` permanently on a middleware mounted inside a `map
443
+ # '/api'` block that no page will ever reach. That is why it is the weakest
444
+ # of the three reach verdicts and why `unmatched` exists: this one answers
445
+ # "was I in a request path", and `unmatched` answers "was I in the one the
446
+ # pages go down".
447
+ def saw_request
448
+ @lock.synchronize { @requests += 1 }
449
+ end
450
+
451
+ # One request reached us on a path the manifest does not name.
452
+ #
453
+ # Separate from `record` rather than a marker string through it, because
454
+ # `record` takes the value of the header the response was served with and
455
+ # this response is served with no header at all — we hand the request to the
456
+ # application before it produces one. Passing a marker no header ever
457
+ # carries would break the one thing `record`'s doc promises: that a customer
458
+ # can grep the header value and find the counter it moved. Matched to
459
+ # `middleware/node/src/manifest.ts:274-284`, which says the same thing about
460
+ # the same counter.
461
+ def miss
462
+ @lock.synchronize { @counters[:unmatched] += 1 }
463
+ end
464
+
465
+ # Adopt a manifest another worker was pushed, if there is one.
466
+ #
467
+ # Called once per request from the middleware. See `RELOAD_INTERVAL_S` for
468
+ # why this exists at all; everything here is deliberately silent, because a
469
+ # cache file that has been deleted, is being written, or was written by a
470
+ # different version of this gem is not a reason to stop serving pages.
471
+ def reload_if_stale
472
+ return if @cache_file.nil? || @cache_file.to_s.empty?
473
+
474
+ now = @clock.call
475
+ return if !@checked_at.nil? && (now - @checked_at) < RELOAD_INTERVAL_S
476
+
477
+ @checked_at = now
478
+ fingerprint = fingerprint_of(@cache_file)
479
+ return if fingerprint.nil? || fingerprint == @cache_fingerprint
480
+
481
+ @cache_fingerprint = fingerprint
482
+ begin
483
+ loaded = JSON.parse(File.read(@cache_file.to_s, encoding: 'UTF-8'))
484
+ rescue SystemCallError, IOError, JSON::ParserError, ArgumentError
485
+ return
486
+ end
487
+ return unless self.class.manifest?(loaded)
488
+ # Our own write, or a re-read of what we already serve. `updated_at` is
489
+ # left alone in that case, so `age` keeps meaning "when did these rules
490
+ # change" rather than "when did we last look at a file".
491
+ return if loaded['version'] == @current['version']
492
+
493
+ @lock.synchronize do
494
+ @current = loaded
495
+ # The mtime we just stat'ed, for the same reason as at boot: this is
496
+ # when the *sibling* wrote these rules, and `now` is when this worker
497
+ # happened to notice. On a forking server every worker adopts at a
498
+ # different moment, so `now` would have four workers reporting four
499
+ # different ages for one push.
500
+ @updated_at = fingerprint[0]
501
+ @source = 'push'
502
+ end
503
+ end
504
+
505
+ def status
506
+ now = @clock.call
507
+ pages = @current['pages'].is_a?(Hash) ? @current['pages'].size : 0
508
+ redirects = @current['redirects'].is_a?(Array) ? @current['redirects'].size : 0
509
+ counters = @counters.dup
510
+ has_rules = pages.positive? || redirects.positive?
511
+ # The shared verdict first, always, and this package's own only when that
512
+ # one has nothing to say. Same order as
513
+ # `middleware/php/src/Store.php:743-747`.
514
+ reason = Degraded.reason(counters, has_rules)
515
+ reach = reach_reason(counters, has_rules, now)
516
+ reason = reach if reason.empty?
517
+
518
+ {
519
+ 'ok' => true,
520
+ 'version' => @current['version'].to_s,
521
+ 'pages' => pages,
522
+ 'redirects' => redirects,
523
+ 'age' => @updated_at.nil? ? nil : (now - @updated_at).round,
524
+ 'source' => @source,
525
+ 'counters' => counters.transform_keys(&:to_s),
526
+ 'requests_seen' => @requests,
527
+ 'uptime' => (now - @started_at).round,
528
+ 'applied_age' => @last_applied_at.nil? ? nil : (now - @last_applied_at).round,
529
+ 'degraded' => !reason.empty?,
530
+ 'degraded_reason' => reason,
531
+ # `true` a push was stored, `false` a push was lost, `nil` nothing has
532
+ # been pushed here yet. Reported rather than folded into `degraded`
533
+ # alone, because "we cannot persist" and "we have not been asked to yet"
534
+ # are different answers and a monitor should be able to see which.
535
+ 'persisted' => @persisted,
536
+ # The reach verdict on its own, whether or not it is the one `degraded`
537
+ # is reporting. Additive, like PHP's `reach_reason` key: without it, a
538
+ # process that is both mounted behind compression *and* unable to
539
+ # persist shows only the first, and the second disappears until the
540
+ # first is fixed.
541
+ 'reach_reason' => reach,
542
+ 'manifest' => @current
543
+ }
544
+ end
545
+
546
+ # What only this package can answer, asked after the shared verdict.
547
+ #
548
+ # Every branch reports an observation. `@persisted == false` means a push was
549
+ # taken and a write was attempted or had nowhere to go; `unmatched` past
550
+ # `KEYS_MATCH_NOTHING_AFTER` with `considered` still at zero means requests
551
+ # arrived and the manifest refused all of them; `@requests.zero?` past
552
+ # `NEVER_SEEN_AFTER_S` means nothing has arrived at all for longer than a boot
553
+ # takes. None of them is inferred from configuration, and all of them are
554
+ # silent while there is nothing to report — `''` here means *not examined*,
555
+ # exactly as it does in the shared function.
556
+ #
557
+ # The two counting verdicts share the one-minute window, and that is
558
+ # deliberate: both of them are "this process has been running long enough
559
+ # that its silence means something", and a process thirty seconds into a
560
+ # deploy has not. Paging somebody on every deploy is how an alert stops being
561
+ # read.
562
+ def reach_reason(counters, has_rules, now)
563
+ return PUSH_NOT_PERSISTED if @persisted == false
564
+
565
+ settled = (now - @started_at) > NEVER_SEEN_AFTER_S
566
+ unmatched = Degraded.count(counters, :unmatched)
567
+ if has_rules && settled && Degraded.count(counters, :considered).zero? &&
568
+ unmatched >= KEYS_MATCH_NOTHING_AFTER
569
+ return format(KEYS_MATCH_NOTHING, count: unmatched)
570
+ end
571
+ return NEVER_IN_REQUEST_PATH if @requests.zero? && settled
572
+
573
+ ''
574
+ end
575
+
576
+ private
577
+
578
+ # `[mtime, size]` for a file, or nil when there is not one to look at.
579
+ #
580
+ # Two signals rather than one because `mtime` has one-second granularity on
581
+ # some filesystems, and a size is free. Neither is a hash of the content:
582
+ # reading the file to decide whether to read the file is the loop this is
583
+ # here to avoid.
584
+ def fingerprint_of(path)
585
+ return nil if path.nil? || path.to_s.empty?
586
+
587
+ stat = File.stat(path.to_s)
588
+ [stat.mtime.to_f, stat.size]
589
+ rescue SystemCallError
590
+ nil
591
+ end
592
+
593
+ # The file's mtime as a Float, or nil when there is not one to read.
594
+ def mtime_of(path)
595
+ stat = fingerprint_of(path)
596
+ stat.nil? ? nil : stat[0]
597
+ end
598
+
599
+ def write_cache(payload)
600
+ if @cache_file.nil? || @cache_file.to_s.empty?
601
+ # Not silence. A push we cannot keep is a push that is lost at the next
602
+ # restart and that never reaches the other workers, and `/status` said
603
+ # nothing about it until 2 Sep 2026 — the §20.1 rule 3 failure at its
604
+ # most expensive, since the engine reads this manifest back before
605
+ # merging the next fix. Recorded here, reported by `reach_reason`.
606
+ @persisted = false
607
+ return
608
+ end
609
+
610
+ FileUtils.mkdir_p(File.dirname(@cache_file.to_s))
611
+ write_atomic(@cache_file.to_s, JSON.generate(payload))
612
+ # Our own write, recorded so `reload_if_stale` does not read it straight
613
+ # back on the next request and call it somebody else's push.
614
+ @cache_fingerprint = fingerprint_of(@cache_file)
615
+ @persisted = true
616
+ rescue SystemCallError, IOError
617
+ # Kept in memory regardless. An unwritable cache costs the rules on the
618
+ # next restart; refusing the push costs them now. Recorded as a failure to
619
+ # persist, so the loss is reported instead of being invisible.
620
+ @persisted = false
621
+ nil
622
+ end
623
+
624
+ # Write to a temporary file in the same directory, then rename onto it.
625
+ #
626
+ # `File.write` truncates and rewrites in place, and this is the only one of
627
+ # the four packages that reads its own cache file from *other processes* on
628
+ # the request path — `reload_if_stale`, up to once a second per worker. A
629
+ # sibling that stats the file inside the truncation window sees `[T, 0]`,
630
+ # stores that as its fingerprint, reads `""`, and defers adoption until the
631
+ # next stat differs from it; on a filesystem with coarse mtime granularity
632
+ # that is a wrong answer rather than a one-second delay. `rename` is atomic
633
+ # within a filesystem on both POSIX and Windows, so a reader sees either the
634
+ # old file or the new one and never a half-written one. The PHP package
635
+ # already does this and named it (`middleware/php/src/Store.php:1011-1032`);
636
+ # this is the same fix, not a second design (§20.1 rule 2).
637
+ #
638
+ # The pid is in the temporary name because the whole reason this file exists
639
+ # is that several forked workers share it, and two of them writing
640
+ # `manifest.json.tmp` at once would rename each other's half-written bytes
641
+ # into place. Within one process `replace` holds the lock, so there is no
642
+ # second writer to collide with.
643
+ def write_atomic(path, body)
644
+ temporary = "#{path}.#{Process.pid}.tmp"
645
+ begin
646
+ File.write(temporary, body, encoding: 'UTF-8')
647
+ File.rename(temporary, path)
648
+ ensure
649
+ begin
650
+ File.unlink(temporary) if File.exist?(temporary)
651
+ rescue SystemCallError
652
+ nil
653
+ end
654
+ end
655
+ end
656
+ end
657
+ end