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,607 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Attaching without editing the application.
4
+ #
5
+ # RUBYOPT="-rfindxpand/auto" bundle exec puma
6
+ # RUBYOPT="-rfindxpand/auto" bundle exec rails server
7
+ # RUBYOPT="-rfindxpand/auto" bundle exec unicorn
8
+ #
9
+ # Ruby's `-r` is Node's `--require` and Python's `sitecustomize`: it runs before
10
+ # the application's first line, which is the only place from which a middleware
11
+ # can guarantee its own position.
12
+ #
13
+ # ## Why this exists, rather than one more line in the install guide
14
+ #
15
+ # `use Findxpand::Middleware` asks a customer to place a middleware correctly in
16
+ # a stack we cannot see, and the instruction we shipped for doing it was **wrong
17
+ # in every language it was written for**. Measured 31 Aug 2026:
18
+ #
19
+ # Express + compression, findxpand first marker `skip-encoded`, page
20
+ # unmodified, `/status` healthy
21
+ # Express + compression, findxpand last 2069 bytes of plain HTML on the
22
+ # wire under `content-encoding: gzip`
23
+ # Django 6.1 + GZipMiddleware, outermost marker `skip-encoded`, original
24
+ # title served, `/status` healthy
25
+ #
26
+ # Rack has the same shape as the Django case and the same only-one-position:
27
+ # `use Rack::Deflater` inside us hands us gzip, and there is nowhere else to
28
+ # stand. This was never a step people were getting wrong — it is a step with no
29
+ # right answer, which is why the answer is to delete the step. `encoding.rb`
30
+ # takes over the negotiation so that outermost is finally the correct place, and
31
+ # this file makes outermost happen without anybody typing anything.
32
+ #
33
+ # ## The hook: every builder, not one class
34
+ #
35
+ # **A Rack builder, prepended — whichever class the server actually uses.**
36
+ #
37
+ # What used to be written here was: "`Rack::Builder#to_app`, prepended. Every
38
+ # Ruby web server that serves a Rack application builds it through that one
39
+ # method." **That is false, and it was false on the server named in our own
40
+ # headline install command.** Puma does not parse `config.ru` with
41
+ # `Rack::Builder`. It carries a vendored copy — `Puma::Rack::Builder`, in
42
+ # `puma/lib/puma/rack/builder.rb`, reached from `Puma::Configuration#load_rackup`
43
+ # — whose `new_from_string` evaluates `Puma::Rack::Builder.new {...}.to_app`.
44
+ # A prepend on `::Rack::Builder` is not on that class and never runs, so
45
+ # `RUBYOPT="-rfindxpand/auto" bundle exec puma` placed a hook, warned about
46
+ # nothing, and attached nothing. `/__findxpand/status` then 404s and the engine
47
+ # reports `installed: false` for a package that is installed and did run: the
48
+ # attach that silently does not attach, §20.1 rule 3, in the one place it costs
49
+ # the most.
50
+ #
51
+ # **MEASURED 2 Sep 2026 on Ruby 3.4.10, Puma 8.0.2, rack 3.2.7 — and the claim
52
+ # above is half right, in a way that matters.** Puma 8 does ship the vendored
53
+ # copy: `puma/rack/builder.rb` is in the gem and `require "puma/rack/builder"`
54
+ # defines `Puma::Rack::Builder`. But it is NOT loaded when the `rack` gem is
55
+ # available, and with rack present Puma parses `config.ru` with the real
56
+ # `::Rack::Builder`. Booting a bare non-Rails `config.ru` under
57
+ # `RUBYOPT="-rfindxpand/auto" puma -b tcp://127.0.0.1:8782` gave
58
+ # `attach_state: "attached", builders: ["Rack::Builder"], built: 1,
59
+ # watching: false` — the by-name prepend caught it and the watcher never fired.
60
+ # An approved title then landed in the served HTML and `origin-confirm` reported
61
+ # `deployed: true`.
62
+ #
63
+ # So the vendored builder is Puma's rack-less FALLBACK rather than its normal
64
+ # path, and the original finding overstated it. The watcher is still the thing
65
+ # that covers that fallback, because the fallback is required lazily — after
66
+ # `findxpand/auto` has already run, which is precisely the case a prepend by
67
+ # name cannot reach. That half is measured too: loading the real
68
+ # `Puma::Rack::Builder` after us and building an app through it returns a
69
+ # `Findxpand::Middleware`, so the TracePoint catches Puma's own class by shape.
70
+ #
71
+ # Both mechanisms are therefore exercised, and neither is redundant:
72
+ #
73
+ # 1. **By name, for what is already loaded** (`BUILDER_CLASSES`). Cheap, exact,
74
+ # and enough for `rackup`, Unicorn, Thin and anything that reaches
75
+ # `Rack::Builder` before we run.
76
+ # 2. **By shape, for what is defined afterwards** (`watch_for_builders`). A
77
+ # class that defines `to_app`, `use` and `run` *is* a Rack builder whatever it
78
+ # is called and whoever vendored it. This catches Puma without our being right
79
+ # about `Puma::Rack::Builder`, and it catches the next server that vendors
80
+ # one.
81
+ #
82
+ # `super` first in every case, so the application is built exactly as it would
83
+ # have been and every `use` in the file is already inside what we are handed.
84
+ #
85
+ # ## Why a TracePoint now, when this file used to refuse one
86
+ #
87
+ # The rejected version was a `TracePoint` **waiting for `Rack::Builder` to be
88
+ # defined**, so that Rack need not be loadable at the moment this file runs. That
89
+ # is still refused, and for the reason written here before: `bundle exec`
90
+ # prepends `-rbundler/setup` to `RUBYOPT`, so the load path is already the
91
+ # Gemfile's by the time we run, and a Rack application's Gemfile contains Rack.
92
+ # It bought a case that does not occur.
93
+ #
94
+ # The case this one buys **always** occurs: the server defines its own builder
95
+ # *after* us, because the server is what `RUBYOPT` runs before. No amount of
96
+ # looking at the moment we load can see a class that does not exist yet, and
97
+ # requiring `puma/rack/builder` ourselves would mean loading a server's internals
98
+ # out of order on the strength of a filename we cannot check from here.
99
+ #
100
+ # What it costs, and this is reasoning rather than a measurement — nothing in
101
+ # this file has been executed: one Ruby-level callback per class or module body
102
+ # that *closes* (`:end`), which is thousands during a Rails boot and rare
103
+ # afterwards, doing an `is_a?` and up to three `method_defined?` calls. It is
104
+ # disarmed the moment an application is built through us, which is the moment it
105
+ # has nothing left to catch. If `TracePoint` cannot be enabled at all — another
106
+ # Ruby, a sandbox — that is **said on stderr** rather than discovered by a
107
+ # customer whose pages never changed.
108
+ #
109
+ # Three alternatives were considered and are not used alone:
110
+ #
111
+ # * **A Railtie inserting at position 0.** It is shipped (`railtie.rb`) and it is
112
+ # not sufficient: index 0 of the Rails stack is still *inside* whatever
113
+ # `config.ru` wrapped around `Rails.application`, and it reaches no Sinatra,
114
+ # Hanami or plain Rack application at all. It is the belt to this file's
115
+ # braces, and it matters in one real case — a Rails process where `RUBYOPT`
116
+ # never arrived but the Gemfile did.
117
+ # * **Patching the server.** Puma, Unicorn, Passenger, Falcon and WEBrick share
118
+ # no request path, no configuration object and no naming convention; a hook per
119
+ # server is five hooks to maintain and five ways to silently miss. Node could
120
+ # do it in one place because every one of its servers emits `request` on an
121
+ # `http.Server`. Ruby has no such single seam below Rack — but every one of
122
+ # them does build through *a* builder, which is what the shape test uses.
123
+ # * **Requiring the server's own builder file ourselves.** One line, and it
124
+ # depends on a path inside somebody else's gem being what we think it is, at
125
+ # the version they pinned, loadable in whatever order we ask for it. The shape
126
+ # test needs to be right about nothing.
127
+ #
128
+ # ## What it reaches, and what it does not
129
+ #
130
+ # Reached, through a builder's `to_app`:
131
+ #
132
+ # * anything booted from a `config.ru` — `rackup`, `puma`, `unicorn`, `thin`,
133
+ # `falcon`, Passenger, WEBrick;
134
+ # * **Rails**, because `rails server` and every one of those servers loads
135
+ # `config.ru`, and the wrap therefore sits outside the Rails stack *and*
136
+ # outside anything `config.ru` itself `use`d;
137
+ # * **Sinatra**, classic and modular, including `ruby app.rb` with no
138
+ # `config.ru`, because `Sinatra::Base.new` builds through `Rack::Builder`.
139
+ # Note what that does *not* mean: Sinatra builds through a builder of its own
140
+ # making, so we are outside everything `config.ru` used **and inside
141
+ # Sinatra's own `use` lines**. The claim that this is "strictly further out
142
+ # than any `use` line" holds for `config.ru` and does not hold for
143
+ # `Sinatra::Base.use`;
144
+ # * **Hanami**, **Roda**, **Grape**, `Rack::URLMap`, and a bare lambda.
145
+ #
146
+ # Reached, through the Railtie, and only when the gem is in the Gemfile so that
147
+ # it is required after Rails: the Rails middleware stack at position 0.
148
+ #
149
+ # **Not reached, and there is no point pretending otherwise:**
150
+ #
151
+ # * a server handed an app object in code rather than through a builder —
152
+ # `Puma::Server.new(MyApp).run`, `Rackup::Handler::WEBrick.run(MyApp)`;
153
+ # * a builder that does not answer to `to_app`, `use` and `run` together. That
154
+ # is the whole of what the shape test recognises, and a server that invents a
155
+ # different DSL is a server we do not see. `attach_state` and `built` say so
156
+ # (see below) rather than leaving it to be guessed;
157
+ # * anything that is not a Rack application: a bare `WEBrick::HTTPServer` with a
158
+ # servlet, `Async::HTTP::Server` used directly, gRPC, ActionCable's own socket
159
+ # path;
160
+ # * a process that never receives `RUBYOPT` — a `bin/rails` wrapper that
161
+ # re-execs, a systemd unit with a scrubbed `Environment=`, a Dockerfile that
162
+ # sets `ENV` in a build stage the runtime stage does not inherit. On Rails the
163
+ # Railtie still catches this; on Sinatra and Hanami nothing does;
164
+ # * **JRuby and TruffleRuby**, in the sense that the mechanism is ordinary Ruby
165
+ # and ought to work and has not been run there. If `TracePoint` is missing or
166
+ # refuses to enable, only the by-name half runs and stderr says so;
167
+ # * **the other workers.** After `fork` — Puma in cluster mode, Unicorn, Passenger
168
+ # — each worker holds its own copy of the manifest, and a push arrives at
169
+ # exactly one of them. `Store#reload_if_stale` closes that by re-reading the
170
+ # cache file when it changes, which is why `FINDXPAND_CACHE_FILE` is not
171
+ # optional on a forking server.
172
+ #
173
+ # ## How you can tell, without trusting any of the above
174
+ #
175
+ # An attach that silently does not attach is the worst outcome of all (§20.1
176
+ # rule 3), so nothing here reports on the strength of a hook it *placed*. Every
177
+ # field is something this process *observed*:
178
+ #
179
+ # attach_state pending / no-token / disabled / no-builder / watching /
180
+ # attached / failed — modelled on
181
+ # `middleware/php/src/Auto.php:135-155`, which made the same
182
+ # change for the same reason: "`attached()` alone is a boolean
183
+ # with four causes behind it"
184
+ # builders the classes actually prepended, by name
185
+ # built how many applications were built through one of them
186
+ # middlewares how many `Findxpand::Middleware` objects exist
187
+ # attach_problem the sentence, when `built` or `middlewares` is zero
188
+ #
189
+ # They are reported by `GET /__findxpand/status` (`Findxpand.attach_report`,
190
+ # merged in by `Admin`). `built: 0` there is the exact signature of the Puma
191
+ # defect above: the Railtie mounted us, so the endpoint answers, and the
192
+ # `RUBYOPT` hook never fired.
193
+ #
194
+ # **The one case this cannot report is the total one**, and it is named rather
195
+ # than papered over: if nothing of ours was ever constructed there is no
196
+ # middleware in the path to answer `/status` at all. Stderr is then the only
197
+ # channel, and it carries every cause that can be observed at boot — no token,
198
+ # no builder, a `TracePoint` that would not enable. What remains unobservable is
199
+ # a process that boots cleanly and simply never builds an application through
200
+ # anything we recognise; nothing in-process can distinguish that from a server
201
+ # that has not finished booting, and inventing a timer that guessed would be a
202
+ # monitor that cries wolf on every Puma master and every `rake` task.
203
+ #
204
+ # ## Failure direction
205
+ #
206
+ # Every path here degrades to *not attached*, never to *broken*. A missing token,
207
+ # a Rack that cannot be loaded, a wrap that raises: the message goes to stderr
208
+ # and the application runs exactly as it would have. This code executes before
209
+ # the first line of somebody's site, and §3 rule 7's fail-closed rule is about
210
+ # writes, credentials and budgets — not about refusing to let a homepage render.
211
+ #
212
+ # The one failure it cannot soften is a `LoadError` on `-rfindxpand/auto`
213
+ # itself: Ruby exits before this file runs. That is why the README's first step
214
+ # is the Gemfile and the second is the `RUBYOPT`.
215
+ #
216
+ # Frozen string literals: on.
217
+
218
+ require 'findxpand'
219
+
220
+ module Findxpand
221
+ module Auto
222
+ # The builder classes we prepend on sight, by name, without requiring any of
223
+ # them.
224
+ #
225
+ # Names rather than constants: this file loads before the application's first
226
+ # line, and `Puma::Rack::Builder` is a `NameError` at that point on every
227
+ # stack including Puma's own. Resolved one segment at a time through
228
+ # `const_defined?` so that a missing namespace is a `nil` rather than an
229
+ # exception, and so that nothing here can force a `require` of a gem the
230
+ # customer did not ask for.
231
+ #
232
+ # This list is a fast path and not the mechanism. Anything defined after us —
233
+ # which is every server's own copy, since the server loads after `RUBYOPT` —
234
+ # is caught by shape in `watch_for_builders`, so a name missing from here
235
+ # costs nothing but the microsecond the watcher takes to recognise it. A name
236
+ # in here that no gem defines costs nothing either: `resolve` answers `nil`
237
+ # and the row never appears in `builders`. `Rackup::Builder` is in the list on
238
+ # that basis — Rack 3 moved `Rack::Server` and `Rack::Handler` into the
239
+ # `rackup` gem, and whether `Builder` went with them could not be checked from
240
+ # this machine.
241
+ BUILDER_CLASSES = %w[
242
+ Rack::Builder
243
+ Rackup::Builder
244
+ Puma::Rack::Builder
245
+ ].freeze
246
+
247
+ # The three methods that make a class a Rack builder.
248
+ #
249
+ # `to_app` alone is not enough — `Rails::Application` and several test
250
+ # harnesses answer to it — and prepending to something that is not a builder
251
+ # would put our `super` call in front of a method that means something else.
252
+ # All three together are the `config.ru` DSL and nothing else has them.
253
+ BUILDER_METHODS = %i[to_app use run].freeze
254
+
255
+ # Guards against a second `-r`, a re-require under a different path, or a
256
+ # `require 'findxpand/auto'` in an initialiser on top of the `RUBYOPT`. Two
257
+ # prepends of the same module are harmless — Ruby ignores the second — but
258
+ # the warning would be printed twice and the answer to `installed?` would
259
+ # stop meaning anything.
260
+ @installed = false
261
+ # name => true, for every class we actually prepended. The keys are the
262
+ # report; the Hash is the idempotence check.
263
+ @attached = {}
264
+ # Applications built through a hook we placed. **The only evidence the hook
265
+ # ran**, as distinct from `@installed`, which says only that it was placed.
266
+ @built = 0
267
+ @built_lock = Mutex.new
268
+ @watch = nil
269
+ @watch_error = nil
270
+
271
+ # Why nothing is attached in this process, or `attached`.
272
+ #
273
+ # `pending` `install` has not run — the file was required and nothing else
274
+ # `no-token` FINDXPAND_TOKEN was empty, so we declined to attach
275
+ # `disabled` FINDXPAND_ENABLED was off. Somebody meant this
276
+ # `no-builder` no builder was loaded and none can be watched for
277
+ # `watching` hooks placed; no application has been built through one yet
278
+ # `attached` an application **was** built through a hook we placed
279
+ # `failed` an exception during install; the application runs unchanged
280
+ #
281
+ # Six causes behind one boolean, which is the change
282
+ # `middleware/php/src/Auto.php:135-155` made after its Laravel provider read
283
+ # `attached() == false` as one particular cause and announced it on every
284
+ # `php artisan` command. `attached` here is a fact about the *hook* — an
285
+ # application came through it — and not a claim that a middleware resulted;
286
+ # `attach_problem` answers that, from `Middleware.constructed`.
287
+ @state = 'pending'
288
+
289
+ class << self
290
+ attr_reader :installed, :built, :state, :watch_error
291
+ end
292
+
293
+ # For the suite only. Does not un-prepend: a module prepended to a class is
294
+ # there for the life of the process, which is why the tests define a fresh
295
+ # class per case rather than reusing one.
296
+ def self.reset!
297
+ @installed = false
298
+ @attached = {}
299
+ @built = 0
300
+ @state = 'pending'
301
+ @watch_error = nil
302
+ @watch&.disable
303
+ @watch = nil
304
+ end
305
+
306
+ # Delegated rather than duplicated: two spellings of the `findxpand:` prefix
307
+ # is the §20.1 rule 2 shape at its smallest, and the prefix is what a
308
+ # customer greps their boot log for.
309
+ def self.warn_stderr(message)
310
+ Findxpand.warn_stderr(message)
311
+ end
312
+
313
+ # Wrap one built application, or hand it back with a reason.
314
+ #
315
+ # Never raises. This is called from inside somebody else's boot, and an
316
+ # exception here is a site that does not start.
317
+ def self.wrap(app)
318
+ return app if app.nil?
319
+ # `to_app` on a builder whose `run` was already our middleware. Harmless to
320
+ # wrap twice — `Middleware::SEEN_KEY` makes the inner one a no-op — but
321
+ # pointless, and one fewer object per request path is one fewer thing to
322
+ # explain in a stack trace.
323
+ return app if app.is_a?(Findxpand::Middleware)
324
+ # **Whatever this is, it is not a Rack application.** The safety net under
325
+ # the shape test: `builder_shape?` recognises a builder by three method
326
+ # names, and a class somewhere in a large application may carry all three
327
+ # and mean something else entirely by them. Wrapping *that* object's return
328
+ # value would hand its caller a `Findxpand::Middleware` where it expected
329
+ # its own type, which is a broken application rather than an unattached
330
+ # one — the direction this file is not allowed to fail in. A Rack
331
+ # application is exactly a thing that answers `call`, so that is the test.
332
+ return app unless app.respond_to?(:call)
333
+
334
+ settings = Findxpand.options_from_env
335
+ return app if settings[:token].empty?
336
+ return app unless settings[:enabled]
337
+
338
+ # Constructed with no options **on purpose**. `Middleware#initialize`
339
+ # resolves every option from this same environment on every construction
340
+ # path, so passing them here as well would be a second reading of one fact
341
+ # — the §20.1 rule 2 shape, and the exact shape that produced the defect
342
+ # this gem shipped: the `RUBYOPT` path read `FINDXPAND_CACHE_FILE` and the
343
+ # other two paths did not. What is read here is only the question *whether
344
+ # to attach at all*, which the constructor does not answer.
345
+ Findxpand::Middleware.new(app)
346
+ rescue StandardError => e
347
+ warn_stderr("could not attach (#{e.class}: #{e.message}); your application is running " \
348
+ 'unchanged.')
349
+ app
350
+ end
351
+
352
+ # Prepended to every builder we find, which is the whole mechanism.
353
+ #
354
+ # `super` first, so the application is built exactly as it would have been
355
+ # and every `use` in the file is already inside what we are handed.
356
+ module Builder
357
+ def to_app
358
+ built = super
359
+ Findxpand::Auto.built!
360
+ Findxpand::Auto.wrap(built)
361
+ end
362
+ end
363
+
364
+ # One application was built through a hook we placed.
365
+ #
366
+ # Counted **before** `wrap` decides anything, because the two questions are
367
+ # different and were being answered by one number: "did the hook run" is
368
+ # this, and "did a middleware result" is `Middleware.constructed`. A hook
369
+ # that ran and declined (no token) reports differently from a hook that never
370
+ # ran at all, and the second is the Puma defect.
371
+ def self.built!
372
+ @built_lock.synchronize do
373
+ @built += 1
374
+ @state = 'attached'
375
+ end
376
+ # Disarmed here. An application has been built through us, so the class
377
+ # that builds applications in this process is one we are already on, and a
378
+ # watcher that goes on watching is a callback on every class body for the
379
+ # life of the process buying nothing.
380
+ #
381
+ # The trade, stated: if some *inner* builder builds first — a `map` block,
382
+ # a Sinatra sub-application — we disarm before the server's own outer
383
+ # builder is defined, and we are then attached but not outermost. That is a
384
+ # degradation (a `Rack::Deflater` outside us can hand us gzip, which we
385
+ # count as `skip-encoded` and report as degraded) rather than a silence,
386
+ # and `builders` names exactly what we got.
387
+ @watch&.disable
388
+ rescue StandardError
389
+ nil
390
+ end
391
+
392
+ # Prepend to every named builder class that is already defined.
393
+ #
394
+ # Idempotent, and safe to call again later: the Railtie calls it a second
395
+ # time, because by the time Rails runs its initializers Rack is certainly
396
+ # loaded even if it was not when `RUBYOPT` ran. Returns the names now
397
+ # attached.
398
+ #
399
+ # `names` is a seam for the suite, not an option. The list it defaults to is
400
+ # a constant because a customer has no business changing it, and a test
401
+ # cannot add `Rack::Builder` to a process that has no Rack in it.
402
+ def self.attach_builders(names = BUILDER_CLASSES)
403
+ names.each do |name|
404
+ next if @attached.key?(name)
405
+
406
+ klass = resolve(name)
407
+ next if klass.nil?
408
+
409
+ attach_class(klass, name)
410
+ end
411
+ @attached.keys
412
+ end
413
+
414
+ # Resolve `A::B::C` without raising and without requiring anything.
415
+ #
416
+ # `Object.const_defined?('Puma::Rack::Builder')` raises `NameError` when
417
+ # `Puma` is not defined rather than answering false, which on a non-Puma
418
+ # stack is every boot — so the path is walked a segment at a time. `false` as
419
+ # the second argument keeps the lookup off `Object`'s ancestors, so a
420
+ # `Builder` constant belonging to something else cannot be mistaken for this
421
+ # one.
422
+ def self.resolve(name)
423
+ name.split('::').reduce(Object) do |scope, segment|
424
+ return nil unless scope.is_a?(Module) && scope.const_defined?(segment, false)
425
+
426
+ scope.const_get(segment, false)
427
+ end
428
+ rescue StandardError
429
+ nil
430
+ end
431
+
432
+ # Prepend to one class, once.
433
+ #
434
+ # The idempotence check is `ancestors.include?`, not our own bookkeeping: a
435
+ # class reopened later, a subclass of one we already hold, and a second call
436
+ # from the Railtie all arrive here, and the ancestor chain is the fact while
437
+ # a Hash of names is only our record of it.
438
+ def self.attach_class(klass, name = nil)
439
+ return false unless klass.is_a?(Module)
440
+ return false if klass.ancestors.include?(Builder)
441
+
442
+ klass.prepend(Builder)
443
+ @attached[name || klass.name || klass.to_s] = true
444
+ true
445
+ rescue StandardError
446
+ # A frozen class, or a Module that refuses a prepend. Not a reason to stop
447
+ # a boot, and the name simply does not appear in `builders`.
448
+ false
449
+ end
450
+
451
+ # Is this a Rack builder, whatever it is called?
452
+ #
453
+ # Asked of every class body that closes after we install. See
454
+ # `BUILDER_METHODS` for why all three are required.
455
+ def self.builder_shape?(klass)
456
+ return false unless klass.is_a?(Module)
457
+
458
+ BUILDER_METHODS.all? { |method| klass.method_defined?(method) }
459
+ rescue StandardError
460
+ false
461
+ end
462
+
463
+ # Watch for a builder class defined after us — which is every server's own.
464
+ #
465
+ # `:end` rather than `:class`, so the body has finished and the methods we
466
+ # test for exist. (`prepend` would work at `:class` too, since `super` is
467
+ # resolved at call time, but then the shape test would answer false for every
468
+ # class in the process and this would be a watcher that never fires.)
469
+ #
470
+ # Returns whether the watch is armed. Never raises: a Ruby without
471
+ # `TracePoint`, or one that refuses to enable it, degrades to the by-name
472
+ # half and says so on stderr from `install`.
473
+ def self.watch_for_builders
474
+ return true unless @watch.nil?
475
+ return false unless defined?(::TracePoint)
476
+
477
+ watch = ::TracePoint.new(:end) do |point|
478
+ # Deliberately no Mutex. Taking one inside a trace callback deadlocks the
479
+ # first time a `require` runs while this file holds it — `Mutex` is not
480
+ # reentrant — and the work being protected is a `prepend` Ruby already
481
+ # makes idempotent plus one Hash write. `install` runs on the main thread
482
+ # before the application exists, so there is no second writer to race.
483
+ #
484
+ # Every line of it inside a `rescue`, because an exception raised in a
485
+ # trace handler propagates into whatever code happened to close a class —
486
+ # somebody else's `require`, at their boot. This file's one promise is
487
+ # that it degrades to *not attached* and never to *broken*, and a raise
488
+ # here is the one place that promise could be broken from.
489
+ begin
490
+ target = point.self
491
+ attach_class(target) if builder_shape?(target)
492
+ rescue StandardError
493
+ nil
494
+ end
495
+ end
496
+ watch.enable
497
+ @watch = watch
498
+ true
499
+ rescue StandardError, NotImplementedError => e
500
+ @watch = nil
501
+ @watch_error = "#{e.class}: #{e.message}"
502
+ false
503
+ end
504
+
505
+ # What this process observed about its own attach, for `/status`.
506
+ #
507
+ # Every value is a count or a name of something that happened. Nothing here
508
+ # is derived from configuration, and nothing reports health for a state it
509
+ # did not examine (§20.1 rule 3).
510
+ def self.report
511
+ {
512
+ 'attach_state' => @state,
513
+ 'builders' => @attached.keys.sort,
514
+ 'built' => @built,
515
+ 'watching' => !@watch.nil? && @watch.enabled?
516
+ }.tap do |report|
517
+ report['watch_error'] = @watch_error unless @watch_error.nil?
518
+ problem = attach_problem
519
+ report['attach_problem'] = problem unless problem.nil?
520
+ end
521
+ end
522
+
523
+ # The sentence for a `-rfindxpand/auto` that did not reach anything, or
524
+ # `nil` when there is nothing to report.
525
+ #
526
+ # `nil` is "we looked and there is nothing wrong", which is only sayable
527
+ # because both inputs are observations: an application was built through us,
528
+ # and a middleware exists. Neither can be true while the attach is a no-op.
529
+ def self.attach_problem
530
+ return nil unless @installed
531
+
532
+ if @built.zero?
533
+ return 'findxpand/auto ran and no application has been built through it. Hooks were ' \
534
+ "placed on #{@attached.keys.sort.inspect}; this server built its application " \
535
+ 'some other way - it parses config.ru with its own builder class, or it was ' \
536
+ 'handed an app object in code. If this endpoint is answering, the Rails ' \
537
+ 'Railtie is what mounted us.'
538
+ end
539
+ if Findxpand::Middleware.constructed.zero?
540
+ return 'an application was built through findxpand/auto and no middleware was added ' \
541
+ 'to it - FINDXPAND_TOKEN was empty, or FINDXPAND_ENABLED was off, at the ' \
542
+ 'moment it was built.'
543
+ end
544
+
545
+ nil
546
+ end
547
+
548
+ # The whole of what a `-rfindxpand/auto` process does before its own first
549
+ # line.
550
+ def self.install
551
+ return false if @installed
552
+
553
+ if ENV['FINDXPAND_TOKEN'].to_s.empty?
554
+ @state = 'no-token'
555
+ warn_stderr('FINDXPAND_TOKEN is not set, so nothing was attached and this process ' \
556
+ 'serves your pages unchanged.')
557
+ return false
558
+ end
559
+ unless Findxpand.flag('FINDXPAND_ENABLED', true)
560
+ # Deliberately silent. Somebody set this on purpose.
561
+ @state = 'disabled'
562
+ return false
563
+ end
564
+
565
+ @installed = true
566
+ load_rack
567
+ attach_builders
568
+ watching = watch_for_builders
569
+ @state = 'watching'
570
+
571
+ if @attached.empty? && !watching
572
+ # Nothing to prepend and no way to notice one appearing. This process
573
+ # cannot attach, and the only channel it has is this line.
574
+ @state = 'no-builder'
575
+ warn_stderr('no Rack builder class is loaded and this Ruby will not let us watch for ' \
576
+ "one (#{@watch_error}), so nothing was attached and this process serves " \
577
+ 'your pages unchanged.')
578
+ elsif !watching
579
+ # The by-name half is live and the by-shape half is not. Said out loud,
580
+ # because this is precisely the configuration in which a server that
581
+ # vendors its own builder — Puma does — attaches nothing and warns
582
+ # nothing, which is the defect this watch exists to end.
583
+ warn_stderr("watching for a server's own builder is unavailable here " \
584
+ "(#{@watch_error}). Servers that parse config.ru with a vendored copy of " \
585
+ 'Rack::Builder - Puma is one - will not be reached. Check ' \
586
+ '/__findxpand/status: `built: 0` means this happened.')
587
+ end
588
+ true
589
+ rescue StandardError => e
590
+ @state = 'failed'
591
+ warn_stderr("bootstrap skipped (#{e.class}: #{e.message}); running unchanged.")
592
+ false
593
+ end
594
+
595
+ # Rack, if this process has it. Not an error when it does not: the Railtie
596
+ # may still attach, the watch may still see a builder, and if nothing does,
597
+ # `/status` and stderr say so rather than implying health.
598
+ def self.load_rack
599
+ require 'rack'
600
+ true
601
+ rescue LoadError
602
+ false
603
+ end
604
+ end
605
+ end
606
+
607
+ Findxpand::Auto.install