From 3ed246c47e6f8cd4e016347d638bcebf94c41266 Mon Sep 17 00:00:00 2001 From: CharlesKWON Date: Sun, 12 Apr 2026 11:57:33 +0900 Subject: [PATCH] =?UTF-8?q?feat(rdd):=20Windows=20LockFileEx=20implementat?= =?UTF-8?q?ion=20=E2=80=94=20real=20byte-range=20locks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/gap-analysis.md | 6 +- hbrdd/dbf/locks_windows.go | 166 ++++++++++++++++++++++++++++++++++--- 2 files changed, 161 insertions(+), 11 deletions(-) diff --git a/docs/gap-analysis.md b/docs/gap-analysis.md index 31783fe..c226f24 100644 --- a/docs/gap-analysis.md +++ b/docs/gap-analysis.md @@ -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 diff --git a/hbrdd/dbf/locks_windows.go b/hbrdd/dbf/locks_windows.go index 28a0d96..bad3fcf 100644 --- a/hbrdd/dbf/locks_windows.go +++ b/hbrdd/dbf/locks_windows.go @@ -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)