diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..0d81fea --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,19 @@ +# Development on the shared desktop + +The user reported a GNOME/Wayland freeze associated with high memory pressure, +nearly exhausted swap, and an AppIndicator extension reload. Treat this as a +development constraint; a larger swap file is not a reason to increase load. + +- Routine project work must not reload, enable, or disable desktop extensions, + restart GNOME Shell, or close/manage the user's terminal windows. +- Run one local compilation at a time with Nim `--parallelBuild:1` and reduced + priority (`nice -n 10` on Linux). Before starting, inspect `MemAvailable` and + `/proc/pressure/memory`; defer compilation to CI if available memory is below + 6 GiB or the memory `full avg10` stall percentage is at least 2. +- Use focused local tests. Keep persistence test workers bounded to four small + processes; run full native platform suites and heavier matrices in CI. +- Put development executables in `.ci/` and select them explicitly for tests. + Preserve the user's configuration, credentials, and active installed binary + during development. Test settings and keys in temporary configuration roots. +- When another session is changing the same checkout, use an isolated worktree + and stage only the files owned by the current task. diff --git a/STATE_PERSISTENCE.md b/STATE_PERSISTENCE.md new file mode 100644 index 0000000..865af1a --- /dev/null +++ b/STATE_PERSISTENCE.md @@ -0,0 +1,34 @@ +# Configuration and log concurrency + +This follow-up addresses the configuration/log concurrency issue recorded in +[the v3 review](CODE_REVIEW-v3.md). Concurrent `get set` operations now lock the +entire read, validation, and write sequence, so changing different options does +not discard another process's update. Key writes, full config saves, and reset +use the same settings lock. Invalid settings release their lock without saving. + +Logs serialize append, retention, and cleaning. Bounded retention writes a +private replacement containing only the retained entries. Historical multiline +entries are scanned incrementally; counting and cleaning do not load the entire +file, and trimming keeps only the requested tail plus the current entry. + +Locks use POSIX `flock` or Windows `LockFileEx`, with a five-second monotonic +wait budget for lock contention. The OS releases ownership when a process exits, including after a +crash. Persistent, empty `.settings.lock` and `get.log.lock` sidecars are retained +so an unlink/recreate race cannot create two independent locks. POSIX lock files +are private, regular files and cannot follow a symbolic link. Windows readers +also take the settings lock to prevent replacement racing an open reader. + +These are cooperating-process locks. They serialize writers using this version; +older binaries and external editors do not participate. A full config save or +reset intentionally replaces all settings. Two-file reset is not a crash-atomic +database transaction, and power-loss durability is not claimed. Unlimited logs +still grow on disk when `log-max-entries=0`; retained memory depends on the +configured entry count and the largest historical entry. + +The bounded worker regressions cover concurrent setters, reset, whole log +entries with/without retention, serialized cleaning, lock deadlines, owner +termination, and symbolic-link rejection. Local work follows [AGENTS.md](AGENTS.md) +to limit pressure on the shared desktop. + +API behavior was checked against the [flock manual](https://man7.org/linux/man-pages/man2/flock.2.html) +and [Microsoft LockFileEx documentation](https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-lockfileex). diff --git a/src/config.nim b/src/config.nim index 674c2c4..13cf732 100644 --- a/src/config.nim +++ b/src/config.nim @@ -21,6 +21,7 @@ when defined(windows): import std/base64 import harness_types +import file_lock import style import utils @@ -700,7 +701,7 @@ when defined(getTest): ## .. code-block:: nim ## runnableExamples: ## discard -proc saveKey*(key: Option[string]) = +proc implSaveKeyUnlocked(key: Option[string]) = let path = getKeyFilePath() if key.isNone: if fileExists(path): @@ -713,6 +714,15 @@ proc saveKey*(key: Option[string]) = else: writePrivateFile(path, value) +proc implSettingsLockPath(): string = + result = getAppConfigDir() / ".settings.lock" + +proc saveKey*(key: Option[string]) = + ## Serialize key updates with config setters and reset. + let lock = acquireFileLock(implSettingsLockPath()) + defer: releaseFileLock(lock) + implSaveKeyUnlocked(key) + ## Loads the API key from platform-specific secure storage. ## ## :returns: The stored key, or none if absent. @@ -720,7 +730,7 @@ proc saveKey*(key: Option[string]) = ## .. code-block:: nim ## runnableExamples: ## discard -proc loadKey*(): Option[string] = +proc implLoadKeyUnlocked(): Option[string] = let path = getKeyFilePath() if not fileExists(path): return none(string) @@ -738,6 +748,12 @@ proc loadKey*(): Option[string] = else: result = some(content) +proc loadKey*(): Option[string] = + when defined(windows): + let lock = acquireFileLock(implSettingsLockPath()) + defer: releaseFileLock(lock) + result = implLoadKeyUnlocked() + # --------------------------------------------------------------------------- # Public API — config persistence # --------------------------------------------------------------------------- @@ -750,7 +766,7 @@ proc loadKey*(): Option[string] = ## .. code-block:: nim ## runnableExamples: ## discard -proc loadConfig*(): Config = +proc implLoadConfigUnlocked(): Config = let path = getConfigFilePath() if not fileExists(path): return defaultConfig() @@ -770,6 +786,13 @@ proc loadConfig*(): Config = " using defaults") result = defaults +proc loadConfig*(): Config = + # Readers also cooperate so Windows replacement never races an open reader. + when defined(windows): + let lock = acquireFileLock(implSettingsLockPath()) + defer: releaseFileLock(lock) + result = implLoadConfigUnlocked() + ## Writes the configuration to disk as pretty-printed JSON. ## ## :param cfg: The configuration to persist. @@ -777,11 +800,16 @@ proc loadConfig*(): Config = ## .. code-block:: nim ## runnableExamples: ## discard -proc saveConfig*(cfg: Config) = +proc implSaveConfigUnlocked(cfg: Config) = let path = getConfigFilePath() let node = implConfigToJson(cfg) writePrivateFile(path, pretty(node, 2) & "\n") +proc saveConfig*(cfg: Config) = + let lock = acquireFileLock(implSettingsLockPath()) + defer: releaseFileLock(lock) + implSaveConfigUnlocked(cfg) + # --------------------------------------------------------------------------- # Public API — display # --------------------------------------------------------------------------- @@ -897,8 +925,10 @@ proc displayConfig*(sk: StyleKind = skSimp) = ## runnableExamples: ## discard proc resetConfig*() = - saveConfig(defaultConfig()) - saveKey(none(string)) + let lock = acquireFileLock(implSettingsLockPath()) + defer: releaseFileLock(lock) + implSaveConfigUnlocked(defaultConfig()) + implSaveKeyUnlocked(none(string)) # --------------------------------------------------------------------------- # Public API — set by name @@ -939,7 +969,9 @@ proc setConfigOption*( saveKey(some(value)) return - var cfg = loadConfig() + let lock = acquireFileLock(implSettingsLockPath()) + defer: releaseFileLock(lock) + var cfg = implLoadConfigUnlocked() case name of "url": cfg.url = value @@ -1056,7 +1088,7 @@ proc setConfigOption*( else: raise newException(GetError, fmt"unknown option '{name}'") - saveConfig(cfg) + implSaveConfigUnlocked(cfg) # --------------------------------------------------------------------------- # Public API — readiness check diff --git a/src/file_lock.nim b/src/file_lock.nim new file mode 100644 index 0000000..b8a39e1 --- /dev/null +++ b/src/file_lock.nim @@ -0,0 +1,92 @@ +## Short, crash-released cross-process locks for configuration and logs. +## Sidecar files stay in place: unlinking a lock could create two lock domains. + +import std/[monotimes, os, times] +import utils + +when defined(windows): + import std/[widestrs, winlean] + + const + LOCKFILE_FAIL_IMMEDIATELY = 1 + LOCKFILE_EXCLUSIVE_LOCK = 2 + + proc lockFileEx(handle: Handle, flags, reserved, low, high: DWORD, + overlapped: POVERLAPPED): WINBOOL {. + stdcall, dynlib: "kernel32", importc: "LockFileEx".} + proc unlockFileEx(handle: Handle, reserved, low, high: DWORD, + overlapped: POVERLAPPED): WINBOOL {. + stdcall, dynlib: "kernel32", importc: "UnlockFileEx".} +elif defined(posix): + import std/posix + + proc flock(fd: cint, operation: cint): cint {.importc, header: "".} + var + lockExclusive {.importc: "LOCK_EX", header: "".}: cint + lockNonblocking {.importc: "LOCK_NB", header: "".}: cint + openNoFollow {.importc: "O_NOFOLLOW", header: "".}: cint + openCloseOnExec {.importc: "O_CLOEXEC", header: "".}: cint +else: + {.error: "State locks require Windows or POSIX file locking".} + +type + FileLockError* = object of GetError + FileLock* = object + when defined(windows): + handle: Handle + else: + fd: cint + +const FILE_LOCK_WAIT_MS* = 5_000 + +proc acquireFileLock*(path: string, timeoutMs = FILE_LOCK_WAIT_MS): FileLock = + ## Acquire an exclusive, non-inheritable lock with a monotonic deadline. + let started = getMonoTime() + var acquired = false + when defined(windows): + let handle = createFileW(newWideCString(path), GENERIC_READ or GENERIC_WRITE, + FILE_SHARE_READ or FILE_SHARE_WRITE, nil, OPEN_ALWAYS, + FILE_ATTRIBUTE_NORMAL, 0) + if handle == INVALID_HANDLE_VALUE: + raise newException(FileLockError, "cannot open state lock: " & osErrorMsg(osLastError())) + defer: + if not acquired: discard closeHandle(handle) + else: + let fd = posix.open(path.cstring, + O_RDWR or O_CREAT or O_NONBLOCK or openNoFollow or openCloseOnExec, Mode(0o600)) + if fd < 0: + raise newException(FileLockError, "cannot open state lock: " & osErrorMsg(osLastError())) + defer: + if not acquired: discard posix.close(fd) + var info: Stat + if fstat(fd, info) != 0 or not S_ISREG(info.st_mode) or fchmod(fd, Mode(0o600)) != 0: + raise newException(FileLockError, "state lock must be a private regular file") + + while true: + when defined(windows): + var overlapped: OVERLAPPED + # Fail immediately on contention; retry only within the common deadline. + if lockFileEx(handle, LOCKFILE_FAIL_IMMEDIATELY or LOCKFILE_EXCLUSIVE_LOCK, + 0, 1, 0, addr overlapped) != 0: + acquired = true + return FileLock(handle: handle) + if getLastError() != ERROR_LOCK_VIOLATION: + raise newException(FileLockError, "cannot acquire state lock: " & osErrorMsg(osLastError())) + else: + if flock(fd, lockExclusive or lockNonblocking) == 0: + acquired = true + return FileLock(fd: fd) + if errno notin [EAGAIN, EWOULDBLOCK, EINTR]: + raise newException(FileLockError, "cannot acquire state lock: " & osErrorMsg(osLastError())) + if (getMonoTime() - started).inMilliseconds >= timeoutMs: + raise newException(FileLockError, "configuration or log is busy; retry the operation") + sleep(10) + +proc releaseFileLock*(lock: FileLock) = + ## Release exactly once, normally from a defer/finally clause. + when defined(windows): + var overlapped: OVERLAPPED + discard unlockFileEx(lock.handle, 0, 1, 0, addr overlapped) + discard closeHandle(lock.handle) + else: + discard posix.close(lock.fd) diff --git a/src/logger.nim b/src/logger.nim index 6894744..4381c99 100644 --- a/src/logger.nim +++ b/src/logger.nim @@ -13,8 +13,9 @@ {.experimental: "strictFuncs".} -import std/[json, strformat, strutils, times, os] +import std/[deques, json, strformat, strutils, times, os] +import file_lock import style import utils @@ -32,47 +33,38 @@ const LOG_ENTRY_SEPARATOR = "\n\n" # Private helpers # --------------------------------------------------------------------------- -## Counts the number of log entries in the file. -## -## :param content: The full log file content. -## :returns: The number of entries detected. +## Recognizes a timestamped entry header, including the historical format. func implIsEntryStart(line: string): bool = result = line.len >= 29 and line[0] == '[' and line[5] == '-' and line[8] == '-' and line[11] == ' ' and line[14] == ':' and line[17] == ':' and line[20 .. 28] == "] query: " -func implCountEntries(content: string): int = +proc implCountEntries(path: string): int = + ## Count incrementally so log inspection and cleaning use bounded memory. result = 0 - for line in content.splitLines(): + for line in lines(path): if implIsEntryStart(line): result += 1 -## Trims the log content so that at most maxEntries remain. -## -## :param content: The full log file content. -## :param maxEntries: Maximum entries to retain. -## :returns: The trimmed content. -func implTrimEntries( - content: string, - maxEntries: int -): string = - if maxEntries <= 0: - return content - var entries: seq[string] = @[] +proc implReadTail(path: string, keep: int): string = + ## Keep only the requested tail while scanning historical multiline logs. + if keep <= 0 or not fileExists(path): + return "" + var entries = initDeque[string]() var entry = "" - for line in content.splitLines(): + for line in lines(path): if implIsEntryStart(line) and entry.len > 0: - entries.add(entry.strip(trailing = true, leading = false)) + entries.addLast(entry.strip(trailing = true, leading = false)) + if entries.len > keep: + discard entries.popFirst() entry = "" entry.add(line & "\n") if entry.strip().len > 0: - entries.add(entry.strip(trailing = true, leading = false)) - if entries.len <= maxEntries: - return content - let kept = - entries[entries.len - maxEntries .. ^1] - result = kept.join(LOG_ENTRY_SEPARATOR) & - LOG_ENTRY_SEPARATOR + entries.addLast(entry.strip(trailing = true, leading = false)) + if entries.len > keep: + discard entries.popFirst() + for retained in entries: + result.add(retained & LOG_ENTRY_SEPARATOR) # --------------------------------------------------------------------------- # Public API @@ -98,35 +90,35 @@ proc logExecution*( ) = try: let path = getLogFilePath() + let lock = acquireFileLock(path & ".lock") + defer: releaseFileLock(lock) let ts = now().format("yyyy-MM-dd HH:mm:ss") let preview = if output.len > MAX_LOG_OUTPUT_LEN: output[0 ..< MAX_LOG_OUTPUT_LEN] & "..." else: output - var f: File - if not open(f, path, fmAppend): - return - try: - when defined(posix): - setFilePermissions(path, {fpUserRead, fpUserWrite}) - # JSON strings preserve multiline values without introducing false - # entry separators or forged log headers during retention trimming. - f.writeLine(fmt"[{ts}] query: " & $(%query)) - f.writeLine(fmt"[{ts}] command: " & $(%command)) - f.writeLine(fmt"[{ts}] exit: {exitCode}") - if preview.len > 0: - f.writeLine(fmt"[{ts}] output: " & $(%preview)) - f.writeLine("") - finally: - f.close() + # Write one complete entry inside the same critical section as retention. + var entry = fmt"[{ts}] query: " & $(%query) & "\n" & + fmt"[{ts}] command: " & $(%command) & "\n" & + fmt"[{ts}] exit: {exitCode}" & "\n" + if preview.len > 0: + entry.add(fmt"[{ts}] output: " & $(%preview) & "\n") + entry.add("\n") if maxEntries > 0: - let content = readFile(path) - let count = implCountEntries(content) - if count > maxEntries: - let trimmed = implTrimEntries( - content, maxEntries) - writeFile(path, trimmed) + writePrivateFile(path, implReadTail(path, maxEntries - 1) & entry) + elif not fileExists(path): + writePrivateFile(path, entry) + else: + var f: File + if not open(f, path, fmAppend): + return + try: + when defined(posix): + setFilePermissions(path, {fpUserRead, fpUserWrite}) + f.write(entry) + finally: + f.close() except CatchableError: discard @@ -142,9 +134,10 @@ proc cleanLog*(): int = if not fileExists(path): return 0 try: - let content = readFile(path) - result = implCountEntries(content) - writeFile(path, "") + let lock = acquireFileLock(path & ".lock") + defer: releaseFileLock(lock) + result = implCountEntries(path) + writePrivateFile(path, "") except CatchableError: result = 0 @@ -170,8 +163,9 @@ proc displayLogInfo*( formatIntOrDisable(maxEntries)) styleKeyValue(sk, "file", path) if fileExists(path): - let content = readFile(path) - let entries = implCountEntries(content) + let lock = acquireFileLock(path & ".lock") + defer: releaseFileLock(lock) + let entries = implCountEntries(path) styleKeyValue(sk, "entries", $entries) let size = getFileSize(path) let sizeStr = diff --git a/tests/test_harness_executor.nim b/tests/test_harness_executor.nim index 8a52b44..faaf809 100644 --- a/tests/test_harness_executor.nim +++ b/tests/test_harness_executor.nim @@ -11,38 +11,50 @@ {.experimental: "strictFuncs".} -import std/[monotimes, unittest, times] +import std/[strutils, unittest] -when defined(windows): - import std/strutils +when defined(posix): + import std/[json, monotimes, os] import harness_executor import harness_protocol import harness_types +when defined(posix): + # The approved child only reads the monotonic clock, sleeps, and writes stdout. + # Record the interval inside the subprocess, excluding shell/sandbox startup. + if paramCount() == 3 and paramStr(1) == "--executor-probe": + let started = getMonoTime().ticks + sleep(parseInt(paramStr(3))) + stdout.writeLine($(%*{ + "label": paramStr(2), "started": started, "finished": getMonoTime().ticks + })) + quit(0) + ## Verifies stable, actually concurrent batch execution. suite "harness tool executor": when defined(posix): test "runs independent calls concurrently in stable order": + let probe = quoteShell(getAppFilename()) & " --executor-probe " let calls = @[ ToolCall( id: "first", toolName: READ_ONLY_SHELL_TOOL, - command: "sleep 0.6; printf first", + command: probe & "first 1000", purpose: "first probe", resultMode: trmReturnRaw ), ToolCall( id: "second", toolName: READ_ONLY_SHELL_TOOL, - command: "sleep 0.2; printf second", + command: probe & "second 200", purpose: "second probe", resultMode: trmReturnRaw ), ToolCall( id: "third", toolName: READ_ONLY_SHELL_TOOL, - command: "sleep 0.2; printf third", + command: probe & "third 200", purpose: "third probe", resultMode: trmReturnRaw ) @@ -51,23 +63,28 @@ suite "harness tool executor": maxTurns: 3, maxToolCalls: 8, maxParallel: 2, - commandTimeoutSec: 2, + commandTimeoutSec: 5, maxOutputBytes: 1024 ) - let started = getMonoTime() let values = executeToolBatch(calls, "bash", budget, 2) - let elapsed = (getMonoTime() - started).inMilliseconds - checkpoint "elapsed=" & $elapsed & " tool=" & - $values[0].elapsedMs & "," & $values[1].elapsedMs & - "," & $values[2].elapsedMs - check values.len == 3 + require values.len == 3 + var intervals: seq[tuple[started, finished: int64]] = @[] + for index, value in values: + checkpoint value.output + require value.exitCode == 0 + check not value.timedOut + let record = parseJson(value.output) + check record["label"].getStr() == calls[index].id + intervals.add((record["started"].getBiggestInt(), + record["finished"].getBiggestInt())) check values[0].callId == "first" - check values[0].output == "first" check values[1].callId == "second" - check values[1].output == "second" check values[2].callId == "third" - check values[2].output == "third" - check elapsed < 750 + # Both short calls must overlap the long call. A serial executor or a + # batch barrier before the third call fails without a host-speed cutoff. + for index in 1..2: + check intervals[index].started < intervals[0].finished + check intervals[0].started < intervals[index].finished else: test "runs independent cmd calls in stable order": let calls = @[ diff --git a/tests/test_state_locking.nim b/tests/test_state_locking.nim new file mode 100644 index 0000000..6b778ea --- /dev/null +++ b/tests/test_state_locking.nim @@ -0,0 +1,206 @@ +## Small cross-process regressions: at most four lightweight worker processes. +import std/[json, monotimes, options, os, osproc, sets, streams, strutils, + tempfiles, times, unittest] +import config, file_lock, logger, utils + +if paramCount() > 0 and paramStr(1) == "--state-worker": + let mode = paramStr(2) + let marker = paramStr(3) + if mode == "hold": + let lock = acquireFileLock(paramStr(4)) + writeFile(marker, "ready") + while true: sleep(100) + releaseFileLock(lock) + elif mode == "timeout": + try: + let lock = acquireFileLock(paramStr(4), timeoutMs = 100) + releaseFileLock(lock) + quit(0) + except FileLockError: + quit(42) + else: + writeFile(marker, "ready") + case mode + of "set": setConfigOption(paramStr(4), paramStr(5)) + of "reset": resetConfig() + of "clean": writeFile(paramStr(4), $cleanLog()) + of "log": + for index in 0..<8: + let tag = paramStr(4) & "-" & $index + logExecution(tag, tag, tag & "\n\nend", 0, parseInt(paramStr(5))) + else: quit(2) + quit(0) + +proc worker(args: seq[string]): Process = + startProcess(getAppFilename(), args = @["--state-worker"] & args, + options = {poStdErrToStdOut}) + +proc cleanup(workers: seq[Process]) = + for process in workers: + if process.running: + process.terminate() + discard process.waitForExit(5_000) + process.close() + +proc waitReady(paths: seq[string]) = + let started = getMonoTime() + for path in paths: + while not fileExists(path): + if (getMonoTime() - started).inMilliseconds > 5_000: + raise newException(IOError, "state worker did not become ready") + sleep(10) + +proc finish(workers: seq[Process]) = + for process in workers: + let code = process.waitForExit(5_000) + if code != 0: + if process.running: + process.terminate() + discard process.waitForExit(5_000) + checkpoint(process.outputStream.readAll()) + check code == 0 + +template isolatedState(body: untyped) = + let root {.inject.} = createTempDir("get-state-locks-", "") + let envName = when defined(windows): "APPDATA" else: "XDG_CONFIG_HOME" + let existed = existsEnv(envName) + let old = getEnv(envName) + putEnv(envName, root) + defer: + if existed: putEnv(envName, old) + else: delEnv(envName) + removeDir(root) + body + +suite "serialized configuration and log updates": + test "concurrent setters wait for the transaction and preserve different fields": + isolatedState: + saveConfig(defaultConfig()) + let initial = readFile(getConfigFilePath()) + var workers: seq[Process] = @[] + var markers: seq[string] = @[] + defer: cleanup(workers) + let lock = acquireFileLock(getAppConfigDir() / ".settings.lock") + try: + for index, option in [("model", "concurrent-model"), + ("url", "https://concurrent.invalid/v1"), + ("markdown", "false"), ("max-rounds", "7")]: + let marker = root / ("ready-" & $index) + markers.add(marker) + workers.add(worker(@["set", marker, option[0], option[1]])) + waitReady(markers) + sleep(100) + check readFile(getConfigFilePath()) == initial + for process in workers: check process.running + finally: + releaseFileLock(lock) + finish(workers) + let cfg = loadConfig() + check cfg.model == "concurrent-model" + check cfg.url == "https://concurrent.invalid/v1" + check not cfg.markdown + check cfg.maxRounds == 7 + + test "reset waits for the shared settings lock and clears configuration and key": + isolatedState: + setConfigOption("model", "before-reset") + saveKey(some("isolated-fixture-key")) + let configBefore = readFile(getConfigFilePath()) + let keyBefore = readFile(getKeyFilePath()) + var workers: seq[Process] = @[] + defer: cleanup(workers) + let marker = root / "reset-ready" + let lock = acquireFileLock(getAppConfigDir() / ".settings.lock") + try: + workers.add(worker(@["reset", marker])) + waitReady(@[marker]) + sleep(100) + check workers[0].running + check readFile(getConfigFilePath()) == configBefore + check readFile(getKeyFilePath()) == keyBefore + finally: + releaseFileLock(lock) + finish(workers) + check loadConfig().model == defaultConfig().model + check loadKey().isNone + + test "invalid settings release the writer lock": + isolatedState: + expect GetError: + setConfigOption("markdown", "invalid") + setConfigOption("markdown", "false") + check not loadConfig().markdown + + test "concurrent log entries stay whole with and without retention": + isolatedState: + for limit in [0, 16]: + discard cleanLog() + var workers: seq[Process] = @[] + try: + for index in 0..<4: + workers.add(worker(@["log", root / ("log-ready-" & $index), + "worker-" & $index, $limit])) + finish(workers) + finally: + cleanup(workers) + let content = readFile(getLogFilePath()) + var seen = initHashSet[string]() + for entry in content.strip().split("\n\n"): + let rows = entry.splitLines() + require rows.len == 4 + let query = parseJson(rows[0].split("query: ", 1)[1]).getStr() + let command = parseJson(rows[1].split("command: ", 1)[1]).getStr() + let output = parseJson(rows[3].split("output: ", 1)[1]).getStr() + check command == query + check output == query & "\n\nend" + check query notin seen + seen.incl(query) + check seen.len == (if limit == 0: 32 else: limit) + + test "clean participates in the log writer lock": + isolatedState: + logExecution("one", "pwd", "one", 0) + logExecution("two", "pwd", "two", 0) + let initial = readFile(getLogFilePath()) + let marker = root / "clean-ready" + let removed = root / "clean-count" + var workers: seq[Process] = @[] + defer: cleanup(workers) + let lock = acquireFileLock(getLogFilePath() & ".lock") + try: + workers.add(worker(@["clean", marker, removed])) + waitReady(@[marker]) + sleep(100) + check workers[0].running + check readFile(getLogFilePath()) == initial + finally: + releaseFileLock(lock) + finish(workers) + check readFile(removed) == "2" + check readFile(getLogFilePath()) == "" + + test "contention times out and a terminated owner releases its lock": + isolatedState: + let path = root / "crash.lock" + let marker = root / "owner-ready" + var workers = @[worker(@["hold", marker, path])] + defer: cleanup(workers) + waitReady(@[marker]) + workers.add(worker(@["timeout", root / "unused", path])) + check workers[1].waitForExit(5_000) == 42 + workers[0].terminate() + discard workers[0].waitForExit(5_000) + let recovered = acquireFileLock(path, timeoutMs = 1_000) + releaseFileLock(recovered) + check fileExists(path) + + when defined(posix): + test "a lock cannot follow a symbolic link": + isolatedState: + let target = root / "unrelated" + let path = root / "symlink.lock" + writeFile(target, "unchanged") + createSymlink(target, path) + expect FileLockError: + discard acquireFileLock(path) + check readFile(target) == "unchanged"