Skip to content

Shadowed mkdir fails when the parent directory is not shadowed #126

Description

@rafaelcaricio

Note

This is AI slop, triggered by @rafaelcaricio. This was human-reviewed and I (the human) did run those commands to reproduce the issue.

I build a coding agent eval harness on top of Gondolin. The harness clones a project into the workspace, and then an agent edits and builds it inside the sandbox. To keep the workspace clean, I shadow any path that the project's .gitignore marks as ignored. I use ShadowProvider with a RealFSProvider backend and writeMode set to "tmpfs", so writes to ignored paths go to a tmpfs layer instead of the real workspace. As a result, build artifacts stay out of the workspace I collect after the run.

The problem

When a program creates a directory that is shadowed, but the parent directory is not shadowed, the mkdir call fails with ENOENT. The parent exists in the merged view the program sees, so the error is confusing.

Real trigger

Real builds fail at this step. The FFmpeg build calls mkdir -p doc/examples/pc-uninstalled:

  • mkdir -p doc/examples/pc-uninstalled at ffbuild/pkgconfig_generate.sh line 46 (source).
  • pc-uninstalled is listed in doc/examples/.gitignore line 16 (source).

The path is ignored, so my predicate shadows it. The mkdir fails and the build stops. I ran two different coding agents against this build, and each one spent about two hours stuck on this single step before I stopped them. Both agents correctly guessed the filesystem was at fault.

How to reproduce

This script reproduces the bug. It sets up the same provider shape I use, then creates a shadowed directory whose parent lives in the backend.

Run it in a folder with Gondolin installed:

mkdir gondolin-repro && cd gondolin-repro
npm init -y >/dev/null
npm install @earendil-works/gondolin
cat > repro.mjs <<'JS'
import { ShadowProvider, MemoryProvider } from "@earendil-works/gondolin";

const backend = new MemoryProvider();
// The parent exists in the backend, like a source tree after checkout.
await backend.mkdir("/project/doc/examples", { recursive: true });

const provider = new ShadowProvider(backend, {
  // Mirror my gitignore shadowing: pc-uninstalled is ignored.
  shouldShadow: ({ path }) => /\/pc-uninstalled(\/|$)/.test(path),
  writeMode: "tmpfs",
  tmpfs: new MemoryProvider(),
});

const target = "/project/doc/examples/pc-uninstalled";
try {
  await provider.mkdir(target);
  console.log("OK: mkdir succeeded. The bug is not present on this version.");
  process.exit(0);
} catch (err) {
  console.log("FAIL: mkdir threw " + (err.code || err.name) + ". The bug is present.");
  console.log("   " + err.message);
  process.exit(1);
}
JS
node repro.mjs

On Gondolin 0.12.0 this prints:

FAIL: mkdir threw ENOENT. The bug is present.
   ENOENT: no such file or directory, mkdir '/project/doc/examples/pc-uninstalled'

Why it happens

In ShadowProvider.mkdir, when the path is shadowed, the code calls tmpfs.mkdir(p, options). The tmpfs is a separate MemoryProvider that starts empty, so it does not contain the parent directory. MemoryProvider.mkdirSync without recursive set calls _ensureParent with create set to false, and that throws ENOENT when the parent is missing.

File writes do not hit this. openSync calls _ensureParent with create set to true, so writing a shadowed file under a parent that is not shadowed works. Only mkdir passes create as false.

Fix

Pass recursive: true when the mkdir is shadowed, so tmpfs creates the parent chain.

async mkdir(entryPath, options) {
    const p = normalizeVfsPath(entryPath);
    if (this.shadowedFor("mkdir", p) || (await this.resolvesToShadowed("mkdir", p))) {
        return this.writeShadowed("mkdir", p, () => this.tmpfs.mkdir(p, { ...options, recursive: true }));
    }
    return this.backend.mkdir(p, options);
}

The same change applies to mkdirSync.

This is safe because the parent is not shadowed. Anything that reads or lists the parent still reads from the backend, so the directories tmpfs creates as parents are never visible. Only the shadowed leaf shows up.

I patched this in my project with this change, and the reproduction above prints OK for me.

Tested on @earendil-works/gondolin 0.12.0.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions