Skip to content

STATUS_DLL_NOT_FOUND (0xC0000135) when the DLL is right there

4 min readDeveloper ToolsDatabases

A Windows executable exits immediately. No stack trace, no log line, no dialog if you launched it from a script. Just an exit code:

text
0xC0000135  (STATUS_DLL_NOT_FOUND)

The frustrating part is that the DLL is not missing. It shipped in the archive, it is on disk, and it is sitting in a subdirectory right next to the executable.

Windows just does not look there.

What actually happens

influxdb3.exe has a load-time dependency on python313.dll, because its query engine embeds Python through PYO3. The vendor's archive puts that DLL in a co-located python\ subdirectory, which looks perfectly reasonable when you unzip it.

Windows resolves load-time dependencies through a fixed search order: the application directory, the system directories, the current working directory, then each entry in PATH. Subdirectories of the application directory are not in that list. A DLL one folder down is as invisible as one that was never shipped.

And because this is a load-time dependency, resolution happens before any of your code runs. The process never starts, so there is nothing to catch and nothing to log. The exit status is the entire diagnostic.

That combination is what makes this expensive to debug. A missing-file error you can search for. An instant exit with a hex code, on a binary whose files are all present, sends people looking at antivirus, permissions, and corrupt downloads first.

The fix

Add the bundled directory to PATH in the environment you spawn the process with. Not the system PATH, and not permanently: just for the child process.

ts
const bundledDir = join(binaryDir, 'python')

spawn(exePath, args, {
  env: {
    ...process.env,
    PATH: `${bundledDir};${process.env.PATH ?? ''}`,
  },
})

Two things worth getting right.

Prepend, do not append. If some other copy of that DLL is already reachable, you want yours to win. A version mismatch on an embedded runtime produces a second, weirder class of failure.

Do it at every spawn site. This is where we got it wrong initially. We fixed the start path, and the same executable is also invoked to verify the install and to create an auth token. Both of those still failed, in flows nobody thinks of as "running the database," so the bug looked intermittent and version-dependent rather than universal.

The more robust option, if you control the packaging, is to flatten the DLL next to the executable so no environment manipulation is needed at all. We could not here, because the vendor's layout is what it is, and rearranging a bundled runtime risks breaking whatever relative paths it uses internally.

The same trap on macOS and Linux

This is not a Windows quirk. Every platform has a search path for dynamic libraries, and every platform's default excludes "some subdirectory near the binary."

macOS fails with a dyld error naming an absolute path, which is a useful hint that the binary was linked against a library location that exists only on the build machine:

text
dyld: Library not loaded: /opt/homebrew/opt/openssl@3/lib/libssl.3.dylib

That is the same class of bug wearing better error text. A binary built where Homebrew put OpenSSL at a specific absolute path will not start on a machine without it. The spawn-time lever is DYLD_FALLBACK_LIBRARY_PATH.

Linux uses LD_LIBRARY_PATH for the same purpose.

Because the shape repeats, it is worth centralizing rather than fixing three times. A single helper that returns the right environment variable for the current platform, spread into every spawn, means adding a new engine cannot reintroduce the bug on one platform only:

ts
function getLibraryEnv(binPath: string): Record<string, string> | undefined {
  const libDir = join(binPath, 'lib')
  if (platform() === 'darwin') return { DYLD_FALLBACK_LIBRARY_PATH: libDir }
  if (platform() === 'linux') return { LD_LIBRARY_PATH: libDir }
  return undefined // Windows takes the PATH treatment above
}

How to diagnose it quickly next time

The reason this eats an afternoon is that the symptom carries no information. Two things make it fast.

Read the imports directly. On Windows, dumpbin /dependents or Dependencies (the modern Dependency Walker replacement) will name the DLL that is not resolving. On macOS use otool -L, on Linux ldd. This turns "it exits instantly" into "it wants this exact file," which is the entire problem solved.

Suspect bundled runtimes first. Embedded Python, embedded Node, a JVM, an OpenSSL copy: anything that ships a language runtime inside the archive is where this lives. A self-contained static binary never has this problem, which is a real argument for preferring one when you get the choice.

If you take one habit away: when a process exits with no output at all, stop reading your own logs and go read the binary's imports. Nothing you wrote has run yet.


We hit this shipping database engines across five platforms at Layerbase, where "the vendor's archive extracts fine" and "the binary starts" turn out to be very different claims.