Files
five/hbrdd/dbf/lock_multi_test.go
CharlesKWON fc1dca9551 feat(rdd): real POSIX file/record locking + gap analysis doc
Replaces the FLOCK/DBRLOCK/DBRUNLOCK no-op stubs with actual
fcntl(F_SETLK) byte-range advisory locks, matching Harbour's
hb_fsLockLarge implementation.

Before: rtlDbRLock always returned .T. regardless of contention.
        Multi-process writers could silently corrupt records.

After:  Non-blocking POSIX byte-range locks per file descriptor.
        Cross-process exclusion verified by a subprocess-spawning
        Go test that witnesses BUSY vs OK transitions.

New files:
  hbrdd/dbf/locks_posix.go    fcntl F_WRLCK/F_UNLCK wrappers
  hbrdd/dbf/locks_windows.go  stub (TODO: LockFileEx)
  hbrdd/dbf/lock_multi_test.go   cross-process verification
  docs/gap-analysis.md        honest Harbour parity assessment

Modified:
  hbrdd/dbf/dbf.go
    - DBFArea gains fileLocked bool + lockedRecs map
    - Close() calls releaseAllLocks() before dropping the fd
  hbrtl/database.go
    - rtlDbRLock / rtlDbRUnlock now delegate to DBFArea.LockRecord /
      UnlockRecord instead of returning fixed .T./NIL
    - New rtlFLock / rtlDbUnlock for FLOCK() / DBUNLOCK()
  hbrtl/register.go
    - FLOCK and DBUNLOCK symbols registered (were missing entirely)
  compiler/analyzer/analyzer.go
    - FLOCK / DBUNLOCK added to RTL known-function set

Lock region layout (non-overlapping on purpose):
  FLOCK region       [0, HeaderLen+1)
  Record N region    [RecordOffset(N), RecordLen)

So a workarea can hold FLOCK and multiple DBRLOCK simultaneously
on the same fd without conflict.

Design rationale (captured in locks_posix.go header):
  * POSIX fcntl, not flock(2) — byte-range + NFS-safe
  * Non-blocking F_SETLK — matches Clipper FLOCK() → .F. semantics
  * Released explicitly on Close to avoid workarea-sharing races
  * Windows falls back to no-op (TODO: LockFileEx)

Verification:
  go test ./hbrdd/dbf/ -run TestFLockBlocksAcrossProcesses  PASS
  go test ./hbrdd/dbf/ -run TestRLockBlocksAcrossProcesses  PASS
  go test ./...                                             ALL PASS
  FiveSql2 43/43                                            100%
  compat_harbour 51/51                                      100%

The gap-analysis doc (docs/gap-analysis.md) is a running inventory
of what works vs what's still missing vs Harbour 3.2, written for
users evaluating Five for production — not a sales pitch.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 17:58:03 +09:00

251 lines
5.9 KiB
Go

// 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:<n>"
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))
}
}