// Copyright (c) 2026 Charles KWON OhJun (charleskwonohjun@gmail.com) // All rights reserved. //go:build !windows // Multi-process file locking test. // Verifies that POSIX byte-range locks (fcntl F_SETLK) actually prevent // concurrent access across independent processes — not just goroutines // within the same process. // // The test spawns subprocesses via `go run` of a tiny helper program so // that each worker has its own file descriptor table and thus its own // lock state (POSIX locks are per-process, not per-fd). package dbf import ( "os" "os/exec" "path/filepath" "testing" "five/hbrdd" ) // helperProgram is compiled into a temporary binary that is invoked by the // test. It opens the DBF, tries to acquire a record lock, prints OK/BUSY. const helperProgram = `package main import ( "fmt" "os" "strconv" "five/hbrdd" "five/hbrdd/dbf" ) func main() { path := os.Args[1] action := os.Args[2] // "flock", "rlock:" drv := &dbf.DBFDriver{} area, err := drv.Open(hbrdd.OpenParams{ Path: path, Alias: "T", Shared: true, }) if err != nil { fmt.Println("ERR:", err) os.Exit(2) } defer area.Close() da := area.(*dbf.DBFArea) switch { case action == "flock": ok, err := da.LockFile() if err != nil { fmt.Println("ERR:", err) os.Exit(2) } if ok { fmt.Println("OK") } else { fmt.Println("BUSY") } default: // "rlock:N" if len(action) > 6 && action[:6] == "rlock:" { n, _ := strconv.Atoi(action[6:]) ok, err := da.LockRecord(uint32(n)) if err != nil { fmt.Println("ERR:", err) os.Exit(2) } if ok { fmt.Println("OK") } else { fmt.Println("BUSY") } } } } ` // buildHelper writes the helper source to a temp dir and compiles it, // returning the path to the resulting binary. func buildHelper(t *testing.T) (string, func()) { t.Helper() dir := t.TempDir() src := filepath.Join(dir, "helper.go") if err := os.WriteFile(src, []byte(helperProgram), 0644); err != nil { t.Fatal(err) } bin := filepath.Join(dir, "helper") cmd := exec.Command("go", "build", "-o", bin, src) // Inherit the parent's module so "five/hbrdd" resolves. cmd.Env = append(os.Environ(), "GO111MODULE=on") cmd.Dir = findProjectRoot(t) if out, err := cmd.CombinedOutput(); err != nil { t.Skipf("cannot build helper (go toolchain unavailable?): %v\n%s", err, out) } cleanup := func() { os.Remove(bin) } return bin, cleanup } func findProjectRoot(t *testing.T) string { t.Helper() dir, _ := os.Getwd() for { if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { return dir } parent := filepath.Dir(dir) if parent == dir { t.Fatal("cannot find go.mod ancestor") } dir = parent } } func createSharedDBF(t *testing.T, path string) { t.Helper() drv := &DBFDriver{} area, err := drv.Create(hbrdd.CreateParams{ Path: path, Fields: []hbrdd.FieldInfo{ {Name: "NAME", Type: 'C', Len: 10}, }, }) if err != nil { t.Fatal(err) } da := area.(*DBFArea) for i := 0; i < 3; i++ { if err := da.Append(); err != nil { t.Fatal(err) } } da.Close() } // TestFLockBlocksAcrossProcesses verifies that FLOCK() held by one process // causes a second process's FLOCK() to return BUSY. func TestFLockBlocksAcrossProcesses(t *testing.T) { helper, cleanup := buildHelper(t) defer cleanup() dir := t.TempDir() dbfPath := filepath.Join(dir, "lock_test.dbf") createSharedDBF(t, dbfPath) // Process A: open shared, acquire FLOCK, keep it held. drv := &DBFDriver{} areaA, err := drv.Open(hbrdd.OpenParams{ Path: dbfPath, Alias: "A", Shared: true, }) if err != nil { t.Fatal(err) } defer areaA.Close() daA := areaA.(*DBFArea) ok, err := daA.LockFile() if err != nil || !ok { t.Fatalf("A LockFile failed: ok=%v err=%v", ok, err) } // Process B (separate OS process): try to FLOCK the same file. out, err := exec.Command(helper, dbfPath, "flock").CombinedOutput() if err != nil { t.Fatalf("helper run failed: %v\n%s", err, out) } got := string(out) if got != "BUSY\n" { t.Errorf("expected helper to see BUSY, got %q", got) } // Release A's lock, retry from B — should now succeed. if err := daA.UnlockFile(); err != nil { t.Fatal(err) } out, err = exec.Command(helper, dbfPath, "flock").CombinedOutput() if err != nil { t.Fatalf("helper run (after unlock) failed: %v\n%s", err, out) } got = string(out) if got != "OK\n" { t.Errorf("expected helper to see OK after unlock, got %q", got) } } // TestRLockBlocksAcrossProcesses verifies DBRLOCK() record-level exclusion. func TestRLockBlocksAcrossProcesses(t *testing.T) { helper, cleanup := buildHelper(t) defer cleanup() dir := t.TempDir() dbfPath := filepath.Join(dir, "rlock_test.dbf") createSharedDBF(t, dbfPath) drv := &DBFDriver{} areaA, err := drv.Open(hbrdd.OpenParams{ Path: dbfPath, Alias: "A", Shared: true, }) if err != nil { t.Fatal(err) } defer areaA.Close() daA := areaA.(*DBFArea) // A locks record 2. ok, err := daA.LockRecord(2) if err != nil || !ok { t.Fatalf("A LockRecord(2) failed: ok=%v err=%v", ok, err) } // B tries to lock the same record — should fail. out, err := exec.Command(helper, dbfPath, "rlock:2").CombinedOutput() if err != nil { t.Fatalf("helper failed: %v\n%s", err, out) } if string(out) != "BUSY\n" { t.Errorf("expected BUSY for same record, got %q", string(out)) } // B tries a different record — should succeed. out, err = exec.Command(helper, dbfPath, "rlock:1").CombinedOutput() if err != nil { t.Fatalf("helper failed: %v\n%s", err, out) } if string(out) != "OK\n" { t.Errorf("expected OK for different record, got %q", string(out)) } // A releases record 2 — B can now lock it. if err := daA.UnlockRecord(2); err != nil { t.Fatal(err) } out, err = exec.Command(helper, dbfPath, "rlock:2").CombinedOutput() if err != nil { t.Fatalf("helper failed: %v\n%s", err, out) } if string(out) != "OK\n" { t.Errorf("expected OK after unlock, got %q", string(out)) } }