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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
if recNo == 0 {
|
||||
return false, nil
|
||||
}
|
||||
if a.lockedRecs != nil && a.lockedRecs[recNo] {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (a *DBFArea) UnlockRecord(recNo uint32) error {
|
||||
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.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)
|
||||
|
||||
Reference in New Issue
Block a user