// Cross-process file locking via an O_EXCL sentinel file.
//
// Target-split layout:
// lock.mbt — shared helpers (release, force-remove, TTL parsing,
// retry loop). All cross-target via @env / @fs.
// lock_js.mbt — JS-target primitives (_try_acquire, _stat_mtime_ms)
// + `with_lock` that wraps f() in a JS try/finally
// (MoonBit try/catch doesn't see JS-level abort()).
// lock_native.mbt — Native-target primitives (extern "C" bindings into
// native_stub.c) + `with_lock` that uses MoonBit
// `defer` for release.
///| Release the lock file by removing it. Cross-target via @fs.
fn _release(path : String) -> Unit {
let _ = try? @fs.remove_file(path)
}
///| Current wall-clock time in epoch milliseconds. Cross-target.
fn _now_ms_lock() -> Int64 {
@env.now().reinterpret_as_int64()
}
///| Best-effort unlink. Returns true if the file was removed or was
///| already gone (ENOENT is treated as success). Cross-target via @fs.
fn _force_remove(path : String) -> Bool {
if !@fs.path_exists(path) {
return true
}
(try? @fs.remove_file(path)) is Ok(_)
}
///| Read the stale-TTL override from the environment. Returns -1 when
///| unset or invalid so the default is used.
fn _parse_ttl_ms_env() -> Int {
match @env.get_env_var("MNEMO_LOCK_STALE_TTL_MS") {
None => -1
Some(raw) =>
match (try? @string.parse_int(raw.view())) {
Ok(n) => if n > 0 { n } else { -1 }
Err(_) => -1
}
}
}
///| Default stale TTL: 30s. A writer holding the sentinel longer than this is
///| almost certainly crashed — memory writes are sub-millisecond atomic rename.
pub let default_lock_stale_ttl_ms : Int = 30000
///| Resolved stale-TTL: env override wins, otherwise default.
fn _effective_stale_ttl_ms() -> Int {
let env = _parse_ttl_ms_env()
if env > 0 { env } else { default_lock_stale_ttl_ms }
}
// Cooperative acquire loop lives in the per-target `with_lock` to
// avoid an extra async-fn boundary that the test scheduler sometimes
// mishandles on rapid repeat runs. `with_lock` inlines the retry
// below; shared helpers here are pure / synchronous.
// --- Smoke tests ---
///|
async test "lock: sequential calls succeed" {
let dir = ffi_tmp_dir_lock()
let lock_path = dir + "/.writing"
let mut count = 0
with_lock(lock_path, fn() { count = count + 1 })
with_lock(lock_path, fn() { count = count + 1 })
assert_eq(count, 2)
ffi_rm_dir_lock(dir)
}
///| Stale sentinel: a writer that crashed left `.writing` behind.
///| Backdating its mtime past the TTL should let the next acquire recover.
async test "lock: stale sentinel is recovered after TTL" {
let dir = ffi_tmp_dir_lock()
let lock_path = dir + "/.writing"
// Create the sentinel as if a crashed writer left it.
ffi_write_empty_file(lock_path)
// Backdate far past any reasonable TTL.
ffi_set_mtime(lock_path, 1000L)
// Force a short TTL for the test (avoid the 30s default).
ffi_set_env_lock("MNEMO_LOCK_STALE_TTL_MS", "50")
let mut count = 0
with_lock(lock_path, fn() { count = count + 1 })
assert_eq(count, 1)
// Sentinel must be gone after with_lock returns.
assert_eq(ffi_file_exists_lock(lock_path), false)
ffi_unset_env_lock("MNEMO_LOCK_STALE_TTL_MS")
ffi_rm_dir_lock(dir)
}
///| Stat / timing primitives used by the recovery path round-trip correctly.
test "lock: mtime and now helpers return sensible values" {
let dir = ffi_tmp_dir_lock()
let path = dir + "/probe"
// Missing file → -1
assert_eq(_stat_mtime_ms(path), -1L)
ffi_write_empty_file(path)
let mtime = _stat_mtime_ms(path)
let now = _now_ms_lock()
assert_eq(mtime > 0L, true)
// A freshly-created file should be no more than a few seconds old.
assert_eq(now - mtime < 10000L, true)
ffi_rm_dir_lock(dir)
}
///| force_remove is idempotent.
test "lock: force_remove is idempotent on missing path" {
let dir = ffi_tmp_dir_lock()
let path = dir + "/does-not-exist"
assert_eq(_force_remove(path), true)
// Still true after a second call.
assert_eq(_force_remove(path), true)
ffi_rm_dir_lock(dir)
}
///| Monotonic counter for uniqueness across rapid calls in one process.
let _lock_tmp_counter : Array[Int] = [0]
///| Cross-target scratch dir for lock tests.
fn ffi_tmp_dir_lock() -> String {
let tmp = (@env.get_env_var("TMPDIR")).unwrap_or("/tmp")
_lock_tmp_counter[0] = _lock_tmp_counter[0] + 1
let dir = tmp +
"/mnemo-lock-" +
@env.now().to_string() +
"-" +
_lock_tmp_counter[0].to_string()
let _ = try? @fs.create_dir(dir)
dir
}
///| Cross-target rmdir.
fn ffi_rm_dir_lock(path : String) -> Unit {
let _ = try? @fs.remove_dir(path)
}
///| Cross-target empty-file writer (simulates a crashed writer sentinel).
fn ffi_write_empty_file(path : String) -> Unit {
let _ = try? @fs.write_string_to_file(path, "")
}
///| Cross-target file exists check.
fn ffi_file_exists_lock(path : String) -> Bool {
@fs.path_exists(path)
}