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>
195 lines
4.6 KiB
Go
195 lines
4.6 KiB
Go
// Copyright (c) 2026 Charles KWON OhJun (charleskwonohjun@gmail.com)
|
|
// All rights reserved.
|
|
|
|
//go:build windows
|
|
|
|
// 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
|
|
|
|
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.dataFile == nil {
|
|
return false, fmt.Errorf("file not open")
|
|
}
|
|
if recNo == 0 {
|
|
recNo = a.recNo
|
|
}
|
|
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.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)
|
|
}
|