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.
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."
echelon 18 days ago [-]
> 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.
samtheprogram 18 days ago [-]
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.
magic_smoke_ee 17 days ago [-]
Exactly. Dynamic languages that allow self-modification create tech debt and bugs implicitly, which is why I prefer statically-compiled languages that have stable ABI/API guarantees. When there are too many "freedoms", there are no promises and zero stability. Static compilation (because all of code paths must be exercised and translated to machine code) or at least gradual-typing of dynamic languages are essential. rbs and sorbet are non-starters, mostly because it's fragmented, optional, not widely-deployed, and lots of extra work. Python demonstrated wiser leadership in this specific area by modifying the language.
ericb 18 days ago [-]
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?
jaynetics 18 days ago [-]
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 18 days ago [-]
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 18 days ago [-]
Jeremy Evans is not in the wrong part of town.
kyledrake 18 days ago [-]
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.
ericb 18 days ago [-]
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 18 days ago [-]
Efforts to freeze more and more objects and classes after initial setup have been a long-standing trend in the Ruby world.
Rapzid 18 days ago [-]
It's like a professional wandered into amateur hour.
Andys 18 days ago [-]
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.
delichon 18 days ago [-]
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.
jaynetics 18 days ago [-]
Regarding from_json, there's an even shorter version built in: `JSON(my_string)`
It's great when you're the only dev on a relatively young project.
viraptor 18 days ago [-]
You can still make the change and then freeze. That's why they recommend doing it at the end of config.
poincaredisk 17 days ago [-]
>Last week I added #from_json method to String and have already used it a lot
Why? What do you get from globally modifying a very important class, instead of just creating a regular method?
pdntspa 18 days ago [-]
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 18 days ago [-]
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 18 days ago [-]
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 18 days ago [-]
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 18 days ago [-]
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 :)
stouset 18 days ago [-]
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
Also this logic will definitely misbehave if there's ever a Ruby 3.xx.
jaynetics 18 days ago [-]
> 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.
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 18 days ago [-]
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 18 days ago [-]
> 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 18 days ago [-]
> 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.
berkes 18 days ago [-]
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 18 days ago [-]
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 18 days ago [-]
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 18 days ago [-]
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.
ilvez 18 days ago [-]
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.
block_dagger 18 days ago [-]
A thought on naming - wouldn’t “freezer” be a more appropriate choice?
jeremyevans 18 days ago [-]
"freezer" was my originally desired name for this gem, but it was already taken.
throwawayi3332 17 days ago [-]
Time to fork Ruby and delete monkey-patching and add traits/extension-methods/UFCS.
foxhop 18 days ago [-]
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.
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.
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."
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.
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.
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.
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
Why? What do you get from globally modifying a very important class, instead of just creating a regular method?
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.
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;
Why not the simpler Also this logic will definitely misbehave if there's ever a Ruby 3.xx.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.
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.
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.
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.
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.
This gem freezes the core classes, essentially closing them, so doing the above raises an error:
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.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.
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.
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.