Skip to content

fix(backup): flush state backup archives through writable handles on Windows - #6258

Merged
huangruiteng merged 3 commits into
loopx-project:mainfrom
JasonBuildAI:codex/backup-state-windows-fsync-q6t3
Oct 11, 2026
Merged

huangruiteng merged 3 commits into
loopx-project:mainfrom
JasonBuildAI:codex/backup-state-windows-fsync-q6t3

Conversation

@JasonBuildAI

@JasonBuildAI JasonBuildAI commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Goal And Delivered Outcome

  • Outcome basis / optional anchor: [Bug]: native Windows: loopx backup-state --execute always fails because both durability fsync calls use read-only file handles #6252 (reproduced defect on native Windows).

  • Goal/source and gap: on native Windows, loopx backup-state --execute - the state-protection command the bootstrap command pack tells the operator to run before a destructive reconnect - cannot complete. The backup path performs two durability flushes and both open the target read-only. snapshotSqliteDatabase flushes the freshly written copy through open(destination, "r") + handle.sync(); Windows rejects that with EPERM -4048 fsync, so any state tree containing an SQLite file aborts. execute_state_backup_plan then reopens the staged archive and manifest with path.open("rb") before os.fsync; Windows rejects that with EBADF Errno 9, so even a backup without SQLite fails. The operator sees only Effect runtime filesystem operation failed or [Errno 9] Bad file descriptor, and the recommended backup never happens.

  • Observable before → after, with the validation row that proves it: the same synthetic-state CLI invocation exits 1 with "error": "Effect runtime filesystem operation failed" (SQLite in scope) and 1 with "error": "[Errno 9] Bad file descriptor" (no SQLite) before, and exits 0 with "ok": true, a written archive/manifest and a recorded sqlite_snapshots entry after (real_entrypoint row). The repository's own suites move from failing to passing on Windows: the SQLite snapshot case in tests/control_plane_ts/sqlite_backup.test.ts and 28 backup-related Python tests (regression_parity row).

  • Issue/task and intended base: Closes [Bug]: native Windows: loopx backup-state --execute always fails because both durability fsync calls use read-only file handles #6252; base main.

Author Declaration

  • Written by: an AI coding agent (Codex, OpenAI), at the human operator's direction and under their review. AI disclosure: this change was drafted with AI assistance at the operator's request, under their direction and review.

Implemented against

Criterion (spec clause) Disposition Symbol / path Test or command
A state backup with SQLite files in scope completes on native Windows and records the snapshot implemented snapshotSqliteDatabase (loopx/control_plane/coordination/sqlite_backup.ts) tests/control_plane_ts/sqlite_backup.test.ts, first case
A state backup without SQLite files completes on native Windows implemented execute_state_backup_plan (loopx/state_backup.py) tests/test_state_backup.py junction and real-CLI cases
The durability flush is still performed on every platform rather than skipped on Windows implemented open(destination, "r+"), path.open("rb+") host probes: r/rb deny the flush on Windows, r+/rb+ perform it
No POSIX behavior change implemented same two lines head TypeScript control-plane failure set is a strict subset of the base set
  • Self-check before submission: ran ruff on the changed Python file, npm run typecheck:control-plane, the focused pytest files, the full TypeScript control-plane suite with a base/head failure-set comparison, loopx check public/private boundary over loopx/ tests/ examples/ docs/, and loopx canary premerge --from-git-diff. Deliberately left out: the unrelated host limitations named under Validation, and a wider sweep for the same read-only flush pattern outside the backup path (the explicit open() audit found no other file-level read-only flush; descriptor-created flushes, mostly directory flushes, were not reclassified), and an independent review probe for the "r+" open on a non-writable destination (dismissed: the preceding driver.backup write already requires a writable destination, so that state is unreachable here).

Scope And Continuation

  • Completed scope and remaining work: complete within this scope. Both flush sites in the backup-state journey now use an update handle; the bytes written and the durability guarantee are unchanged.
  • Slice boundary / successor: complete within this scope; no successor needed. Bounded future-facing refactoring considered: sharing one "flush this file" helper across the two sites would need a cross-language seam between the TypeScript coordination owner and the Python archive owner for two one-line call sites; not applied.

Validation

  • Tested revision: 327c0a7
  • Run state: finished
  • Input classes: synthetic
Check kind Result Public-safe evidence / limitation
static passed ruff check loopx/state_backup.py tests/test_state_backup.py: All checks passed. npm run typecheck:control-plane: clean. git diff --check: clean.
unit passed TS: node --test --experimental-sqlite --experimental-strip-types tests/control_plane_ts/sqlite_backup.test.ts - the SQLite snapshot and invalid-request cases pass on native Windows. Python: tests/test_state_backup.py 5 passed, 1 failed for the pre-existing host limit below.
regression_parity passed Base aedb376db vs head on the same native-Windows host. Full TS control-plane suite (4453 tests): base 69 failed / 4324 passed, head 66 failed / 4327 passed, and no failing name appears on the head that was absent on the base. Backup-related Python files (same set both sides): base 63 failed / 25 passed / 2 skipped, head 35 failed / 53 passed / 2 skipped, again with no new failing name. Reverting only the two product lines restores the original EPERM/EBADF failures.
real_entrypoint passed Native Windows with a qualified Node.js, synthetic runtime root: python -m loopx.cli --format json --runtime-root <runtime> backup-state --project <project> --output-dir <backups> --no-automations --no-skills --current-project-only --execute exits 1 with "error": "Effect runtime filesystem operation failed" (SQLite in scope) and 1 with "error": "[Errno 9] Bad file descriptor" (no SQLite) on the base, and exits 0 with "ok": true, a written archive and manifest, and one recorded sqlite_snapshots entry on the head.
manual failed python -m mypy: one error, loopx/control_plane/effect_runtime.py:432: Module has no attribute "WNOHANG", reproduced identically with the change stashed. Pre-existing Windows-host limitation, not a diff effect.
manual failed loopx canary premerge --from-git-diff: diff hygiene, changed-file compile and public/private boundary checks passed; 12 of 19 selected checks passed and 7 failed. Each failing smoke was re-run with the change stashed and failed identically (Windows symlink privilege, missing bash, smoke-local Windows path/newline assumptions), i.e. host environment, not a diff effect.
  • Coverage and gaps: both changed lines are exercised by the suites above on a native Windows host, and by Linux CI, where they passed before and after because POSIX permits a flush through a read-only handle. The Windows-only failure cannot be reproduced by Linux CI, and the windows-powershell lane does not run either affected suite, which is how this shipped. The flush open is only reached for a destination the process can write, because driver.backup must open that database for update first: a probe that makes the destination read-only fails with attempt to write a readonly database before the flush, so the writable-handle change adds no new failure mode for a narrowing POSIX umask. Untested here: a Windows host with symlink privilege, where the second TS case would also run; on this host it stops at symlink before reaching backup code, identically with the change stashed.

Frontend / Visual Evidence

  • UI impact: none
  • Before: N/A
  • After: N/A
  • States and viewports shown: N/A
  • Source data: none
  • Attention review: N/A - no user-visible LoopX surface changed; the affected surface is CLI JSON output on Windows.
  • Craft review: N/A - no visual surface.

Type of Change

  • Bug fix

LoopX Area

  • Control plane (goals, todos, quota, scheduler, registry, runtime)

Technical Direction

  • Direction / acceptance reference, when applicable: N/A for this bounded Windows durability repair; no RFC obligation is claimed.

Shared-authority RFC fixture impact

  • Production-scale fixture schema: N/A
  • Semantic dimensions changed, or reviewed no-impact rationale: N/A - no Enum/Literal/closed-set vocabulary or serialization contract changed, so the diff-scoped semantic inventory advisory is not applicable.
  • Provider conformance arms run: N/A
  • Read-only legacy/file/PostgreSQL three-arm rehearsal (required for promotion, runtime-routing, or compatibility-projection changes): N/A

Boundary Checklist

  • Neither the diff nor this PR body/comments/attachments disclose private state, credentials, raw traces or verifier output, internal links, or local machine paths (including .loopx/, .codex/goals/, and live ACTIVE_GOAL_STATE.md).
  • I did not duplicate maintainer-owned benchmark work unless a maintainer split out a public issue for it.
  • I kept the change scoped to the linked issue/task.
  • I completed the visual evidence section for UI changes, or marked UI impact none.
  • Every commit includes a DCO Signed-off-by trailer (git commit -s).

…Windows

On native Windows `loopx backup-state --execute` cannot complete. The SQLite
snapshot owner flushes the freshly written copy through a read-only handle
(`open(destination, "r")` + `sync()`), and Windows flushes require write
access, so every state tree that contains a SQLite file aborts with
`EPERM -4048 fsync`. Without SQLite in scope the archive owner then reopens
the staged archive and manifest read-only (`path.open("rb")`) before
`os.fsync`, which Windows rejects with `EBADF Errno 9`. The user sees only
"Effect runtime filesystem operation failed" or "[Errno 9] Bad file
descriptor", and the recommended pre-reconnect state backup never happens.

Both flushes are deliberate durability steps, so keep them and open the
handles for update (`"r+"` / `"rb+"`) on every platform instead of dropping
the flush on Windows. The SQLite snapshot regression case also asserted the
POSIX owner permission bit, which Windows stat reports as 0o666 for every
writable file, so that line is skipped where the host has no such mode.

Signed-off-by: JasonBuildAI <jasonbuildai@gmail.com>
JasonBuildAI added a commit to JasonBuildAI/loopx that referenced this pull request Oct 11, 2026
`test_duplicate_targets_reuse_snapshot_and_preserve_ordinary_files` asserts
the staged archive and manifest report mode 0o600, which Windows stat cannot
express: it reports 0o666 for every writable file. The case only reaches
that line on Windows after the durability flush fix, so a host with symlink
privilege would have failed there instead of at the pre-existing symlink
setup. Guard the assertion the same way as its TypeScript counterpart.

Signed-off-by: JasonBuildAI <jasonbuildai@gmail.com>
@JasonBuildAI

Copy link
Copy Markdown
Contributor Author

Independent review disposition (reviewing agent, on head 1655799ac; current head 327c0a75):

  • POSIX owner-mode assertion in tests/test_state_backup.py - the duplicate-target case asserted 0o600 on the staged archive and manifest, which Windows stat cannot express (it reports 0o666 for every writable file). Fixed in 327c0a75, guarded the same way as the TypeScript counterpart.
  • "r+" on a umask-narrowed destination - investigated and dismissed with a probe: the flush is only reached after driver.backup opens the destination database for update, and that write fails first on a non-writable destination (attempt to write a readonly database), so the previous "r" handle could not have completed the snapshot in that state either. No change made; the probe note is in the PR body.
  • Reviewed conclusions on the rest: value (Windows users cannot run the documented pre-reconnect backup-state), single-problem scope, and the fail-before/pass-after behavior of both suites were independently reproduced on a native Windows host. Static checks confirmed run: ruff, TypeScript typecheck, git diff --check; the mypy WNOHANG error is a pre-existing Windows-host limitation also present on the base.

AI disclosure: this review and this comment were produced with AI assistance at the operator's request, under their direction and review.

@loopx-agent loopx-agent left a comment •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewer: model_agent; gpt-6.1-sol; OpenAI; runtime_reported; reasoning_effort=xhigh

APPROVE. 精确 head 327c0a756510b9140f695a19eb310327b7ded436。

动机

在 Windows 上执行本地状态备份、需要取得可验证备份文件的维护者。

用户执行 backup-state 时,旧版用只读文件句柄请求强制落盘;Windows 会返回 EPERM 或 EBADF,SQLite 快照、压缩归档或清单不能完成。修复改用可写句柄后,仍先校验与落盘再发布备份,用户能取得可回读的归档和摘要清单。

真实 CLI 在含 SQLite 和仅普通文件的布局中生成归档与匹配清单,恢复数据库内容及文件摘要可读。Windows 可写句柄约束模型区分 base 的失败与 head/wheel 的成功;dry-run、损坏 SQLite 和暂存落盘失败均不发布备份。

本批修复既有本地备份的跨平台落盘方式;不自动恢复或激活备份、不扩大读取范围或权限,也不宣称本评审运行过原生 Windows、ACL 安全资格或 PostgreSQL 存储迁移。

改动思路

保持两个既有 IO owner 的校验与 fsync 顺序,只把句柄改为 r+/rb+,即可满足 Windows 的可写要求;删掉落盘或吞掉异常会削弱持久性。SQLite 快照仍由共享 TS owner 提供,Python 仅负责归档字节,不新增平行存储决策源。

当前 PR 一起修复 SQLite 快照、归档、清单三处落盘及两份测试的 Windows 权限断言;不改备份格式、发现范围、恢复权限或存储选型。

具体改动

spec_ref:https://github.com/loopx-project/loopx/issues/6252;spec_revision:aedb376db32a2d8a2e93686ee3800715463654e0。Issue6252 创建/更新于2026-10-11T17:01:52Z;同时对照该 merge base 的 configuration-backup 合同。本轮对照的 main commit 为 c51ec1a,相关合同未变化;不是把它误当作此分支的祖先。

  • writable-durable-snapshot:implemented;The existing online SQLite snapshot passes integrity check and flushes through a writable handle before use.
  • verified-archive-manifest:implemented;Backups with and without SQLite flush archive/manifest and expose readback hashes for the copied bytes.
  • failure-publication-boundary:implemented;Dry-run has no archive effects; corrupt snapshots or staged durability failure do not publish partial output; existing backup preservation remains.
  • platform-permission-contract:implemented;POSIX private output mode remains 0600; Windows does not assert POSIX mode bits as an ACL guarantee.

关键代码讲解

  • snapshotSqliteDatabase 将已预留快照文件的 open("r") 改为 open("r+");在线 backup、完整性校验、sync、finally close 与失败删除都保留。共享的 authority-format-upgrade 调用也直接复用它。
  • execute_state_backup_plan 将已设私有权限的暂存归档与清单由 path.open("rb") 改为 path.open("rb+");os.fsync 成功后才到原来的 os.replace 发布,不用重试或静默降级隐藏失败。
  • 两份测试仅在非 Windows 时断言 0600;文件摘要、成员内容、数据库完整性、覆盖保护及回读断言仍执行。Windows stat mode 不是 POSIX 权限承诺,这不是扩大读取或发布范围。
  • 使用真实 Node SQLite owner 与真实 CLI 分别验收两层落盘,再通过正常前端资产构建、wheel 打包与隔离安装验证组合入口;未替换数据库或 fsync 为成功 mock。

对主干的风险

19 项真实备份/成员完整性/来源保留 pytest、14 项实际 Node SQLite 快照与 authority-format-upgrade 测试、control-plane typecheck、公共 state-backup smoke、Ruff、diff、语义与 IO manifest 检查通过。冻结 CLI oracle:base 5 通过、2 个 Windows Python 可写句柄用例失败;head 与 wheel 各 7/7 通过。实际 Node 可写约束探针 base 2 通过/1 失败,head 3/3 通过。

本机不是 Windows。fcntl 实际句柄访问模式与真实 Node FileHandle.sync 的约束模型验证可写条件,但不等于原生 Windows、junction、ACL、进程池或崩溃耐久资格。作者提供的 Windows 整套测试红项与 focal 结果没有由本评审独立运行;本次未查询 CI,也不把评审通过当作合并就绪。未触及 PostgreSQL provider。

正常构建前端 chat assets 后打包,安装到隔离环境,在 checkout 外用 python -I 验证模块来源和实际调用;未绕过包装要求。语义 advisory 未检测到支持的新词汇载体;完整语义 smoke 通过但45个 unresolved producer sites仍保留,不能宣称全树语义已证明。IO manifest291站点检查通过。初始探针错误把 config 同步也计成两个暂存 fsync,且把 Node preload 注入 IPC 启动造成两版本探针故障;日志保留。纠正探针后,真实 CLI 的最终暂存句柄、故障发布保护及独立 Node owner 约束仍完整验收,不宣称旧 IPC 探针被产品修好。 作者声明与本评审独立证据分开,CI未查询、轮询或等待。

我的整体评价

未发现这一有界修复的阻塞问题。保持两个既有 IO owner 的校验与 fsync 顺序,只把句柄改为 r+/rb+,即可满足 Windows 的可写要求;删掉落盘或吞掉异常会削弱持久性。SQLite 快照仍由共享 TS owner 提供,Python 仅负责归档字节,不新增平行存储决策源。 当前 PR 一起修复 SQLite 快照、归档、清单三处落盘及两份测试的 Windows 权限断言;不改备份格式、发现范围、恢复权限或存储选型。 未来维护整理已检查:两个当前消费者继续复用同一 SQLite owner,无需为两个打开模式增加通用 helper。 用户的现有操作路径没有增加输入、确认或授权步骤,失败与下一次工作仍能诚实回读。阶段恢复经验仅作提示,不继承历史 approval,不宣布母路线图完成、生产采用或合并就绪。

English verdict: APPROVE - exact head 327c0a7. Writable flush handles repair the reproduced durability precondition without bypassing sync, integrity or staged publication; native Windows not independently run.

@loopx-agent

loopx-agent commented Oct 11, 2026 •

Copy link
Copy Markdown
Collaborator

Reviewer: model_agent; gpt-6.1-sol; OpenAI; runtime_reported; reasoning_effort=xhigh

English verdict: APPROVE - exact head 327c0a7. Writable flush handles repair the reproduced durability precondition without bypassing sync, integrity or staged publication; native Windows not independently run.

Motivation

A Windows maintainer runs backup-state and expects a readable archive plus hash manifest. Readonly handles previously caused EPERM in the Node SQLite snapshot sync and EBADF in Python archive/manifest fsync. The candidate uses writable handles while retaining verification and forced durability before publication. Actual CLI stages a byte-witness archive with/without real SQLite, reads SHA-256 and file-member byte counts, extracts and reopens a database with fixed expected rows. Source and isolated wheel pass; actual shared SQLite format-upgrade caller tests pass. The scope is this existing bounded repair; it does not certify parent roadmap completion, production adoption or new authority.

Approach and ownership

The existing TS online SQLite owner remains shared by state backup and authority-format upgrade. Python retains the specialized private archive publisher. Change r to r+ and rb to rb+, rather than dropping fsync, swallowing errors, introducing platform fallback or duplicating storage policy. All three flush points and both platform mode assertions are delivered together. The current PR boundary is the SQLite snapshot, archive and manifest writable flushes plus correct platform assertions; no format, discovery, restore or provider-selection changes.

Specification and code walkthrough

spec_ref: https://github.com/loopx-project/loopx/issues/6252; spec_revision: aedb376db32a2d8a2e93686ee3800715463654e0. Issue6252 was created/updated at2026-10-11T17:01:52Z; the accepted configuration-backup contract is unchanged between this immutable merge base and the independently inspected main commit c51ec1a.

  • writable-durable-snapshot: implemented; The existing online SQLite snapshot passes integrity check and flushes through a writable handle before use.
  • verified-archive-manifest: implemented; Backups with and without SQLite flush archive/manifest and expose readback hashes for the copied bytes.
  • failure-publication-boundary: implemented; Dry-run has no archive effects; corrupt snapshots or staged durability failure do not publish partial output; existing backup preservation remains.
  • platform-permission-contract: implemented; POSIX private output mode remains 0600; Windows does not assert POSIX mode bits as an ACL guarantee.

The SQLite path still reserves a private target, performs online backup, removes journal leftovers, checks integrity, syncs and closes; failure removes its created output. The archive publisher still copies actual bytes, records member witnesses, chmods private staging, fsyncs both files and then performs the existing replacements. Windows tests stop asserting POSIX mode bits; POSIX0600 and all byte/database/readback assertions remain. The shared format-upgrade consumer is unchanged and tested with actual SQLite.

Positive walkthrough: Actual CLI stages a byte-witness archive with/without real SQLite, reads SHA-256 and file-member byte counts, extracts and reopens a database with fixed expected rows. Source and isolated wheel pass; actual shared SQLite format-upgrade caller tests pass.

Negative walkthrough: Dry-run creates no output directory; corrupt SQLite refuses publication; deliberately fail the staged archive fsync after config synchronization and assert neither archive nor manifest publishes. Existing pytest also preserves a prior backup on corrupt source failure.

Validation and risks to main

19 actual backup/member-integrity/retention pytest cases,14 real Node SQLite/format-upgrade tests,control-plane typecheck,the public backup smoke,Ruff,diff checks,semantic smoke and291-site IO manifest passed. Frozen actual CLI cases cover ordinary bytes/SQLite, hashes and restored rows,dry-run,corrupt DB and staged-fsync publication failure. Immutable base passes5 controls and fails2 simulated Python writable-handle cases; exact head and separately installed wheel each pass7/7. A real FileHandle access-mode guard with actual sync call-through independently yields base2 pass/1 readonlyEPERM and head3/3. Normal chat asset build and ordinary wheel packaging preceded isolated installation. Outside-checkout python -I module provenance was verified; runtime/database/filesystem paths were not success mocks. The semantic advisory found no supported new vocabulary carrier, but45 unresolved producer sites remain after the full passing semantic smoke.

The reviewer did not run native Windows. The handle constraint model is narrower than Windows ACL,junction,crash durability or process-pool qualification. Author-provided native-Windows suite failures/claims are not independent reviewer evidence; CI was not consulted and merge readiness is separate. No PostgreSQL provider changes. Initial probe failures are preserved: its expected descriptor count overlooked an earlier config sync, and Node preload IPC startup did not produce a verifiable response on either base/head. The corrected immutable probe retains final staged handle and failure-publication assertions; direct Node-owner tests plus ordinary real CLI/wheel qualify the changed composition without claiming that old IPC failure was fixed.

Overall assessment

APPROVE for the exact head, with no identified blocker in this bounded slice. Sustained recovery/next-operation and truthful failure/readback are preserved without extra user input or approval steps. The future-facing pass found the existing shared SQLite owner and private staged publisher sufficient; no speculative helper or protocol is needed. Current repository experience was advisory and independently checked, not inherited proof. This approval is not a native-Windows qualification or permission to merge.

@huangruiteng
huangruiteng merged commit 54dd04d into loopx-project:main Oct 11, 2026
0 of 5 checks passed
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]: native Windows: loopx backup-state --execute always fails because both durability fsync calls use read-only file handles

3 participants