Rust’s worst feature
mina86.com
mina86.com
The amount of hyperbole in this article makes it a bit hard to take the author all that seriously.
Is there evidence more baking won't happen? While i have my loves and hates about rust, it definitely always felt like they had a pretty thorough/careful process for additions like this. If you go constructively into the threads and offer some concerns, you will usually get some reasonable response.
(All processes of course, fail, so this is not always true, but it's mostly true)
While i think it's fine to write rants on blogs, and don't feel like everyone has a responsibility to file bugs or whatever before they write a rant about it, if you actually want to see this "worst feature" fixed, this probably won't help very much.
(IE You don't have to be constructive, or even helpful, but if you want to be constructive or helpful, this ain't how you do it)
Being honest, plenty of times we throw “Ergonomics” as an argument, however, are ergonomics more a feeling of how good are the API usage instead of actually prove with examples and design choices?
No, actually there is a lot of evidence that it will still be worked on.
Normally I would just say look at the issue linked in the nightly docs but due to an overlap of tacking PR and moving it from std to core PR it's not supper useful.
Tracking Issue: https://github.com/rust-lang/rust/issues/78485
If the author has constructive critique they should probably mention it there (after skimming through the discussion to make sure this wasn't already considered and not done due to subtleties they overlooked in the blog post (like e.g. that it's a standard/core feature which has to work across all targets and as such can't rely on anything being initialized to 0 by the OS, or that depending on the global allocator used you definitely can't rely on things being zeroed even if the OS only hands out zeroed memory, etc. etc.))
P.S. Also, folks, don't re-use buffers without zeroing unless you absolutely need the performance and know what you're doing.
Most functions could be inferred, but the ultimate source of basically all of these write only APIs is FFI functions, which in turn call systemcalls.
You're at least going to need a way to annotate the FFI calls and systemcalls to describe to the compiler how they access data.
If you're doing some sort of zero-copy IO, the time to clear the buffer might be non-trivial (not huge, but non-trivial). It's true that you need a large enough buffer that syscall/ffi overhead doesn't dominate, but that's not unrealistic.
It's rare that we care about this, that's true, that's why generally rust has been fine with "just zero buffers". There are definitely domains that care though.
If you can think of a different approach of how the compiler can figure out automatically what memory has become initialized by a random function call I’m all ears.
The compiler doesn’t reasonably have any of that information to understand what read is filling in at runtime because that information is encoded purely at runtime and the compiler has no reasoning mechanism even close to answering runtime data flow questions.
There’s also all sorts of complexity that has to do with the kinds of transformations that are possible as the legal information that exists at the language level is often erased before it gets to the stack/register piece and vice versa the language layer knows nothing about registers and minimal stuff about stack.
This is the same reason that the compiler fails to compile something like:
for _ in 1..10 {
let x: String = create_new_string();
eprintln(“{x}”);
}
Fails to hoist x out of the loop even if the returned string is String::new(“ABC”) unless maybe LTO is on (and even then maybe not). Basically the compiler’s “magic” is very limited to static transformations that follow as-if - the compiler must know the static transformation is blindly identical and the amount of reasoning about the structure is often very limited.Said another way, if the compiler could do the optimizations you’re hypothesizing, it would be equivalent to applying a mid level performance engineer to every code base it encounters.
I think it's theoretically possible, but at the cost of much longer compile times, and greater complexity in the compiler.
fn read<T, N: size_t>(&mut self, buf: &mut [MaybeUninit<T>] becomes &[T; N] after call) -> N {
… enforces the body initializes N elements out of buf
}
and then rules that &mut [T] can also be supplied to such functions that today could only accept a &mut [MaybeUninit<T>] transparently.A more likely interface you could write today would look like:
fn read_uninit<T>(&mut self, buf: &mut [MaybeUninit<T>]) -> (&[T], &[MaybeUninit<T>]) {
… enforces the body initializes N elements out of buf
}
You still have to cast &[T] into &[MaybeUninit<T>] somehow.Problem would be: how do you express “you can only access the buffer you sent me through the read-only slice I returned, but you have to free that same buffer when you’re done calling me?
I think that can be done using a function creating a read buffer for a given input stream that
- during calls to read is ‘owned for writing’ by that stream (so, it has to borrow a capability that the creator of the buffer doesn’t have. I don’t think Rust currently supports that)
- where stream.read returns a read only slice whose lifetime is bound to that of the buffer
So, the creator of the buffer can only pass it to read to get a slice back that contains precisely the data read.
The stream can write to the entire buffer.
unsafe{ std::mem::transmute(slice) }
This is probably the only way that will ever exist, because let slice: &mut [NonZeroU8] = ...;
let slice_uninit: &mut [MaybeUninit<NonZeroU8>] = ...;
let nonzero_uninit: &mut MaybeUninit<NonZeroU8> = &mut slice_uninit[0];
*nonzero_uninit = MaybeUninit::zeroed();
slice[0]; // Undefined behavior for sure by now.
Is all safe except for the cast.I.e. MaybeUninit<T> allows you to write invalid bit-patterns to T, so you can't safely cast a reference to T to it (and if you do unsafely cast a reference to T to it you can't soundly write an invalid bit pattern). All current forms of safely making a MaybeUninit take ownership of the value they are declaring to be MaybeUninit for this reason.
I guess at some point we might get methods for this on types that can take on all bit patterns - if/when that's encoded as a trait.
(This isn't the theoretical ivory tower kind of UB. Operating systems regularly remap a page that hasn't yet been written to.)
(E.g. imagine doing a write to an array at offset x - this is safe in Rust, so the compiler turns that into code that checks that x is within the bounds of that array, then writes at that offset. If the value of x can change, then now this code can overwrite some other variable anywhere in your program, giving you a bug that's very hard to track down)
This is not exactly an optimization though, in the sense that it will mess up even thoroughly unoptimized code (with more likelihood, due to caching optimizations being absent).
So that is to say, even the generation of basic unoptimized intermediate code for a language construct relies on assumptions like that certain quantities will not spontaneously deviate from their last stored value.
That's baked into the code generation template for the construct that someone may well have written by hand. If it is optimization, it is that coder's optimization.
The intermediate code for a checked array access, though, should be indicating that the value of the indexing expression is to be moved into a temporary register. The code which checks the value and performs the access refers to that temporary register. Only if the storage for the temporary registers (the storage to which they are translated by the back end) changes randomly would there be a problem. Like if some dynamically allocated location is used as an array index, e,g. array[foo.i] where foo is a reference to something heap allocated, the compiler cannot emit code which checks the range of foo.i, and then again refers to foo.i in the access. It has to evaluate foo.i to an abstract temporary, and refer to that. In the generated target code, that will be a machine register, or a location on the stack. If the machine register or stack are flaky, all bets are off, sure. But we have been talking about memory that is only flaky until it is written to. The temporary in question is written to!
You'd almost certainly pass it as a function parameter, prima facie in a register/on the stack, sure, and therefore in unoptimised code nothing weird would happen. But an optimising compiler might inline the function call, observe that the value doesn't escape, and then if registers are already full it might choose to access the same memory address twice (no reason to copy it onto the stack, and spilling other registers would cost more).
I don't know how likely this exact scenario is, but it's the kind of thing that can happen. Today's compilers stack dozens of optimisation passes, most of which don't know anything about what the others are doing, and all of which make basic assumptions like that the values at memory addresses aren't going to change under them (unless they're specifically marked as volatile). When one of those assumptions is broken, even compiler authors can't generally predict what the effects will be.
1. Initially map the not-yet-written page to a read-only page full of zeros (the same one for all allocations: only one exists in the whole system).
2. When a write takes place, copy-on-write clone that page to a newly allocated zero-filled-page, then allow the write to proceed.
Having a WO reference would allow these read_buf APIs to express they only write and never read so the uninitialized memory is safe to pass directly.
Why? Can't the programmer just do this himself?
The programmer, on the other hand, can do this, but the point is to make this implicitly possible by making it more explicit that read does not read from the buffer (and therefore allowing it to accept uninitialized memory).
It comes down to a piece of syntactic sugar, plus "result location semantics" guaranteeing that you won't have a copy. E.g.:
my_ptr.* = .{
.x = 42,
.y = 53
};
No matter how you choose to construct the intermediate fields (x and y in this example) with continues or other control flow, the very last step should be something that sets every field at once. If you miss one, the compiler will yell at you. If it compiles, the assembly is as if you filled in each field by hand.I'd like the backend to notice that a variable is being dropped and "re-created" on every iteration, and then figuring out how to initialise over a "dropped" zombie value. It'd be nice to have something like this, because I'm pretty sure people don't do this kind of optimisation all the time (it's annoying to leak the variable that should survive loops outside of the loop becase it's not mentioned elsewhere).
1. Add any such pools you can reason about at compile-time (Rust does this, or at least it did the last time I profiled anything rusty at my last job).
2. Dynamically infer how such pools should behave at runtime.
(1) has the same sorts of problems you see with auto-vectorization. It gives a modest boost for 'free' (Rust's implementation when I benchmarked it was sub-par, and a naive user-space pool would have been better), but it won't kick in that often. Perf wins for 'free' often aren't a bad thing.
(2) isn't terrible if you're okay with a GC language. Many GC's effectively do something similar internally. The code complexity is similar.
However, both ideas also have the potential to fall prey to the issue we were trying to circumvent in the first place. The "backend notic[ing]" only covers the performance aspect of the equation. The real value is in having a dedicated syntax for compiler-erroring partial initializations, combined with the knowledge that such code is free (compared to less-safe state-of-the-art options) at runtime.
I think you're observing that APIs like in TFA are easy to mis-use, and my proposal says "make them easy to use," where you're proposing "make the compiler smart enough to not need them"?
This feels like exactly what you want the compiler to think about: a case where optimization comes at the cost of organization.
Because as far as the compiler is concerned it appears to change the behaviour, unless the compiler gets very fancy.
> Can't the programmer just do this himself?
Yes, but it's not really desirable for them to have to (and would arguably make the code less maintainable if they did). Doing the right thing should be easy.
In this specific example there's no issue because the result of read() is being used to only write as much data as was read, but to me this seems like a pretty complicated and unlikely assumption to write optimizations for.
There are cases where C can do loop hoisting, but the cases are a subset of what Rust does and this isn’t one of those.
There are some important caveats, though, around trap or non-value representations. Basically, the value held by the storage for a variable may not correspond to a valid value of the variable's type.
For example, a bool variable usually takes a full byte but only has 2 valid representations in many ABIs (0 for false, 1 for true). That leaves 254 trap representations with 8-bit bytes, and trying to read any of these is undefined behavior.
Furthermore, a variable may be stored in a register (unless you take its address with &), and registers can store values wider than the variable type--e.g., even though int has no trap representations in memory of the same size, nowadays it's usually smaller than a register--or be in a state that makes them unreadable. Trying to read such a value is also undefined behavior.
So, reading memory in general is defined behavior (just with an indeterminate value) but it has to actually be memory and you have to be reading it into a type that can accept arbitrary bit patterns.
{
let mut buf = [0; 4096];
loop {
...
}
}
That accomplishes exactly the same goal but there's an argument -- not well made in the blog post -- that the compiler should be able to do some form of this hoisting automatically. In C, it would be automatic, because C doesn't make a zero-initialized promise for stack-allocated variables. In Rust it's not because the array is specified as zero-initialized. Of course, C's behavior comes with certain drawbacks of its own. ;)Rust's behavior isn't unreasonable. It's just a potential missed optimization, but automating it is challenging.
Out of all problems I have encountered with Rust, this is a particularly minor one.
Switching off Trump mode for a moment, I don't see why you would want to declare the buffer inside the loop, given that keeping it alive for the entire time of the loop is actually the semantics you want.
A less obvious example would be...
- A struct, A, which has an init_from_file method that deserializes data from a Read: R
- Another struct, B, which has its own init_from_file, and a variable number of A as one of its fields. B::init_from_file needs to deserialize by calling A::init_from_file in a tight loop.
This example is the same as the first, except now we've disguised the inefficiency with separation of concerns. A compiler can inline A::init_from_file into B::init_from_file to yield the same code as in the example.
If the compiler actually does that, that would be a good example. As long as I don't have to be careful to write my code in a way that some obscure compiler optimisation understands.
I still would not rely on that optimisation, though. If I think this could be an actual bottle neck, I would make the shared buffer explicit in my code.
If I am interacting with from IO space, I would much rather write the interaction code myself for the machine at hand than farm it out to an array of third party crates. ::shrug::
getting the machinery to let it properly be hoisted smoothly and safely would be nice, but it isn't required.
personally I think rust macros are very painful and the "worst feature", but that's speaking as someone who did a fair bit of Common Lisp.
Why? It seems the only thing on that list that will cause UB is using the function with a different reader (one that inspects the uninitialized bytes). Why would any of the other listed possible changes break it?
[1]: https://doc.rust-lang.org/std/ptr/#pointer-to-reference-conv...
Rust calls references to uninitialized memory undefined behavior because it wants to reserve the right to implement some kinds of optimizations using that fact in the future, however currently no optimizations do, and in fact the standard library depends on the compiler being well behaved here.
It's possible at some point in the future some optimization will be added which would cause problems for references to uninitialized memory, but it seems more likely that the requirements on references will eventually be relaxed instead.
[1]: https://github.com/rust-lang/rust/issues/119241#issuecomment...
The pain of paying for buffer init is real, however. The last two projects have both seen perf hits from it.
Is the main point that things like `slice_freeze_mut` could also be used for slices of e.g. `struct Coordinate { x: u32, y: u32, z: u32 }`?
It would obviously not work for f64 things, since there also not all bit-patterns are valid.
There's no safety problem as long as the arbitrary value is deterministic, which it is, being process RAM. The "uninitialized data read" bugs reported from instrumentation tools in C code are because the code is assuming the value has some semantics. The read itself has no value and is presumably an artifact of the bug, but it is safe.
The article discusses how it is in fact, on Linux with memory returned from at least one very common allocator, not deterministic. Ctrl-f tautology.
C code that reads uninitialized data is presumed to be buggy, because it wouldn't be doing that unless it thought the memory was initialized. But the read itself is not unsafe.
Rust is just confused, and is preventing all reads from uninitialized data a-priori instead of relying on its perfectly working type system to tell it whether the uninitialized data is safe to use. And that has performance impact, as described in the linked article, which has then resulted in some terrible API choices to evade.
Again, the article literally points to how this is not true given modern allocators. The memory that Linux exposes to processes will change without being written to prior to being initialized given how allocators manage it. This isn't a fiction of the C-standard or rust reference, it's what actually happens in the real world on a regular basis.
Rust is not confused, it is correctly observing what is allowed to actually happen to uninitialized memory while the process does nothing to it.
You could change the C/Rust specification of that memory. You could in your C/rust implementation declare that the OS swapping out pages of uninitialized memory counts as a write just like any other, and that it's the programmers (allocators) responsibility to make sure those writes obey the normal aliasing rules. Doing so would be giving up performance though, because the fact that writing to memory has the side-effect of cancelling collection of freed pages is a powerful way for processes to quickly communicate with the OS. (You'd probably also cause other issues with memory mapped IO, values after the end of the stack changing, and so on, but we can just focus on this one issue for now).
> C code that reads uninitialized data is presumed to be buggy, because it wouldn't be doing that unless it thought the memory was initialized. But the read itself is not unsafe. Rust is just confused, and is preventing all reads from uninitialized data a-priori instead of relying on its perfectly working type system to tell it whether the uninitialized data is safe to use
Reads of uninitialized memory is unsafe full stop. That’s literally what Rust’s memory safety is about. If you give that up you’re not in safe land and you can always use unsafe & all the risks that come with that to try to write more optimal code / abstractions.
This article is literally about the mechanisms Rust is trying to stabilize how you use the type system to go from a block of uninitialized memory & is aware of writes occuring so that it can take a &[MaybeUninit<T>] and give you back a &[T] after a call to read which wrote into the slice. But reading uninitialized memory by definition is tautologically unsafe. It doesn’t mean that the computer will pull out a knife and kill you, but it does mean you’re no longer memory safe.
Reading uninitialized (but correctly owned) memory is not inherently unsafe. It has strange (and mostly useless) semantics, but it's not a safety issue. E.g. something like the OpenSSL key generation that Debian broke - [uninitialised buffer] ^= [generated random values] - is safe, and afterwards you can read from that buffer and get (safe, real, stable) values.
Not true; if it's an object that has its address taken, of a type that does not have a trap representation, then reading it results in an unspecified value and is not UB. Which aligns with what one might naively expect to happen.
No, this is directly addressed in the article.
RAM access to uninitialized memory is not deterministic and can change. See MADV_FREE.
This is yet another unrelated tangent. Can you explain why it it you think the presence of MADV_FREE disallows Rust from allowing writes to uninitialized memory?
The entire discussion is how to make an API which defines which portions of the memory can be read from because they have been written to, so we can expose an API that cannot be misused. There have never been any constraints on writing to uninitialized memory.
That's correct at a hardware level. It's not correct from a userspace program's perspective when interacting with Linux if the memory in question is uninitialized.
Not even there, due to memory caches.
Suppose you're writing an OS for a modern computer. You have full access to the subset of hardware actually exposed to you. You have a single process (having not yet done anything with IPI, APIC, ...) so far. I think two things are true, and I'm much more confident about the first one:
1. If you write to a region of RAM, then at any point in the future (ignoring hardware failures) if you read that same region you'll read the value you wrote, unless you issue write instructions to that same region first.
2. If you read from a region of RAM, then at any point in the future (ignoring hardware failures) if you read that same region you'll read the value you previously read, unless you issue write instructions to that same region first.
Caches matter, but they're hidden from software, except where that hiding is too expensive (hence, atomics and whatnot for you to synchronize between processes).
Is my view of the world too simplistic? Is there some way in which those caches are even more poorly behaved than I imagined?
It's not. There are no artifacts of cache incoherence visible on modern devices that are treated as part of the C language undefined behavior rules. The language runtime can assume memory is just memory.
Obviously there are visible artifacts, but all that code lies outside the realm of standard C, even though you write it in C and build it with a C compiler. This is one of the reasons I find arguments that start from the UB rules as postulates unpersuasive. At the end of the day we have to write code for real hardware.
The tl;dr version is that Rust as currently understood is just never going to be able to express stuff like incoherent DMA spaces. As witnessed here it still struggles with representing read() in a way that doesn't require zero-filling the buffer.
We have to write code for real (often future) compilers, and compilers are generally unsympathetic to code whose behaviour is undefined under the standard (I think this is unreasonable behaviour by compiler maintainers, but that argument has been lost).
In my experience C compilers don't really think or care about what happens for code that triggers UB, so it wouldn't at all surprise me if it was possible to get a compiler to emit code that exposes cache incoherence (i.e. it reads and writes memory in a pattern that does not have defined behaviour according to the underlying platform's memory model (would need extra barrier instructions etc.), and then on that hardware the result is a cache incoherence effect). Probably not on x86 with its friendly memory model, but maybe on ARM or the like (Alpha used to be a notorious place to see such bugs).
2.The specific scenario I had in mind was: write value X to address A from core 1, write value Y to A from core 2, read value from address A from core 1 (still X in core 1's cache), core 1 cache gets invalidated, read value from address A from core 1 again (now it fetches Y from memory).
See [1] for reference on how it applies to C specifically, especially "Absent any constraints on a multi-core system, … one thread can observe the values change in an order different from the order another thread wrote them".
> C code that reads uninitialized data is presumed to be buggy, because it wouldn't be doing that unless it thought the memory was initialized. But the read itself is not unsafe.
The read itself is very much unsafe because it's undefined behavior. The compiler is allowed to assume the programmer doesn't allow reads to uninitialized memory to happen, so if the programmer does allow a read from uninitialized memory, any false conclusion can follow from the false assumption.
This is a problem even in trivial cases; see [1] and try commenting out and switching arround the calls to foo and bar. The behavior is very unintuitive, because reads from uninitialized memory is unsafe.
The discussion is about RAM and Rust, not C. And the particular use of uninitialized data in C that corresponds to the linked article (as a target buffer for a read call) is clearly not undefined behavior.
This is a classic HN tangent, basically. You're making the discussion worse and not better.
The question is "Why can't Rust act like C when faced with empty buffers?", and the answer has nothing to do with undefined behavior or unsafe. They just got it wrong.
However, in C, the programmer doesn't need to prove to the compiler that uninitialized memory is never read, they are just expected to prevent it from happening. In this case, it's clear to the programer that there's no undefined behavior.
In Rust though, the compiler must be able to statically verify no undefined behavior can occur (except due to unsafe sections). It's not possible to statically verify this in either the Rust or C case, because not enough information is encoded into the type signature of `read`. The article discusses a couple of ways that information might be encoded so that Rust can be more like C, and discusses their trade-offs. C explicitly sidesteps this by placing the responsibility entirely on the programmer.
So to directly answer your question "Why can't Rust act like C when faced with empty buffers?", it's because the Rust compiler cannot yet statically verify there's no undefined behavior in this case, even though there is in fact no undefined behavior, and one of the primary design goals of Rust is to statically prevent undefined behavior.
And to what's perhaps the initial question, this is discussed using the term "safety" simply because Rust defines things which can't be statically verified to not invoke undefined behavior as "unsafe". Perhaps a better term would be "not yet statically provable as safe", but it's a bit of a mouthful.
Uh... yes it can. It's a memory write to the uninitialized region. Writes are not undefined, nor unsafe, and never have been. They aren't in C, they aren't in hardware. Writes are fine.
The bug here is API design, not verification constraints.
Tl;dr: its not to do with any hardware concept, the compiler can substitute any value for a read of uninitialised memory, and the value does not have to be stable.
Turns out that's not the case on freshly returned uninitiated allocations. The first read could return old data (say "1"), and the second read could return a freshly zeroed page ("0").
If the allocation is backed by the kernel, then it will be zero-filled for security reasons. If it's backed by user-space malloc then who knows; but there's never a scenario where a mallocated page is quietly replaced by a zero-filled page behind the scenes.
That's already the case for the AnyBitPattern stuff though. (Indeed according to the docs AnyBitPattern traits already get cast from uninitialized bytes, which in C/LLVM semantics are not necessarily frozen, even if in practice Linux would not be remapping the pages they're in).
> Rust is designed to allow 100% safe bare metal development
I wouldn't say that since bare metal rust always needs some unsafe; rather it's designed to allow managed, contained use of unsafe constructs in code that's say 98% safe. The whole purpose of this BorrowedBuf is already something like that.
So for an array of bools (if Rust matches SysV) freeze wouldn't be sound, even without the madvise problem.
That's what "// SAFETY: u8 has no invalid bit patterns." is discussing. That while types in general can u8 specifically does not (none of the u*/i* integer types do) so freezing a buffer of u8s is sound.
I suspect even reading an array of uninitialized u8s would cause havoc just from LLVM miscompiles alone.
The process and reasoning are described here https://doc.rust-lang.org/book/appendix-07-nightly-rust.html
> A motivated programmer can try adding necessary support to actively maintained packages [...] but what if one is stuck at an older version of the crate or deals with apparently abandoned crates
One could maybe do some programming? I mean, hell, if most of the work has already been done for you, then what's holding you back? Besides, why would you want to use outdated, bug-ridden libraries filled with vulnerabilities instead of something well-maintained?
No activity doesn't mean abandoned, it can just mean super stable and few bugs to fix.
Abandoned is a big worry though.
I wonder if we need a canary file, but in the repo. An "I'm still here" file.
The one thing I would look at instead is open issues.
Maintained means they are. There may be no new features though.
(these are my definitions, but I think there should be similar concepts in all dev's heads)
If a library is abandoned, and no one is around updating the code for vulnerabilities, it's trouble. That's because one day you can use it, and the next you cannot.
(Yes you can personally patch it, but that's an issue that's come up. And how will you know? Look at all the bugtrackers daily to see people yelling "HEY!"?)
Actually, a big exception is libraries that have external dependencies that change. A client library for an API, for example. Those can break quickly.
This entirely depends on the library, but I just generally don't see a lot of security fixes in library updates. But for compiler updates, I do.
I'm speaking super broadly, and this will be very different for a Python graphing library versus a C networking library.
It made me think: `std::hex,base64` vs `boost::hex,base64`, but then assuredly those would be incompatible because $REASONS.
...but what if it was like: `v2025::std::hex,base64` vs `boost::v2025::std::hex,base64` (ie: explicitly stating you're adhering to the v2025 guidelines w.r.t. memory management or parameter naming or whatnot).
It's a roundabout way of saying: `tokio::async_foo`, `boost::async_bar`, `v2026::std::async::foo,bar`, where as the marketplace of ideas settles on "better" ways of dealing with async (or whatever) there can eventually be compatibility between different object "modalities" (ways of working).
`std::hex,base64` should be quite stable, but having a path for `...::webapp::` and `...::database::` to eventually interop seems really useful for a language to encourage?
Libraries are a bit different though, because of semver compatibility, but unfortunately they are wrong often enough that you can't really rely on them anyway.
It's beyond wrong. For example, at the core of plenty of numerical libraries is 30+ year old fortran code from netlib that works great. It's just done.
It does what it's supposed to. It does not have meaningful bugs. There is no reason to change it.
Obviously, one can (and people do) rewrite it in another language to avoid having to have a fortran compiler around, or because they want to make some other tradeoff (usually performance).
But it's otherwise done. There is no need to make new releases of the code. It does what it is supposed to do.
That's what you want out of libraries past a certain point - not new features, you just want them to do what you want and not break you.
That's not actually true, for libraries that interact with the platform. For example, Mac OS changed some type definition in header files in their arch transition, which makes building a pre-M1 rust project that depends on a contemporary library version that interfaces with the OS (like SDL) on an ARM host without any changes impossible. You need to update your dep (and potentially the way you consume them) just to be able to build the project, or procure a host supported by your dependency (one that existed when it was written, so an X86 machine).
We could argue all day that this is "not a bug" or the user's fault for using a new host and it was never supported or any other deflection. But it is a concrete example of "this code hasn't changed and the passage of time shifted the ground from under it".
> apparently abandoned
To mean:
> hasn't changed in a while
Then I will agree that that doesn't need to mean it has to be laden with problems. I was going more on the basis of it meaning that there are a number of unresolved bug reports as well as a lack of activity. In the Common Lisp community there is generally agreement that software can be simply finished (although there is also an amount of broken abandonware).
My main point is if people are using libraries with problems that aren't getting fixed, they shouldn't be afraid to take over maintenance or to migrate to libraries which don't have those problems.