Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
6b15e99
Add checked startup config file transactions
Guffawaffle Sep 11, 2026
7f09fe0
Reject macOS FIFO save targets without blocking
Guffawaffle Sep 11, 2026
44b1549
Add bounded snapshot save scheduling core
Guffawaffle Sep 11, 2026
06d7dcc
Add explicit snapshot worker lifecycle and native fixture CI
Guffawaffle Sep 11, 2026
136e3a5
Verify transaction permissions and canonical alias locking
Guffawaffle Sep 11, 2026
acf0891
Add explicit drain-accepted shutdown mode
Guffawaffle Sep 11, 2026
fb39259
Add bounded process-owned snapshot save service
Guffawaffle Sep 11, 2026
3822d45
Route Windows quit shortcut through the game lifecycle
Guffawaffle Sep 11, 2026
6a3abc1
Add lazy snapshot supervisor and native quit drain adapter
Guffawaffle Sep 11, 2026
f5c5e31
Serialize quit votes with host activation and resume consumption
Guffawaffle Sep 11, 2026
c586060
Preserve inherited Windows ACLs through private staging
Guffawaffle Sep 11, 2026
0b8238c
test: distinguish inherited ACL regression failures
Guffawaffle Sep 11, 2026
408c93e
fix: initialize private ACLs with modern inheritance control
Guffawaffle Sep 11, 2026
bc94fe4
test: capture native inheritance flags in ACL fixture
Guffawaffle Sep 11, 2026
6bfd08e
fix: reject ambiguous legacy inheritance before replacing files
Guffawaffle Sep 11, 2026
c4095c7
test: verify legacy refusal under platform temp defaults
Guffawaffle Sep 11, 2026
87008da
Restore Windows force-close quit shortcut
Guffawaffle Sep 12, 2026
fdc5350
Give force-close a 500ms native save cancellation grace period
Guffawaffle Sep 12, 2026
a1e84d6
Make force-close cancellation visible directly at queue selection
Guffawaffle Sep 12, 2026
a5ccdd8
Make cancellation contention fixture wait for joined workers
Guffawaffle Sep 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .github/workflows/persistence-tests.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
name: Persistence fixtures

on:
pull_request:
push:
branches: [main, dev]
workflow_dispatch:

permissions:
contents: read

jobs:
native-fixtures:
strategy:
fail-fast: false
matrix:
runner: [windows-2025-vs2026, macos-26, macos-26-intel]
runs-on: ${{ matrix.runner }}
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
- name: Windows fixtures
if: runner.os == 'Windows'
shell: pwsh
run: |
foreach ($fixture in @('file_transaction', 'snapshot_save_queue', 'snapshot_save_worker', 'snapshot_save_service', 'snapshot_save_host', 'force_close')) {
clang++ -std=c++23 -Wall -Wextra -Werror -I mods/src "tests/${fixture}_test.cc" -o "${fixture}_test.exe" -ladvapi32
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
& "./${fixture}_test.exe"
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
}
- name: macOS fixtures
if: runner.os == 'macOS'
shell: bash
env:
MACOSX_DEPLOYMENT_TARGET: '13.5'
run: |
for fixture in file_transaction snapshot_save_queue snapshot_save_worker snapshot_save_service snapshot_save_host force_close; do
clang++ -std=c++23 -Wall -Wextra -Werror -pthread -I mods/src "tests/${fixture}_test.cc" -o "${fixture}_test"
"./${fixture}_test"
done
99 changes: 99 additions & 0 deletions docs/CONFIG_FILE_TRANSACTIONS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# Checked startup config output

`file_transaction::Write` is a synchronous local-file primitive. It is used by
Config's private `SaveStartup` for its two existing startup outputs. It is not a
runtime settings API or a substitute for a future asynchronous coordinator.

Initial user-config creation uses CreateOnly. A concurrent creator wins without
being overwritten. Generated runtime-vars output uses ReplaceSnapshot. TOML is
serialized and parsed before file staging; the existing warning header and Windows
text-mode line endings are retained. Save failure is reported without aborting
initialization. Existing user-config documents are not rewritten by this change.

Transactions resolve supported symlink targets, reject hard-linked/non-regular
targets, attempt a cooperative sibling `.lock` once, and stage at most4 MiB in an
exclusively created same-directory transaction folder. Lock files remain in place;
their lifetime is separate from OS lock ownership. Staging names are bounded and
exclusive, not secure by secrecy. Windows transaction folders restrict access to
owner/administrators/SYSTEM; macOS creates them with mode0700. Unexpected staging
collisions are never opened or truncated.

Writes, flush and close are checked. Windows existing-file replacement uses
ReplaceFileW with an owned backup and without ignore-ACL-error flags; new files
use a move without replacement. macOS preserves metadata using fcopyfile and uses
same-filesystem rename, with exclusive rename for creation and a parent-directory
fsync after commit. Platform documentation is not a power-loss test.

Results distinguish NotCommitted, Conflict, Busy, Committed,
DurabilityUnverified and RecoveryRequired. Committed means the supported local
operation completed, not guaranteed survival of every hardware/power failure.
Windows's unsupported ReplaceFile write-through flag is not used. A post-commit
directory-flush error does not pretend the write failed before commit.

Documented Windows partial replacement failures retain the transaction folder and
its old/new files for recovery. Cleanup touches only owned filenames, never sweeps
a directory, and records cleanup errors. There is no automatic startup recovery or
rollback into a destination another writer may have changed. An interruption may
leave a private staging folder; recovery/retention coordination is required before
this primitive supports live user-document edits.

## Boundaries

- Callers supply trusted paths already routed by File::MakePath. This is not an
arbitrary-path sandbox or a destination registry. Windows UNC/device/alternate
stream destinations are rejected in this first local-filesystem implementation.
- Canonicalization and no-follow checks are useful validation, not exclusion of an
adversary swapping parent directories or links. Uncooperative same-permission
writers remain outside the guarantee. Only participants using the same lock are
serialized; canonical alias/case behavior still needs platform fixtures.
- No conflict-aware edits, schema merge, bounded runtime queue, UI completion,
shutdown coordinator, cloud persistence or log-writer replacement is introduced.
- Mod-state PR267 retains its existing API/implementation. Adoption of these shared
primitives is a separate explicit migration after platform validation.

## Tests

Run the isolated standalone fixture (never against a real game config):

```powershell
clang++ -std=c++23 -Wall -Wextra -Werror -I mods/src tests/file_transaction_test.cc -o file_transaction_test.exe -ladvapi32
./file_transaction_test.exe
```

On macOS omit `-ladvapi32` and use the native compiler. The fixture covers create
races, replacement, partial staged output, injected pre-commit failure boundaries,
post-commit uncertainty, size/path rejection and hard links. Windows additionally
tests held locks/targets and emulates the documented1177 partial-replacement
postcondition, checking that the old copy survives. This is not a naturally
triggered OS1177 or power-loss test. Symlink creation explicitly reports a skip
when unavailable. Production builds contain no fault-injection interface.
On macOS, FIFO destination and lock fixtures run in child processes with a
three-second deadline, verifying rejection without blocking on a FIFO peer.

Native fixture CI runs Windows and macOS ARM/Intel. Fixtures check preservation of
a restrictive Windows DACL and macOS mode/owner/group/extended attribute, plus
contention through a symlink while the canonical lock is held. These are specific
cases, not exhaustive ACL or filesystem coverage. Interrupted-process recovery
and storage behavior under real faults still require follow-up evidence. Runtime
latency/queue tests belong to the asynchronous phase.
# Windows inherited-permission correction

Windows `ReplaceFileW` can rebuild inherited entries against the replacement's
parent directory. A private staging container with no inheritable entries caused
an ordinary inherited-only target ACL to become empty after a successful replace.
The writer now gives that container inherit-only copies of the target's inherited
file allow/deny entries. These entries grant no access to the container, and the
payload retains its protected private ACL until replacement. The resulting target
retains the original explicit/inherited permission policy, including inherited denies.

Unprotected legacy descriptors without native `SE_DACL_AUTO_INHERITED` are also
rejected before staging. `GetSecurityInfo` can synthesize inherited entries for
such a descriptor without updating the file; Windows client and server replacement
behavior differs for this case. The writer checks the native descriptor using
`GetKernelObjectSecurity` and does not migrate the original's permissions.

Unknown inherited ACE forms/propagation flags, oversized ACLs and null target DACLs
are rejected before staging; the writer does not guess their inheritance behavior.
The Windows regression fixture creates an external file with inherited allow/deny
permissions and verifies two consecutive replacements preserve both content access
and the ordered ACL. Existing protected explicit-ACL coverage remains in place.
Loading
Loading