Skip to content

fix(sessions): replace append(true) with write(true)+seek to fix Windows file lock (LockFileEx os error 5)#436

Merged
penso merged 7 commits intomoltis-org:mainfrom
vanduc2514:fix/sessions-windows-append-lock
Mar 23, 2026
Merged

fix(sessions): replace append(true) with write(true)+seek to fix Windows file lock (LockFileEx os error 5)#436
penso merged 7 commits intomoltis-org:mainfrom
vanduc2514:fix/sessions-windows-append-lock

Conversation

@vanduc2514
Copy link
Copy Markdown
Contributor

@vanduc2514 vanduc2514 commented Mar 14, 2026

Summary

Fixes #434.

Root Cause

On Windows, OpenOptions::append(true) opens the file with FILE_APPEND_DATA access — a restricted subset of GENERIC_WRITE that explicitly strips FILE_WRITE_DATA. This is intentional in the Rust stdlib (source):

// stdlib — get_access_mode() for append(true)
(false, _, true, None) => Ok(c::FILE_GENERIC_WRITE & !c::FILE_WRITE_DATA),

However, the Windows LockFileEx API requires:

The handle must have been created with either the GENERIC_READ or GENERIC_WRITE access right.

FILE_APPEND_DATA alone satisfies neither requirement, so LockFileEx returns ERROR_ACCESS_DENIED (os error 5) — even with no other process holding the file.

This is a known issue in the Rust ecosystem: rust-lang/rust#54118, closed as "by design" — the fix is the caller's responsibility.

Fix

In SessionStore::append, replace:

OpenOptions::new().create(true).append(true).open(&path)?

with:

OpenOptions::new()
    .create(true)
    .write(true)
    .truncate(false)
    .open(&path)?
// then under the exclusive lock:
(*guard).seek(SeekFrom::End(0))?;
writeln!(*guard, "{line}")?;

write(true) maps to GENERIC_WRITE, which satisfies LockFileEx. Append semantics are preserved by seeking to EOF while holding the exclusive lockfd_lock::RwLock::write() calls LockFileEx without LOCKFILE_FAIL_IMMEDIATELY, so it is a blocking call that serialises all writers at the OS level. No two callers can race between the seek and the write.

.truncate(false) is added explicitly to silence the suspicious_open_options Clippy lint (which fires when write(true).create(true) appears without a declared truncation intent).

Cross-platform considerations

Platform LockFileEx / flock access requirement Effect of this change
Windows LockFileEx requires GENERIC_READ or GENERIC_WRITE Fixes the ERROR_ACCESS_DENIED (os error 5)
Linux / macOS flock(LOCK_EX) has no access-right requirement — works on any fd The seek is a no-op cost; behaviour is identical

The two existing replace_history methods already use .write(true).truncate(true) correctly and are unaffected. save_config and the log/network-filter appenders use a Rust std::sync::Mutex for in-process serialisation and never call fd_lock, so they are also unaffected.

Changes

  • crates/sessions/src/store.rs — fix append() open mode + seek under lock

Validation

Completed

  • cargo test -p moltis-sessions -- store:: — 33 tests pass on macOS
  • No secrets or private tokens
  • Rust fmt check: cargo +nightly-2025-11-30 fmt --all -- --check
  • Clippy (sessions crate): cargo +nightly-2025-11-30 clippy -Z unstable-options -p moltis-sessions --all-targets -- -D warnings — clean

Remaining

  • just release-preflight — currently blocked by a pre-existing cmake-not-installed failure in llama-cpp-sys-2 on this host, unrelated to this change
  • Manual verification on a Windows host

Manual QA

  1. Run Moltis on Windows (pre-built binary or cargo run)
  2. Start a chat session — send a message and wait for the assistant response
  3. Verify no file lock failed: Access is denied. (os error 5) appears in the log
  4. Verify the session is persisted and readable after restart

Copilot AI review requested due to automatic review settings March 14, 2026 19:02
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes a Windows-specific bug where OpenOptions::append(true) opens files with FILE_APPEND_DATA access, which doesn't satisfy LockFileEx's requirement for GENERIC_WRITE, causing "Access is denied" (os error 5). The fix uses write(true) + seek(SeekFrom::End(0)) under an exclusive lock.

Changes:

  • Replace .append(true) with .write(true) and add a seek(SeekFrom::End(0)) call under the exclusive file lock to preserve append semantics
  • Add Windows-specific regression test

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 14, 2026

Greptile Summary

This PR fixes a Windows-only file-lock failure (ERROR_ACCESS_DENIED, os error 5) in SessionStore::append by replacing OpenOptions::append(true) with write(true) followed by an explicit seek(SeekFrom::End(0)) under the acquired exclusive lock.

Key changes in crates/sessions/src/store.rs:

  • OpenOptions::append(true) is replaced with OpenOptions::write(true)write(true) maps to GENERIC_WRITE on Windows, which satisfies the LockFileEx access-right requirement, whereas FILE_APPEND_DATA (used by append(true)) does not.
  • Seek and SeekFrom are added to the std::io import list.
  • (*guard).seek(SeekFrom::End(0))? is called immediately after acquiring the exclusive write lock, before the writeln!, preserving correct append semantics while holding the lock that serialises all writers.
  • The PR description claims a #[cfg(target_os = "windows")] regression test was added, but no such test appears in the diff or the file. All existing tests were validated on macOS only; the Windows-specific code path has no automated coverage.

Confidence Score: 4/5

  • The fix is logically correct and safe to merge on all platforms, but the promised Windows regression test is absent.
  • The seek-to-EOF-under-exclusive-lock approach correctly replicates O_APPEND semantics and the root cause analysis (FILE_APPEND_DATA vs GENERIC_WRITE and LockFileEx) is accurate and well-documented. The change is minimal and isolated to SessionStore::append. Score is 4 rather than 5 only because: (1) the Windows regression test described in the PR is missing, leaving the specific bug path without automated coverage, and (2) the "Remaining" checklist items (clippy, preflight, manual Windows verification) are explicitly open.
  • crates/sessions/src/store.rs — verify the missing Windows regression test is either added or the PR description is corrected

Important Files Changed

Filename Overview
crates/sessions/src/store.rs Replaces append(true) with write(true) + seek(SeekFrom::End(0)) under the exclusive lock in SessionStore::append to fix LockFileEx os error 5 on Windows. The fix is logically sound — append semantics are preserved by seeking to EOF while holding the exclusive lock, which serialises all writers at the OS level. However, the Windows-specific regression test (test_append_does_not_fail_with_access_denied_on_windows) that the PR description claims was added is absent from the diff and the file.

Sequence Diagram

sequenceDiagram
    participant Caller
    participant AppendFn as SessionStore::append
    participant OS as OS File API

    Caller->>AppendFn: append(key, message)
    AppendFn->>OS: OpenOptions write(true) create(true) open(path)
    note over OS: Windows: GENERIC_WRITE satisfies LockFileEx
    OS-->>AppendFn: File handle returned
    AppendFn->>OS: fd_lock RwLock write() — blocking exclusive lock
    OS-->>AppendFn: Exclusive lock acquired
    AppendFn->>OS: seek(SeekFrom::End(0)) — position cursor at EOF
    OS-->>AppendFn: cursor at end of file
    AppendFn->>OS: writeln — write line at EOF
    OS-->>AppendFn: write complete
    AppendFn->>OS: drop guard — release exclusive lock
    AppendFn-->>Caller: Ok(())
Loading

Comments Outside Diff (1)

  1. crates/sessions/src/store.rs, line 431 (link)

    Claimed regression test is missing

    The PR description explicitly states:

    Added #[cfg(target_os = "windows")] regression test test_append_does_not_fail_with_access_denied_on_windows

    However, no such test appears anywhere in the diff or in the file's test module (lines 431–871). The existing tests (test_append_and_read, test_read_last_n, etc.) were not modified and none of them is the promised Windows-only regression test.

    Without this test, the only validation evidence is the macOS cargo test run listed in the PR ("33 tests pass on macOS"), which cannot exercise the LockFileEx code path being fixed. A #[cfg(target_os = "windows")] test that calls append() multiple times concurrently and asserts no os error 5 would catch any future regression on Windows CI.

    Please add the test before merging, or update the PR description to accurately reflect that manual Windows QA is the only verification.

Last reviewed commit: 00dc258

@vanduc2514 vanduc2514 force-pushed the fix/sessions-windows-append-lock branch from 00dc258 to 3cf2565 Compare March 14, 2026 19:14
@vanduc2514
Copy link
Copy Markdown
Contributor Author

Removed Test from PR description due to duplication and there should be no host-specific test for this fix

…ck on Windows

On Windows, OpenOptions::append(true) opens the file with FILE_APPEND_DATA
access — a restricted subset of GENERIC_WRITE that LockFileEx rejects with
'Access is denied' (os error 5). Fixes moltis-org#434.

Entire-Checkpoint: d5b5fb8650fe
@vanduc2514 vanduc2514 force-pushed the fix/sessions-windows-append-lock branch from 8ea959c to 4570b55 Compare March 19, 2026 18:36
@penso
Copy link
Copy Markdown
Collaborator

penso commented Mar 23, 2026

Thanks for the detailed write-up on the Windows LockFileEx root cause — the store.rs fix is solid and well-explained.

I do have concerns about the CI workflow changes bundled in here:

1. always() + skipped pattern is fragile

Every build job now uses:

if: >-
  always() &&
  (needs.clippy.result == 'success' || needs.clippy.result == 'skipped') &&
  ...

always() means build jobs will run even if upstream jobs fail — the conditions try to guard against that, but if a new needs: dependency is added later and someone forgets to update the if: condition, builds could run after a failure. The safer pattern here is !cancelled() instead of always().

2. if-no-files-found: ignore on artifact uploads

Every upload-artifact step now silently succeeds when no files are produced. In a real release, a build that produces zero artifacts would go unnoticed. Previously the upload would fail loudly — that's a useful safety net to keep.

3. These CI changes haven't been validated

No checks ran on this branch, so the release workflow changes themselves haven't been tested by an actual workflow dispatch.

4. Scope

The session fix (7 lines in store.rs) and the CI dry-run overhaul (60+ lines in release.yml) are independent concerns. Mixing them means we can't revert one without the other.

Suggestion: Could you split this into two PRs? I'd love to merge the store.rs fix right away — it's clean and ready. The CI dry-run changes can be reviewed and validated separately (ideally tested with an actual workflow_dispatch dry-run).

@vanduc2514
Copy link
Copy Markdown
Contributor Author

Hi @penso , thank you for your time to review and leave a feedback to my PR. The whole idea of changing the release action because I need a way to build windows artifact for verifying from my end. I modified the action to build only if dry-run is enabled. But you are right, the release action change should be decoupled from the fix. And there should be a separate action focusing on building the artifact only.

Entire-Checkpoint: 9807c7643bbc
@penso penso merged commit e52715b into moltis-org:main Mar 23, 2026
@vanduc2514 vanduc2514 deleted the fix/sessions-windows-append-lock branch March 23, 2026 15:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: 'file lock failed: Access is denied' when persisting session on Windows

3 participants