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>
251 lines
5.9 KiB
Go
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))
|
|
}
|
|
}
|