feat(rdd): Windows LockFileEx implementation — real byte-range locks

Replace the no-op Windows lock stub with actual kernel32 LockFileEx /
UnlockFileEx calls via syscall.LazyDLL (zero external dependency).

- LOCKFILE_EXCLUSIVE_LOCK | LOCKFILE_FAIL_IMMEDIATELY for non-blocking
  semantics matching Clipper FLOCK() → .F.
- Same lock region layout as POSIX: header region for FLOCK, record
  offsets for DBRLOCK — compatible across platforms
- Handles returned as syscall.Handle from os.File.Fd()

Note: full Windows cross-compile still blocked by unrelated issues
(mmap in cdx/ntx, termios in debugcli.go). The lock code itself
compiles cleanly with //go:build windows.

Also updates gap-analysis.md to reflect Windows lock status.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-12 11:57:33 +09:00
parent fc1dca9551
commit 3ed246c47e
2 changed files with 161 additions and 11 deletions

View File

@@ -108,7 +108,11 @@ per-fd, so same-process tests would be meaningless).
### Limitations
- **Windows**: still a no-op stub. `LockFileEx` wrapper needed (~1 day).
- **Windows**: real `LockFileEx`/`UnlockFileEx` implementation in
`locks_windows.go` using `kernel32.dll` direct syscalls (no external
dependency). Note: full Windows cross-compile requires separate fixes
for mmap (`hbrdd/cdx`, `hbrdd/ntx`) and termios (`hbrt/debugcli.go`)
which are unrelated to locking.
- **Advisory only**: processes that don't call `FLOCK`/`DBRLOCK` bypass
protection. Same as Harbour and Clipper — this is expected behavior.
- **No timeout**: Harbour's `HB_SET_LOCKRETRY` is not honored. Callers

View File

@@ -3,45 +3,191 @@
//go:build windows
// Windows file locking stub for DBF shared mode.
// TODO: implement with LockFileEx (LOCKFILE_EXCLUSIVE_LOCK | LOCKFILE_FAIL_IMMEDIATELY).
// For now, behaves like the previous no-op (always succeeds).
// Windows file locking for DBF shared mode.
// Uses kernel32 LockFileEx / UnlockFileEx for byte-range exclusive locks,
// matching Harbour's hb_fsLockLarge on Windows.
//
// Lock region layout (identical to the POSIX implementation):
// - FLOCK: [0, HeaderLen+1) file-wide exclusive
// - RLOCK: [RecordOffset(n), RecordLen) per-record exclusive
//
// Non-blocking: LOCKFILE_FAIL_IMMEDIATELY returns immediately if another
// process holds a conflicting lock — matching Clipper FLOCK() → .F.
package dbf
func (a *DBFArea) LockFile() (bool, error) {
a.fileLocked = true
import (
"fmt"
"syscall"
"unsafe"
)
const flockOffset = int64(0)
func flockLen(a *DBFArea) int64 {
return int64(a.header.HeaderLen) + 1
}
var (
modkernel32 = syscall.NewLazyDLL("kernel32.dll")
procLockFileEx = modkernel32.NewProc("LockFileEx")
procUnlockFileEx = modkernel32.NewProc("UnlockFileEx")
)
// Windows constants
const (
_LOCKFILE_EXCLUSIVE_LOCK = 0x00000002
_LOCKFILE_FAIL_IMMEDIATELY = 0x00000001
_ERROR_LOCK_VIOLATION = 33
)
func splitOffset(off int64) (lo, hi uint32) {
return uint32(off & 0xFFFFFFFF), uint32(off >> 32)
}
// tryLock acquires an exclusive byte-range lock via LockFileEx.
// Non-blocking: returns (false, nil) on contention.
func tryLock(handle syscall.Handle, start, length int64) (bool, error) {
oLo, oHi := splitOffset(start)
lLo, lHi := splitOffset(length)
ol := &syscall.Overlapped{
Offset: oLo,
OffsetHigh: oHi,
}
flags := uint32(_LOCKFILE_EXCLUSIVE_LOCK | _LOCKFILE_FAIL_IMMEDIATELY)
r1, _, err := procLockFileEx.Call(
uintptr(handle),
uintptr(flags),
0, // reserved
uintptr(lLo),
uintptr(lHi),
uintptr(unsafe.Pointer(ol)),
)
if r1 == 0 {
// Call failed
if errno, ok := err.(syscall.Errno); ok && errno == _ERROR_LOCK_VIOLATION {
return false, nil // another process holds it
}
return false, err
}
return true, nil
}
// unlockRange releases a previously acquired byte-range lock via UnlockFileEx.
func unlockRange(handle syscall.Handle, start, length int64) error {
oLo, oHi := splitOffset(start)
lLo, lHi := splitOffset(length)
ol := &syscall.Overlapped{
Offset: oLo,
OffsetHigh: oHi,
}
r1, _, err := procUnlockFileEx.Call(
uintptr(handle),
0, // reserved
uintptr(lLo),
uintptr(lHi),
uintptr(unsafe.Pointer(ol)),
)
if r1 == 0 {
return err
}
return nil
}
// winHandle returns the Windows HANDLE for the data file.
func (a *DBFArea) winHandle() syscall.Handle {
return syscall.Handle(a.dataFile.Fd())
}
// LockFile tries to acquire an exclusive FLOCK on the DBF file.
func (a *DBFArea) LockFile() (bool, error) {
if a.dataFile == nil {
return false, fmt.Errorf("file not open")
}
if a.fileLocked {
return true, nil
}
ok, err := tryLock(a.winHandle(), flockOffset, flockLen(a))
if err != nil {
return false, err
}
if ok {
a.fileLocked = true
}
return ok, nil
}
// UnlockFile releases the file-wide lock held by this workarea.
func (a *DBFArea) UnlockFile() error {
if a.dataFile == nil || !a.fileLocked {
return nil
}
if err := unlockRange(a.winHandle(), flockOffset, flockLen(a)); err != nil {
return err
}
a.fileLocked = false
return nil
}
// LockRecord tries to acquire an exclusive lock on a single record.
func (a *DBFArea) LockRecord(recNo uint32) (bool, error) {
if a.lockedRecs == nil {
a.lockedRecs = make(map[uint32]bool)
if a.dataFile == nil {
return false, fmt.Errorf("file not open")
}
if recNo == 0 {
recNo = a.recNo
}
a.lockedRecs[recNo] = true
return true, nil
if recNo == 0 {
return false, nil
}
if a.lockedRecs != nil && a.lockedRecs[recNo] {
return true, nil
}
start := a.header.RecordOffset(recNo)
length := int64(a.header.RecordLen)
ok, err := tryLock(a.winHandle(), start, length)
if err != nil {
return false, err
}
if ok {
if a.lockedRecs == nil {
a.lockedRecs = make(map[uint32]bool, 16)
}
a.lockedRecs[recNo] = true
}
return ok, nil
}
// UnlockRecord releases a single record lock. recNo == 0 unlocks ALL.
func (a *DBFArea) UnlockRecord(recNo uint32) error {
if a.lockedRecs == nil {
if a.dataFile == nil {
return nil
}
if recNo == 0 {
if a.lockedRecs == nil {
return nil
}
length := int64(a.header.RecordLen)
for r := range a.lockedRecs {
start := a.header.RecordOffset(r)
_ = unlockRange(a.winHandle(), start, length)
}
a.lockedRecs = nil
return nil
}
if a.lockedRecs == nil || !a.lockedRecs[recNo] {
return nil
}
start := a.header.RecordOffset(recNo)
length := int64(a.header.RecordLen)
if err := unlockRange(a.winHandle(), start, length); err != nil {
return err
}
delete(a.lockedRecs, recNo)
return nil
}
// releaseAllLocks is called on Close to release any held locks.
func (a *DBFArea) releaseAllLocks() {
_ = a.UnlockFile()
_ = a.UnlockRecord(0)