DEV Community

Pranab Sarkar
Pranab Sarkar

Posted on

I wrote down the cause of this leak two months before anyone reported it

At 00:19:25Z on 2026-10-13, wolf-crazy opened issue #251 on YantrikDB, the Rust memory engine for AI agents that I maintain. A host running the engine through yantrikdb-hermes-plugin had collected about 60 directories, roughly 490MB, over about eight days. One potion-2M-<pid> directory per process start. None of them ever removed.

The directories come from BundledEmbedder, the zero-setup default. Its model, potion-base-2M, is about 7.9MB of files compiled into the binary with include_bytes!. The loader, model2vec_rs::StaticModel::from_pretrained, reads from a directory, so the first use in every process writes those bytes to disk. This is what the write looked like:

let temp = std::env::temp_dir()
    .join("yantrikdb")
    .join(format!("potion-2M-{}", std::process::id()));
std::fs::create_dir_all(&temp)
    .map_err(|e| format!("create temp dir {}: {e}", temp.display()))?;
Enter fullscreen mode Exit fullscreen mode

A new directory for every PID, and nothing anywhere in the file that deletes one.

PR #128

wolf-crazy's report cross-referenced PR #128, "process-global model init + download retry." That PR fixed a different bug, a race during concurrent model initialisation. Its commit message says "the extraction path is keyed only on process::id()."

I wrote that PR. I merged it on 2026-08-12.

So the mechanism behind this leak had been sitting in my own words, in my own merged commit, for two months. I'd noticed the PID keying while fixing the race, written it down, and done nothing else with it. It took someone else's disk filling up for it to matter to me.

My reply

I answered 15 minutes and 44 seconds after the issue went up. I grepped the file on origin/main at 3dbb86a for remove_dir_all, impl Drop and TempDir, found none of them, and confirmed the bug.

wolf-crazy had proposed two fixes: cache the extracted model in a stable place keyed by model identity, or remove the directory on drop. I picked the cache, under dirs::cache_dir(), because it also gets rid of the one-copy-per-process-start cost instead of only cleaning up after it. I also gave a stopgap for anyone already sitting on a pile of these:

find "$TMPDIR/yantrikdb" -maxdepth 1 -name 'potion-2M-*' -mtime +1 -exec rm -rf {} +
Enter fullscreen mode Exit fullscreen mode

Remove-on-drop turned out to be a non-starter anyway, and the PR body later explained why: "MODEL is a static OnceLock>, so the model lives for the whole process and a Drop on BundledEmbedder never runs. Cleanup-on-drop cannot fire." That OnceLock is the one #128 introduced.

The first version was already safe

wolf-crazy opened PR #252 at 00:49:28Z. The first commit, 7f60cf0, keyed the extraction directory on a fingerprint of the model's contents and left it under std::env::temp_dir()/yantrikdb/. Every process start reuses one directory. The host still only needs a writable $TMPDIR, same as before. That fixes the leak completely and introduces no new requirement.

At 03:55Z wolf-crazy force-pushed a rework, b9cedc1, moving the directory to <dirs::cache_dir()>/yantrikdb/models/potion-base-2M-<fingerprint>. Their comment on the PR: "reworked along the direction you gave on #251."

That rework is where the risk came in, and it came in through my suggestion.

The review

I reviewed b9cedc1 at 04:21:43Z and requested changes. The main point:

The bundled model is the default path, the one that has to work without network access or setup. Today it only needs a writable $TMPDIR. With this PR it needs a writable cache dir, and hosts where HOME is set but read-only would fail on startup where they work now: systemd units with ProtectHome=read-only, containers with a read-only root filesystem and a tmpfs /tmp, and service users with HOME=/nonexistent. dirs::cache_dir() returns Some in all of those, so the existing None fallback doesn't help.

I asked for the fallback to cover the whole extract step, since "a directory that exists but is read-only fails at the write." My issue reply had said nothing about any of this. I didn't see the problem with my own idea until there was code for it, and the first code for it was this commit.

The second request was about the regression test, which checked !s.contains(&std::process::id().to_string()) against a path that also holds a 64-character hex fingerprint and a model name. Any PID whose digits show up somewhere in that string fails the test for no reason connected to the bug. My review listed them: "PIDs 1–9, 17, 18, 20, 25, 36, 42, 57 and 23 others below 100,000."

I recounted that against the real fingerprint while writing this. It's 18 others, 34 in total. I got the count wrong in the comment asking for the test to be exact.

The same review closed with a note that nothing in the PR cleans up directories that had already leaked, and that we'd cover the cleanup from #251 in the release notes.

The second commit

d37c2c5 landed at 06:47:55Z. It moves extraction into a shared extract_into(dir), and init_model() now looks like this:

fn init_model() -> Result<StaticModel, String> {
    let primary = extraction_dir();
    let dir = match extract_into(&primary) {
        Ok(()) => primary,
        Err(e) => {
            let fallback = std::env::temp_dir().join("yantrikdb").join(dir_name());
            extract_into(&fallback).map_err(|e2| {
                format!(
                    "{e}; temp-dir fallback {} also failed: {e2}",
                    fallback.display()
                )
            })?;
            fallback
        }
    };
    StaticModel::from_pretrained(&dir, None, None, None)
        .map_err(|e| format!("model2vec_rs load: {e}"))
}
Enter fullscreen mode Exit fullscreen mode

Any failure on the cache path, at any stage, retries the whole extraction under $TMPDIR with the same fingerprint-keyed name, and if that fails too, the error carries both causes. wolf-crazy tested it with XDG_CACHE_HOME=/proc/self/nope, a cache directory that resolves but can't be written, and the model-dependent tests passed through the fallback. They reported 2204 passing for cargo test -p yantrikdb --lib and 2215 with --all-features. Across both commits the change is +146/-22.

CI on d37c2c5 started around 06:54Z. The Windows leg finished last, at 07:15:05Z. Everything was green: Linux x86 and ARM, Windows, macOS, Python 3.10.

Where the 18 hours went

I approved at 12:30:57Z: "I read the diff but didn't re-run the suite myself. Between that and CI, this looks ready — queuing it for a final merge decision." That was about five hours and sixteen minutes after CI went green.

The merge came at 18:45:39Z, about six hours after that approval, with one last comment: "CI is green across Linux x86/ARM, Windows, macOS and py3.10. Thanks for the careful work and the quick turnaround, @wolf-crazy."

From issue to merge took 18 hours 26 minutes. wolf-crazy's work fits inside two stretches, 00:49 to 03:55 and 04:21 to 06:48, under six hours combined. CI took about twenty minutes. From 07:15, when the last check passed, to 18:45, the PR was green, reviewed and waiting on me. That's eleven and a half hours, the biggest single block of the whole timeline.

I don't think that's a problem, and I know some maintainers would read it as one. A green CI run and an approved diff from a contributor who had already caught his own regression once is exactly the kind of PR that's safe to leave behind a dozen other things for most of a day. The alternative is merging faster than I actually review, and a stale disk-filling bug that sat unfixed in my own PR description for two months doesn't get worse by sitting reviewed-and-green for half a day more.

The release-notes cleanup I promised hasn't been written yet. The last release was v0.23.2 on 2026-10-02, and nothing has shipped since, this fix included. Until there's a release, the find command from the issue is the only cleanup anyone has.


Pranab Sarkar, Independent Researcher

Top comments (0)