[HN Gopher] Ruby-refrigerator: Freeze all core Ruby classes
___________________________________________________________________
Ruby-refrigerator: Freeze all core Ruby classes
Author : thunderbong
Score : 118 points
Date : 2024-12-28 19:17 UTC (3 days ago)
(HTM) web link (github.com)
(TXT) w3m dump (github.com)
| ilvez wrote:
| Thanks for linking. I have an huge legacy app that could use
| analysis like this.
|
| Wondering how it works with Rails or the the analysis starts
| after I freeze? So I could only track my app specific
| modifications, since thats the interesting part.
| stouset wrote:
| Love this.
|
| In my own projects most of my Ruby classes are data objects that
| I freeze (along with their instance variables) immediately after
| creation. Methods can query the state of these objects but
| anything that involves changing state requires returning new
| objects (possibly of different classes).
|
| Not mutating anything has prevented _so many_ bugs. And as an
| added bonus most of my code has been able to be parallelized
| pretty trivially.
|
| One style nit though; version_int =
| RUBY_VERSION[0..2].sub('.', '').to_i filepath = lambda do
| File.expand_path(File.join(File.expand_path(__FILE__),
| "../../module_names/#{version_int}.txt")) end if
| version_int >= 18 # :nocov: version_int -= 1
| until File.file?(filepath.call) # :nocov: end
|
| Why not the simpler version =
| RUBY_VERSION[0..2].sub('.', '').to_i path =
| version.downto(18) .map { |v|
| "#{__FILE__}/../../module_names/#{v}.txt" } .map {
| |p| File.expand_path(p) } .detect { |p| File.file?(p) }
|
| Also this logic will definitely misbehave if there's ever a Ruby
| 3.xx.
| jaynetics wrote:
| > In my own projects most of my Ruby classes are data objects
| that I freeze (along with their instance variables) immediately
| after creation
|
| As of Ruby 3.2 you can use the Data class for this. Its
| instances are immutable by default and there is a convenient
| `#with` method to create modified copies.
|
| https://docs.ruby-lang.org/en/3.2/Data.html
|
| One issue in both cases is that an attribute may be a complex
| object (e.g. a Hash), which not only is mutable itself, but may
| contain mutable objects at arbitrary nesting depths. Gems like
| https://github.com/dkubb/ice_nine or
| https://github.com/jaynetics/leto (shameless plug) can be used
| to "deep-freeze" such structures.
| berkes wrote:
| I've used 'ice_nine' previously. When returning to some Ruby
| work after some significant rust-gigs, I really loved the
| "immutable by default" idea and kept running into issues
| caused by accidental or unwanted mutation.
|
| I couldn't use it though. For several reasons. I'd expect
| your leto (thanks!) has the same issues:
|
| - deep-freezing large object trees added noticable lag. Makes
| sense, because we iterate over large structures, multiple
| times per code path.
|
| - the idea of "copy on mutation" albeit a pattern I love,
| doesn't play nice with Ruby's GC. In apps with large data
| structures being pushed around (your typical REST+CRUD app,
| or anything related to im-/export, ETL etc) the GC now kicks
| in far, far more often _and_ has more to clean up. I believe
| this was /is being worked on, don't know the details though.
|
| - colleagues, especially the seniors entrenched in decade+ of
| Rails/Ruby work, didn't buy it, and tryd working around it
| with variations of "set_foo(val) {
| self.deep_clone.tap(&:unfreeze).tap(|s| s.foo =
| val).tap(&:freeze) }", which dragged a large app to
| screetching halt crashing servers and causing OOM kills.
|
| I then remembered my own advice I often give:
|
| > Don't try to make Ruby another Rust. We already have Rust,
| us that instead. Don't try to get Ruby to work exactly like
| Java. Just use Java if you need that.
| jaynetics wrote:
| > deep-freezing large object trees added noticable lag.
| Makes sense, because we iterate over large structures,
| multiple times per code path.
|
| Yes. The main issue is that objects can reference each
| other in various ways, and we need to check these for each
| individual object. I wouldn't recommend deep-freezing large
| structures in this way unless they are only set up once, at
| boot time.
|
| > the idea of "copy on mutation" albeit a pattern I love,
| doesn't play nice with Ruby's GC.
|
| Data#with re-uses references, so it should limit GC
| pressure. But it's probably not convenient if you need to
| patch deeply nested objects.
|
| > Don't try to make Ruby another Rust. We already have
| Rust, us that instead.
|
| I think that's good advice, but it's also nice that we can
| make Ruby behave like a more strict language in parts of a
| codebase if we need only some parts to be rigid.
| berkes wrote:
| > but it's also nice that we can make Ruby behave like a
| more strict language in parts of a codebase
|
| Certainly! But that should IMO be i) part of the language
| and runtime - e.g. some immutable do/end block, ii) used,
| and promoted by rails or at least some other popular
| frameworks and iii) become "idiomatic Ruby", in the sense
| that it's commonly known and used and iv) be default part
| of linters and checkers like rubocops defaults.
|
| Otherwise it will always remain that weird idea that some
| people once saw, or read about, maybe tried and ran into
| issues, but no-one ever uses in practice.
|
| I've seen so many good ideas like this in Ruby/Rails that
| ultimately failed or never really got off the ground.
| From the awesome "trailblazer" to the multiple attempts
| at async ruby.
| pdntspa wrote:
| As someone who inherited a codebase where we make liberal use of
| a monkeypatched `Object.const_missing`, and which breaks the code
| in frustrating and mysterious ways, thank you!
| EdwardDiego wrote:
| Trying to remember how Zed Shaw once phrased some of the
| shenanigans in Ruby codebases, I'm pretty sure it was something
| about "chainsaw juggling monkey patching."
| dominicrose wrote:
| I used to work with smalltalk. There was a TON of added methods
| on the class Object and others. When I said something about it I
| was told this is OOP :)
| vidarh wrote:
| With refinements, there is now little excuse to do that
| globally in Ruby any more.
|
| Refinements let you "temporarily" modify a class within a
| lexical scope. E.g I have an experimental parser generator that
| heavily monkeypatches core classes, but only when lexically
| inside the grammar definition.
|
| It lets you have the upsides of the footguns, but keeping them
| tightly controlled.
| igouy wrote:
| I used to work with Smalltalk. There was a TON of added methods
| on the class Object and others. (A hidden array on every object
| because ...?) When I said something about it I was told OK
| let's get rid of them. So we stripped out the "clever stuff"
| back to as-provided-by-the-vendor :)
| berkes wrote:
| Does freezing an object add overhead to running it? Memory, CPU
| cycles etc?
|
| In many languages, "frozen" or immutable objects typically allow
| for better performance, less GC overhead, and lighter data-
| structures when running parallel.
|
| But I can imagine, in Ruby (at least the default Matz' ruby
| runtime), where this freezing is the non-default and added to the
| language posterior, it makes everything slower. And I would
| imagine running a "freeze" loop over several hundreds, (or
| thousands?) of ob-classes takes some ms as well.
|
| I would think this overhead is negligible, maybe even
| unmeasurable small. This is purely out of interest.
| chris12321 wrote:
| The gem isn't freezing instances of the objects, but rather the
| classes themselves. In Ruby (nearly) everything is an object,
| so for example Array is an object of type Class. Classes are
| also open by defualt, allowing them to be altered at run time.
| There's nothing stopping me doing the following in my app:
| irb(main):001* class Array irb(main):002* def hello
| irb(main):003* puts "hello" irb(main):004* end
| irb(main):005> end => :hello irb(main):006>
| [].hello hello
|
| This sort of monkey patching is used a lot in the ActiveSupport
| gem to give convenient methods such as:
| irb(main):001:0> 3.days.ago => Sat, 28 Dec 2024
| 13:10:24.562785251 GMT +00:00
|
| Integer is extented with the `days` method making date maths
| very intuative.
|
| This gem freezes the core classes, essentially closing them, so
| doing the above raises an error: irb(main):007>
| require 'refrigerator' => true irb(main):008>
| Refrigerator.freeze_core => nil irb(main):009*
| class Array irb(main):010* def hello
| irb(main):011* puts "hello" irb(main):012* end
| irb(main):013> end (irb):10:in `<class:Array>': can't
| modify frozen class: Array (FrozenError)
|
| Looking at the code in the gem, all it's doing is calling
| https://apidock.com/ruby/Object/freeze on all call modules. The
| frozen flag is an inbuilt part of the language and as far as
| I'm aware has a performance benefit. In fact the major verison
| of Ruby will have all string instances frozen by default.
| berkes wrote:
| I know that it freezes the classes (which are also objects,
| indeed). And I saw it does this by reading classnames from a
| list of textfiles. Both is not fast. The "freeze" isn't that
| invasive, from what I can see in the c code, it merely flips
| a flag at the objects metadata table. But I can imagine the
| reading from files isn't that fast.
|
| And tracking the mutation flags isn't free either. Though I
| expect not that big. Checking for the state of this flag at
| every possible mutation is quite some overhead, I'd presume.
| But no idea how heavy that'd be.
|
| Again, compared to typical boot stuff like Rails' zeitwerk
| autoload mapping, it's nothing. And in runtime the only
| penalty will be when something tries to mutate a class obj,
| which I'd expect happens almost only on boot in most
| codebases anyway.
|
| Though I know quite some "gems" and even older Rails
| implementations (mostly around AR) that would monkeypatch in
| runtime on instantation even. Like when a state-machine gets
| built on :new or when methods get added based on database-
| columns and types, every time such an instance is returned
| from a db response.
| chris12321 wrote:
| I didn't mean to imply you didn't know that stuff, I just
| tend to write comments from the basics for anyone
| unfamiliar who may be reading. Yup I would expect it to
| have an (extremely small) boot time impact. Though I don't
| think it would have a runtime impact, since surely ruby has
| to check the frozen flag on mutation whether the flag has
| been set to true or not? Also by including the gem you
| preclude mutating the class objects anyway, since it raises
| an error.
| delichon wrote:
| Ruby String has a #to_json method I use frequently. Last week I
| added #from_json method to String and have already used it a lot.
| I love this ability to extend Ruby, and think the advantages
| outweigh the downsides, at least for pure additions rather than
| behavior changes to existing methods. I'd like a feature to only
| freeze the changes and allow additions.
| viraptor wrote:
| You can still make the change and then freeze. That's why they
| recommend doing it at the end of config.
| jaynetics wrote:
| Regarding from_json, there's an even shorter version built in:
| `JSON(my_string)`
|
| Regarding redefinition of methods, Ruby can emit warnings when
| that happens (and you can make those fail your build for
| example). https://alchemists.io/articles/ruby_warnings
| brink wrote:
| It's great when you're the only dev on a relatively young
| project.
| block_dagger wrote:
| A thought on naming - wouldn't "freezer" be a more appropriate
| choice?
| jeremyevans wrote:
| "freezer" was my originally desired name for this gem, but it
| was already taken.
| bingemaker wrote:
| Reminds me of this dark joke
| https://github.com/garybernhardt/base. This gem creates a "Base"
| class which literally contains everything!
| ericb wrote:
| I don't think I'd use this in production. Testing/development--
| sure.
|
| A class added a method with a require or dynamic definition and
| that was cause to crash a production activity of some kind? You'd
| discover the attempted modification via a new FrozenError being
| raised unexpectedly and crashing what you were doing.
|
| Ruby is made to let you extend core classes--Rails does it all
| over the place. If I put a require behind a feature flag, this is
| probably going to surprise me when it fails. It might also make
| junior devs think gems "don't work" or are buggy if you use it in
| development, when they work fine? How well does this play with
| dynamic class loading in dev work in Rails? I would think it
| would be problematic as you can't draw a line in the sand about
| when everything is loaded, so it is a safe time to "freeze."
| jaynetics wrote:
| Its a safety thing, and it's probably difficult to use it
| effectively with rails.
|
| E.g. in a project with lots of dependencies, things can break
| if two libs patch the same class after an update. A worse
| scenario: malicious code could be smuggled into core classes by
| any library that is compromised, e.g. to exfiltrate information
| at runtime. This would grant access even to information that is
| so sensitive that the system does not store it.
| LegionMammal978 wrote:
| Except for carefully sandboxed languages, malicious code can
| generally exfiltrate process memory regardless of what the
| language constructs are. In the case of Ruby code, this could
| be with Fiddle or with more esoteric means like
| /proc/self/mem. At worst, patching classes can make it a bit
| easier.
| rubyfan wrote:
| Jeremy Evans is not in the wrong part of town.
| Rapzid wrote:
| It's like a professional wandered into amateur hour.
| ericb wrote:
| That's fair, and I removed that comment for seeming snarky or
| directed at the author--it wasn't. My meaning was, like
| strong typing, it is an idea from a different context that
| works well there, but may not translate well to the Ruby
| world given expectations and usage patterns.
| vidarh wrote:
| Efforts to freeze more and more objects and classes _after_
| initial setup have been a long-standing trend in the Ruby
| world.
| kyledrake wrote:
| Jeremy Evans is definitely not in the wrong part of town. I
| use his Sequel gem in production and it is perhaps the best
| piece of software ever written for ruby. Studying how it is
| implemented is a textbook example of how to develop complex
| ruby DSLs really well without getting too deep in the
| metaprogramming muck.
| echelon wrote:
| > Ruby is made to let you extend core classes
|
| This is not the way to build long-lived software that outlives
| your team. This is how you create upgrade and migration
| headaches and make it difficult for new people to join and be
| productive.
|
| Chasing down obscure behaviors and actions at a distance is not
| fun. Being blocked from upgrades is not fun. Having to patch a
| thousand failing tests is not fun.
|
| I have serious battle scars from these bad practices.
| ericb wrote:
| I like to hear these stories--feel free to share. I guess,
| usually, I feel like the battle scars are from Rails users,
| though, which is made up of hundreds of core extensions which
| make it nicer to use, so _reducing_ the practice is a good
| recommendation, but _removing_ the practice seems like a
| nonstarter?
| samtheprogram wrote:
| There's some nuance here.
|
| Application and nearly all library code should not do this.
| (There can be exceptions for libraries, but they should be
| used sparingly.)
|
| A framework like Rails? A reasonable place to do this sort of
| stuff, because a framework implies your entire application
| depends on the framework, and the framework is well managed
| (otherwise you wouldn't be using it).
|
| Like you said: "you" shouldn't do this. I feel like your pain
| from this comes from someone being too clever, outside of a
| framework, hijacking core methods and classes.
| Andys wrote:
| Lesson learned for me though, if you "put a require behind a
| feature flag", you'll get surprise failures when your staging
| and test environments are no longer able to properly test what
| might happen in production. Put the require outside the flag
| and make the flag wrap the smallest possible part of the
| feature.
| foxhop wrote:
| I was just thinking about something like this for very small web
| applications (1,200 line app.py & 600 lines of html templates),
| something lighter than requiring a working docker install.
|
| a handful of dependencies (mostly Pyramid based at the moment) &
| whatever dependencies those have, pull it all down & serve it out
| of a tarball or zip file of a portable virtualenv.
| jeremyevans wrote:
| It's cool to see this posted here. Refrigerator has been around
| for a number of years, but it doesn't get much press. It was
| originally developed as part of my work getting production Ruby
| web applications to run in chroot environments, where you cannot
| load libraries after chrooting (for more details, see
| https://code.jeremyevans.net/presentations/rubyhack2018/inde...).
| This allows you to emulate that type of chroot restriction
| without superuser access. It also allows you to detect monkey
| patches of core classes in libraries.
|
| Note that it doesn't prevent or discourage monkey-patching, it
| just requires that you apply your monkey-patches before freezing
| the core classes. The idea here is similar to OpenBSD's pledge,
| where startup and runtime are considered different phases of an
| application's lifecycle. After startup, you limit what the
| runtime can do. With refrigerator, you freeze the core classes at
| the end of startup. So monkey patches are allowed during startup,
| but not at runtime.
| throwawayi3332 wrote:
| Time to fork Ruby and delete monkey-patching and add
| traits/extension-methods/UFCS.
___________________________________________________________________
(page generated 2024-12-31 23:01 UTC)