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_ctl → pg_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.
Summary
The pre-extract guard added in #144 checks for
bin/pg_ctl(no extension). On Windows the real binary ispg_ctl.exe, soos.Stat(filepath.Join(binariesPath, "bin", "pg_ctl"))always returnsIsNotExist, and everyStart()re-extracts the archive intobinariesPath, no matter how many times it has already run.This is fine for the default config where each
EmbeddedPostgresextracts into its ownruntimePath, but when callers point a sharedBinariesPath()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 lockedbin/postgres.exe:renameOrIgnoreswallowssyscall.EEXISTbut notERROR_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+:
Linux/macOS pass; Windows fails on the second
Start().Suggested fix
Make the guard OS-aware in
downloadAndExtractBinary:Or, equivalently, probe with
exec.LookPathwhich already handles PATHEXT on Windows.Workaround until fixed
Drop an empty
bin/pg_ctlmarker file in the sharedBinariesPathafter the first successfulStart(). The guard then sees the file and skips extraction;os/execon Windows still resolvespg_ctl→pg_ctl.exevia PATHEXT, so nothing tries to execute the empty marker. On Linux/macOS the realpg_ctlis 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.