[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)