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
|
### 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
|
- **Advisory only**: processes that don't call `FLOCK`/`DBRLOCK` bypass
|
||||||
protection. Same as Harbour and Clipper — this is expected behavior.
|
protection. Same as Harbour and Clipper — this is expected behavior.
|
||||||
- **No timeout**: Harbour's `HB_SET_LOCKRETRY` is not honored. Callers
|
- **No timeout**: Harbour's `HB_SET_LOCKRETRY` is not honored. Callers
|
||||||
|
|||||||
@@ -3,45 +3,191 @@
|
|||||||
|
|
||||||
//go:build windows
|
//go:build windows
|
||||||
|
|
||||||
// Windows file locking stub for DBF shared mode.
|
// Windows file locking for DBF shared mode.
|
||||||
// TODO: implement with LockFileEx (LOCKFILE_EXCLUSIVE_LOCK | LOCKFILE_FAIL_IMMEDIATELY).
|
// Uses kernel32 LockFileEx / UnlockFileEx for byte-range exclusive locks,
|
||||||
// For now, behaves like the previous no-op (always succeeds).
|
// 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
|
package dbf
|
||||||
|
|
||||||
func (a *DBFArea) LockFile() (bool, error) {
|
import (
|
||||||
a.fileLocked = true
|
"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
|
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 {
|
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
|
a.fileLocked = false
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LockRecord tries to acquire an exclusive lock on a single record.
|
||||||
func (a *DBFArea) LockRecord(recNo uint32) (bool, error) {
|
func (a *DBFArea) LockRecord(recNo uint32) (bool, error) {
|
||||||
if a.lockedRecs == nil {
|
if a.dataFile == nil {
|
||||||
a.lockedRecs = make(map[uint32]bool)
|
return false, fmt.Errorf("file not open")
|
||||||
}
|
}
|
||||||
if recNo == 0 {
|
if recNo == 0 {
|
||||||
recNo = a.recNo
|
recNo = a.recNo
|
||||||
}
|
}
|
||||||
a.lockedRecs[recNo] = true
|
if recNo == 0 {
|
||||||
return true, nil
|
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 {
|
func (a *DBFArea) UnlockRecord(recNo uint32) error {
|
||||||
if a.lockedRecs == nil {
|
if a.dataFile == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if recNo == 0 {
|
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
|
a.lockedRecs = nil
|
||||||
return 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)
|
delete(a.lockedRecs, recNo)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// releaseAllLocks is called on Close to release any held locks.
|
||||||
func (a *DBFArea) releaseAllLocks() {
|
func (a *DBFArea) releaseAllLocks() {
|
||||||
_ = a.UnlockFile()
|
_ = a.UnlockFile()
|
||||||
_ = a.UnlockRecord(0)
|
_ = a.UnlockRecord(0)
|
||||||
|
|||||||
Reference in New Issue
Block a user