hew_datetime_parse() returned -1 on failure. It also returned -1 for any valid timestamp representing a date before 1970-01-01.
So if you were parsing a datetime that came back negative and checking for the error sentinel, you couldn’t tell whether the parse succeeded or failed — the result was the same either way. Any code that gated on result < 0 was broken for pre-Unix-epoch dates, quietly and without any indication.
This is the oldest bug in the category, and I’m a bit embarrassed it was there at all. It’s a C idiom — use a value that the function normally wouldn’t return — ported directly into a context where the assumption no longer held. Somebody (probably me) wrote hew_datetime_parse thinking “timestamps are always positive, so -1 is fine,” and that held until we started testing with historical dates.
The fix: thread-local error slots
The problem with sentinel values is that they borrow meaning from the return type’s value space, and sooner or later that space runs out. The fix is to stop doing that — put error state somewhere else.
thread_local! {
static LAST_DATETIME_ERROR: RefCell<Option<String>> = const { RefCell::new(None) };
}On success, the slot is cleared. On failure, it’s written with an error message. The return value is always a real value — no reserved sentinels. If the caller cares whether a result is an error, they call hew_datetime_last_error() afterward; if it’s None, the call succeeded.
The same pattern went into cron, crypto/jwt, net/tls, and encoding/json. All of them had functions that were using their return value to signal two different things, and all of them had edge cases where the signal and the data overlapped. Commit 98f0b04b covers datetime specifically — the others followed the same structure.
Parsers without depth limits
Three parsers accepted arbitrarily nested input. This isn’t a theoretical concern; it’s the kind of thing that shows up in production when someone accidentally feeds a parser a generated payload, or when someone less accidentally feeds it a crafted one.
MessagePack (6614fac8): A deeply nested array [[[[...]]]] could exhaust the call stack before producing any output. The fix is a pre-scan with a simulated stack — walk the structure before attempting to parse it, reject anything more than 128 levels deep. The real parse never starts if the depth check fails.
YAML (985920fa): YAML anchors and aliases — &name to define, *name to reference — can produce exponential expansion. A five-level alias chain produces a billion nodes from a dozen input bytes. The fix is a pre-scan that enforces three separate limits before parsing: 1 MiB maximum input size, 32 anchors, 1024 alias references. Any of those exceeded and the parse is rejected with an error. (YAML’s anchor/alias feature is genuinely useful and I’ve used it plenty of times. I had not thought carefully about what it does at the limit.)
Decompression (3e0cc51c): gzip, deflate, and zlib decompression had no cap on output size. A crafted payload can decompress to hundreds of times its input size — this is the “zip bomb” class of inputs. The fix required a breaking change: max_output_len is now a required parameter on all three decompression functions. This is the change that shows up in the v0.4.0 release notes under “gzip_decompress requires max_output_len.” For code that doesn’t need a tighter bound, the default ceiling to pass is 64 MiB.
Making max_output_len required rather than optional was intentional. An optional parameter with a large default invites callers to not think about it. A required parameter makes the decision explicit — you have to pass something, even if that something is the generous default.
Mutex poisoning in the QUIC event queue
If a thread panics while holding a mutex, Rust marks that mutex as “poisoned.” Subsequent lock calls return Err(PoisonError) rather than the guard. This is a deliberate design — it prevents other threads from silently operating on state that may be corrupted.
The QUIC event queue was handling this with an early return:
let mut queue = match self.queue.lock() {
Ok(q) => q,
Err(_) => return, // mutex poisoned, give up
};Giving up means the message is dropped. No error surfaced, no diagnostic, nothing in the logs — the enqueue just doesn’t happen, and the caller has no idea. In practice this meant that if any thread panicked while holding the queue lock, all subsequent messages to that queue would silently vanish.
The fix (f6ae9bfa) is unwrap_or_else(PoisonError::into_inner), which recovers the last valid queue state from the poison error and continues:
let mut queue = self.queue.lock()
.unwrap_or_else(PoisonError::into_inner);A poisoned mutex means a thread panicked, which is already a problem that should be surfaced elsewhere. Silently abandoning all operations on the queue afterward isn’t a useful response to it — it just makes debugging the original panic harder by hiding its effects.
How this connects to the compiler work
The fail-closed work from April 10 was about internal type information — checker output that needed to survive intact through to codegen, with no silent reconstruction if something was missing. The stdlib work is the same pattern applied to external API contracts: what does a library function promise, and what happens when an unusual input arrives?
The categories that showed up — sentinel values borrowed from C idioms, parsers written without thinking about adversarial input, mutex error handling written by someone who’d never seen a PoisonError in production — are unsurprising in retrospect. None of them were urgent. Most would only manifest under specific inputs. The v0.3.0 hardening made the compiler more aggressive about finding silent failures; running the same scrutiny over the stdlib found the same kinds of assumptions-that-looked-fine.