Skip to content

Windows: bin/pg_ctl existence check misses .exe suffix, causing re-extract on every Start #170

@anukhe15-jpg

Description

@anukhe15-jpg

Summary

The pre-extract guard added in #144 checks for bin/pg_ctl (no extension). On Windows the real binary is pg_ctl.exe, so os.Stat(filepath.Join(binariesPath, "bin", "pg_ctl")) always returns IsNotExist, and every Start() re-extracts the archive into binariesPath, no matter how many times it has already run.

This is fine for the default config where each EmbeddedPostgres extracts into its own runtimePath, but when callers point a shared BinariesPath() at a cached location to reuse the extracted binaries across runs (common in test suites), the re-extraction collides with an already-running instance's locked bin/postgres.exe:

unable to extract postgres archive: rename
  binaries\temp_3287377135\bin\postgres.exe
  binaries\v16\bin\postgres.exe: Access is denied

renameOrIgnore swallows syscall.EEXIST but not ERROR_ACCESS_DENIED, which is what Windows returns when the destination file is held open by a running process.

Affected: embedded-postgres v1.34 (the fix in #144 was introduced in commit 1da3a3f, 2024-11-25).

Reproducer

Windows host, Go 1.23+:

func TestWindowsExtractRace(t *testing.T) {
    tmp := t.TempDir()
    binaries := filepath.Join(tmp, "binaries", "v16")

    cfg := func(port uint32, runtime string) embeddedpostgres.Config {
        return embeddedpostgres.DefaultConfig().
            Version(embeddedpostgres.V16).
            Port(port).
            BinariesPath(binaries).
            RuntimePath(runtime).
            DataPath(filepath.Join(runtime, "data"))
    }

    // First instance: extracts binaries to "binaries/v16", PG starts.
    pg1 := embeddedpostgres.NewDatabase(cfg(5433, filepath.Join(tmp, "rt1")))
    require.NoError(t, pg1.Start())
    t.Cleanup(func() { _ = pg1.Stop() })

    // Second instance with the SAME binariesPath. The guard at
    // embedded_postgres.go:155 looks for "bin/pg_ctl" (no .exe) and
    // does not find it on Windows, so extraction re-runs and tries to
    // rename onto bin/postgres.exe, which pg1 has locked.
    pg2 := embeddedpostgres.NewDatabase(cfg(5434, filepath.Join(tmp, "rt2")))
    require.NoError(t, pg2.Start()) // fails on Windows with "Access is denied"
    t.Cleanup(func() { _ = pg2.Stop() })
}

Linux/macOS pass; Windows fails on the second Start().

Suggested fix

Make the guard OS-aware in downloadAndExtractBinary:

pgCtl := "pg_ctl"
if runtime.GOOS == "windows" {
    pgCtl = "pg_ctl.exe"
}
_, binDirErr := os.Stat(filepath.Join(ep.config.binariesPath, "bin", pgCtl))

Or, equivalently, probe with exec.LookPath which already handles PATHEXT on Windows.

Workaround until fixed

Drop an empty bin/pg_ctl marker file in the shared BinariesPath after the first successful Start(). The guard then sees the file and skips extraction; os/exec on Windows still resolves pg_ctlpg_ctl.exe via PATHEXT, so nothing tries to execute the empty marker. On Linux/macOS the real pg_ctl is already there, so the helper is a no-op. We're using this in our test harness — happy to upstream it as a PR if useful.

Related: #154 (default config wipes binaries on every Start) — same family of issue, different root cause.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions