From 5749a3bf8d25a482a167511800c95de81f0801dc Mon Sep 17 00:00:00 2001 From: luohui1 <3053763193@qq.com> Date: Sat, 3 Oct 2026 02:53:06 -0700 Subject: [PATCH 1/4] feat: add Windows WinFsp services and native tray preview --- .github/workflows/ci.yml | 11 +- .gitignore | 1 + README.md | 8 +- cmd/codexfold-tray/assets/app.manifest | 17 + .../assets/codexfold-attention.ico | Bin 0 -> 14651 bytes cmd/codexfold-tray/assets/codexfold.ico | Bin 0 -> 10260 bytes cmd/codexfold-tray/assets/generate.go | 100 +++ cmd/codexfold-tray/main_other.go | 7 + cmd/codexfold-tray/main_windows.go | 61 ++ cmd/codexfold-tray/resources_windows_test.go | 32 + cmd/codexfold-tray/rsrc_windows_amd64.syso | Bin 0 -> 27332 bytes cmd/codexfold-tray/rsrc_windows_arm64.syso | Bin 0 -> 27332 bytes docs/windows-preview.md | 275 ++++++++ go.mod | 5 +- go.sum | 8 + internal/cli/fs.go | 67 +- internal/cli/fs_enroll.go | 90 ++- internal/cli/fs_enroll_alias.go | 52 ++ internal/cli/fs_enroll_alias_windows_test.go | 66 ++ internal/cli/fs_enroll_child_other.go | 7 + internal/cli/fs_enroll_child_windows.go | 13 + internal/cli/fs_enroll_heartbeat_test.go | 124 ++++ internal/cli/fs_enroll_install_windows.go | 394 +++++++++++ internal/cli/fs_enroll_run.go | 157 +++++ internal/cli/fs_enroll_run_test.go | 329 +++++++++ internal/cli/fs_enroll_service_other.go | 7 + internal/cli/fs_enroll_service_windows.go | 164 +++++ .../cli/fs_enroll_service_windows_test.go | 121 ++++ internal/cli/fs_enroll_writer.go | 8 + internal/cli/fs_enroll_writer_windows.go | 34 +- internal/cli/fs_enroll_writer_windows_test.go | 138 ++++ internal/cli/fs_enrollment_reclaim_test.go | 5 +- internal/cli/fs_managed_registry.go | 3 +- internal/cli/fs_managed_reload_wake_test.go | 32 + internal/cli/fs_namespace.go | 2 +- internal/cli/fs_namespace_activation_test.go | 33 +- internal/cli/fs_namespace_readiness.go | 12 +- internal/cli/fs_service.go | 23 +- internal/cli/fs_service_fskit_other.go | 2 +- internal/cli/fs_service_live_update_other.go | 7 + .../cli/fs_service_live_update_windows.go | 100 +++ internal/cli/fs_service_runtime_windows.go | 103 ++- internal/cli/fs_status_other.go | 13 + internal/cli/fs_status_windows.go | 59 ++ internal/cli/fs_test.go | 9 +- internal/cli/fs_windows_core.go | 125 ++++ internal/cli/fs_windows_core_other.go | 20 + internal/cli/fs_windows_core_test.go | 47 ++ internal/dirsync/sync_test.go | 22 + internal/dirsync/sync_unix.go | 8 + internal/dirsync/sync_windows.go | 25 + internal/enroll/control.go | 58 +- internal/enroll/control_read_other.go | 9 + internal/enroll/control_read_windows.go | 44 ++ internal/enroll/control_windows_test.go | 33 + internal/fskitstatus/status.go | 4 +- internal/mountfs/drive_mount_other.go | 7 + internal/mountfs/drive_mount_windows.go | 68 ++ internal/mountfs/drive_mount_windows_test.go | 68 ++ internal/mountfs/filesystem.go | 4 +- internal/mountfs/fuse_errno_unix.go | 7 + internal/mountfs/fuse_errno_windows.go | 59 ++ internal/mountfs/fuse_errno_windows_test.go | 31 + internal/mountfs/host.go | 72 +- internal/mountfs/host_cgofuse.go | 210 ++++-- internal/mountfs/host_platform_windows.go | 8 +- internal/mountfs/host_safety_test.go | 10 + internal/mountfs/host_safety_windows_test.go | 45 ++ internal/mountfs/native_append.go | 4 +- internal/mountfs/native_open_unix.go | 9 + internal/mountfs/native_open_windows.go | 37 + internal/mountfs/windows_core_backend.go | 105 +++ internal/mountfs/windows_core_host.go | 439 ++++++++++++ internal/mountfs/windows_core_rpc.go | 203 ++++++ internal/mountfs/windows_core_test.go | 146 ++++ internal/mountfs/windows_volume_prefix.go | 19 + internal/pack/accounting.go | 88 +++ internal/pack/accounting_test.go | 31 + internal/pack/build.go | 3 +- internal/pack/recovery.go | 7 +- internal/service/binary_update.go | 4 +- internal/service/build_status.go | 16 + internal/service/mount_probe_windows.go | 61 +- internal/service/mount_probe_windows_test.go | 40 ++ internal/service/service_test.go | 7 +- internal/service/windows.go | 51 +- internal/service/windows_core_image.go | 67 ++ internal/service/windows_core_image_test.go | 98 +++ internal/service/windows_core_offline.go | 76 +++ internal/service/windows_enrollment.go | 59 ++ internal/service/windows_enrollment_test.go | 55 ++ internal/sessionns/activation.go | 10 +- internal/sessionns/link_unix.go | 8 + internal/sessionns/link_windows.go | 67 ++ internal/sessionns/routing_guard.go | 60 +- .../sessionns/routing_guard_windows_test.go | 55 ++ internal/storage/gc.go | 4 +- internal/tray/accounting.go | 96 +++ internal/tray/accounting_test.go | 70 ++ internal/tray/dashboard.html | 580 ++++++++++++++++ internal/tray/diagnostics.go | 42 ++ internal/tray/diagnostics_windows.go | 128 ++++ internal/tray/files_other.go | 8 + internal/tray/files_windows.go | 34 + internal/tray/history.go | 272 ++++++++ internal/tray/history_test.go | 108 +++ internal/tray/logs.go | 42 ++ internal/tray/policy.go | 172 +++++ internal/tray/policy_test.go | 104 +++ internal/tray/status.go | 316 +++++++++ internal/tray/status_test.go | 99 +++ internal/tray/testdata/windows-smoke.cjs | 231 +++++++ internal/tray/window_windows.go | 642 ++++++++++++++++++ internal/tray/window_windows_test.go | 31 + internal/vfs/initial_publish.go | 2 +- internal/vfs/initial_publish_windows_test.go | 99 +++ internal/vfs/native_retirement.go | 4 +- internal/vfs/session.go | 4 +- internal/vfs/session_deletion_lease_other.go | 9 + .../vfs/session_deletion_lease_windows.go | 11 + internal/vfs/session_deletion_object_other.go | 2 +- .../vfs/session_deletion_object_windows.go | 111 +++ .../session_deletion_object_windows_test.go | 82 +++ internal/vfs/session_deletion_purge.go | 30 +- internal/vfs/session_deletion_rename_other.go | 2 +- .../vfs/session_deletion_rename_windows.go | 86 +++ .../session_deletion_rename_windows_test.go | 47 ++ internal/vfs/session_deletion_safety.go | 2 +- internal/vfs/writer_lease_guard.go | 2 +- internal/vfs/writer_lock_unix.go | 13 + internal/vfs/writer_lock_windows.go | 101 ++- scripts/enable-windows-enrollment-service.ps1 | 33 + scripts/enable-windows-enrollment-worker.ps1 | 7 + scripts/enable-windows-local.ps1 | 71 ++ scripts/launch-windows-local.ps1 | 53 ++ scripts/restore-windows-local.ps1 | 29 + scripts/stop-windows-test.ps1 | 34 + scripts/test-windows-use.ps1 | 172 +++++ scripts/upgrade-windows-core.ps1 | 63 ++ scripts/windows-client-smoke.cjs | 124 ++++ scripts/windows-fixture.go | 98 +++ scripts/windows-full-fixture.go | 278 ++++++++ scripts/windows-local-common.ps1 | 82 +++ scripts/windows-local-fixture.go | 204 ++++++ scripts/windows-model-smoke.cjs | 149 ++++ ...3\345\274\217\344\275\277\347\224\250.cmd" | 4 + ...6\351\200\232\345\255\230\345\202\250.cmd" | 4 + 147 files changed, 10307 insertions(+), 192 deletions(-) create mode 100644 cmd/codexfold-tray/assets/app.manifest create mode 100644 cmd/codexfold-tray/assets/codexfold-attention.ico create mode 100644 cmd/codexfold-tray/assets/codexfold.ico create mode 100644 cmd/codexfold-tray/assets/generate.go create mode 100644 cmd/codexfold-tray/main_other.go create mode 100644 cmd/codexfold-tray/main_windows.go create mode 100644 cmd/codexfold-tray/resources_windows_test.go create mode 100644 cmd/codexfold-tray/rsrc_windows_amd64.syso create mode 100644 cmd/codexfold-tray/rsrc_windows_arm64.syso create mode 100644 docs/windows-preview.md create mode 100644 internal/cli/fs_enroll_alias.go create mode 100644 internal/cli/fs_enroll_alias_windows_test.go create mode 100644 internal/cli/fs_enroll_child_other.go create mode 100644 internal/cli/fs_enroll_child_windows.go create mode 100644 internal/cli/fs_enroll_heartbeat_test.go create mode 100644 internal/cli/fs_enroll_install_windows.go create mode 100644 internal/cli/fs_enroll_run.go create mode 100644 internal/cli/fs_enroll_run_test.go create mode 100644 internal/cli/fs_enroll_service_other.go create mode 100644 internal/cli/fs_enroll_service_windows.go create mode 100644 internal/cli/fs_enroll_service_windows_test.go create mode 100644 internal/cli/fs_enroll_writer_windows_test.go create mode 100644 internal/cli/fs_managed_reload_wake_test.go create mode 100644 internal/cli/fs_service_live_update_other.go create mode 100644 internal/cli/fs_service_live_update_windows.go create mode 100644 internal/cli/fs_status_other.go create mode 100644 internal/cli/fs_status_windows.go create mode 100644 internal/cli/fs_windows_core.go create mode 100644 internal/cli/fs_windows_core_other.go create mode 100644 internal/cli/fs_windows_core_test.go create mode 100644 internal/dirsync/sync_test.go create mode 100644 internal/dirsync/sync_unix.go create mode 100644 internal/dirsync/sync_windows.go create mode 100644 internal/enroll/control_read_other.go create mode 100644 internal/enroll/control_read_windows.go create mode 100644 internal/enroll/control_windows_test.go create mode 100644 internal/mountfs/drive_mount_other.go create mode 100644 internal/mountfs/drive_mount_windows.go create mode 100644 internal/mountfs/drive_mount_windows_test.go create mode 100644 internal/mountfs/fuse_errno_unix.go create mode 100644 internal/mountfs/fuse_errno_windows.go create mode 100644 internal/mountfs/fuse_errno_windows_test.go create mode 100644 internal/mountfs/host_safety_windows_test.go create mode 100644 internal/mountfs/native_open_unix.go create mode 100644 internal/mountfs/native_open_windows.go create mode 100644 internal/mountfs/windows_core_backend.go create mode 100644 internal/mountfs/windows_core_host.go create mode 100644 internal/mountfs/windows_core_rpc.go create mode 100644 internal/mountfs/windows_core_test.go create mode 100644 internal/mountfs/windows_volume_prefix.go create mode 100644 internal/pack/accounting.go create mode 100644 internal/pack/accounting_test.go create mode 100644 internal/service/mount_probe_windows_test.go create mode 100644 internal/service/windows_core_image.go create mode 100644 internal/service/windows_core_image_test.go create mode 100644 internal/service/windows_core_offline.go create mode 100644 internal/service/windows_enrollment.go create mode 100644 internal/service/windows_enrollment_test.go create mode 100644 internal/sessionns/link_unix.go create mode 100644 internal/sessionns/link_windows.go create mode 100644 internal/sessionns/routing_guard_windows_test.go create mode 100644 internal/tray/accounting.go create mode 100644 internal/tray/accounting_test.go create mode 100644 internal/tray/dashboard.html create mode 100644 internal/tray/diagnostics.go create mode 100644 internal/tray/diagnostics_windows.go create mode 100644 internal/tray/files_other.go create mode 100644 internal/tray/files_windows.go create mode 100644 internal/tray/history.go create mode 100644 internal/tray/history_test.go create mode 100644 internal/tray/logs.go create mode 100644 internal/tray/policy.go create mode 100644 internal/tray/policy_test.go create mode 100644 internal/tray/status.go create mode 100644 internal/tray/status_test.go create mode 100644 internal/tray/testdata/windows-smoke.cjs create mode 100644 internal/tray/window_windows.go create mode 100644 internal/tray/window_windows_test.go create mode 100644 internal/vfs/initial_publish_windows_test.go create mode 100644 internal/vfs/session_deletion_lease_other.go create mode 100644 internal/vfs/session_deletion_lease_windows.go create mode 100644 internal/vfs/session_deletion_object_windows.go create mode 100644 internal/vfs/session_deletion_object_windows_test.go create mode 100644 internal/vfs/session_deletion_rename_windows.go create mode 100644 internal/vfs/session_deletion_rename_windows_test.go create mode 100644 scripts/enable-windows-enrollment-service.ps1 create mode 100644 scripts/enable-windows-enrollment-worker.ps1 create mode 100644 scripts/enable-windows-local.ps1 create mode 100644 scripts/launch-windows-local.ps1 create mode 100644 scripts/restore-windows-local.ps1 create mode 100644 scripts/stop-windows-test.ps1 create mode 100644 scripts/test-windows-use.ps1 create mode 100644 scripts/upgrade-windows-core.ps1 create mode 100644 scripts/windows-client-smoke.cjs create mode 100644 scripts/windows-fixture.go create mode 100644 scripts/windows-full-fixture.go create mode 100644 scripts/windows-local-common.ps1 create mode 100644 scripts/windows-local-fixture.go create mode 100644 scripts/windows-model-smoke.cjs create mode 100644 "\345\220\257\347\224\250\346\255\243\345\274\217\344\275\277\347\224\250.cmd" create mode 100644 "\346\201\242\345\244\215\346\231\256\351\200\232\345\255\230\345\202\250.cmd" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dfae646..d5970d1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -70,7 +70,7 @@ jobs: args: check windows-compile: - name: windows-compile + name: windows-preview-checks runs-on: windows-latest steps: - uses: actions/checkout@v7.0.1 @@ -80,7 +80,16 @@ jobs: cache: true - name: Compile all test packages without claiming runtime validation run: go test ./... -run '^$' -count=1 + - name: Test Windows adapters, worker lifecycle and tray state + run: go test ./internal/tray ./internal/enroll ./internal/cli ./internal/service ./internal/mountfs ./internal/pack ./internal/dirsync -run 'TestMonitor|TestPolicy|TestDashboard|TestHistory|TestIncidents|TestUnreadable|TestPeriodicEnrollment|TestEnrollmentWorker|TestPersistentEnrollment|TestWindows|TestFuse|TestPublishedLogicalBytes' -count=1 -timeout=120s + - name: Test the WinFsp frontend and engine handover + run: go test -tags winfsp ./internal/mountfs -run 'TestWindowsCore|TestWindowsFuseErrorMapping|TestFuse' -count=1 -timeout=120s - run: go build ./cmd/codexfold + - name: Build Windows mount and native tray + run: | + go build -tags winfsp -o "$env:RUNNER_TEMP/codexfold-winfsp.exe" ./cmd/codexfold + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + go build -ldflags='-H=windowsgui' -o "$env:RUNNER_TEMP/codexfold-tray.exe" ./cmd/codexfold-tray race: name: race diff --git a/.gitignore b/.gitignore index c3254d8..1db1d81 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ .DS_Store .worktrees/ /.tmp/ +/.tmp-test-windows-runtime/ # Xcode build products and derived data platform/darwin/fskit/build/ diff --git a/README.md b/README.md index 92f1570..5b818d3 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ The requirements and release gates for normal JSONL paths backed transparently b |---|---|---|---| | macOS 27 | Apple-native Swift FSKit | Historical signed build 102 real-client and native-mount matrix; current worktree requalification is partial | `fs-engine-preview` | | Linux | FUSE3 | Real unprivileged mount, mutation, remount, recovery, performance, and user-service lifecycle | Preview; real Codex client validation remains incomplete | -| Windows | WinFsp | Cross-build and compile coverage | Not runtime-validated | +| Windows | WinFsp, SCM services and native WebView2 tray | Complete local-copy Codex app-server lifecycle, exact-byte restart, persistent worker and guarded live engine replacement on an isolated mount | Tested preview flows; Desktop GUI, reboot and SYSTEM engine-split qualification remain incomplete | The transparent filesystem preview has these explicit boundaries: @@ -30,11 +30,11 @@ The transparent filesystem preview has these explicit boundaries: - Release source metadata is `0.3.0 (103)`. Build 103 compiles and passes nested signature verification, while the completed mounted and real-client matrix remains historical build 102 evidence. Changes in the current worktree require their own candidate attachment, real-client workload, restart, fault-injection, and recovery evidence. - The earlier synchronous FUSE-T NFS route remains historical validation evidence and a development fallback only. FUSE-T's third-party FSKit backend remains rejected after deterministic byte-loss and cache-invalidation failures; it is not the Apple-native FSKit implementation in this repository. - Linux FUSE3 has real unprivileged read, append, copy-on-write, truncate, archive rename, crash recovery, remount, performance, and `systemd --user` lifecycle evidence. -- Windows has a WinFsp adapter and native Windows Service host that cross-compile, but no real Windows/WinFsp host has validated them yet. -- The production service and production Codex home remain disabled by default. The current worktree's isolated flow has observed a real Cockpit **Start**, an isolated Desktop/app-server process chain, and an unchanged production Codex process. It has not yet attached the current CodexFold candidate, observed a real Desktop task mutation through that managed route, or completed candidate-only fault injection and recovery; `codexInstanceAcceptanceComplete`, `codexFoldCandidateAcceptanceComplete`, and `realAcceptanceComplete` therefore remain false. Promotion is blocked by that exact evidence matrix, not by a fixed observation period. +- This Windows branch has passed complete local-copy tests with Codex 0.159.2 app-server: resume, history reads, recorder writes, fork, archive/unarchive of a compressed thread, and a separate managed deletion with physical purge. SCM stop/start preserves exact bytes and client access. A persistent SCM folding worker supports pause/resume and fresh progress; the native tray adds policy controls, 30-day aggregate history, incident details and JSON diagnostics. An isolated real WinFsp mount passed storage-engine replacement and failed-candidate rollback while preserving the frontend PID and mount nonce. A legacy installation needs one offline upgrade to enable that split. The local legacy namespace has been activated; the full Desktop GUI, reboot/power loss and SYSTEM engine-split qualification remain unverified. See the [Windows preview guide](docs/windows-preview.md). +- The macOS production service and production Codex home remain disabled by default. The current worktree's isolated macOS flow has observed a real Cockpit **Start**, an isolated Desktop/app-server process chain, and an unchanged production Codex process. It has not yet attached the current CodexFold candidate, observed a real Desktop task mutation through that managed route, or completed candidate-only fault injection and recovery; `codexInstanceAcceptanceComplete`, `codexFoldCandidateAcceptanceComplete`, and `realAcceptanceComplete` therefore remain false. Promotion is blocked by that exact evidence matrix, not by a fixed observation period. - CodexFold recovery operates only on CodexFold-owned components. It never quits, restarts, signals, or reopens a running Codex process. On macOS the backend, supervisor, and incident helper are resident; the menu-bar App is restarted after a crash but an explicit user Quit remains a quit. - The native macOS menu-bar App shows current health, measured space saved, current read/write speed, compact trends, selectable 1-hour/24-hour/7-day/30-day history, and the timeline of failures that lasted at least ten seconds. A standalone menu-bar launch that has never observed or registered the file-service runtime shows an unconnected state instead of reporting a critical incident. History stores only timestamps, health, counts, byte totals, and aggregate transfer rates, never session content. -- The current worktree validation has not installed, updated, stopped, restarted, or signaled the production CodexFold service, FSKit App/extension, or LaunchAgent set. Any exact production target requires a new explicit user authorization before an apply or lifecycle operation. +- The current macOS worktree validation has not installed, updated, stopped, restarted, or signaled the production CodexFold service, FSKit App/extension, or LaunchAgent set. Any exact production target requires explicit user authorization before an apply or lifecycle operation. See [the Linux FUSE3 validation](docs/validation-linux-fuse3.md) and [the macOS canary validation](docs/validation-macos-canary.md) for the evidence boundary. The default build remains storage-only; platform mounts require explicit build tags and installed host prerequisites. diff --git a/cmd/codexfold-tray/assets/app.manifest b/cmd/codexfold-tray/assets/app.manifest new file mode 100644 index 0000000..382e623 --- /dev/null +++ b/cmd/codexfold-tray/assets/app.manifest @@ -0,0 +1,17 @@ + + + + CodexFold + + + + + + + + + true/pm + PerMonitorV2,PerMonitor + + + diff --git a/cmd/codexfold-tray/assets/codexfold-attention.ico b/cmd/codexfold-tray/assets/codexfold-attention.ico new file mode 100644 index 0000000000000000000000000000000000000000..a27be91bdc7fcf589870b161011e1bcc9b95a901 GIT binary patch literal 14651 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 m1~_@PP?E!C{= left && x <= right && y >= top && y <= bottom && dx*dx+dy*dy <= radius*radius +} + +func glyph(size int, warning bool) *image.NRGBA { + img := image.NewNRGBA(image.Rect(0, 0, size, size)) + const samples = 4 + for y := 0; y < size; y++ { + for x := 0; x < size; x++ { + var r, g, b, a float64 + for sy := 0; sy < samples; sy++ { + for sx := 0; sx < samples; sx++ { + px := (float64(x) + (float64(sx)+.5)/samples) * 64 / float64(size) + py := (float64(y) + (float64(sy)+.5)/samples) * 64 / float64(size) + c := color.NRGBA{} + if rounded(px, py, 2, 2, 62, 62, 14) { + c = color.NRGBA{uint8(66 - py*.13), uint8(125 - py*.2), uint8(201 - py*.15), 255} + } + // Stacked sheets flowing into a compact storage drive. + if rounded(px, py, 19, 15, 45, 18, 1.5) || rounded(px, py, 16, 22, 48, 25, 1.5) || rounded(px, py, 12, 30, 52, 49, 4) { + c = color.NRGBA{248, 251, 255, 255} + } + if rounded(px, py, 18, 38, 35, 41, 1.5) || math.Hypot(px-44, py-39.5) <= 1.8 { + c = color.NRGBA{58, 112, 185, 255} + } + if warning && math.Hypot(px-51, py-51) <= 10 { + c = color.NRGBA{255, 255, 255, 255} + if math.Hypot(px-51, py-51) <= 8 { + c = color.NRGBA{208, 142, 39, 255} + } + } + r += float64(c.R) * float64(c.A) / 255 + g += float64(c.G) * float64(c.A) / 255 + b += float64(c.B) * float64(c.A) / 255 + a += float64(c.A) + } + } + if a > 0 { + img.SetNRGBA(x, y, color.NRGBA{uint8(r * 255 / a), uint8(g * 255 / a), uint8(b * 255 / a), uint8(a / (samples * samples))}) + } + } + } + return img +} + +func writeIcon(name string, warning bool) { + sizes := []int{16, 20, 24, 32, 40, 48, 64, 128, 256} + var frames [][]byte + for _, size := range sizes { + var frame bytes.Buffer + if err := png.Encode(&frame, glyph(size, warning)); err != nil { + panic(err) + } + frames = append(frames, frame.Bytes()) + } + var output bytes.Buffer + write := func(value any) { + if err := binary.Write(&output, binary.LittleEndian, value); err != nil { + panic(err) + } + } + write(uint16(0)) + write(uint16(1)) + write(uint16(len(sizes))) + offset := 6 + 16*len(sizes) + for i, size := range sizes { + write([4]byte{byte(size % 256), byte(size % 256), 0, 0}) + write(uint16(1)) + write(uint16(32)) + write(uint32(len(frames[i]))) + write(uint32(offset)) + offset += len(frames[i]) + } + for _, frame := range frames { + output.Write(frame) + } + if err := os.WriteFile("assets/"+name+".ico", output.Bytes(), 0644); err != nil { + panic(err) + } +} + +func main() { writeIcon("codexfold", false); writeIcon("codexfold-attention", true) } diff --git a/cmd/codexfold-tray/main_other.go b/cmd/codexfold-tray/main_other.go new file mode 100644 index 0000000..8700203 --- /dev/null +++ b/cmd/codexfold-tray/main_other.go @@ -0,0 +1,7 @@ +//go:build !windows + +package main + +import "fmt" + +func main() { fmt.Println("The CodexFold tray companion is available on Windows.") } diff --git a/cmd/codexfold-tray/main_windows.go b/cmd/codexfold-tray/main_windows.go new file mode 100644 index 0000000..06460bd --- /dev/null +++ b/cmd/codexfold-tray/main_windows.go @@ -0,0 +1,61 @@ +//go:build windows + +package main + +//go:generate go run assets/generate.go +//go:generate go run github.com/akavel/rsrc@v0.10.2 -manifest assets/app.manifest -ico assets/codexfold.ico,assets/codexfold-attention.ico -arch amd64 -o rsrc_windows_amd64.syso +//go:generate go run github.com/akavel/rsrc@v0.10.2 -manifest assets/app.manifest -ico assets/codexfold.ico,assets/codexfold-attention.ico -arch arm64 -o rsrc_windows_arm64.syso + +import ( + "flag" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/samekind/codexfold/internal/codex" + "github.com/samekind/codexfold/internal/tray" +) + +func main() { + store := flag.String("store", "", "CodexFold store directory; defaults to CODEX_HOME/fold-store") + background := flag.Bool("background", false, "Start in the notification area") + quit := flag.Bool("quit", false, "Close the tray for this store without stopping the filesystem") + diagnostics := flag.String("diagnostics", "", "Export aggregate diagnostics to a JSON file and exit") + flag.Parse() + if *store == "" { + home, err := codex.ResolveHome("") + if err != nil { + tray.ShowError(err) + os.Exit(1) + } + *store = filepath.Join(home, "fold-store") + } + absolute, err := filepath.Abs(*store) + if err == nil && *diagnostics != "" { + output, outputErr := filepath.Abs(*diagnostics) + if outputErr == nil { + monitor := tray.NewMonitor(absolute) + outputErr = tray.ExportDiagnostics(output, monitor.DiagnosticSnapshot(time.Now())) + } + if outputErr != nil { + fmt.Fprintln(os.Stderr, outputErr) + os.Exit(1) + } + return + } + if err == nil && *quit { + if err := tray.RequestExit(absolute); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + return + } + if err == nil { + err = tray.Run(absolute, *background) + } + if err != nil { + tray.ShowError(fmt.Errorf("CodexFold: %w", err)) + os.Exit(1) + } +} diff --git a/cmd/codexfold-tray/resources_windows_test.go b/cmd/codexfold-tray/resources_windows_test.go new file mode 100644 index 0000000..7431b8c --- /dev/null +++ b/cmd/codexfold-tray/resources_windows_test.go @@ -0,0 +1,32 @@ +//go:build windows + +package main + +import ( + "golang.org/x/sys/windows" + "testing" +) + +func TestWindowsIconResourcesAndDPIManifest(t *testing.T) { + var instance windows.Handle + if err := windows.GetModuleHandleEx(0, nil, &instance); err != nil { + t.Fatal(err) + } + user32 := windows.NewLazySystemDLL("user32.dll") + for _, resource := range []uintptr{2, 12} { + for _, size := range []uintptr{16, 24, 32, 48, 64} { + icon, _, err := user32.NewProc("LoadImageW").Call(uintptr(instance), resource, 1, size, size, 0) + if icon == 0 { + t.Fatalf("resource %d at %d pixels: %v", resource, size, err) + } + user32.NewProc("DestroyIcon").Call(icon) + } + } + if contextProc := user32.NewProc("GetThreadDpiAwarenessContext"); contextProc.Find() == nil { + context, _, _ := contextProc.Call() + equal, _, _ := user32.NewProc("AreDpiAwarenessContextsEqual").Call(context, ^uintptr(3)) + if equal == 0 { + t.Fatal("tray executable does not enable PerMonitorV2 through its manifest") + } + } +} diff --git a/cmd/codexfold-tray/rsrc_windows_amd64.syso b/cmd/codexfold-tray/rsrc_windows_amd64.syso new file mode 100644 index 0000000000000000000000000000000000000000..c2ec89f32edfc907d8f165e9d4437298a08c9239 GIT binary patch literal 27332 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 literal 0 HcmV?d00001 diff --git a/cmd/codexfold-tray/rsrc_windows_arm64.syso b/cmd/codexfold-tray/rsrc_windows_arm64.syso new file mode 100644 index 0000000000000000000000000000000000000000..09272d4ca61ac931d3e9343744b6681e913f1a97 GIT binary patch literal 27332 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/fs/native-fskit directory") command.Flags().StringVar(&operationTracePath, "operation-trace", "", "Absolute path for sanitized FUSE operation names") @@ -1604,19 +1641,19 @@ func startManagedStatusHeartbeat(ctx context.Context, reporter *managedStatusRep func runManagedReloadLoop(ctx context.Context, minimumDelay time.Duration, maximumDelay time.Duration, load func() error, report func(error)) { delayState := newManagedReloadDelayState(minimumDelay, maximumDelay, managedRecoveryDeadline) delay := delayState.minimumDelay - runManagedReloadLoopWithDelay(ctx, delayState, delay, load, report) + runManagedReloadLoopWithDelay(ctx, delayState, delay, nil, load, report) } -func runManagedReloadLoopAfterInitial(ctx context.Context, minimumDelay time.Duration, maximumDelay time.Duration, initialErr error, load func() error, report func(error)) { +func runManagedReloadLoopAfterInitial(ctx context.Context, minimumDelay time.Duration, maximumDelay time.Duration, initialErr error, wake <-chan struct{}, load func() error, report func(error)) { delayState := newManagedReloadDelayState(minimumDelay, maximumDelay, managedRecoveryDeadline) // The synchronous startup load has already observed current state. A // healthy backend can wait for the normal poll; a failed load must retry // promptly so the ten-second incident deadline remains meaningful. delay := delayState.next(initialErr, time.Now()) - runManagedReloadLoopWithDelay(ctx, delayState, delay, load, report) + runManagedReloadLoopWithDelay(ctx, delayState, delay, wake, load, report) } -func runManagedReloadLoopWithDelay(ctx context.Context, delayState managedReloadDelayState, delay time.Duration, load func() error, report func(error)) { +func runManagedReloadLoopWithDelay(ctx context.Context, delayState managedReloadDelayState, delay time.Duration, wake <-chan struct{}, load func() error, report func(error)) { for { timer := time.NewTimer(delay) select { @@ -1626,6 +1663,8 @@ func runManagedReloadLoopWithDelay(ctx context.Context, delayState managedReload } return case <-timer.C: + case <-wake: + timer.Stop() } err := load() report(err) diff --git a/internal/cli/fs_enroll.go b/internal/cli/fs_enroll.go index 89ef991..4011fa1 100644 --- a/internal/cli/fs_enroll.go +++ b/internal/cli/fs_enroll.go @@ -10,6 +10,7 @@ import ( "path/filepath" "sort" "strings" + "sync" "time" "github.com/samekind/codexfold/internal/codex" @@ -39,6 +40,8 @@ type enrollmentFlags struct { statusPaths []string reclaimWorkers int backgroundIdle func() bool + externalWorker bool + filesystemReady func() error } type enrollmentApplyHooks struct { @@ -92,6 +95,7 @@ var runEnrollmentCommand = func(ctx context.Context, args []string) error { } } command.Env = enrollmentChildEnvironment(os.Environ()) + configureEnrollmentChild(command) output, err := command.CombinedOutput() if err != nil { return fmt.Errorf("%s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(string(output))) @@ -142,6 +146,9 @@ func newFSEnrollCommand() *cobra.Command { command.AddCommand(newFSEnrollPlanCommand()) command.AddCommand(newFSEnrollApplyCommand()) command.AddCommand(newFSEnrollReclaimCommand()) + command.AddCommand(newFSEnrollRunCommand()) + command.AddCommand(newFSEnrollStopCommand()) + addPlatformEnrollmentCommands(command) return command } @@ -322,6 +329,10 @@ func applyEnrollmentCycle(ctx context.Context, flags enrollmentFlags, hooks enro foldErrors = append(foldErrors, fmt.Errorf("prepare enrollment for %s: %w", decision.SessionID, err)) continue } + if err := normalizeEnrollmentAlias(ctx, home, &decision); err != nil { + foldErrors = append(foldErrors, fmt.Errorf("normalize enrollment path for %s: %w", decision.SessionID, err)) + continue + } if err := runEnrollmentCommand(ctx, []string{"fold", decision.SessionID, "--codex-home", home, "--store", store, "--apply", "--overwrite"}); err != nil { if ctx.Err() != nil { return result, err @@ -461,7 +472,7 @@ func runPeriodicEnrollment(ctx context.Context, flags enrollmentFlags, interval if control.Enabled { nextApply = time.Now() } - progress := applyEnrollmentControl(newEnrollmentProgress(flags, control, enroll.PhaseDisabled), flags, control) + progress := applyEnrollmentControl(newEnrollmentProgress(flags, control, enrollmentInactivePhase(control)), flags, control) if control.Enabled { progress.Phase = enroll.PhaseIdle progress.NextCheckAt = nextApply @@ -506,7 +517,7 @@ func runPeriodicEnrollment(ctx context.Context, flags enrollmentFlags, interval progress.Phase = enroll.PhaseIdle progress.NextCheckAt = nextApply } else { - progress.Phase = enroll.PhaseDisabled + progress.Phase = enrollmentInactivePhase(control) progress.NextCheckAt = time.Time{} } publishEnrollmentProgress(flags, progress) @@ -515,10 +526,12 @@ func runPeriodicEnrollment(ctx context.Context, flags enrollmentFlags, interval if !control.Enabled || nextApply.IsZero() || now.Before(nextApply) { progress = applyEnrollmentControl(progress, flags, control) if control.Enabled { - progress.Phase = enroll.PhaseIdle + if progress.Phase != enroll.PhaseWaitingReclaim { + progress.Phase = enroll.PhaseIdle + } progress.NextCheckAt = nextApply } else { - progress.Phase = enroll.PhaseDisabled + progress.Phase = enrollmentInactivePhase(control) progress.NextCheckAt = time.Time{} } publishEnrollmentProgress(flags, progress) @@ -536,6 +549,11 @@ func runPeriodicEnrollment(ctx context.Context, flags enrollmentFlags, interval cycleCtx, cycleCancel := context.WithCancel(ctx) watchDone := make(chan struct{}) + // The cycle can spend minutes inside a child command. Keep publishing + // worker liveness without inventing completed operations, and serialize + // those writes with phase/progress callbacks so an older snapshot cannot + // overwrite a newer one. + var progressMu sync.Mutex go func() { defer close(watchDone) poll := enrollmentPolicyPollInterval @@ -553,16 +571,27 @@ func runPeriodicEnrollment(ctx context.Context, flags enrollmentFlags, interval cycleCancel() return } + progressMu.Lock() + publishEnrollmentProgress(flags, progress) + progressMu.Unlock() } } }() result, err := runServiceEnrollmentCycle(cycleCtx, cycleFlags, enrollmentApplyHooks{ - onManagedCount: func(count int) { progress.ManagedCount = count }, + onManagedCount: func(count int) { + progressMu.Lock() + defer progressMu.Unlock() + progress.ManagedCount = count + }, onPhase: func(phase string) { + progressMu.Lock() + defer progressMu.Unlock() progress.Phase = phase publishEnrollmentProgress(flags, progress) }, onProgress: func(done, total int) { + progressMu.Lock() + defer progressMu.Unlock() progress.CycleDone = done progress.CycleTotal = total publishEnrollmentProgress(flags, progress) @@ -603,7 +632,7 @@ func runPeriodicEnrollment(ctx context.Context, flags enrollmentFlags, interval progress.NextCheckAt = nextApply } else { nextApply = time.Time{} - progress.Phase = enroll.PhaseDisabled + progress.Phase = enrollmentInactivePhase(control) progress.NextCheckAt = time.Time{} } progress = applyEnrollmentControl(progress, flags, control) @@ -638,10 +667,31 @@ func resolveEnrollmentControl(flags enrollmentFlags, flagInterval time.Duration) } control, err := enroll.LoadControl(path) if err != nil { - return enroll.Control{ + invalid := enroll.Control{ Present: true, ConfigError: fmt.Sprintf("enrollment policy is invalid: %v", err), } + if flags.externalWorker { + invalid.BlockedPhase = enroll.PhaseConfigInvalid + } + return invalid + } + if flags.externalWorker { + // A missing worker policy must not revive process defaults. The service + // stays available for a later valid policy, without changing user intent. + control.Present = true + control.RequestedEnabled = control.Enabled + if control.Enabled { + readyErr := requireBuiltinEnrollmentPaused(enrollmentStorePath(flags), time.Now()) + if readyErr == nil && flags.filesystemReady != nil { + readyErr = flags.filesystemReady() + } + if readyErr != nil { + control.Enabled = false + control.BlockedPhase = enroll.PhaseWaitingFilesystem + control.BlockReason = readyErr.Error() + } + } } if !control.Present { return flagEnrollmentControl(flags, flagInterval) @@ -663,6 +713,9 @@ func flagEnrollmentControl(flags enrollmentFlags, flagInterval time.Duration) en } func enrollmentControlPath(flags enrollmentFlags) string { + if flags.externalWorker { + return enroll.WorkerControlPath(enrollmentStorePath(flags)) + } if flags.storeDir != "" { if !filepath.IsAbs(flags.storeDir) { return "" @@ -695,6 +748,9 @@ func effectiveEnrollmentInterval(control enroll.Control, flagInterval time.Durat func enrollmentControlChanged(previous enroll.Control, next enroll.Control) bool { return previous.Present != next.Present || previous.ConfigError != next.ConfigError || + previous.RequestedEnabled != next.RequestedEnabled || + previous.BlockedPhase != next.BlockedPhase || + previous.BlockReason != next.BlockReason || previous.Enabled != next.Enabled || previous.Interval != next.Interval || previous.StableFor != next.StableFor || @@ -731,14 +787,17 @@ func newEnrollmentProgress(flags enrollmentFlags, control enroll.Control, phase } func applyEnrollmentControl(progress enroll.Progress, flags enrollmentFlags, control enroll.Control) enroll.Progress { - progress.Enabled = control.Enabled + progress.Enabled = control.Enabled || control.RequestedEnabled progress.Interval = control.Interval progress.StableFor = control.StableFor progress.ArchivedOnly = control.ArchivedOnly if control.ConfigError != "" { progress.LastError = control.ConfigError progress.ErrorKind = "configuration" - } else if progress.ErrorKind == "configuration" { + } else if control.BlockReason != "" { + progress.LastError = control.BlockReason + progress.ErrorKind = "filesystem" + } else if progress.ErrorKind == "configuration" || progress.ErrorKind == "filesystem" { progress.LastError = "" progress.ErrorKind = "" } @@ -751,6 +810,13 @@ func applyEnrollmentControl(progress enroll.Progress, flags enrollmentFlags, con return progress } +func enrollmentInactivePhase(control enroll.Control) string { + if control.BlockedPhase != "" { + return control.BlockedPhase + } + return enroll.PhaseDisabled +} + func enrollmentStorePath(flags enrollmentFlags) string { if flags.storeDir != "" { return filepath.Clean(flags.storeDir) @@ -797,7 +863,11 @@ func enrollmentManagedCount(flags enrollmentFlags, result FSEnrollmentApplyResul func publishEnrollmentProgress(flags enrollmentFlags, progress enroll.Progress) { paths := append([]string(nil), flags.statusPaths...) if store := enrollmentStorePath(flags); store != "" { - paths = append(paths, enroll.ProgressPath(store)) + if flags.externalWorker { + paths = append(paths, enroll.WorkerProgressPath(store)) + } else { + paths = append(paths, enroll.ProgressPath(store)) + } } seen := make(map[string]struct{}, len(paths)) for _, path := range paths { diff --git a/internal/cli/fs_enroll_alias.go b/internal/cli/fs_enroll_alias.go new file mode 100644 index 0000000..6581a89 --- /dev/null +++ b/internal/cli/fs_enroll_alias.go @@ -0,0 +1,52 @@ +package cli + +import ( + "context" + "errors" + "os" + "path/filepath" + "runtime" + "strings" + + "github.com/samekind/codexfold/internal/codex" + "github.com/samekind/codexfold/internal/enroll" +) + +// Older mounted hosts do not recognize a verbatim drive prefix when deriving +// a path relative to the Codex home. Normalize only the selected idle session, +// after proving both spellings identify the same unchanged file. No file moves. +func normalizeEnrollmentAlias(ctx context.Context, home string, decision *enroll.Decision) error { + if runtime.GOOS != "windows" || !strings.HasPrefix(decision.RolloutPath, `\\?\`) { + return nil + } + plain := strings.TrimPrefix(decision.RolloutPath, `\\?\`) + if len(filepath.VolumeName(plain)) != 2 || !filepath.IsAbs(plain) { + return errors.New("unsupported extended enrollment path") + } + if _, err := canonicalRelativeRoute(home, plain); err != nil { + return err + } + original, err := os.Stat(decision.RolloutPath) + if err != nil { + return err + } + target, err := os.Stat(plain) + if err != nil { + return err + } + if !original.Mode().IsRegular() || !os.SameFile(original, target) || original.Size() != decision.Fingerprint.Size || original.ModTime().UnixNano() != decision.Fingerprint.ModTimeUnixNano { + return errors.New("enrollment path alias identity or fingerprint changed") + } + digest, err := hashPath(plain) + if err != nil { + return err + } + if digest.Bytes != decision.Fingerprint.Size { + return errors.New("enrollment alias changed while hashing") + } + if _, err := codex.RouteSession(ctx, codex.RouteOptions{CodexHome: home, SessionID: decision.SessionID, ExpectedPath: decision.RolloutPath, Target: codex.RouteTarget{Path: plain, Bytes: digest.Bytes, SHA256: digest.SHA256}}); err != nil { + return err + } + decision.RolloutPath = plain + return nil +} diff --git a/internal/cli/fs_enroll_alias_windows_test.go b/internal/cli/fs_enroll_alias_windows_test.go new file mode 100644 index 0000000..3d76aff --- /dev/null +++ b/internal/cli/fs_enroll_alias_windows_test.go @@ -0,0 +1,66 @@ +//go:build windows + +package cli + +import ( + "context" + "database/sql" + "os" + "path/filepath" + "testing" + + "github.com/samekind/codexfold/internal/codex" + "github.com/samekind/codexfold/internal/enroll" +) + +func TestEnrollmentNormalizesOnlyIdenticalWindowsAlias(t *testing.T) { + home, _, _ := fsFixture(t, true) + plain := filepath.Join(home, "sessions", "alias.jsonl") + if err := os.MkdirAll(filepath.Dir(plain), 0o700); err != nil { + t.Fatal(err) + } + content := []byte("{\"type\":\"session_meta\"}\n") + if err := os.WriteFile(plain, content, 0o600); err != nil { + t.Fatal(err) + } + alias := `\\?\` + plain + db, err := sql.Open("sqlite", filepath.Join(home, "state_5.sqlite")) + if err != nil { + t.Fatal(err) + } + defer db.Close() + if _, err := db.Exec(`update threads set rollout_path=? where id='session'`, alias); err != nil { + t.Fatal(err) + } + info, err := os.Stat(alias) + if err != nil { + t.Fatal(err) + } + d := enroll.Decision{SessionID: "session", RolloutPath: alias, Fingerprint: enroll.Fingerprint{Size: info.Size(), ModTimeUnixNano: info.ModTime().UnixNano()}} + changed := d + changed.Fingerprint.Size++ + if err := normalizeEnrollmentAlias(context.Background(), home, &changed); err == nil { + t.Fatal("changed source accepted") + } + var route string + if err := db.QueryRow(`select rollout_path from threads where id='session'`).Scan(&route); err != nil || route != alias { + t.Fatalf("failed check changed route: %s %v", route, err) + } + if err := normalizeEnrollmentAlias(context.Background(), home, &d); err != nil { + t.Fatal(err) + } + sessions, err := codex.LoadSessions(home) + if err != nil { + t.Fatal(err) + } + if len(sessions) != 1 || sessions[0].RolloutPath != plain || d.RolloutPath != plain { + t.Fatalf("route not normalized: %#v", sessions) + } + data, err := os.ReadFile(alias) + if err != nil || string(data) != string(content) { + t.Fatalf("alias bytes changed: %v", err) + } + if _, err := canonicalNativeRoute(home, filepath.Join(home, "native"), d.RolloutPath); err != nil { + t.Fatal(err) + } +} diff --git a/internal/cli/fs_enroll_child_other.go b/internal/cli/fs_enroll_child_other.go new file mode 100644 index 0000000..cf4b6c3 --- /dev/null +++ b/internal/cli/fs_enroll_child_other.go @@ -0,0 +1,7 @@ +//go:build !windows + +package cli + +import "os/exec" + +func configureEnrollmentChild(*exec.Cmd) {} diff --git a/internal/cli/fs_enroll_child_windows.go b/internal/cli/fs_enroll_child_windows.go new file mode 100644 index 0000000..50a2f9d --- /dev/null +++ b/internal/cli/fs_enroll_child_windows.go @@ -0,0 +1,13 @@ +//go:build windows + +package cli + +import ( + "golang.org/x/sys/windows" + "os/exec" + "syscall" +) + +func configureEnrollmentChild(command *exec.Cmd) { + command.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: windows.CREATE_NO_WINDOW | windows.BELOW_NORMAL_PRIORITY_CLASS} +} diff --git a/internal/cli/fs_enroll_heartbeat_test.go b/internal/cli/fs_enroll_heartbeat_test.go new file mode 100644 index 0000000..87d41ea --- /dev/null +++ b/internal/cli/fs_enroll_heartbeat_test.go @@ -0,0 +1,124 @@ +package cli + +import ( + "bytes" + "context" + "os" + "testing" + "time" + + "github.com/samekind/codexfold/internal/enroll" +) + +func TestPeriodicEnrollmentHeartbeatsDuringLongOperation(t *testing.T) { + for _, external := range []bool{false, true} { + name := "builtin" + if external { + name = "worker" + } + t.Run(name, func(t *testing.T) { + oldCycle, oldPoll := runServiceEnrollmentCycle, enrollmentPolicyPollInterval + t.Cleanup(func() { runServiceEnrollmentCycle, enrollmentPolicyPollInterval = oldCycle, oldPoll }) + enrollmentPolicyPollInterval = 10 * time.Millisecond + store := t.TempDir() + flags := enrollmentFlags{storeDir: store, externalWorker: external} + policyPath, progressPath := enroll.ControlPath(store), enroll.ProgressPath(store) + var builtinBefore []byte + if external { + if err := enroll.SaveControl(policyPath, enroll.Control{Interval: time.Minute, StableFor: time.Hour}); err != nil { + t.Fatal(err) + } + if err := enroll.SaveProgress(progressPath, enroll.Progress{Phase: enroll.PhaseDisabled}); err != nil { + t.Fatal(err) + } + var err error + builtinBefore, err = os.ReadFile(progressPath) + if err != nil { + t.Fatal(err) + } + policyPath, progressPath = enroll.WorkerControlPath(store), enroll.WorkerProgressPath(store) + } + if err := enroll.SaveControl(policyPath, enroll.Control{Enabled: true, Interval: time.Minute, StableFor: time.Hour}); err != nil { + t.Fatal(err) + } + started, advance := make(chan struct{}), make(chan struct{}) + runServiceEnrollmentCycle = func(ctx context.Context, _ enrollmentFlags, hooks enrollmentApplyHooks) (FSEnrollmentApplyResult, error) { + hooks.onManagedCount(124) + hooks.onPhase(enroll.PhaseReclaiming) + hooks.onProgress(18, 20) + close(started) + select { + case <-advance: + hooks.onManagedCount(125) + hooks.onProgress(19, 20) + case <-ctx.Done(): + return FSEnrollmentApplyResult{}, ctx.Err() + } + <-ctx.Done() + return FSEnrollmentApplyResult{}, ctx.Err() + } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { runPeriodicEnrollment(ctx, flags, time.Minute, nil); close(done) }() + t.Cleanup(func() { + cancel() + select { + case <-done: + case <-time.After(3 * time.Second): + t.Error("enrollment loop did not stop") + } + }) + select { + case <-started: + case <-time.After(3 * time.Second): + t.Fatal("long operation did not start") + } + first, err := enroll.LoadProgress(progressPath) + if err != nil { + t.Fatal(err) + } + waitForHeartbeat := func(after time.Time, completed, managed int) enroll.Progress { + t.Helper() + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + p, err := enroll.LoadProgress(progressPath) + if err == nil && p.UpdatedAt.After(after) && p.CycleDone == completed && p.ManagedCount == managed { + if !p.Enabled || p.Phase != enroll.PhaseReclaiming || p.CycleTotal != 20 { + t.Fatalf("heartbeat changed active operation: %#v", p) + } + return p + } + time.Sleep(enrollmentPolicyPollInterval) + } + t.Fatal("progress timestamp did not advance during long operation") + return enroll.Progress{} + } + second := waitForHeartbeat(first.UpdatedAt, 18, 124) + third := waitForHeartbeat(second.UpdatedAt, 18, 124) + close(advance) + fourth := waitForHeartbeat(third.UpdatedAt, 19, 125) + waitForHeartbeat(fourth.UpdatedAt, 19, 125) + if external { + after, err := os.ReadFile(enroll.ProgressPath(store)) + if err != nil || !bytes.Equal(builtinBefore, after) { + t.Fatal("worker heartbeat overwrote filesystem progress") + } + } + cancel() + select { + case <-done: + case <-time.After(3 * time.Second): + t.Fatal("active cycle did not stop") + } + stopped, err := os.ReadFile(progressPath) + if err != nil { + t.Fatal(err) + } + time.Sleep(5 * enrollmentPolicyPollInterval) + after, err := os.ReadFile(progressPath) + if err != nil || !bytes.Equal(stopped, after) { + t.Fatal("heartbeat continued after loop stopped") + } + }) + } +} diff --git a/internal/cli/fs_enroll_install_windows.go b/internal/cli/fs_enroll_install_windows.go new file mode 100644 index 0000000..0f9feee --- /dev/null +++ b/internal/cli/fs_enroll_install_windows.go @@ -0,0 +1,394 @@ +//go:build windows + +package cli + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/samekind/codexfold/internal/enroll" + "github.com/samekind/codexfold/internal/service" + "github.com/samekind/codexfold/internal/sessionns" + "github.com/spf13/cobra" + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/svc" + "golang.org/x/sys/windows/svc/mgr" +) + +type enrollmentInstallResult struct { + Applied bool `json:"applied"` + Binding service.WindowsEnrollmentConfig `json:"binding"` + FilesystemPID uint32 `json:"filesystem_pid"` + WorkerPID uint32 `json:"worker_pid"` + Policy enroll.Control `json:"policy"` + Error string `json:"error,omitempty"` +} + +func newEnrollmentServiceInstallCommand() *cobra.Command { + var home, mount, resultPath string + var apply bool + command := &cobra.Command{Use: "install", Short: "Install or update only the persistent Windows enrollment service", Args: cobra.NoArgs, + RunE: func(command *cobra.Command, _ []string) error { + result, err := installEnrollmentService(command.Context(), home, mount, apply) + if err != nil { + result.Error = err.Error() + } + if resultPath != "" { + if !filepath.IsAbs(resultPath) { + return errors.New("absolute installation result path is required") + } + data, marshalErr := json.MarshalIndent(result, "", " ") + if marshalErr != nil { + return marshalErr + } + if _, writeErr := service.WriteDefinition(resultPath, append(data, '\n'), true); writeErr != nil { + return errors.Join(err, writeErr) + } + // GUI-subsystem/elevated processes may have no usable stdout. The + // result file is the output channel in that invocation. + return err + } + if err != nil { + return err + } + return writeJSON(command, result) + }} + command.Flags().StringVar(&home, "codex-home", "", "Explicit absolute user Codex home") + command.Flags().StringVar(&mount, "mount", "V:/", "Mounted canonical namespace drive") + command.Flags().StringVar(&resultPath, "result", "", "Write an installation result for an elevated invocation") + command.Flags().BoolVar(&apply, "apply", false, "Install the automatic Windows enrollment service without remounting") + return command +} + +// Use native tools by absolute system path, including when the installer was +// elevated from a user shell whose PATH could resolve a different executable. +type enrollmentSystemRunner struct{} + +func (enrollmentSystemRunner) Run(ctx context.Context, name string, args ...string) ([]byte, error) { + system, err := windows.GetSystemDirectory() + if err != nil { + return nil, err + } + command := exec.CommandContext(ctx, filepath.Join(system, name), args...) + configureEnrollmentChild(command) + return command.CombinedOutput() +} + +func installEnrollmentService(ctx context.Context, home, mount string, apply bool) (result enrollmentInstallResult, runErr error) { + if !filepath.IsAbs(home) || !filepath.IsAbs(mount) { + return result, errors.New("absolute home and mount paths are required") + } + home, mount = filepath.Clean(home), filepath.Clean(mount) + programRoot := filepath.Join(os.Getenv("ProgramFiles"), "CodexFold", "Enrollment") + dataRoot := filepath.Join(os.Getenv("ProgramData"), "CodexFold", "Enrollment") + if !filepath.IsAbs(programRoot) || !filepath.IsAbs(dataRoot) { + return result, errors.New("Windows installation roots are not absolute") + } + owner, err := windows.GetCurrentProcessToken().GetTokenUser() + if err != nil { + return result, err + } + config := service.WindowsEnrollmentConfig{Version: 1, ServiceName: service.WindowsEnrollmentServiceName, OwnerSID: owner.User.Sid.String(), + BinaryPath: filepath.Join(programRoot, "codexfold-enroll.exe"), CodexHome: home, Store: filepath.Join(home, "fold-store"), NativeRoot: filepath.Join(home, "fold-native"), Mount: mount, + StdoutPath: filepath.Join(dataRoot, "stdout.log"), StderrPath: filepath.Join(dataRoot, "stderr.log")} + definition := filepath.Join(dataRoot, "service.json") + result.Binding = config + policy, err := enroll.LoadControl(enroll.WorkerControlPath(config.Store)) + if err != nil { + return result, err + } + if !policy.Present { + policy = enroll.Control{Present: true, Enabled: true, Interval: time.Minute, StableFor: time.Hour, BatchSize: 5} + } + result.Policy = policy + if !apply { + return result, nil + } + if !windows.GetCurrentProcessToken().IsElevated() { + return result, errors.New("administrator elevation is required to install the enrollment service") + } + if err := service.ProbeMount(mount); err != nil { + return result, err + } + namespace, err := sessionns.Inspect(sessionns.Options{Home: home, Mount: mount, NativeRoot: config.NativeRoot}) + if err != nil || !namespace.Active { + return result, errors.Join(errors.New("the bound canonical namespace must be active"), err) + } + manager, err := mgr.Connect() + if err != nil { + return result, err + } + defer manager.Disconnect() + fsService, err := manager.OpenService(serviceLabel) + if err != nil { + return result, err + } + defer fsService.Close() + fsStatus, err := fsService.Query() + if err != nil || fsStatus.State != svc.Running { + return result, errors.New("the filesystem service must remain running") + } + fsConfig, err := fsService.Config() + if err != nil { + return result, err + } + fsDefinition := filepath.Join(os.Getenv("ProgramData"), "CodexFold", "service.json") + fsBytes, err := os.ReadFile(fsDefinition) + if err != nil { + return result, err + } + boundFS, err := service.ParseWindowsConfig(fsBytes) + if err != nil || boundFS.ServiceName != serviceLabel || !strings.Contains(fsConfig.BinaryPathName, fsDefinition) || !serviceArgumentEquals(boundFS.Arguments, "--codex-home", home) || !serviceArgumentEquals(boundFS.Arguments, "--store", config.Store) { + return result, errors.New("the running filesystem service belongs to another installation") + } + result.FilesystemPID = fsStatus.ProcessId + runner := enrollmentSystemRunner{} + for _, directory := range []string{programRoot, dataRoot} { + if err := os.MkdirAll(directory, 0o700); err != nil { + return result, err + } + if info, err := os.Lstat(directory); err != nil || info.Mode()&os.ModeSymlink != 0 { + return result, errors.New("enrollment installation directory must not be a link") + } + if output, err := runner.Run(ctx, "icacls.exe", directory, "/inheritance:r", "/grant:r", "*S-1-5-18:(OI)(CI)F", "*S-1-5-32-544:(OI)(CI)F", "*S-1-5-32-545:(OI)(CI)RX"); err != nil { + return result, fmt.Errorf("protect enrollment installation: %w: %s", err, output) + } + } + installLock, err := service.AcquireProcessLock(filepath.Join(dataRoot, "install.lock")) + if err != nil { + return result, err + } + defer installLock.Close() + // Re-read after taking the installer lock, before pausing the worker. + if current, err := enroll.LoadControl(enroll.WorkerControlPath(config.Store)); err != nil { + return result, err + } else if current.Present { + policy = current + result.Policy = current + } + var previousDefinition []byte + previousServiceRunning := false + // Stop only an existing, explicitly owned worker service. A raw CLI worker + // is stopped via its hot policy so no Codex or filesystem process is touched. + if existing, err := manager.OpenService(config.ServiceName); err == nil { + defer existing.Close() + previous, err := existing.Config() + if err != nil || !strings.Contains(previous.BinaryPathName, config.BinaryPath) || !strings.Contains(previous.BinaryPathName, definition) { + return result, errors.New("an unrelated service uses the enrollment service name") + } + oldBinding, err := os.ReadFile(definition) + if err != nil { + return result, err + } + binding, err := service.ParseWindowsEnrollmentConfig(oldBinding) + if err != nil || !strings.EqualFold(binding.CodexHome, home) || binding.OwnerSID != config.OwnerSID { + return result, errors.New("the enrollment service is bound to another user or home") + } + previousStatus, err := existing.Query() + if err != nil { + return result, err + } + if err := stopEnrollmentSCM(ctx, existing); err != nil { + return result, err + } + previousDefinition = oldBinding + // Reinstalling restores this owned service if the replacement fails. + previousServiceRunning = previousStatus.State == svc.Running + } + paused := policy + paused.Enabled = false + if err := enroll.SaveControl(enroll.WorkerControlPath(config.Store), paused); err != nil { + return result, err + } + // Always restore the captured user intent, including when a later install + // step fails. Restoring a paused setting must never silently enable folding. + defer func() { + if !result.Applied { + runErr = errors.Join(runErr, enroll.SaveControl(enroll.WorkerControlPath(config.Store), policy)) + } + }() + if err := waitEnrollmentLockReleased(ctx, config.Store); err != nil { + return result, err + } + builtin, err := enroll.LoadControl(enroll.ControlPath(config.Store)) + if err != nil { + return result, err + } + if !builtin.Present { + builtin = policy + } + builtin.Enabled = false + if err := enroll.SaveControl(enroll.ControlPath(config.Store), builtin); err != nil { + return result, err + } + candidate, err := os.Executable() + if err != nil { + return result, err + } + if strings.EqualFold(candidate, config.BinaryPath) { + return result, errors.New("install from a separate candidate binary") + } + update, err := service.StageBinaryUpdate(candidate, config.BinaryPath) + if err != nil { + return result, err + } + if err := update.Promote(); err != nil { + _ = update.Rollback() + _ = update.Commit() + return result, err + } + defer func() { + if runErr != nil && !result.Applied { + // Keep a failed replacement from leaving an enabled but broken service. + if installed, err := manager.OpenService(config.ServiceName); err == nil { + stopErr := stopEnrollmentSCM(context.Background(), installed) + if stopErr != nil { + installed.Close() + runErr = errors.Join(runErr, stopErr) + return + } + if len(previousDefinition) == 0 { + runErr = errors.Join(runErr, installed.Delete()) + } + installed.Close() + } + rollbackErr := update.Rollback() + runErr = errors.Join(runErr, rollbackErr) + if rollbackErr != nil { + return + } + if len(previousDefinition) > 0 { + _, restoreErr := service.WriteDefinition(definition, previousDefinition, true) + runErr = errors.Join(runErr, restoreErr, enroll.SaveControl(enroll.WorkerControlPath(config.Store), policy)) + if restoreErr == nil && previousServiceRunning { + runErr = errors.Join(runErr, (service.WindowsManager{Runner: runner}).Start(context.Background(), config.ServiceName)) + } + } + } + runErr = errors.Join(runErr, update.Commit()) + }() + data, err := json.MarshalIndent(config, "", " ") + if err != nil { + return result, err + } + if _, err := service.WriteDefinition(definition, append(data, '\n'), true); err != nil { + return result, err + } + if _, err := service.ParseWindowsEnrollmentConfig(data); err != nil { + return result, err + } + platform := service.WindowsManager{Runner: runner} + if err := platform.InstallEnrollment(ctx, config.ServiceName, config.BinaryPath, definition); err != nil { + return result, err + } + if err := enroll.SaveControl(enroll.WorkerControlPath(config.Store), policy); err != nil { + return result, err + } + startAttempt := time.Now() + if err := platform.Start(ctx, config.ServiceName); err != nil { + return result, err + } + workerService, err := manager.OpenService(config.ServiceName) + if err != nil { + return result, err + } + defer workerService.Close() + deadline := time.Now().Add(30 * time.Second) + for { + status, err := workerService.Query() + if err != nil { + return result, err + } + progress, _ := enroll.LoadProgress(enroll.WorkerProgressPath(config.Store)) + if status.State == svc.Running && progress.UpdatedAt.After(startAttempt) && progress.Phase != enroll.PhaseStopped && progress.Phase != enroll.PhaseConfigInvalid && progress.Enabled == policy.Enabled { + result.WorkerPID = status.ProcessId + break + } + if time.Now().After(deadline) { + return result, errors.New("enrollment service did not publish a fresh status; inspect its stderr log") + } + select { + case <-ctx.Done(): + return result, ctx.Err() + case <-time.After(100 * time.Millisecond): + } + } + fsAfter, err := fsService.Query() + if err != nil || fsAfter.State != svc.Running || fsAfter.ProcessId != result.FilesystemPID { + return result, errors.New("filesystem service identity changed during enrollment installation") + } + // Correct the final filesystem recovery action without restarting it. Its + // existing two-retry limit could otherwise strand the mount after a crash. + if output, err := runner.Run(ctx, "sc.exe", "failure", serviceLabel, "reset=", "86400", "actions=", "restart/5000/restart/15000/restart/60000"); err != nil { + return result, fmt.Errorf("filesystem recovery configuration: %w: %s", err, output) + } + result.Applied = true + return result, nil +} + +func serviceArgumentEquals(args []string, flag, value string) bool { + for index, arg := range args { + if arg == flag && index+1 < len(args) { + return strings.EqualFold(filepath.Clean(args[index+1]), filepath.Clean(value)) + } + } + return false +} + +func stopEnrollmentSCM(ctx context.Context, owned *mgr.Service) error { + status, err := owned.Query() + if err != nil { + return err + } + if status.State == svc.Stopped { + return nil + } + if status.State != svc.StopPending { + if _, err := owned.Control(svc.Stop); err != nil { + return err + } + } + deadline := time.Now().Add(40 * time.Second) + for { + status, err = owned.Query() + if err != nil { + return err + } + if status.State == svc.Stopped { + return nil + } + if time.Now().After(deadline) { + return errors.New("enrollment service did not finish stopping") + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(100 * time.Millisecond): + } + } +} + +func waitEnrollmentLockReleased(ctx context.Context, store string) error { + deadline := time.Now().Add(40 * time.Second) + for { + status, err := service.InspectProcessLock(filepath.Join(store, "enrollment", "worker.lock")) + if !status.Held { + return err + } + if time.Now().After(deadline) { + return errors.New("the previous enrollment worker did not release its lock") + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(100 * time.Millisecond): + } + } +} diff --git a/internal/cli/fs_enroll_run.go b/internal/cli/fs_enroll_run.go new file mode 100644 index 0000000..69a0923 --- /dev/null +++ b/internal/cli/fs_enroll_run.go @@ -0,0 +1,157 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/samekind/codexfold/internal/enroll" + "github.com/samekind/codexfold/internal/service" + "github.com/spf13/cobra" +) + +// A separately updatable enrollment worker keeps the mounted filesystem alive. +// It only runs after the built-in loop has acknowledged its disabled policy. +func requireBuiltinEnrollmentPaused(store string, now time.Time) error { + control, err := enroll.LoadControl(enroll.ControlPath(store)) + if err != nil { + return err + } + if !control.Present || control.Enabled { + return errors.New("pause built-in enrollment before running the separate worker") + } + progress, err := enroll.LoadProgress(enroll.ProgressPath(store)) + if err != nil { + return err + } + if progress.Enabled || progress.Phase != enroll.PhaseDisabled || progress.UpdatedAt.IsZero() || now.Sub(progress.UpdatedAt) > 15*time.Second || progress.UpdatedAt.After(now.Add(5*time.Second)) { + return errors.New("waiting for the running filesystem to acknowledge paused enrollment") + } + return nil +} + +func newFSEnrollRunCommand() *cobra.Command { + var flags enrollmentFlags + var apply bool + command := &cobra.Command{ + Use: "run", Short: "Run a separately updatable enrollment worker without remounting", + Args: cobra.NoArgs, + RunE: func(command *cobra.Command, _ []string) error { + if !apply || !flags.canonicalNamespace { + return errors.New("worker requires --apply and --canonical-namespace") + } + return runEnrollmentWorker(command.Context(), flags, false) + }, + } + addEnrollmentFlags(command, &flags) + command.Flags().BoolVar(&apply, "apply", false, "Allow bounded background enrollment") + return command +} + +// The SCM host keeps this loop alive even when the user pauses folding. Both +// startup and later mount outages use the same fail-closed, retrying control +// resolution; there is no one-shot startup deadline to strand the worker. +func runEnrollmentWorker(parent context.Context, flags enrollmentFlags, persistent bool) error { + store := enrollmentStorePath(flags) + if store == "" || !filepath.IsAbs(store) { + return errors.New("worker requires an absolute store path") + } + lock, err := service.AcquireProcessLock(filepath.Join(store, "enrollment", "worker.lock")) + if err != nil { + return err + } + defer lock.Close() + log, err := os.OpenFile(filepath.Join(store, "enrollment", "worker.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) + if err != nil { + return err + } + defer log.Close() + flags.externalWorker = true + ctx, cancel := context.WithCancel(parent) + defer cancel() + watchDone := make(chan struct{}) + go func() { + defer close(watchDone) + if persistent { + <-ctx.Done() + return + } + ticker := time.NewTicker(enrollmentPolicyPollInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + requested, err := enroll.LoadControl(enroll.WorkerControlPath(store)) + if err == nil && (!requested.Present || !requested.Enabled) { + cancel() + return + } + } + } + }() + fmt.Fprintf(log, "%s worker started pid=%d persistent=%t\n", time.Now().UTC().Format(time.RFC3339), os.Getpid(), persistent) + runPeriodicEnrollment(ctx, flags, defaultServiceEnrollmentInterval, func(result FSEnrollmentApplyResult, err error) { + fmt.Fprintf(log, "%s selected=%d applied=%d error=%v\n", time.Now().UTC().Format(time.RFC3339), result.Apply.Selected, result.Apply.Applied, err) + }) + cancel() + <-watchDone + control := resolveEnrollmentControl(flags, defaultServiceEnrollmentInterval) + phase := enroll.PhaseDisabled + if persistent { + phase = enroll.PhaseStopped + } + progress := applyEnrollmentControl(newEnrollmentProgress(flags, control, phase), flags, control) + publishEnrollmentProgress(flags, progress) + fmt.Fprintf(log, "%s worker stopped\n", time.Now().UTC().Format(time.RFC3339)) + return nil +} + +func newFSEnrollStopCommand() *cobra.Command { + var flags enrollmentFlags + var apply bool + command := &cobra.Command{Use: "stop", Short: "Stop only the separate enrollment worker", Args: cobra.NoArgs, + RunE: func(command *cobra.Command, _ []string) error { + if !apply { + return errors.New("worker stop requires --apply") + } + store := enrollmentStorePath(flags) + if !filepath.IsAbs(store) { + return errors.New("absolute store path required") + } + control, err := enroll.LoadControl(enroll.WorkerControlPath(store)) + if err != nil { + return err + } + if !control.Present { + return nil + } + control.Enabled = false + if err := enroll.SaveControl(enroll.WorkerControlPath(store), control); err != nil { + return err + } + deadline := time.Now().Add(30 * time.Second) + for { + status, err := service.InspectProcessLock(filepath.Join(store, "enrollment", "worker.lock")) + if !status.Held { + return err + } + if time.Now().After(deadline) { + return errors.New("enrollment worker has not finished stopping") + } + select { + case <-command.Context().Done(): + return command.Context().Err() + case <-time.After(100 * time.Millisecond): + } + } + }, + } + addEnrollmentFlags(command, &flags) + command.Flags().BoolVar(&apply, "apply", false, "Disable and stop the separate worker") + return command +} diff --git a/internal/cli/fs_enroll_run_test.go b/internal/cli/fs_enroll_run_test.go new file mode 100644 index 0000000..0a2bcf5 --- /dev/null +++ b/internal/cli/fs_enroll_run_test.go @@ -0,0 +1,329 @@ +package cli + +import ( + "bytes" + "context" + "os" + "path/filepath" + "sync/atomic" + "testing" + "time" + + "github.com/samekind/codexfold/internal/enroll" + "github.com/samekind/codexfold/internal/service" +) + +func TestEnrollmentWorkerRequiresPausedFreshBuiltin(t *testing.T) { + store := t.TempDir() + now := time.Now() + control := enroll.Control{Enabled: false, Interval: time.Minute, StableFor: time.Hour, BatchSize: 1} + if err := requireBuiltinEnrollmentPaused(store, now); err == nil { + t.Fatal("absent policy accepted") + } + if err := enroll.SaveControl(enroll.ControlPath(store), control); err != nil { + t.Fatal(err) + } + for _, p := range []enroll.Progress{ + {Enabled: true, Phase: enroll.PhaseFolding, UpdatedAt: now}, + {Enabled: false, Phase: enroll.PhaseDisabled, UpdatedAt: now.Add(-time.Minute)}, + } { + if err := enroll.SaveProgress(enroll.ProgressPath(store), p); err != nil { + t.Fatal(err) + } + if err := requireBuiltinEnrollmentPaused(store, now); err == nil { + t.Fatal("active or stale acknowledgement accepted") + } + } + if err := enroll.SaveProgress(enroll.ProgressPath(store), enroll.Progress{Phase: enroll.PhaseDisabled, UpdatedAt: now}); err != nil { + t.Fatal(err) + } + if err := requireBuiltinEnrollmentPaused(store, now); err != nil { + t.Fatal(err) + } + control.Enabled = true + if err := enroll.SaveControl(enroll.ControlPath(store), control); err != nil { + t.Fatal(err) + } + if err := requireBuiltinEnrollmentPaused(store, now); err == nil { + t.Fatal("re-enabled built-in loop accepted") + } +} + +func TestEnrollmentWorkerResumesAfterStaleHeartbeat(t *testing.T) { + store := t.TempDir() + control := enroll.Control{Interval: time.Minute, StableFor: time.Hour, BatchSize: 1} + if err := enroll.SaveControl(enroll.ControlPath(store), control); err != nil { + t.Fatal(err) + } + writeHeartbeat := func(at time.Time) { + t.Helper() + if err := enroll.SaveProgress(enroll.ProgressPath(store), enroll.Progress{Phase: enroll.PhaseDisabled, UpdatedAt: at}); err != nil { + t.Fatal(err) + } + } + writeHeartbeat(time.Now()) + control.Enabled = true + if err := enroll.SaveControl(enroll.WorkerControlPath(store), control); err != nil { + t.Fatal(err) + } + oldCycle, oldPoll := runServiceEnrollmentCycle, enrollmentPolicyPollInterval + t.Cleanup(func() { runServiceEnrollmentCycle, enrollmentPolicyPollInterval = oldCycle, oldPoll }) + enrollmentPolicyPollInterval = 10 * time.Millisecond + started := make(chan int32, 4) + canceled := make(chan struct{}, 1) + var cycles atomic.Int32 + runServiceEnrollmentCycle = func(ctx context.Context, _ enrollmentFlags, _ enrollmentApplyHooks) (FSEnrollmentApplyResult, error) { + n := cycles.Add(1) + started <- n + if n == 1 { + <-ctx.Done() + canceled <- struct{}{} + return FSEnrollmentApplyResult{}, ctx.Err() + } + return FSEnrollmentApplyResult{}, nil + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + command := newFSEnrollRunCommand() + command.SetArgs([]string{"--apply", "--canonical-namespace", "--codex-home", store, "--store", store}) + done := make(chan error, 1) + exited := make(chan struct{}) + go func() { defer close(exited); done <- command.ExecuteContext(ctx) }() + defer func() { cancel(); <-exited }() + awaitCycle := func(want int32) { + t.Helper() + select { + case n := <-started: + if n != want { + t.Fatalf("cycle = %d, want %d", n, want) + } + case err := <-done: + t.Fatalf("worker exited before recovery: %v", err) + case <-ctx.Done(): + t.Fatal("worker did not resume") + } + } + awaitPaused := func() { + t.Helper() + for { + progress, err := enroll.LoadProgress(enroll.WorkerProgressPath(store)) + if err == nil && progress.Enabled && progress.Phase == enroll.PhaseWaitingFilesystem && progress.ErrorKind == "filesystem" { + break + } + select { + case err := <-done: + t.Fatalf("worker exited on stale heartbeat: %v", err) + case <-ctx.Done(): + t.Fatal("worker did not pause") + case <-time.After(10 * time.Millisecond): + } + } + select { + case err := <-done: + t.Fatalf("worker exited while paused: %v", err) + case <-time.After(5 * enrollmentPolicyPollInterval): + } + } + awaitCycle(1) + writeHeartbeat(time.Now().Add(-time.Minute)) + select { + case <-canceled: + case <-ctx.Done(): + t.Fatal("active cycle was not canceled") + } + awaitPaused() + writeHeartbeat(time.Now()) + awaitCycle(2) + // Also recover when sleep interrupts the idle interval between cycles. + writeHeartbeat(time.Now().Add(-time.Minute)) + awaitPaused() + writeHeartbeat(time.Now()) + awaitCycle(3) + // An explicit stop still works even if the filesystem heartbeat is stale. + writeHeartbeat(time.Now().Add(-time.Minute)) + control.Enabled = false + if err := enroll.SaveControl(enroll.WorkerControlPath(store), control); err != nil { + t.Fatal(err) + } + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-ctx.Done(): + t.Fatal("explicit stop did not terminate worker") + } +} + +func TestPersistentEnrollmentWorkerWaitsAndKeepsUserPolicy(t *testing.T) { + store := t.TempDir() + policy := enroll.Control{Enabled: true, Interval: time.Minute, StableFor: time.Hour, BatchSize: 5} + if err := enroll.SaveControl(enroll.WorkerControlPath(store), policy); err != nil { + t.Fatal(err) + } + oldCycle, oldPoll := runServiceEnrollmentCycle, enrollmentPolicyPollInterval + t.Cleanup(func() { runServiceEnrollmentCycle, enrollmentPolicyPollInterval = oldCycle, oldPoll }) + enrollmentPolicyPollInterval = 10 * time.Millisecond + cycles := make(chan enrollmentFlags, 4) + runServiceEnrollmentCycle = func(_ context.Context, flags enrollmentFlags, _ enrollmentApplyHooks) (FSEnrollmentApplyResult, error) { + select { + case cycles <- flags: + default: + } + return FSEnrollmentApplyResult{}, nil + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + done := make(chan error, 1) + exited := make(chan struct{}) + go func() { + defer close(exited) + done <- runEnrollmentWorker(ctx, enrollmentFlags{codexHome: store, storeDir: store, canonicalNamespace: true}, true) + }() + defer func() { cancel(); <-exited }() + awaitPhase := func(phase string, enabled bool) { + t.Helper() + for { + p, err := enroll.LoadProgress(enroll.WorkerProgressPath(store)) + if err == nil && p.Phase == phase && p.Enabled == enabled { + return + } + select { + case err := <-done: + t.Fatalf("persistent worker exited: %v", err) + case <-ctx.Done(): + t.Fatalf("did not reach %s: %#v %v", phase, p, err) + case <-time.After(10 * time.Millisecond): + } + } + } + awaitCycle := func() { + t.Helper() + select { + case flags := <-cycles: + if flags.batchSize != 5 || flags.stableFor != time.Hour { + t.Fatalf("lost user policy: %#v", flags) + } + case <-ctx.Done(): + t.Fatal("worker did not resume after readiness") + } + } + // No startup heartbeat: the service publishes an accurate waiting state + // and remains alive. It must never fabricate an acknowledgement. + awaitPhase(enroll.PhaseWaitingFilesystem, true) + select { + case <-cycles: + t.Fatal("cycle started before filesystem readiness") + default: + } + if err := enroll.SaveControl(enroll.ControlPath(store), enroll.Control{Interval: time.Minute, StableFor: time.Hour}); err != nil { + t.Fatal(err) + } + if err := enroll.SaveProgress(enroll.ProgressPath(store), enroll.Progress{Phase: enroll.PhaseDisabled, UpdatedAt: time.Now()}); err != nil { + t.Fatal(err) + } + awaitCycle() + // User pause keeps the service available for subsequent hot re-enable. + policy.Enabled = false + if err := enroll.SaveControl(enroll.WorkerControlPath(store), policy); err != nil { + t.Fatal(err) + } + awaitPhase(enroll.PhaseDisabled, false) + if err := os.WriteFile(enroll.WorkerControlPath(store), []byte("{"), 0o600); err != nil { + t.Fatal(err) + } + awaitPhase(enroll.PhaseConfigInvalid, false) + policy.Enabled = true + if err := enroll.SaveControl(enroll.WorkerControlPath(store), policy); err != nil { + t.Fatal(err) + } + awaitCycle() + before, err := os.ReadFile(enroll.WorkerControlPath(store)) + if err != nil { + t.Fatal(err) + } + cancel() + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("SCM cancellation did not stop worker") + } + after, err := os.ReadFile(enroll.WorkerControlPath(store)) + if err != nil || !bytes.Equal(before, after) { + t.Fatal("service shutdown changed persisted user intent") + } + p, err := enroll.LoadProgress(enroll.WorkerProgressPath(store)) + if err != nil || !p.Enabled || p.Phase != enroll.PhaseStopped { + t.Fatalf("shutdown status = %#v, %v", p, err) + } +} + +func TestEnrollmentWorkerPolicyStopsCycleAndKeepsBuiltinProgress(t *testing.T) { + store := t.TempDir() + control := enroll.Control{Interval: time.Minute, StableFor: time.Hour, BatchSize: 1} + if err := enroll.SaveControl(enroll.ControlPath(store), control); err != nil { + t.Fatal(err) + } + if err := enroll.SaveProgress(enroll.ProgressPath(store), enroll.Progress{Phase: enroll.PhaseDisabled, UpdatedAt: time.Now()}); err != nil { + t.Fatal(err) + } + before, err := os.ReadFile(enroll.ProgressPath(store)) + if err != nil { + t.Fatal(err) + } + control.Enabled = true + if err := enroll.SaveControl(enroll.WorkerControlPath(store), control); err != nil { + t.Fatal(err) + } + oldCycle, oldPoll := runServiceEnrollmentCycle, enrollmentPolicyPollInterval + t.Cleanup(func() { runServiceEnrollmentCycle, enrollmentPolicyPollInterval = oldCycle, oldPoll }) + enrollmentPolicyPollInterval = 10 * time.Millisecond + started := make(chan struct{}) + runServiceEnrollmentCycle = func(ctx context.Context, _ enrollmentFlags, _ enrollmentApplyHooks) (FSEnrollmentApplyResult, error) { + close(started) + <-ctx.Done() + return FSEnrollmentApplyResult{}, ctx.Err() + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + command := newFSEnrollRunCommand() + command.SetArgs([]string{"--apply", "--canonical-namespace", "--codex-home", store, "--store", store}) + done := make(chan error, 1) + go func() { done <- command.ExecuteContext(ctx) }() + select { + case <-started: + case <-ctx.Done(): + t.Fatal("worker did not start") + } + if lock, err := service.AcquireProcessLock(filepath.Join(store, "enrollment", "worker.lock")); err == nil { + lock.Close() + t.Fatal("duplicate worker lock accepted") + } + control.Enabled = false + if err := enroll.SaveControl(enroll.WorkerControlPath(store), control); err != nil { + t.Fatal(err) + } + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-ctx.Done(): + t.Fatal("worker did not stop") + } + after, err := os.ReadFile(enroll.ProgressPath(store)) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(before, after) { + t.Fatal("worker overwrote built-in progress") + } + progress, err := enroll.LoadProgress(enroll.WorkerProgressPath(store)) + if err != nil { + t.Fatal(err) + } + if progress.Enabled || progress.Phase != enroll.PhaseDisabled { + t.Fatalf("worker did not acknowledge stop: %#v", progress) + } +} diff --git a/internal/cli/fs_enroll_service_other.go b/internal/cli/fs_enroll_service_other.go new file mode 100644 index 0000000..40dc561 --- /dev/null +++ b/internal/cli/fs_enroll_service_other.go @@ -0,0 +1,7 @@ +//go:build !windows + +package cli + +import "github.com/spf13/cobra" + +func addPlatformEnrollmentCommands(_ *cobra.Command) {} diff --git a/internal/cli/fs_enroll_service_windows.go b/internal/cli/fs_enroll_service_windows.go new file mode 100644 index 0000000..ca1bcf4 --- /dev/null +++ b/internal/cli/fs_enroll_service_windows.go @@ -0,0 +1,164 @@ +//go:build windows + +package cli + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + "unsafe" + + "github.com/samekind/codexfold/internal/service" + "github.com/samekind/codexfold/internal/sessionns" + "github.com/spf13/cobra" + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/svc" +) + +func addPlatformEnrollmentCommands(parent *cobra.Command) { + command := &cobra.Command{Use: "service", Short: "Run the persistent Windows enrollment service"} + command.AddCommand(newEnrollmentServiceCommand(false), newEnrollmentServiceCommand(true)) + command.AddCommand(newEnrollmentServiceInstallCommand()) + parent.AddCommand(command) +} + +func newEnrollmentServiceCommand(validateOnly bool) *cobra.Command { + var definition string + name := "run" + if validateOnly { + name = "validate" + } + command := &cobra.Command{Use: name, Args: cobra.NoArgs, RunE: func(command *cobra.Command, _ []string) error { + if !filepath.IsAbs(definition) { + return errors.New("absolute enrollment service binding path is required") + } + data, err := os.ReadFile(definition) + if err != nil { + return err + } + config, err := service.ParseWindowsEnrollmentConfig(data) + if err != nil { + return err + } + if _, err := windows.StringToSid(config.OwnerSID); err != nil { + return fmt.Errorf("invalid enrollment owner SID: %w", err) + } + if validateOnly { + return writeJSON(command, config) + } + binary, err := os.Executable() + if err != nil { + return err + } + if !strings.EqualFold(filepath.Clean(binary), filepath.Clean(config.BinaryPath)) { + return errors.New("enrollment binding binary does not match this executable") + } + isService, err := svc.IsWindowsService() + if err != nil { + return err + } + if !isService { + return errors.New("enrollment service must be started by the Service Control Manager") + } + if err := containEnrollmentServiceChildren(); err != nil { + return err + } + stdout, stderr, closeLogs, err := openWindowsServiceLogs(service.WindowsConfig{StdoutPath: config.StdoutPath, StderrPath: config.StderrPath}) + if err != nil { + return err + } + defer closeLogs() + if err := windows.SetPriorityClass(windows.CurrentProcess(), windows.BELOW_NORMAL_PRIORITY_CLASS); err != nil { + return err + } + flags := enrollmentFlags{codexHome: config.CodexHome, storeDir: config.Store, mountPoint: config.Mount, nativeRoot: config.NativeRoot, canonicalNamespace: true, stableFor: time.Hour, batchSize: 1, externalWorker: true} + flags.filesystemReady = func() error { + if err := service.ProbeMount(config.Mount); err != nil { + return err + } + state, err := sessionns.Inspect(sessionns.Options{Home: config.CodexHome, Mount: config.Mount, NativeRoot: config.NativeRoot}) + if err != nil { + return err + } + if !state.Active { + return errors.New("the bound canonical namespace is not active") + } + return nil + } + handler := &windowsEnrollmentService{log: stderr, run: func(ctx context.Context) error { return runEnrollmentWorker(ctx, flags, true) }} + _ = stdout + return svc.Run(config.ServiceName, handler) + }} + command.Flags().StringVar(&definition, "definition", "", "Administrator-owned enrollment service binding") + return command +} + +// Children inherit this job. If SCM restarts a crashed host, its interrupted +// pack/migrate children cannot keep mutating the store alongside the new host. +// The host deliberately retains the non-inheritable handle until process exit: +// closing it here would terminate the host itself before reporting SCM status. +func containEnrollmentServiceChildren() error { + job, err := windows.CreateJobObject(nil, nil) + if err != nil { + return err + } + limits := windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{} + limits.BasicLimitInformation.LimitFlags = windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE + if _, err := windows.SetInformationJobObject(job, windows.JobObjectExtendedLimitInformation, uintptr(unsafe.Pointer(&limits)), uint32(unsafe.Sizeof(limits))); err != nil { + windows.CloseHandle(job) + return err + } + if err := windows.AssignProcessToJobObject(job, windows.CurrentProcess()); err != nil { + windows.CloseHandle(job) + return err + } + return nil +} + +type windowsEnrollmentService struct { + run func(context.Context) error + log io.Writer +} + +func (s *windowsEnrollmentService) Execute(_ []string, requests <-chan svc.ChangeRequest, changes chan<- svc.Status) (bool, uint32) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + changes <- svc.Status{State: svc.StartPending, CheckPoint: 1, WaitHint: 15000} + done := make(chan error, 1) + go func() { done <- s.run(ctx) }() + running := svc.Status{State: svc.Running, Accepts: svc.AcceptStop | svc.AcceptShutdown} + changes <- running + for { + select { + case err := <-done: + // A normal user pause never exits the persistent loop. An unexpected + // return must trigger SCM recovery, including a nil return. + _, _ = fmt.Fprintf(s.log, "enrollment service exited unexpectedly: %v\n", err) + return false, 1 + case request := <-requests: + switch request.Cmd { + case svc.Interrogate: + changes <- running + case svc.Stop, svc.Shutdown: + changes <- svc.Status{State: svc.StopPending, CheckPoint: 1, WaitHint: 30000} + cancel() + select { + case err := <-done: + if err != nil && !errors.Is(err, context.Canceled) { + _, _ = fmt.Fprintf(s.log, "enrollment shutdown failed: %v\n", err) + return false, 1 + } + return false, 0 + case <-time.After(30 * time.Second): + _, _ = fmt.Fprintln(s.log, "enrollment shutdown timed out") + return false, 1 + } + } + } + } +} diff --git a/internal/cli/fs_enroll_service_windows_test.go b/internal/cli/fs_enroll_service_windows_test.go new file mode 100644 index 0000000..33cb8ca --- /dev/null +++ b/internal/cli/fs_enroll_service_windows_test.go @@ -0,0 +1,121 @@ +//go:build windows + +package cli + +import ( + "context" + "io" + "os" + "os/exec" + "path/filepath" + "strconv" + "testing" + "time" + + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/svc" +) + +func TestWindowsEnrollmentSCMStopAndShutdown(t *testing.T) { + for _, control := range []svc.Cmd{svc.Stop, svc.Shutdown} { + requests := make(chan svc.ChangeRequest, 1) + changes := make(chan svc.Status, 8) + stopped := make(chan uint32, 1) + handler := &windowsEnrollmentService{log: io.Discard, run: func(ctx context.Context) error { <-ctx.Done(); return ctx.Err() }} + go func() { _, code := handler.Execute(nil, requests, changes); stopped <- code }() + if start := <-changes; start.State != svc.StartPending { + t.Fatal(start) + } + if running := <-changes; running.State != svc.Running || running.Accepts&svc.AcceptShutdown == 0 { + t.Fatal(running) + } + requests <- svc.ChangeRequest{Cmd: control} + if stop := <-changes; stop.State != svc.StopPending { + t.Fatal(stop) + } + select { + case code := <-stopped: + if code != 0 { + t.Fatalf("stop code=%d", code) + } + case <-time.After(time.Second): + t.Fatal("SCM control did not cancel worker") + } + } +} + +func TestWindowsEnrollmentJobContainsChildren(t *testing.T) { + ready := filepath.Join(t.TempDir(), "child-pid") + command := exec.Command(os.Args[0], "-test.run=^TestWindowsEnrollmentJobHostHelper$", "-test.timeout=20s") + command.Env = append(os.Environ(), "CODEXFOLD_TEST_JOB_HOST=1", "CODEXFOLD_TEST_JOB_READY="+ready) + configureEnrollmentChild(command) + if err := command.Start(); err != nil { + t.Fatal(err) + } + defer func() { + if command.ProcessState == nil { + _ = command.Process.Kill() + _ = command.Wait() + } + }() + deadline := time.Now().Add(5 * time.Second) + var pid int + for pid == 0 { + data, err := os.ReadFile(ready) + if err == nil { + pid, _ = strconv.Atoi(string(data)) + } + if time.Now().After(deadline) { + t.Fatal("isolated job child did not start") + } + time.Sleep(10 * time.Millisecond) + } + child, err := windows.OpenProcess(windows.SYNCHRONIZE|windows.PROCESS_TERMINATE, false, uint32(pid)) + if err != nil { + t.Fatal(err) + } + defer windows.CloseHandle(child) + defer windows.TerminateProcess(child, 1) + if err := command.Process.Kill(); err != nil { + t.Fatal(err) + } + _ = command.Wait() + result, err := windows.WaitForSingleObject(child, 5000) + if err != nil || result != windows.WAIT_OBJECT_0 { + t.Fatalf("orphan child survived host crash: wait=%d err=%v", result, err) + } +} + +func TestWindowsEnrollmentJobHostHelper(t *testing.T) { + if os.Getenv("CODEXFOLD_TEST_JOB_HOST") != "1" { + t.Skip("isolated subprocess helper") + } + if err := containEnrollmentServiceChildren(); err != nil { + t.Fatal(err) + } + child := exec.Command(os.Args[0], "-test.run=^TestWindowsEnrollmentJobChildHelper$", "-test.timeout=20s") + child.Env = append(os.Environ(), "CODEXFOLD_TEST_JOB_CHILD=1") + configureEnrollmentChild(child) + if err := child.Start(); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(os.Getenv("CODEXFOLD_TEST_JOB_READY"), []byte(strconv.Itoa(child.Process.Pid)), 0o600); err != nil { + t.Fatal(err) + } + _ = child.Wait() +} + +func TestWindowsEnrollmentJobChildHelper(t *testing.T) { + if os.Getenv("CODEXFOLD_TEST_JOB_CHILD") != "1" { + t.Skip("isolated subprocess helper") + } + time.Sleep(15 * time.Second) +} + +func TestWindowsEnrollmentSCMUnexpectedReturnTriggersRecovery(t *testing.T) { + handler := &windowsEnrollmentService{log: io.Discard, run: func(context.Context) error { return nil }} + _, code := handler.Execute(nil, make(chan svc.ChangeRequest), make(chan svc.Status, 8)) + if code == 0 { + t.Fatal("unexpected exit was reported as a user stop") + } +} diff --git a/internal/cli/fs_enroll_writer.go b/internal/cli/fs_enroll_writer.go index 72f7ffa..5e2e973 100644 --- a/internal/cli/fs_enroll_writer.go +++ b/internal/cli/fs_enroll_writer.go @@ -3,6 +3,7 @@ package cli import ( "context" "path/filepath" + "runtime" "strings" "github.com/samekind/codexfold/internal/codex" @@ -21,6 +22,13 @@ func probeFilesystemMigrationWriter(ctx context.Context, session codex.Session, sessions = append(sessions, codex.Session{ID: session.ID, RolloutPath: alias}) } } + if runtime.GOOS == "windows" && len(sessions) > 1 { + // Canonical migration supplies the validated physical source and + // retained snapshot. Probing the mounted alias after publishing its + // managed owner would conflict with our own migration writer lease. + // Existing native writers remain visible as handles on these files. + sessions = sessions[1:] + } writers, err := enrollmentWriterProbe(ctx, sessions) if err != nil { return false, err diff --git a/internal/cli/fs_enroll_writer_windows.go b/internal/cli/fs_enroll_writer_windows.go index a895b48..5ebee84 100644 --- a/internal/cli/fs_enroll_writer_windows.go +++ b/internal/cli/fs_enroll_writer_windows.go @@ -6,6 +6,7 @@ import ( "context" "errors" "fmt" + "time" "github.com/samekind/codexfold/internal/codex" "golang.org/x/sys/windows" @@ -21,15 +22,24 @@ func detectEnrollmentWriters(ctx context.Context, sessions []codex.Session) (map if err != nil { return nil, fmt.Errorf("encode rollout path for native handle probe: %w", err) } - handle, err := windows.CreateFile( - path, - windows.GENERIC_READ|windows.GENERIC_WRITE, - 0, - nil, - windows.OPEN_EXISTING, - windows.FILE_ATTRIBUTE_NORMAL, - 0, - ) + var handle windows.Handle + deadline := time.Now().Add(250 * time.Millisecond) + for { + handle, err = windows.CreateFile(path, windows.GENERIC_READ|windows.GENERIC_WRITE, 0, nil, windows.OPEN_EXISTING, windows.FILE_ATTRIBUTE_NORMAL, 0) + if !errors.Is(err, windows.ERROR_SHARING_VIOLATION) && !errors.Is(err, windows.ERROR_LOCK_VIOLATION) { + break + } + if time.Now().After(deadline) { + break + } + // Closing a WinFsp alias can finish its backing-handle cleanup + // asynchronously. Do not mistake our preceding probe for a writer. + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(10 * time.Millisecond): + } + } if err == nil { if closeErr := windows.CloseHandle(handle); closeErr != nil { return nil, fmt.Errorf("close native rollout probe: %w", closeErr) @@ -40,6 +50,12 @@ func detectEnrollmentWriters(ctx context.Context, sessions []codex.Session) (map writers[session.ID] = true continue } + if errors.Is(err, windows.ERROR_FILE_NOT_FOUND) || errors.Is(err, windows.ERROR_PATH_NOT_FOUND) { + // SQLite can retain a route after its rollout has been removed. + // Let the planner mark that individual path invalid; its mount and + // namespace gates still block enrollment during an actual outage. + continue + } return nil, fmt.Errorf("probe native rollout handle %s: %w", session.ID, err) } return writers, nil diff --git a/internal/cli/fs_enroll_writer_windows_test.go b/internal/cli/fs_enroll_writer_windows_test.go new file mode 100644 index 0000000..09f7c2c --- /dev/null +++ b/internal/cli/fs_enroll_writer_windows_test.go @@ -0,0 +1,138 @@ +//go:build windows + +package cli + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + "time" + + "github.com/samekind/codexfold/internal/codex" + "github.com/samekind/codexfold/internal/enroll" + "golang.org/x/sys/windows" +) + +func TestWindowsWriterProbeMissingRoutesDoNotBlockOtherSessions(t *testing.T) { + home, store, _ := fsFixture(t, true) + missing := addEnrollmentFixtureSession(t, home, "missing", 1) + if err := os.Remove(missing); err != nil { + t.Fatal(err) + } + live := addEnrollmentFixtureSession(t, home, "live", 2) + writer, err := os.OpenFile(live, os.O_RDWR, 0) + if err != nil { + t.Fatal(err) + } + defer writer.Close() + allowEnrollmentNamespaceReadiness(t) + oldMount := mountHealthProbe + mountHealthProbe = func(string) error { return nil } + t.Cleanup(func() { mountHealthProbe = oldMount }) + flags := enrollmentFlags{codexHome: home, storeDir: store, mountPoint: filepath.Join(home, "mount"), canonicalNamespace: true, stableFor: time.Nanosecond, batchSize: 1} + first, _, err := buildEnrollmentPlan(context.Background(), flags) + if err != nil { + t.Fatal(err) + } + if err := enroll.SaveObservations(enrollmentObservationPath(store), first.Observations); err != nil { + t.Fatal(err) + } + second, _, err := buildEnrollmentPlan(context.Background(), flags) + if err != nil { + t.Fatal(err) + } + if len(second.Selected) != 1 || second.Selected[0].SessionID != "session" { + t.Fatalf("healthy session was blocked: %#v", second) + } + for _, id := range []string{"missing", "live"} { + found := false + for _, d := range second.Decisions { + if d.SessionID != id { + continue + } + want := enroll.ReasonInvalidPath + if id == "live" { + want = enroll.ReasonWriterActive + } + for _, r := range d.Reasons { + if r == want { + found = true + } + } + } + if !found { + t.Fatalf("missing blocking reason for %s: %#v", id, second) + } + } + if _, err := detectEnrollmentWriters(context.Background(), []codex.Session{{ID: "missing-parent", RolloutPath: filepath.Join(home, "absent", "file.jsonl")}}); err != nil { + t.Fatal(err) + } +} + +func TestWindowsWriterProbeStillRejectsAccessDenied(t *testing.T) { + name := filepath.Join(t.TempDir(), "readonly.jsonl") + if err := os.WriteFile(name, []byte("{}\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Chmod(name, 0o400); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(name, 0o600) }) + _, err := detectEnrollmentWriters(context.Background(), []codex.Session{{ID: "readonly", RolloutPath: name}}) + if !errors.Is(err, windows.ERROR_ACCESS_DENIED) { + t.Fatalf("access error must not be skipped: %v", err) + } +} + +func TestWindowsWriterProbeWaitsForClosedHandleAndBlocksLiveWriter(t *testing.T) { + path := filepath.Join(t.TempDir(), "rollout.jsonl") + if err := os.WriteFile(path, []byte("{}\n"), 0o600); err != nil { + t.Fatal(err) + } + file, err := os.OpenFile(path, os.O_RDWR, 0) + if err != nil { + t.Fatal(err) + } + sessions := []codex.Session{{ID: "session", RolloutPath: path}} + writers, err := detectEnrollmentWriters(context.Background(), sessions) + if err != nil || !writers["session"] { + _ = file.Close() + t.Fatalf("live writer was not blocked: %v, %v", writers, err) + } + closed := make(chan struct{}) + go func() { time.Sleep(40 * time.Millisecond); _ = file.Close(); close(closed) }() + writers, err = detectEnrollmentWriters(context.Background(), sessions) + <-closed + if err != nil || writers["session"] { + t.Fatalf("closed backing handle was reported as an active writer: %v, %v", writers, err) + } +} + +func TestWindowsMigrationWriterProbeUsesPhysicalSources(t *testing.T) { + root := t.TempDir() + source := filepath.Join(root, "source.jsonl") + retained := filepath.Join(root, "retained.jsonl") + for _, path := range []string{source, retained} { + if err := os.WriteFile(path, []byte("{}\n"), 0o600); err != nil { + t.Fatal(err) + } + } + // The mounted alias is owned by the migration lease at cutover and + // cannot be exclusively opened. Native writers still hold source handles. + session := codex.Session{ID: "session", RolloutPath: filepath.Join(root, "unavailable-mount", "rollout.jsonl")} + active, err := probeFilesystemMigrationWriter(context.Background(), session, source, retained) + if err != nil || active { + t.Fatalf("physical source probe: active=%t err=%v", active, err) + } + file, err := os.OpenFile(source, os.O_RDWR, 0) + if err != nil { + t.Fatal(err) + } + defer file.Close() + active, err = probeFilesystemMigrationWriter(context.Background(), session, source, retained) + if err != nil || !active { + t.Fatalf("live physical writer was not blocked: active=%t err=%v", active, err) + } +} diff --git a/internal/cli/fs_enrollment_reclaim_test.go b/internal/cli/fs_enrollment_reclaim_test.go index 67048f6..50c17cb 100644 --- a/internal/cli/fs_enrollment_reclaim_test.go +++ b/internal/cli/fs_enrollment_reclaim_test.go @@ -61,7 +61,7 @@ func TestPeriodicEnrollmentDeferredReclaimStaysIncompleteWithoutError(t *testing runPeriodicEnrollment(ctx, enrollmentFlags{storeDir: store}, time.Minute, nil) }() defer func() { cancel(); <-done }() - deadline := time.Now().Add(time.Second) + deadline := time.Now().Add(3 * enrollmentPolicyPollInterval) for time.Now().Before(deadline) { progress, err := enroll.LoadProgress(enroll.ProgressPath(store)) if err == nil && progress.Phase == enroll.PhaseWaitingReclaim { @@ -72,7 +72,8 @@ func TestPeriodicEnrollmentDeferredReclaimStaysIncompleteWithoutError(t *testing } time.Sleep(time.Millisecond) } - t.Fatal("deferred cleanup was not published") + progress, err := enroll.LoadProgress(enroll.ProgressPath(store)) + t.Fatalf("deferred cleanup was not published: progress=%#v error=%v", progress, err) } func TestEnrollmentReclaimProgressWaitsForMaintenance(t *testing.T) { diff --git a/internal/cli/fs_managed_registry.go b/internal/cli/fs_managed_registry.go index 452b73b..d446687 100644 --- a/internal/cli/fs_managed_registry.go +++ b/internal/cli/fs_managed_registry.go @@ -12,6 +12,7 @@ import ( "path/filepath" "sort" + "github.com/samekind/codexfold/internal/dirsync" "github.com/samekind/codexfold/internal/storage" ) @@ -487,7 +488,7 @@ func syncManagedSessionRegistryRoot(root *os.Root) error { return err } defer directory.Close() - if err := directory.Sync(); err != nil { + if err := dirsync.Sync(directory); err != nil { return fmt.Errorf("sync managed session registry directory: %w", err) } return nil diff --git a/internal/cli/fs_managed_reload_wake_test.go b/internal/cli/fs_managed_reload_wake_test.go new file mode 100644 index 0000000..420032e --- /dev/null +++ b/internal/cli/fs_managed_reload_wake_test.go @@ -0,0 +1,32 @@ +package cli + +import ( + "context" + "testing" + "time" +) + +func TestManagedRecoveryRequestWakesHealthyReload(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + wake := make(chan struct{}, 1) + loaded := make(chan struct{}, 1) + done := make(chan struct{}) + go func() { + defer close(done) + runManagedReloadLoopAfterInitial(ctx, time.Minute, 10*time.Minute, nil, wake, + func() error { loaded <- struct{}{}; return nil }, func(error) {}) + }() + wake <- struct{}{} + select { + case <-loaded: + case <-time.After(2 * time.Second): + t.Fatal("recovery request did not wake the healthy backend's long poll") + } + cancel() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("reload did not stop after cancellation") + } +} diff --git a/internal/cli/fs_namespace.go b/internal/cli/fs_namespace.go index c459239..a9d0572 100644 --- a/internal/cli/fs_namespace.go +++ b/internal/cli/fs_namespace.go @@ -81,7 +81,7 @@ func newFSNamespaceActivateCommand() *cobra.Command { if err != nil { return err } - if err := waitForCanonicalNamespaceActivation(command.Context(), options.Mount, options.NativeRoot, 30*time.Second); err != nil { + if err := waitForCanonicalNamespaceActivation(command.Context(), options.Home, options.Mount, options.NativeRoot, 30*time.Second); err != nil { _, rollbackErr := sessionns.Deactivate(options) return errors.Join(fmt.Errorf("wait for canonical namespace passthrough: %w", err), rollbackErr) } diff --git a/internal/cli/fs_namespace_activation_test.go b/internal/cli/fs_namespace_activation_test.go index d26f2c4..bde6e37 100644 --- a/internal/cli/fs_namespace_activation_test.go +++ b/internal/cli/fs_namespace_activation_test.go @@ -42,6 +42,37 @@ func TestWaitForCanonicalNativePassthroughRequiresMatchingFileSize(t *testing.T) } } +func TestWaitForCanonicalNamespaceRequiresHomePathVisibility(t *testing.T) { + root := t.TempDir() + home := filepath.Join(root, "home") + mount := filepath.Join(root, "mount") + native := filepath.Join(root, "native") + for _, base := range []string{home, mount, native} { + for _, namespace := range []string{"sessions", "archived_sessions"} { + if err := os.MkdirAll(filepath.Join(base, namespace), 0o700); err != nil { + t.Fatal(err) + } + } + } + for _, base := range []string{mount, native} { + if err := os.WriteFile(filepath.Join(base, "sessions", "rollout.jsonl"), []byte("history\n"), 0o600); err != nil { + t.Fatal(err) + } + } + if err := waitForCanonicalNativePassthrough(context.Background(), mount, native, time.Second); err != nil { + t.Fatal(err) + } + if err := waitForCanonicalNamespacePassthrough(context.Background(), home, mount, native, time.Millisecond); err == nil { + t.Fatal("a healthy mount with an invisible canonical home passed activation") + } + if err := os.WriteFile(filepath.Join(home, "sessions", "rollout.jsonl"), []byte("history\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := waitForCanonicalNamespacePassthrough(context.Background(), home, mount, native, time.Second); err != nil { + t.Fatal(err) + } +} + func TestFSNamespaceActivationRollsBackWhenPassthroughDoesNotBecomeReady(t *testing.T) { root := t.TempDir() home := filepath.Join(root, "home") @@ -67,7 +98,7 @@ func TestFSNamespaceActivationRollsBackWhenPassthroughDoesNotBecomeReady(t *test mountHealthProbe = func(string) error { return nil } t.Cleanup(func() { mountHealthProbe = previousMountProbe }) previousReadiness := waitForCanonicalNamespaceActivation - waitForCanonicalNamespaceActivation = func(context.Context, string, string, time.Duration) error { + waitForCanonicalNamespaceActivation = func(context.Context, string, string, string, time.Duration) error { return errors.New("mounted tree incomplete") } t.Cleanup(func() { waitForCanonicalNamespaceActivation = previousReadiness }) diff --git a/internal/cli/fs_namespace_readiness.go b/internal/cli/fs_namespace_readiness.go index 3ac6d16..e414f81 100644 --- a/internal/cli/fs_namespace_readiness.go +++ b/internal/cli/fs_namespace_readiness.go @@ -22,7 +22,7 @@ type canonicalNamespaceReadiness struct { // have repopulated, so readiness is intentionally distinct from mount health. var enrollmentCanonicalNamespaceReadinessProbe = probeEnrollmentCanonicalNamespaceReadiness -var waitForCanonicalNamespaceActivation = waitForCanonicalNativePassthrough +var waitForCanonicalNamespaceActivation = waitForCanonicalNamespacePassthrough func probeEnrollmentCanonicalNamespaceReadiness(home string, mount string, nativeRoot string) canonicalNamespaceReadiness { status, err := sessionns.Inspect(sessionns.Options{Home: home, Mount: mount, NativeRoot: nativeRoot, MountProbe: mountHealthProbe}) @@ -32,6 +32,9 @@ func probeEnrollmentCanonicalNamespaceReadiness(home string, mount string, nativ if err := probeCanonicalNativePassthrough(mount, nativeRoot); err != nil { return canonicalNamespaceReadiness{Active: true} } + if err := probeCanonicalNativePassthrough(home, nativeRoot); err != nil { + return canonicalNamespaceReadiness{Active: true} + } return canonicalNamespaceReadiness{Active: true, Ready: true} } @@ -44,6 +47,10 @@ func probeCanonicalNativePassthrough(mount string, nativeRoot string) error { } func waitForCanonicalNativePassthrough(ctx context.Context, mount string, nativeRoot string, timeout time.Duration) error { + return waitForCanonicalNamespacePassthrough(ctx, "", mount, nativeRoot, timeout) +} + +func waitForCanonicalNamespacePassthrough(ctx context.Context, home string, mount string, nativeRoot string, timeout time.Duration) error { if timeout <= 0 { timeout = 30 * time.Second } @@ -51,6 +58,9 @@ func waitForCanonicalNativePassthrough(ctx context.Context, mount string, native var lastErr error for { lastErr = probeCanonicalNativePassthroughMetadata(mount, nativeRoot, true) + if lastErr == nil && home != "" { + lastErr = probeCanonicalNativePassthroughMetadata(home, nativeRoot, true) + } if lastErr == nil { return nil } diff --git a/internal/cli/fs_service.go b/internal/cli/fs_service.go index bded328..9357a6c 100644 --- a/internal/cli/fs_service.go +++ b/internal/cli/fs_service.go @@ -208,15 +208,29 @@ func newFSServiceUpdateBinaryCommand() *cobra.Command { } return errors.Join(fmt.Errorf("promote filesystem service binary: %w", err), rollbackErr, cleanupErr, restartErr) } + restoreEngineBinding := func() error { return nil } + if platform == service.PlatformWindows { + restoreEngineBinding, err = service.PrepareWindowsCoreOfflineUpdate(target) + if err != nil { + rollbackErr := update.Rollback() + var restartErr error + if rollbackErr == nil { + restartErr = startPlatformService(command.Context(), platform, definition, mount) + _ = update.Commit() + } + return errors.Join(err, rollbackErr, restartErr) + } + } if err := startPlatformService(command.Context(), platform, definition, mount); err != nil { _ = stopPlatformService(command.Context(), platform, definition) + bindingErr := restoreEngineBinding() rollbackErr := update.Rollback() var restartErr error - if rollbackErr == nil { + if rollbackErr == nil && bindingErr == nil { restartErr = startPlatformService(command.Context(), platform, definition, mount) _ = update.Commit() } - return errors.Join(fmt.Errorf("start verified filesystem service binary: %w", err), rollbackErr, restartErr) + return errors.Join(fmt.Errorf("start verified filesystem service binary: %w", err), bindingErr, rollbackErr, restartErr) } if err := update.Commit(); err != nil { return fmt.Errorf("remove filesystem binary rollback artifact: %w", err) @@ -951,7 +965,10 @@ func startPlatformService(ctx context.Context, platform service.Platform, defini if err := manager.Start(ctx, label); err != nil { return err } - if _, err := manager.WaitHealthy(ctx, label, mountPoint, 15*time.Second); err != nil { + // Canonical startup validates the retained native history before + // publishing the mount. A complete multi-gigabyte home can exceed + // the short timeout used for the original engine-only preview. + if _, err := manager.WaitHealthy(ctx, label, mountPoint, 5*time.Minute); err != nil { _ = manager.Stop(ctx, label) return err } diff --git a/internal/cli/fs_service_fskit_other.go b/internal/cli/fs_service_fskit_other.go index 14fb690..3a4ad0f 100644 --- a/internal/cli/fs_service_fskit_other.go +++ b/internal/cli/fs_service_fskit_other.go @@ -9,7 +9,7 @@ import ( "github.com/spf13/cobra" ) -func addLiveDaemonUpdateCommand(*cobra.Command) {} +func addLiveDaemonUpdateCommand(parent *cobra.Command) { addWindowsLiveDaemonUpdateCommand(parent) } func prepareFSKitAppPlatform(context.Context, string, string, bool) (fsKitAppTransaction, error) { return nil, errors.New("native FSKit app installation is available only on macOS") diff --git a/internal/cli/fs_service_live_update_other.go b/internal/cli/fs_service_live_update_other.go new file mode 100644 index 0000000..4d92f4f --- /dev/null +++ b/internal/cli/fs_service_live_update_other.go @@ -0,0 +1,7 @@ +//go:build !windows && !darwin + +package cli + +import "github.com/spf13/cobra" + +func addWindowsLiveDaemonUpdateCommand(*cobra.Command) {} diff --git a/internal/cli/fs_service_live_update_windows.go b/internal/cli/fs_service_live_update_windows.go new file mode 100644 index 0000000..c2bca0e --- /dev/null +++ b/internal/cli/fs_service_live_update_windows.go @@ -0,0 +1,100 @@ +//go:build windows + +package cli + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os/exec" + "path/filepath" + "time" + + "github.com/samekind/codexfold/internal/mountfs" + "github.com/samekind/codexfold/internal/mountid" + "github.com/samekind/codexfold/internal/service" + "github.com/spf13/cobra" + "os" +) + +func addWindowsLiveDaemonUpdateCommand(parent *cobra.Command) { + parent.AddCommand(newWindowsCoreUpdateCommand()) + parent.AddCommand(&cobra.Command{Use: "core-protocol", Hidden: true, Args: cobra.NoArgs, + RunE: func(command *cobra.Command, _ []string) error { + return writeJSON(command, struct { + Version int `json:"version"` + }{mountfs.WindowsCoreProtocol}) + }}) +} + +func newWindowsCoreUpdateCommand() *cobra.Command { + var definitionPath string + var apply, jsonOutput bool + command := &cobra.Command{Use: "update-daemon-live ", Short: "Replace the Windows storage engine while preserving its resident WinFsp mount", Args: cobra.ExactArgs(1), + RunE: func(command *cobra.Command, arguments []string) error { + candidate, err := filepath.Abs(arguments[0]) + if err != nil { + return err + } + definition, err := resolveServiceDefinitionPath(definitionPath) + if err != nil { + return err + } + store, err := service.DefinitionStore(service.PlatformWindows, definition) + if err != nil { + return err + } + mount, err := service.DefinitionMountPoint(service.PlatformWindows, definition) + if err != nil { + return err + } + probe, cancelProbe := context.WithTimeout(command.Context(), 5*time.Second) + defer cancelProbe() + executable := exec.CommandContext(probe, candidate, "fs", "service", "core-protocol") + configureEnrollmentChild(executable) + output, err := executable.Output() + var protocol struct { + Version int `json:"version"` + } + if err != nil || json.Unmarshal(output, &protocol) != nil || protocol.Version != mountfs.WindowsCoreProtocol { + return errors.New("candidate does not support the Windows engine protocol") + } + before, err := os.ReadFile(filepath.Join(mount, mountid.Path)) + if err != nil { + return err + } + identity, err := mountid.Parse(before) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(command.Context(), 4*time.Minute) + defer cancel() + var result mountfs.CoreUpdateResult + if err := mountfs.CallWindowsCoreControl(ctx, store, "Update", mountfs.CoreUpdateRequest{Candidate: candidate, Apply: apply}, &result); err != nil { + return err + } + if apply { + after, err := os.ReadFile(filepath.Join(mount, mountid.Path)) + if err != nil { + return err + } + current, err := mountid.Parse(after) + if err != nil { + return err + } + if current.Nonce != identity.Nonce || current.BuildSHA256 != result.CandidateSHA256 { + return errors.New("engine switched but the mounted identity could not be verified") + } + } + if jsonOutput { + return writeJSON(command, result) + } + _, err = fmt.Fprintf(command.OutOrStdout(), "dry_run=%t changed=%t old_engine=%d new_engine=%d mount_preserved=%t build=%s recovery=%s\n", result.DryRun, result.Changed, result.PreviousPID, result.ReplacementPID, result.MountPreserved, result.Build, result.RecoveryBinary) + return err + }} + addServiceDefinitionFlags(command, &definitionPath) + command.Flags().BoolVar(&apply, "apply", false, "Replace the quiescent engine without stopping the mount or clients") + command.Flags().BoolVar(&jsonOutput, "json", false, "Emit JSON output") + return command +} diff --git a/internal/cli/fs_service_runtime_windows.go b/internal/cli/fs_service_runtime_windows.go index 1dd4c8c..431c284 100644 --- a/internal/cli/fs_service_runtime_windows.go +++ b/internal/cli/fs_service_runtime_windows.go @@ -8,17 +8,101 @@ import ( "fmt" "io" "os" + "os/exec" "path/filepath" + "syscall" "time" "github.com/samekind/codexfold/internal/mountfs" "github.com/samekind/codexfold/internal/service" "github.com/spf13/cobra" + "golang.org/x/sys/windows" "golang.org/x/sys/windows/svc" ) func addPlatformServiceCommands(parent *cobra.Command) { parent.AddCommand(newFSServiceRunCommand()) + parent.AddCommand(newFSServiceChildCommand()) +} + +func newFSServiceChildCommand() *cobra.Command { + var definitionPath string + command := &cobra.Command{ + Use: "child", Hidden: true, Args: cobra.NoArgs, + RunE: func(command *cobra.Command, _ []string) error { + if !filepath.IsAbs(definitionPath) { + return errors.New("absolute Windows service definition path is required") + } + definition, err := os.ReadFile(filepath.Clean(definitionPath)) + if err != nil { + return err + } + config, err := service.ParseWindowsConfig(definition) + if err != nil { + return err + } + if config.ServiceName != serviceLabel { + return errors.New("Windows service definition name does not match this binary") + } + if err := os.Setenv("CODEXFOLD_WINDOWS_GLOBAL_MOUNT", "1"); err != nil { + return err + } + ctx, cancel := context.WithCancel(command.Context()) + defer cancel() + // The SCM parent owns this pipe. Closing it, including if the + // parent unexpectedly exits, requests a normal WinFsp unmount. + go func() { _, _ = io.Copy(io.Discard, os.Stdin); cancel() }() + serve := newFSServeCommand() + arguments := append([]string(nil), config.Arguments[2:]...) + arguments = append(arguments, "--frontend=windows-proxy") + serve.SetArgs(arguments) + serve.SetOut(command.OutOrStdout()) + serve.SetErr(command.ErrOrStderr()) + serve.SilenceErrors, serve.SilenceUsage = true, true + err = serve.ExecuteContext(ctx) + if errors.Is(err, context.Canceled) { + return nil + } + return err + }, + } + command.Flags().StringVar(&definitionPath, "definition", "", "Absolute Windows service definition path") + return command +} + +func runWindowsServiceChild(ctx context.Context, config service.WindowsConfig, definitionPath string, stdout, stderr io.Writer) error { + // WinFsp's FUSE loop calls FspServiceRun internally. It cannot run in + // the process already hosting Go's svc.Run (ERROR_SERVICE_ALREADY_RUNNING). + child := exec.Command(config.BinaryPath, "fs", "service", "child", "--definition", definitionPath) + child.Stdout, child.Stderr = stdout, stderr + child.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: windows.CREATE_NO_WINDOW} + control, err := child.StdinPipe() + if err != nil { + return err + } + defer control.Close() + if err := child.Start(); err != nil { + return err + } + done := make(chan error, 1) + go func() { done <- child.Wait() }() + select { + case err := <-done: + if err == nil && ctx.Err() == nil { + return errors.New("Windows filesystem child exited unexpectedly") + } + return err + case <-ctx.Done(): + _ = control.Close() + select { + case err := <-done: + return err + case <-time.After(25 * time.Second): + _ = child.Process.Kill() + <-done + return errors.New("Windows filesystem child did not finish unmounting") + } + } } func newFSServiceRunCommand() *cobra.Command { @@ -58,16 +142,20 @@ func newFSServiceRunCommand() *cobra.Command { return err } defer closeLogs() + // WinFsp writes startup diagnostics through the process standard + // handles, independently of Cobra's writers. SCM supplies no console. + os.Stdout = stdout.(*os.File) + os.Stderr = stderr.(*os.File) + if err := windows.SetStdHandle(windows.STD_OUTPUT_HANDLE, windows.Handle(os.Stdout.Fd())); err != nil { + return err + } + if err := windows.SetStdHandle(windows.STD_ERROR_HANDLE, windows.Handle(os.Stderr.Fd())); err != nil { + return err + } handler := &windowsFSService{ log: stderr, run: func(ctx context.Context) error { - serve := newFSServeCommand() - serve.SetArgs(config.Arguments[2:]) - serve.SetOut(stdout) - serve.SetErr(stderr) - serve.SilenceErrors = true - serve.SilenceUsage = true - return serve.ExecuteContext(ctx) + return runWindowsServiceChild(ctx, config, definitionPath, stdout, stderr) }, } return svc.Run(config.ServiceName, handler) @@ -87,6 +175,7 @@ func (s *windowsFSService) Execute(_ []string, requests <-chan svc.ChangeRequest defer cancel() done := make(chan error, 1) changes <- svc.Status{State: svc.StartPending, CheckPoint: 1, WaitHint: 15000} + _, _ = fmt.Fprintln(s.log, "starting Windows filesystem service") go func() { done <- s.run(ctx) }() running := svc.Status{State: svc.Running, Accepts: svc.AcceptStop | svc.AcceptShutdown} changes <- running diff --git a/internal/cli/fs_status_other.go b/internal/cli/fs_status_other.go new file mode 100644 index 0000000..822c024 --- /dev/null +++ b/internal/cli/fs_status_other.go @@ -0,0 +1,13 @@ +//go:build !windows + +package cli + +import ( + "context" + "github.com/samekind/codexfold/internal/mountfs" + "io" +) + +func startPlatformDaemonStatus(context.Context, string, string, *mountfs.IOActivityCounter, io.Writer) func() { + return func() {} +} diff --git a/internal/cli/fs_status_windows.go b/internal/cli/fs_status_windows.go new file mode 100644 index 0000000..402152b --- /dev/null +++ b/internal/cli/fs_status_windows.go @@ -0,0 +1,59 @@ +//go:build windows + +package cli + +import ( + "context" + "crypto/rand" + "fmt" + "io" + "os" + "path/filepath" + "time" + + "github.com/samekind/codexfold/internal/fskitstatus" + "github.com/samekind/codexfold/internal/mountfs" + "github.com/samekind/codexfold/internal/service" +) + +// The tray consumes aggregate status only; it never reads rollout contents. +func startPlatformDaemonStatus(parent context.Context, store, mount string, activity *mountfs.IOActivityCounter, diagnostics io.Writer) func() { + ctx, cancel := context.WithCancel(parent) + done := make(chan struct{}) + go func() { + defer close(done) + path := filepath.Join(store, "fs", "status", "daemon.json") + instance := rand.Text() + var sequence uint64 + publish := func(state, detail string) { + sequence++ + totals := activity.Snapshot() + snapshot := fskitstatus.Snapshot{ + Component: "daemon", State: state, Detail: detail, + MountPoint: mount, ResourcePath: filepath.Join(store, "fs"), PID: os.Getpid(), + PublisherInstanceID: instance, BackendID: filepath.Clean(store), ObservationSequence: sequence, + ReadBytesTotal: &totals.ReadBytes, WrittenBytesTotal: &totals.WrittenBytes, + } + if err := fskitstatus.Write(path, snapshot); err != nil { + _, _ = fmt.Fprintf(diagnostics, "write Windows daemon status: %v\n", err) + } + } + publish("starting", "Waiting for the WinFsp mount") + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + publish("stopped", "Filesystem service stopped") + return + case <-ticker.C: + if err := service.ProbeMount(mount); err != nil { + publish("unavailable", err.Error()) + } else { + publish("healthy", "WinFsp mount is responding") + } + } + } + }() + return func() { cancel(); <-done } +} diff --git a/internal/cli/fs_test.go b/internal/cli/fs_test.go index 279a67b..d1367fc 100644 --- a/internal/cli/fs_test.go +++ b/internal/cli/fs_test.go @@ -223,7 +223,7 @@ func TestFSNamespaceActivateAndDeactivateCommandsPreserveNativeFiles(t *testing. t.Cleanup(func() { mountHealthProbe = previousProbe }) mountHealthProbe = func(string) error { return nil } previousReadiness := waitForCanonicalNamespaceActivation - waitForCanonicalNamespaceActivation = func(context.Context, string, string, time.Duration) error { return nil } + waitForCanonicalNamespaceActivation = func(context.Context, string, string, string, time.Duration) error { return nil } t.Cleanup(func() { waitForCanonicalNamespaceActivation = previousReadiness }) executeFS(t, []string{ "fs", "namespace", "activate", "--apply", @@ -1916,7 +1916,12 @@ func TestPeriodicEnrollmentRecoversAfterMalformedPolicyIsFixed(t *testing.T) { started := make(chan struct{}, 1) runServiceEnrollmentCycle = func(context.Context, enrollmentFlags, enrollmentApplyHooks) (FSEnrollmentApplyResult, error) { - started <- struct{}{} + // Only the first start is observed below. Later 1ms cycles must not + // block the scheduler or cancellation while status is being polled. + select { + case started <- struct{}{}: + default: + } return FSEnrollmentApplyResult{}, nil } ctx, cancel := context.WithCancel(context.Background()) diff --git a/internal/cli/fs_windows_core.go b/internal/cli/fs_windows_core.go new file mode 100644 index 0000000..b849524 --- /dev/null +++ b/internal/cli/fs_windows_core.go @@ -0,0 +1,125 @@ +//go:build windows + +package cli + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "time" + + "github.com/samekind/codexfold/internal/enroll" + "github.com/samekind/codexfold/internal/mountfs" + "github.com/samekind/codexfold/internal/service" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +type windowsCoreBootKey struct{} + +func prepareWindowsStorageEngine(command *cobra.Command) (func(), error) { + boot, err := mountfs.ReadWindowsCoreBoot(os.Stdin) + if err != nil { + return nil, err + } + ctx, cancel := context.WithCancel(command.Context()) + ctx = context.WithValue(ctx, windowsCoreBootKey{}, boot) + command.SetContext(ctx) + go func() { _, _ = io.Copy(io.Discard, os.Stdin); cancel() }() + return cancel, nil +} + +func serveWindowsStorageEngine(ctx context.Context, command *cobra.Command, filesystem *mountfs.Filesystem, onReady func()) error { + boot, ok := command.Context().Value(windowsCoreBootKey{}).(mountfs.WindowsCoreBoot) + if !ok { + return errors.New("storage engine has no private parent binding") + } + return mountfs.ServeWindowsCore(ctx, filesystem, boot, onReady) +} + +func serveWindowsResidentHost(command *cobra.Command, home, store, mount string, foreground bool) error { + lock, err := service.AcquireProcessLock(filepath.Join(store, "fs", "frontend.lock")) + if err != nil { + return err + } + defer lock.Close() + binary, err := os.Executable() + if err != nil { + return err + } + root := filepath.Join(store, "fs", "windows-core") + if os.Getenv("CODEXFOLD_WINDOWS_GLOBAL_MOUNT") == "1" { + // SYSTEM never trusts an executable pointer from the user's store. + root = filepath.Join(filepath.Dir(binary), "Core") + } + arguments := []string{"fs", "serve"} + command.Flags().Visit(func(flag *pflag.Flag) { + if flag.Name != "frontend" { + arguments = append(arguments, "--"+flag.Name+"="+flag.Value.String()) + } + }) + arguments = append(arguments, "--frontend=windows-engine") + host, err := mountfs.NewWindowsCoreHost(command.Context(), mountfs.WindowsCoreOptions{ + Binary: binary, Root: root, Store: store, Arguments: arguments, + Stdout: command.OutOrStdout(), Stderr: command.ErrOrStderr(), + BeforeUpdate: func() error { return requireWindowsEnrollmentPaused(store) }, + }) + if err != nil { + return err + } + defer host.Close() + stopControl, err := host.ServeControl(command.Context()) + if err != nil { + return err + } + defer stopControl() + activity := &mountfs.IOActivityCounter{} + var recorder func(string) + if trace, _ := command.Flags().GetString("operation-trace"); trace != "" { + var closer io.Closer + recorder, closer, err = newOperationRecorder(trace) + if err != nil { + return err + } + defer closer.Close() + } + stopStatus := startPlatformDaemonStatus(command.Context(), store, mount, activity, command.ErrOrStderr()) + defer stopStatus() + return mountfs.Mount(command.Context(), mountfs.HostOptions{ + MountPoint: mount, StorageRoot: store, NamespaceRoot: home, Backend: host, + BackendBuild: host.BuildSHA256, BackendHealthy: host.Healthy, + BuildSHA256: host.BuildSHA256(), Foreground: foreground, Activity: activity, + OperationRecorder: recorder, + }) +} + +func requireWindowsEnrollmentPaused(store string) error { + for _, paths := range [][2]string{{enroll.ControlPath(store), enroll.ProgressPath(store)}, {enroll.WorkerControlPath(store), enroll.WorkerProgressPath(store)}} { + policy, err := enroll.LoadControl(paths[0]) + if err != nil { + return err + } + if policy.Present && policy.Enabled { + return errors.New("pause automatic folding before a live engine update") + } + if _, err := os.Stat(paths[1]); errors.Is(err, os.ErrNotExist) && !policy.Present { + continue + } else if err != nil { + return fmt.Errorf("cannot inspect the paused folding acknowledgement: %w", err) + } + progress, err := enroll.LoadProgress(paths[1]) + if errors.Is(err, os.ErrNotExist) && !policy.Present { + continue + } + if err != nil { + return fmt.Errorf("cannot verify the paused folding acknowledgement: %w", err) + } + if progress.Phase != enroll.PhaseDisabled || progress.UpdatedAt.IsZero() || time.Since(progress.UpdatedAt) > 30*time.Second || time.Until(progress.UpdatedAt) > 5*time.Second { + return errors.New("wait for a fresh paused folding acknowledgement before a live engine update") + } + } + return nil +} diff --git a/internal/cli/fs_windows_core_other.go b/internal/cli/fs_windows_core_other.go new file mode 100644 index 0000000..afd1d54 --- /dev/null +++ b/internal/cli/fs_windows_core_other.go @@ -0,0 +1,20 @@ +//go:build !windows + +package cli + +import ( + "context" + "errors" + "github.com/samekind/codexfold/internal/mountfs" + "github.com/spf13/cobra" +) + +func prepareWindowsStorageEngine(*cobra.Command) (func(), error) { + return nil, errors.New("Windows engine unavailable") +} +func serveWindowsStorageEngine(context.Context, *cobra.Command, *mountfs.Filesystem, func()) error { + return errors.New("Windows engine unavailable") +} +func serveWindowsResidentHost(*cobra.Command, string, string, string, bool) error { + return errors.New("Windows engine unavailable") +} diff --git a/internal/cli/fs_windows_core_test.go b/internal/cli/fs_windows_core_test.go new file mode 100644 index 0000000..bb38d45 --- /dev/null +++ b/internal/cli/fs_windows_core_test.go @@ -0,0 +1,47 @@ +//go:build windows + +package cli + +import ( + "github.com/samekind/codexfold/internal/enroll" + "testing" + "time" +) + +func TestWindowsCorePauseAcknowledgements(t *testing.T) { + store := t.TempDir() + if err := requireWindowsEnrollmentPaused(store); err != nil { + t.Fatal("no loop configured", err) + } + if err := enroll.SaveProgress(enroll.ProgressPath(store), enroll.Progress{StorePath: store, Phase: enroll.PhaseDisabled, UpdatedAt: time.Now()}); err != nil { + t.Fatal(err) + } + if err := requireWindowsEnrollmentPaused(store); err != nil { + t.Fatal("absent external worker must not block a fresh builtin pause", err) + } + policy := enroll.Control{Present: true, Enabled: false, Interval: time.Minute, StableFor: time.Hour, BatchSize: 5} + if err := enroll.SaveControl(enroll.WorkerControlPath(store), policy); err != nil { + t.Fatal(err) + } + if err := requireWindowsEnrollmentPaused(store); err == nil { + t.Fatal("missing configured worker acknowledgement accepted") + } + if err := enroll.SaveProgress(enroll.WorkerProgressPath(store), enroll.Progress{StorePath: store, Phase: enroll.PhaseReclaiming, UpdatedAt: time.Now()}); err != nil { + t.Fatal(err) + } + if err := requireWindowsEnrollmentPaused(store); err == nil { + t.Fatal("active worker accepted") + } + if err := enroll.SaveProgress(enroll.WorkerProgressPath(store), enroll.Progress{StorePath: store, Phase: enroll.PhaseDisabled, UpdatedAt: time.Now().Add(-time.Minute)}); err != nil { + t.Fatal(err) + } + if err := requireWindowsEnrollmentPaused(store); err == nil { + t.Fatal("stale pause accepted") + } + if err := enroll.SaveProgress(enroll.WorkerProgressPath(store), enroll.Progress{StorePath: store, Phase: enroll.PhaseDisabled, UpdatedAt: time.Now()}); err != nil { + t.Fatal(err) + } + if err := requireWindowsEnrollmentPaused(store); err != nil { + t.Fatal(err) + } +} diff --git a/internal/dirsync/sync_test.go b/internal/dirsync/sync_test.go new file mode 100644 index 0000000..fed6882 --- /dev/null +++ b/internal/dirsync/sync_test.go @@ -0,0 +1,22 @@ +package dirsync + +import ( + "os" + "testing" +) + +func TestDirectoryAndClosedHandle(t *testing.T) { + directory, err := os.Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + if err := Sync(directory); err != nil { + t.Fatal(err) + } + if err := directory.Close(); err != nil { + t.Fatal(err) + } + if err := Sync(directory); err == nil { + t.Fatal("closed directory was accepted") + } +} diff --git a/internal/dirsync/sync_unix.go b/internal/dirsync/sync_unix.go new file mode 100644 index 0000000..ad1883b --- /dev/null +++ b/internal/dirsync/sync_unix.go @@ -0,0 +1,8 @@ +//go:build !windows + +// Package dirsync isolates the platform-specific directory flush policy. +package dirsync + +import "os" + +func Sync(directory *os.File) error { return directory.Sync() } diff --git a/internal/dirsync/sync_windows.go b/internal/dirsync/sync_windows.go new file mode 100644 index 0000000..ca2d02c --- /dev/null +++ b/internal/dirsync/sync_windows.go @@ -0,0 +1,25 @@ +//go:build windows + +package dirsync + +import ( + "fmt" + "os" +) + +// Sync validates the directory handle on Windows. Windows does not expose the +// POSIX fsync-directory operation on a read-only directory handle: File.Sync +// calls FlushFileBuffers and fails with ERROR_ACCESS_DENIED. Data files must +// still be flushed before publication. Existing MoveFileEx write-through paths +// remain in use, but this is NOT a power-loss durability guarantee for directory +// metadata. Windows remains a preview until its crash/power-loss gates pass. +func Sync(directory *os.File) error { + info, err := directory.Stat() + if err != nil { + return err + } + if !info.IsDir() { + return fmt.Errorf("directory sync requires a directory: %s", directory.Name()) + } + return nil +} diff --git a/internal/enroll/control.go b/internal/enroll/control.go index befb6df..ffc17a7 100644 --- a/internal/enroll/control.go +++ b/internal/enroll/control.go @@ -2,6 +2,8 @@ package enroll import ( "bytes" + "crypto/sha256" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -15,14 +17,17 @@ const ( controlVersion = 1 progressVersion = 1 - PhaseDisabled = "disabled" - PhaseIdle = "idle" - PhaseChecking = "checking" - PhaseFolding = "folding" - PhasePacking = "packing" - PhaseMigrating = "migrating" - PhaseReclaiming = "reclaiming" - PhaseWaitingReclaim = "waiting-reclaim" + PhaseDisabled = "disabled" + PhaseIdle = "idle" + PhaseChecking = "checking" + PhaseFolding = "folding" + PhasePacking = "packing" + PhaseMigrating = "migrating" + PhaseReclaiming = "reclaiming" + PhaseWaitingReclaim = "waiting-reclaim" + PhaseWaitingFilesystem = "waiting-filesystem" + PhaseConfigInvalid = "config-invalid" + PhaseStopped = "stopped" ) // Control is the hot-reloaded automatic-folding policy written by the Host GUI @@ -39,6 +44,11 @@ type Control struct { // cannot be decoded or validated. A malformed policy must fail closed // instead of silently reviving stale process flags. ConfigError string + // Runtime gating is separate from the persisted user's intent. These fields + // are internal and never written back into the policy file. + RequestedEnabled bool + BlockedPhase string + BlockReason string } type controlFile struct { @@ -97,14 +107,36 @@ func ProgressPath(store string) string { return filepath.Join(filepath.Clean(store), "enrollment", "status.json") } +func WorkerControlPath(store string) string { + return filepath.Join(filepath.Clean(store), "enrollment", "worker-policy.json") +} + +func WorkerProgressPath(store string) string { + return filepath.Join(filepath.Clean(store), "enrollment", "worker-status.json") +} + func LoadControl(path string) (Control, error) { - data, err := os.ReadFile(path) + control, _, err := LoadControlRevision(path) + return control, err +} + +// LoadControlRevision binds the displayed settings to the exact bytes read. +// A malformed file still has a revision, allowing an explicit GUI repair to +// reject a concurrent replacement rather than silently overwriting it. +func LoadControlRevision(path string) (Control, string, error) { + data, err := readControlFile(path) if errors.Is(err, os.ErrNotExist) { - return Control{}, nil + return Control{}, "", nil } if err != nil { - return Control{}, err + return Control{}, "", err } + digest := sha256.Sum256(data) + control, err := decodeControl(data) + return control, hex.EncodeToString(digest[:]), err +} + +func decodeControl(data []byte) (Control, error) { decoder := json.NewDecoder(bytes.NewReader(data)) decoder.DisallowUnknownFields() var stored controlFile @@ -164,7 +196,7 @@ func SaveControl(path string, control Control) error { } func LoadProgress(path string) (Progress, error) { - data, err := os.ReadFile(path) + data, err := readControlFile(path) if errors.Is(err, os.ErrNotExist) { return Progress{}, nil } @@ -298,7 +330,7 @@ func writeAtomicJSON(path string, temporaryPattern string, value any) error { if err := temporary.Close(); err != nil { return err } - if err := os.Rename(temporaryPath, path); err != nil { + if err := replaceControlFile(temporaryPath, path); err != nil { return err } return syncObservationDirectory(directory) diff --git a/internal/enroll/control_read_other.go b/internal/enroll/control_read_other.go new file mode 100644 index 0000000..366b84d --- /dev/null +++ b/internal/enroll/control_read_other.go @@ -0,0 +1,9 @@ +//go:build !windows + +package enroll + +import "os" + +func readControlFile(path string) ([]byte, error) { return os.ReadFile(path) } + +func replaceControlFile(source, target string) error { return os.Rename(source, target) } diff --git a/internal/enroll/control_read_windows.go b/internal/enroll/control_read_windows.go new file mode 100644 index 0000000..44e7037 --- /dev/null +++ b/internal/enroll/control_read_windows.go @@ -0,0 +1,44 @@ +//go:build windows + +package enroll + +import ( + "errors" + "io" + "os" + "time" + + "golang.org/x/sys/windows" +) + +// Policy/status writers publish by atomic replacement. Readers must allow +// delete sharing so a concurrent hot-policy update can replace their old file. +func readControlFile(path string) ([]byte, error) { + name, err := windows.UTF16PtrFromString(path) + if err != nil { + return nil, err + } + handle, err := windows.CreateFile(name, windows.GENERIC_READ, windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, nil, windows.OPEN_EXISTING, windows.FILE_ATTRIBUTE_NORMAL, 0) + if err != nil { + return nil, &os.PathError{Op: "open", Path: path, Err: err} + } + file := os.NewFile(uintptr(handle), path) + defer file.Close() + return io.ReadAll(file) +} + +func replaceControlFile(source, target string) error { + deadline := time.Now().Add(time.Second) + for { + err := os.Rename(source, target) + if err == nil { + return nil + } + if !(errors.Is(err, windows.ERROR_ACCESS_DENIED) || errors.Is(err, windows.ERROR_SHARING_VIOLATION) || errors.Is(err, windows.ERROR_LOCK_VIOLATION)) || time.Now().After(deadline) { + return err + } + // An older host or external reader may briefly omit delete sharing. + // Retry the atomic replacement; never remove the visible policy. + time.Sleep(10 * time.Millisecond) + } +} diff --git a/internal/enroll/control_windows_test.go b/internal/enroll/control_windows_test.go new file mode 100644 index 0000000..80ba98d --- /dev/null +++ b/internal/enroll/control_windows_test.go @@ -0,0 +1,33 @@ +//go:build windows + +package enroll + +import ( + "os" + "testing" + "time" +) + +func TestPolicyUpdateWaitsForLegacyReaderWithoutDeleteSharing(t *testing.T) { + path := ControlPath(t.TempDir()) + control := Control{Interval: time.Minute, StableFor: time.Hour, BatchSize: 1} + if err := SaveControl(path, control); err != nil { + t.Fatal(err) + } + reader, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + closed := make(chan struct{}) + go func() { time.Sleep(50 * time.Millisecond); _ = reader.Close(); close(closed) }() + control.Enabled = true + err = SaveControl(path, control) + <-closed + if err != nil { + t.Fatal(err) + } + got, err := LoadControl(path) + if err != nil || !got.Enabled { + t.Fatalf("updated policy not visible: %#v %v", got, err) + } +} diff --git a/internal/fskitstatus/status.go b/internal/fskitstatus/status.go index bcbea0b..846acc0 100644 --- a/internal/fskitstatus/status.go +++ b/internal/fskitstatus/status.go @@ -12,6 +12,8 @@ import ( "path/filepath" "sync" "time" + + "github.com/samekind/codexfold/internal/dirsync" ) const ( @@ -523,7 +525,7 @@ func syncRootDirectory(root *os.Root) error { if err != nil { return err } - syncErr := directory.Sync() + syncErr := dirsync.Sync(directory) closeErr := directory.Close() return errors.Join(syncErr, closeErr) } diff --git a/internal/mountfs/drive_mount_other.go b/internal/mountfs/drive_mount_other.go new file mode 100644 index 0000000..942feac --- /dev/null +++ b/internal/mountfs/drive_mount_other.go @@ -0,0 +1,7 @@ +//go:build !windows + +package mountfs + +func prepareWindowsDriveMount(string) (bool, error) { return false, nil } +func platformMountTarget(path string) string { return path } +func platformMountSecurity(string) ([]string, error) { return nil, nil } diff --git a/internal/mountfs/drive_mount_windows.go b/internal/mountfs/drive_mount_windows.go new file mode 100644 index 0000000..39289af --- /dev/null +++ b/internal/mountfs/drive_mount_windows.go @@ -0,0 +1,68 @@ +//go:build windows + +package mountfs + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/svc" +) + +func windowsDriveRoot(path string) (byte, bool) { + path = filepath.Clean(path) + if len(path) != 3 || path[1] != ':' || path[2] != '\\' { + return 0, false + } + letter := strings.ToUpper(path[:1])[0] + return letter, letter >= 'A' && letter <= 'Z' +} + +func prepareWindowsDriveMount(path string) (bool, error) { + letter, drive := windowsDriveRoot(path) + if !drive { + return false, nil + } + drives, err := windows.GetLogicalDrives() + if err != nil { + return true, err + } + if drives&(1<= int64(len(f.mountIdentity)) { + identity := f.healthBytes() + if offset < 0 || offset >= int64(len(identity)) { f.recordIO("read", name, handle, offset, len(destination), 0) return 0 } - n := copy(destination, f.mountIdentity[offset:]) + n := copy(destination, identity[offset:]) f.recordIO("read", name, handle, offset, len(destination), n) return n } - n, errno := f.core.Read(handle, destination, offset) + n, errno := f.operations().Read(handle, destination, offset) if errno != 0 { - result := -int(errno) + result := fuseResult(errno) f.recordIO("read", name, handle, offset, len(destination), result) return result } f.recordIO("read", name, handle, offset, len(destination), n) + f.activity.recordRead(n) return n } func (f *fuseFilesystem) Write(name string, data []byte, offset int64, handle uint64) int { - n, errno := f.core.Write(handle, data, offset) + n, errno := f.operations().Write(handle, data, offset) if errno != 0 { - result := -int(errno) + result := fuseResult(errno) f.recordIO("write", name, handle, offset, len(data), result) return result } f.recordIO("write", name, handle, offset, len(data), n) + f.activity.recordWrite(n) return n } func (f *fuseFilesystem) Truncate(name string, size int64, handle uint64) int { var errno syscall.Errno if handle == 0 || handle == ^uint64(0) { - errno = f.core.TruncatePath(name, size) + errno = f.operations().TruncatePath(name, size) } else { - errno = f.core.Truncate(handle, size) + errno = f.operations().Truncate(handle, size) } - result := -int(errno) + result := fuseResult(errno) f.record(fmt.Sprintf("truncate kind=%s handle=%d size=%d result=%d", operationKind(name), handle, size, result)) return result } @@ -215,7 +254,7 @@ func (f *fuseFilesystem) Flush(name string, handle uint64) int { f.recordHandleResult("flush", name, handle, 0) return 0 } - result := -int(f.core.Flush(handle)) + result := fuseResult(f.operations().Flush(handle)) f.recordHandleResult("flush", name, handle, result) return result } @@ -225,7 +264,7 @@ func (f *fuseFilesystem) Fsync(name string, dataOnly bool, handle uint64) int { f.record(fmt.Sprintf("fsync kind=%s handle=%d datasync=%t result=0", operationKind(name), handle, dataOnly)) return 0 } - result := -int(f.core.Fsync(handle)) + result := fuseResult(f.operations().Fsync(handle)) f.record(fmt.Sprintf("fsync kind=%s handle=%d datasync=%t result=%d", operationKind(name), handle, dataOnly, result)) return result } @@ -235,49 +274,49 @@ func (f *fuseFilesystem) Release(name string, handle uint64) int { f.recordHandleResult("release", name, handle, 0) return 0 } - result := -int(f.core.Release(handle)) + result := fuseResult(f.operations().Release(handle)) f.recordHandleResult("release", name, handle, result) return result } func (f *fuseFilesystem) Mkdir(name string, mode uint32) int { - result := -int(f.core.Mkdir(name, mode)) + result := fuseResult(f.operations().Mkdir(name, mode)) f.recordResult("mkdir", name, result) return result } func (f *fuseFilesystem) Rmdir(name string) int { - result := -int(syscall.ENOSYS) + result := fuseResult(syscall.ENOSYS) f.recordResult("rmdir", name, result) return result } func (f *fuseFilesystem) Link(oldName string, _ string) int { - result := -int(syscall.ENOSYS) + result := fuseResult(syscall.ENOSYS) f.recordResult("link", oldName, result) return result } func (f *fuseFilesystem) Symlink(_ string, newName string) int { - result := -int(syscall.ENOSYS) + result := fuseResult(syscall.ENOSYS) f.recordResult("symlink", newName, result) return result } func (f *fuseFilesystem) Readlink(name string) (int, string) { - result := -int(syscall.ENOSYS) + result := fuseResult(syscall.ENOSYS) f.recordResult("readlink", name, result) return result, "" } func (f *fuseFilesystem) Rename(oldName string, newName string) int { - result := -int(f.core.Rename(oldName, newName)) + result := fuseResult(f.operations().Rename(oldName, newName)) f.recordResult("rename", oldName, result) return result } func (f *fuseFilesystem) Unlink(name string) int { - result := -int(f.core.Unlink(name)) + result := fuseResult(f.operations().Unlink(name)) f.recordResult("unlink", name, result) return result } @@ -287,11 +326,14 @@ func (f *fuseFilesystem) Access(name string, _ uint32) int { if cleanPath(name) == "/"+mountid.Path { return 0 } - _, errno := f.core.Getattr(name) - return -int(errno) + _, errno := f.operations().Getattr(name) + return fuseResult(errno) } func (f *fuseFilesystem) Chmod(name string, mode uint32) int { + if remote, ok := f.backend.(HostMetadataBackend); ok { + return fuseResult(remote.Metadata("chmod", name, mode, 0, time.Time{}, time.Time{})) + } path, managed, errc := f.metadataPath(name) if errc != 0 { f.recordResult("chmod", name, errc) @@ -306,6 +348,9 @@ func (f *fuseFilesystem) Chmod(name string, mode uint32) int { } func (f *fuseFilesystem) Chown(name string, uid uint32, gid uint32) int { + if remote, ok := f.backend.(HostMetadataBackend); ok { + return fuseResult(remote.Metadata("chown", name, uid, gid, time.Time{}, time.Time{})) + } path, managed, errc := f.metadataPath(name) if errc != 0 { f.recordResult("chown", name, errc) @@ -320,6 +365,13 @@ func (f *fuseFilesystem) Chown(name string, uid uint32, gid uint32) int { } func (f *fuseFilesystem) Utimens(name string, times []fuse.Timespec) int { + if remote, ok := f.backend.(HostMetadataBackend); ok { + if len(times) != 2 { + return fuseResult(syscall.EINVAL) + } + return fuseResult(remote.Metadata("utimens", name, 0, 0, + time.Unix(times[0].Sec, times[0].Nsec), time.Unix(times[1].Sec, times[1].Nsec))) + } path, managed, errc := f.metadataPath(name) if errc != 0 { f.recordResult("utimens", name, errc) @@ -328,7 +380,7 @@ func (f *fuseFilesystem) Utimens(name string, times []fuse.Timespec) int { result := 0 if !managed { if len(times) != 2 { - result = -int(syscall.EINVAL) + result = fuseResult(syscall.EINVAL) } else { result = setFileTimes(path, times) } @@ -383,13 +435,16 @@ func (f *fuseFilesystem) Removexattr(name string, attribute string) int { } func (f *fuseFilesystem) xattrPath(name string, create bool) (string, int) { + if f.core == nil { + return "", fuseResult(syscall.ENOSYS) + } cleaned := cleanPath(name) if _, _, errno := f.core.sessionForPath(cleaned); errno == 0 { f.core.mu.RLock() root := f.core.nativeRoot f.core.mu.RUnlock() if root == "" { - return "", -int(syscall.ENOTSUP) + return "", fuseResult(syscall.ENOTSUP) } carrier := managedXattrCarrier(root, cleaned) if create { @@ -409,7 +464,7 @@ func (f *fuseFilesystem) xattrPath(name string, create bool) (string, int) { if native, ok := f.core.nativePath(cleaned); ok { return native, 0 } - return "", -int(syscall.ENOENT) + return "", fuseResult(syscall.ENOENT) } func (f *fuseFilesystem) metadataPath(name string) (string, bool, int) { @@ -420,7 +475,7 @@ func (f *fuseFilesystem) metadataPath(name string) (string, bool, int) { if native, ok := f.core.nativePath(cleaned); ok { return native, false, 0 } - return "", false, -int(syscall.ENOENT) + return "", false, fuseResult(syscall.ENOENT) } func unixResult(err error) int { @@ -429,9 +484,9 @@ func unixResult(err error) int { } var errno syscall.Errno if errors.As(err, &errno) { - return -int(errno) + return fuseResult(errno) } - return -int(syscall.EIO) + return fuseResult(syscall.EIO) } func (f *fuseFilesystem) record(operation string) { @@ -491,6 +546,12 @@ func translateOpenFlags(flags int) int { if flags&fuse.O_EXCL != 0 { translated |= os.O_EXCL } + if runtime.GOOS == "windows" && translated&os.O_WRONLY != 0 { + // WinFsp may read boundary blocks through a write handle while + // preparing an append. Windows access checks have already approved + // the caller; the backing handle needs both capabilities. + translated = translated&^os.O_WRONLY | os.O_RDWR + } return translated } @@ -515,11 +576,22 @@ func mountHost(ctx context.Context, options HostOptions) (result error) { if err != nil { return fmt.Errorf("generate mount identity: %w", err) } + statRoot := options.StorageRoot + if statRoot == "" { + statRoot = filepath.Dir(options.MountPoint) + if runtime.GOOS == "windows" && statRoot == options.MountPoint { + return errors.New("Windows drive mounts require a physical storage root") + } + } filesystem := &fuseFilesystem{ - core: options.Filesystem, - recorder: options.OperationRecorder, - mountIdentity: []byte(identity), - statRoot: filepath.Dir(options.MountPoint), + core: options.Filesystem, + backend: options.Backend, + backendBuild: options.BackendBuild, + backendHealthy: options.BackendHealthy, + recorder: options.OperationRecorder, + mountIdentity: []byte(identity), + statRoot: statRoot, + activity: options.Activity, } host := fuse.NewFileSystemHost(filesystem) backing, err := prepareMountedBacking(options.MountPoint) @@ -533,12 +605,28 @@ func mountHost(ctx context.Context, options HostOptions) (result error) { } }() arguments := []string{"-o", "fsname=codexfold", "-o", "default_permissions", "-o", "attr_timeout=0", "-o", "entry_timeout=0", "-o", "negative_timeout=0"} + securityArguments, err := platformMountSecurity(options.NamespaceRoot) + if err != nil { + return err + } + arguments = append(arguments, securityArguments...) if options.Foreground { arguments = append(arguments, "-f") } if runtime.GOOS == "darwin" { arguments = append(arguments, "-o", "backend=nfs", "-o", "volname=CodexFold") } + mountTarget := platformMountTarget(options.MountPoint) + if runtime.GOOS == "windows" && filepath.Dir(options.MountPoint) == options.MountPoint { + arguments = append(arguments, "-o", "volname=CodexFold") + if !strings.HasPrefix(mountTarget, `\\.\`) { + // Foreground previews use a network drive so ordinary clients can + // canonicalize its paths without administrator privileges. SCM + // uses a Mount Manager disk drive, which also supports the NTFS + // junctions in the canonical session namespace. + arguments = append(arguments, "-o", "VolumePrefix="+windowsVolumePrefix(options.NamespaceRoot, options.MountPoint)) + } + } done := make(chan struct{}) go func() { select { @@ -561,7 +649,7 @@ func mountHost(ctx context.Context, options HostOptions) (result error) { } policyDone <- err }() - mounted := host.Mount(options.MountPoint, arguments) + mounted := host.Mount(mountTarget, arguments) cancelPolicy() policyErr := <-policyDone backingErr := backing.Close() diff --git a/internal/mountfs/host_platform_windows.go b/internal/mountfs/host_platform_windows.go index 330a020..ccf56e5 100644 --- a/internal/mountfs/host_platform_windows.go +++ b/internal/mountfs/host_platform_windows.go @@ -37,17 +37,17 @@ func setFileTimes(path string, times []fuse.Timespec) int { } func setExtendedAttribute(string, string, []byte, int) int { - return -int(syscall.ENOSYS) + return fuseResult(syscall.ENOSYS) } func getExtendedAttribute(string, string) (int, []byte) { - return -int(syscall.ENOSYS), nil + return fuseResult(syscall.ENOSYS), nil } func listExtendedAttributes(string) (int, []string) { - return -int(syscall.ENOSYS), nil + return fuseResult(syscall.ENOSYS), nil } func removeExtendedAttribute(string, string) int { - return -int(syscall.ENOSYS) + return fuseResult(syscall.ENOSYS) } diff --git a/internal/mountfs/host_safety_test.go b/internal/mountfs/host_safety_test.go index 497df42..50c345e 100644 --- a/internal/mountfs/host_safety_test.go +++ b/internal/mountfs/host_safety_test.go @@ -3,6 +3,7 @@ package mountfs import ( "os" "path/filepath" + "runtime" "testing" ) @@ -21,6 +22,9 @@ func TestPrepareMountPointRejectsOrdinaryFiles(t *testing.T) { } func TestPrepareMountPointCreatesAndSealsMissingBackingDirectory(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("WinFsp requires an absent mount point") + } mountPoint := filepath.Join(t.TempDir(), "missing", "mount") if err := prepareMountPoint(mountPoint); err != nil { t.Fatal(err) @@ -35,6 +39,9 @@ func TestPrepareMountPointCreatesAndSealsMissingBackingDirectory(t *testing.T) { } func TestPrepareMountPointSealsEmptyBackingDirectory(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("WinFsp requires an absent mount point") + } mountPoint := filepath.Join(t.TempDir(), "mount") if err := os.MkdirAll(mountPoint, 0o700); err != nil { t.Fatal(err) @@ -63,6 +70,9 @@ func TestPrepareMountPointRejectsSymlink(t *testing.T) { } mountPoint := filepath.Join(root, "mount") if err := os.Symlink(target, mountPoint); err != nil { + if runtime.GOOS == "windows" { + t.Skipf("symlink prerequisite unavailable: %v", err) + } t.Fatal(err) } diff --git a/internal/mountfs/host_safety_windows_test.go b/internal/mountfs/host_safety_windows_test.go new file mode 100644 index 0000000..e8be36a --- /dev/null +++ b/internal/mountfs/host_safety_windows_test.go @@ -0,0 +1,45 @@ +//go:build windows + +package mountfs + +import ( + "os" + "path/filepath" + "testing" +) + +func TestPrepareWinFspMountPointLeavesAbsentDirectory(t *testing.T) { + for _, exists := range []bool{false, true} { + path := filepath.Join(t.TempDir(), "parent", "mount") + if exists { + if err := os.MkdirAll(path, 0o700); err != nil { + t.Fatal(err) + } + } + if err := prepareMountPoint(path); err != nil { + t.Fatal(err) + } + if _, err := os.Lstat(path); !os.IsNotExist(err) { + t.Fatalf("mount point must be absent before WinFsp starts: %v", err) + } + if err := os.WriteFile(filepath.Join(path, "unexpected.jsonl"), []byte("write"), 0o600); err == nil { + t.Fatal("unmounted path accepted a write") + } + if info, err := os.Stat(filepath.Dir(path)); err != nil || !info.IsDir() { + t.Fatalf("mount parent missing: %v", err) + } + } +} + +func TestPrepareWinFspMountPointPreservesOrdinaryFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "mount") + if err := os.WriteFile(path, []byte("keep"), 0o600); err != nil { + t.Fatal(err) + } + if err := prepareMountPoint(path); err == nil { + t.Fatal("ordinary file accepted") + } + if contents, err := os.ReadFile(path); err != nil || string(contents) != "keep" { + t.Fatalf("ordinary file changed: %q %v", contents, err) + } +} diff --git a/internal/mountfs/native_append.go b/internal/mountfs/native_append.go index f264bea..860bd22 100644 --- a/internal/mountfs/native_append.go +++ b/internal/mountfs/native_append.go @@ -14,6 +14,8 @@ import ( "strings" "sync" "unicode/utf8" + + "github.com/samekind/codexfold/internal/dirsync" ) const ( @@ -659,7 +661,7 @@ func syncDirectory(path string) error { if err != nil { return err } - syncErr := directory.Sync() + syncErr := dirsync.Sync(directory) closeErr := directory.Close() return errors.Join(syncErr, closeErr) } diff --git a/internal/mountfs/native_open_unix.go b/internal/mountfs/native_open_unix.go new file mode 100644 index 0000000..1f933fc --- /dev/null +++ b/internal/mountfs/native_open_unix.go @@ -0,0 +1,9 @@ +//go:build !windows + +package mountfs + +import "os" + +func openNativeBacking(path string, flags int, mode os.FileMode) (*os.File, error) { + return os.OpenFile(path, flags, mode) +} diff --git a/internal/mountfs/native_open_windows.go b/internal/mountfs/native_open_windows.go new file mode 100644 index 0000000..0b83666 --- /dev/null +++ b/internal/mountfs/native_open_windows.go @@ -0,0 +1,37 @@ +//go:build windows + +package mountfs + +import ( + "os" + + "golang.org/x/sys/windows" +) + +func openNativeBacking(path string, flags int, _ os.FileMode) (*os.File, error) { + pointer, err := windows.UTF16PtrFromString(path) + if err != nil { + return nil, err + } + access := uint32(windows.GENERIC_READ) + if flags&(os.O_WRONLY|os.O_RDWR) != 0 { + access |= windows.GENERIC_WRITE + } + creation := uint32(windows.OPEN_EXISTING) + if flags&os.O_CREATE != 0 { + creation = windows.OPEN_ALWAYS + if flags&os.O_EXCL != 0 { + creation = windows.CREATE_NEW + } + } + attributes := uint32(windows.FILE_ATTRIBUTE_NORMAL) + if flags&os.O_SYNC != 0 { + attributes |= windows.FILE_FLAG_WRITE_THROUGH + } + // Keep native readers and append handles usable across archive moves. + handle, err := windows.CreateFile(pointer, access, windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, nil, creation, attributes, 0) + if err != nil { + return nil, &os.PathError{Op: "open", Path: path, Err: err} + } + return os.NewFile(uintptr(handle), path), nil +} diff --git a/internal/mountfs/windows_core_backend.go b/internal/mountfs/windows_core_backend.go new file mode 100644 index 0000000..2dff9ea --- /dev/null +++ b/internal/mountfs/windows_core_backend.go @@ -0,0 +1,105 @@ +//go:build windows + +package mountfs + +import ( + "syscall" + "time" +) + +var _ HostBackend = (*WindowsCoreHost)(nil) +var _ HostMetadataBackend = (*WindowsCoreHost)(nil) + +func (host *WindowsCoreHost) executeLocked(request CoreRequest) CoreResponse { + var reply CoreResponse + if host.closed || host.process == nil || !host.healthy.Load() { + reply.Errno = syscall.EIO + return reply + } + request.Token = host.process.token + if err := host.process.client.Call("Core.Execute", request, &reply); err != nil { + // A lost response, including a write response, is ambiguous. Return an + // error to the caller and never replay the operation on another engine. + host.healthy.Store(false) + reply = CoreResponse{Errno: syscall.EIO} + } + return reply +} + +func (host *WindowsCoreHost) execute(request CoreRequest) CoreResponse { + host.gate.RLock() + defer host.gate.RUnlock() + return host.executeLocked(request) +} + +func (host *WindowsCoreHost) Getattr(name string) (Attr, syscall.Errno) { + reply := host.execute(CoreRequest{Operation: "getattr", Name: name}) + return reply.Attr, reply.Errno +} +func (host *WindowsCoreHost) ReadDir(name string) ([]string, syscall.Errno) { + reply := host.execute(CoreRequest{Operation: "readdir", Name: name}) + return reply.Entries, reply.Errno +} +func (host *WindowsCoreHost) Open(name string, flags int) (uint64, syscall.Errno) { + host.gate.RLock() + defer host.gate.RUnlock() + reply := host.executeLocked(CoreRequest{Operation: "open", Name: name, Flags: flags}) + if reply.Errno == 0 { + host.handlesMu.Lock() + host.handles[reply.Handle] = true + host.handlesMu.Unlock() + } + return reply.Handle, reply.Errno +} +func (host *WindowsCoreHost) Read(handle uint64, destination []byte, offset int64) (int, syscall.Errno) { + if len(destination) > coreMaxIO { + return 0, syscall.EINVAL + } + reply := host.execute(CoreRequest{Operation: "read", Handle: handle, Length: len(destination), Offset: offset}) + if reply.Errno == 0 { + copy(destination, reply.Data) + } + return reply.Count, reply.Errno +} +func (host *WindowsCoreHost) Write(handle uint64, data []byte, offset int64) (int, syscall.Errno) { + if len(data) > coreMaxIO { + return 0, syscall.EINVAL + } + reply := host.execute(CoreRequest{Operation: "write", Handle: handle, Data: data, Offset: offset}) + return reply.Count, reply.Errno +} +func (host *WindowsCoreHost) TruncatePath(name string, size int64) syscall.Errno { + return host.execute(CoreRequest{Operation: "truncate-path", Name: name, Size: size}).Errno +} +func (host *WindowsCoreHost) Truncate(handle uint64, size int64) syscall.Errno { + return host.execute(CoreRequest{Operation: "truncate", Handle: handle, Size: size}).Errno +} +func (host *WindowsCoreHost) Flush(handle uint64) syscall.Errno { + return host.execute(CoreRequest{Operation: "flush", Handle: handle}).Errno +} +func (host *WindowsCoreHost) Fsync(handle uint64) syscall.Errno { + return host.execute(CoreRequest{Operation: "fsync", Handle: handle}).Errno +} +func (host *WindowsCoreHost) Release(handle uint64) syscall.Errno { + host.gate.RLock() + defer host.gate.RUnlock() + errno := host.executeLocked(CoreRequest{Operation: "release", Handle: handle}).Errno + // Release means the frontend caller has closed this handle even if the + // engine has failed. A later cutover still requires a clean engine stop. + host.handlesMu.Lock() + delete(host.handles, handle) + host.handlesMu.Unlock() + return errno +} +func (host *WindowsCoreHost) Mkdir(name string, mode uint32) syscall.Errno { + return host.execute(CoreRequest{Operation: "mkdir", Name: name, Mode: mode}).Errno +} +func (host *WindowsCoreHost) Rename(name, target string) syscall.Errno { + return host.execute(CoreRequest{Operation: "rename", Name: name, Target: target}).Errno +} +func (host *WindowsCoreHost) Unlink(name string) syscall.Errno { + return host.execute(CoreRequest{Operation: "unlink", Name: name}).Errno +} +func (host *WindowsCoreHost) Metadata(operation, name string, mode, uid uint32, access, modified time.Time) syscall.Errno { + return host.execute(CoreRequest{Operation: operation, Name: name, Mode: mode, UID: uid, AccessTime: access, ModTime: modified}).Errno +} diff --git a/internal/mountfs/windows_core_host.go b/internal/mountfs/windows_core_host.go new file mode 100644 index 0000000..e036f9b --- /dev/null +++ b/internal/mountfs/windows_core_host.go @@ -0,0 +1,439 @@ +//go:build windows + +package mountfs + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/rpc" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "syscall" + "time" + + "github.com/Microsoft/go-winio" + "github.com/samekind/codexfold/internal/buildid" + "golang.org/x/sys/windows" +) + +type WindowsCoreOptions struct { + Binary, Root, Store string + Arguments []string + Stdout, Stderr io.Writer + BeforeUpdate func() error + ReadyTimeout time.Duration +} +type coreProcess struct { + command *exec.Cmd + control io.WriteCloser + client *rpc.Client + token, binary, build string + done chan struct{} + err error +} +type WindowsCoreHost struct { + gate sync.RWMutex + handlesMu sync.Mutex + handles map[uint64]bool + process *coreProcess + options WindowsCoreOptions + healthy atomic.Bool + build atomic.Value + closed bool +} +type CoreInfo struct { + Version, HostPID, EnginePID, OpenHandles int + Binary, Build string + Healthy bool +} +type CoreUpdateRequest struct { + Candidate string + Apply bool +} +type CoreUpdateResult struct { + CoreInfo + CandidateSHA256 string + PreviousPID, ReplacementPID int + Changed, DryRun, MountPreserved bool + RecoveryBinary string +} +type coreImage struct { + Version int + SHA256 string +} + +func WindowsCoreControlPipe(store string) string { + digest := sha256.Sum256([]byte(strings.ToLower(filepath.Clean(store)))) + return `\\.\pipe\codexfold-core-control-` + hex.EncodeToString(digest[:16]) +} + +func NewWindowsCoreHost(ctx context.Context, options WindowsCoreOptions) (*WindowsCoreHost, error) { + for _, path := range []string{options.Binary, options.Root, options.Store} { + if !filepath.IsAbs(path) { + return nil, errors.New("absolute engine binary, image root and store are required") + } + } + if options.ReadyTimeout <= 0 { + options.ReadyTimeout = 2 * time.Minute + } + if err := os.MkdirAll(options.Root, 0700); err != nil { + return nil, err + } + info, err := os.Lstat(options.Root) + if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return nil, errors.New("engine image root must be an ordinary directory") + } + binary := options.Binary + data, err := os.ReadFile(filepath.Join(options.Root, "current.json")) + if err == nil { + var image coreImage + if err := json.Unmarshal(data, &image); err != nil || image.Version != WindowsCoreProtocol || !buildid.ValidSHA256(image.SHA256) { + return nil, errors.New("persisted engine binding is invalid") + } + binary = filepath.Join(options.Root, image.SHA256+".exe") + if info, err := os.Lstat(binary); err != nil || !info.Mode().IsRegular() { + return nil, errors.New("persisted engine image is not an ordinary file") + } + if actual, err := buildid.FileSHA256(binary); err != nil || actual != image.SHA256 { + return nil, errors.New("persisted engine image failed verification") + } + } else if !errors.Is(err, os.ErrNotExist) { + return nil, err + } + host := &WindowsCoreHost{options: options, handles: map[uint64]bool{}} + process, err := host.start(ctx, binary) + if err != nil { + return nil, err + } + host.process = process + host.build.Store(process.build) + host.healthy.Store(true) + return host, nil +} + +func (host *WindowsCoreHost) start(ctx context.Context, binary string) (*coreProcess, error) { + build, err := buildid.FileSHA256(binary) + if err != nil { + return nil, err + } + boot := WindowsCoreBoot{Pipe: `\\.\pipe\codexfold-core-` + rand.Text(), Token: rand.Text() + rand.Text()} + command := exec.Command(binary, host.options.Arguments...) + command.Stdout, command.Stderr = host.options.Stdout, host.options.Stderr + command.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: windows.CREATE_NO_WINDOW} + control, err := command.StdinPipe() + if err != nil { + return nil, err + } + if err := command.Start(); err != nil { + control.Close() + return nil, err + } + process := &coreProcess{command: command, control: control, token: boot.Token, binary: binary, build: build, done: make(chan struct{})} + go func() { process.err = command.Wait(); close(process.done) }() + if err := json.NewEncoder(control).Encode(boot); err != nil { + _ = host.stop(process) + return nil, err + } + ready, cancel := context.WithTimeout(ctx, host.options.ReadyTimeout) + defer cancel() + for { + connection, err := winio.DialPipeContext(ready, boot.Pipe) + if err == nil { + client := rpc.NewClient(connection) + var reply CoreResponse + call := client.Go("Core.Execute", CoreRequest{Token: boot.Token, Operation: "hello"}, &reply, make(chan *rpc.Call, 1)) + select { + case result := <-call.Done: + if result.Error == nil && reply.Version == WindowsCoreProtocol && reply.PID == command.Process.Pid && reply.Build == build { + process.client = client + return process, nil + } + case <-ready.Done(): + } + _ = client.Close() + } + select { + case <-process.done: + _ = control.Close() + return nil, fmt.Errorf("engine exited before becoming ready: %w", process.err) + case <-ready.Done(): + _ = host.stop(process) + return nil, fmt.Errorf("engine readiness: %w", ready.Err()) + case <-time.After(50 * time.Millisecond): + } + } +} + +func (host *WindowsCoreHost) stop(process *coreProcess) error { + if process == nil { + return nil + } + _ = process.control.Close() + select { + case <-process.done: + if process.client != nil { + _ = process.client.Close() + } + return process.err + case <-time.After(25 * time.Second): + // A graceful shutdown that failed is never treated as a successful + // cutover. Kill only this owned child, then return an explicit failure. + _ = process.command.Process.Kill() + <-process.done + if process.client != nil { + _ = process.client.Close() + } + return errors.New("engine did not finish its graceful shutdown") + } +} + +func (host *WindowsCoreHost) BuildSHA256() string { + value, _ := host.build.Load().(string) + return value +} +func (host *WindowsCoreHost) Healthy() bool { + if !host.healthy.Load() { + return false + } + host.gate.RLock() + defer host.gate.RUnlock() + if host.process == nil { + return false + } + select { + case <-host.process.done: + return false + default: + return true + } +} + +func (host *WindowsCoreHost) infoLocked() CoreInfo { + host.handlesMu.Lock() + handles := len(host.handles) + host.handlesMu.Unlock() + info := CoreInfo{Version: WindowsCoreProtocol, HostPID: os.Getpid(), OpenHandles: handles, Build: host.BuildSHA256(), Healthy: host.healthy.Load()} + if host.process != nil { + info.EnginePID = host.process.command.Process.Pid + info.Binary = host.process.binary + } + return info +} + +func (host *WindowsCoreHost) Update(ctx context.Context, request CoreUpdateRequest) (CoreUpdateResult, error) { + if !filepath.IsAbs(request.Candidate) { + return CoreUpdateResult{}, errors.New("absolute candidate path is required") + } + info, err := os.Lstat(request.Candidate) + if err != nil || !info.Mode().IsRegular() { + return CoreUpdateResult{}, errors.New("candidate must be an ordinary executable file") + } + candidateSHA, err := buildid.FileSHA256(request.Candidate) + if err != nil { + return CoreUpdateResult{}, err + } + host.gate.Lock() + defer host.gate.Unlock() + result := CoreUpdateResult{CoreInfo: host.infoLocked(), CandidateSHA256: candidateSHA, DryRun: !request.Apply, Changed: candidateSHA != host.BuildSHA256()} + if host.closed { + return result, errors.New("resident mount is stopping") + } + if !result.Changed { + result.MountPreserved = true + return result, nil + } + if result.OpenHandles != 0 { + return result, fmt.Errorf("live update waits for %d open file handles to close", result.OpenHandles) + } + if host.options.BeforeUpdate != nil { + if err := host.options.BeforeUpdate(); err != nil { + return result, err + } + } + if !request.Apply { + return result, nil + } + image, err := host.stage(request.Candidate, candidateSHA) + if err != nil { + return result, err + } + previous := host.process + result.PreviousPID, result.RecoveryBinary = previous.command.Process.Pid, previous.binary + host.healthy.Store(false) + if err := host.stop(previous); err != nil { + return result, fmt.Errorf("old engine did not stop cleanly; candidate was not started: %w", err) + } + cutover, cancelCutover := context.WithTimeout(ctx, 45*time.Second) + replacement, updateErr := host.start(cutover, image) + cancelCutover() + if updateErr == nil { + updateErr = host.persist(candidateSHA) + } + if updateErr != nil { + if replacement != nil { + _ = host.stop(replacement) + } + rollback, rollbackErr := host.start(ctx, previous.binary) + if rollbackErr == nil { + host.process = rollback + host.build.Store(rollback.build) + host.healthy.Store(true) + } + return result, errors.Join(fmt.Errorf("candidate failed; previous engine restored when possible: %w", updateErr), rollbackErr) + } + host.process = replacement + host.build.Store(replacement.build) + host.healthy.Store(true) + result.CoreInfo = host.infoLocked() + result.ReplacementPID, result.MountPreserved = replacement.command.Process.Pid, true + return result, nil +} + +func (host *WindowsCoreHost) stage(candidate, sha string) (string, error) { + image := filepath.Join(host.options.Root, sha+".exe") + if existing, err := os.Lstat(image); err == nil { + if !existing.Mode().IsRegular() { + return "", errors.New("engine image is not an ordinary file") + } + if actual, err := buildid.FileSHA256(image); err != nil || actual != sha { + return "", errors.New("existing engine image failed verification") + } + return image, nil + } else if !errors.Is(err, os.ErrNotExist) { + return "", err + } + source, err := os.Open(candidate) + if err != nil { + return "", err + } + defer source.Close() + temporary, err := os.CreateTemp(host.options.Root, ".engine-*.tmp") + if err != nil { + return "", err + } + name := temporary.Name() + defer os.Remove(name) + _, copyErr := io.Copy(temporary, source) + syncErr := temporary.Sync() + closeErr := temporary.Close() + if err := errors.Join(copyErr, syncErr, closeErr); err != nil { + return "", err + } + if actual, err := buildid.FileSHA256(name); err != nil || actual != sha { + return "", errors.New("candidate changed while being staged") + } + if err := os.Rename(name, image); err != nil { + return "", err + } + return image, nil +} + +func (host *WindowsCoreHost) persist(sha string) error { + data, err := json.Marshal(coreImage{Version: WindowsCoreProtocol, SHA256: sha}) + if err != nil { + return err + } + file, err := os.CreateTemp(host.options.Root, ".current-*.tmp") + if err != nil { + return err + } + name := file.Name() + defer os.Remove(name) + _, writeErr := file.Write(data) + syncErr := file.Sync() + closeErr := file.Close() + if err := errors.Join(writeErr, syncErr, closeErr); err != nil { + return err + } + from, _ := windows.UTF16PtrFromString(name) + to, _ := windows.UTF16PtrFromString(filepath.Join(host.options.Root, "current.json")) + return windows.MoveFileEx(from, to, windows.MOVEFILE_REPLACE_EXISTING|windows.MOVEFILE_WRITE_THROUGH) +} + +func (host *WindowsCoreHost) Close() error { + host.gate.Lock() + defer host.gate.Unlock() + if host.closed { + return nil + } + host.closed = true + host.healthy.Store(false) + return host.stop(host.process) +} + +type coreControlServer struct { + host *WindowsCoreHost + ctx context.Context +} + +func (control *coreControlServer) Info(_ struct{}, reply *CoreInfo) error { + control.host.gate.RLock() + defer control.host.gate.RUnlock() + *reply = control.host.infoLocked() + return nil +} +func (control *coreControlServer) Update(request CoreUpdateRequest, reply *CoreUpdateResult) error { + result, err := control.host.Update(control.ctx, request) + *reply = result + return err +} + +func (host *WindowsCoreHost) ServeControl(ctx context.Context) (func(), error) { + security, err := corePipeSecurity() + if err != nil { + return nil, err + } + listener, err := winio.ListenPipe(WindowsCoreControlPipe(host.options.Store), &winio.PipeConfig{SecurityDescriptor: security}) + if err != nil { + return nil, err + } + server := rpc.NewServer() + if err := server.RegisterName("Control", &coreControlServer{host: host, ctx: ctx}); err != nil { + listener.Close() + return nil, err + } + go func() { + for { + connection, err := listener.Accept() + if err != nil { + return + } + go func() { + defer connection.Close() + _ = connection.SetDeadline(time.Now().Add(4 * time.Minute)) + server.ServeConn(connection) + }() + } + }() + return func() { _ = listener.Close() }, nil +} + +func CallWindowsCoreControl(ctx context.Context, store, method string, argument, reply any) error { + connection, err := winio.DialPipeContext(ctx, WindowsCoreControlPipe(store)) + if err != nil { + return fmt.Errorf("resident Windows engine control is unavailable; this mount may require one offline upgrade: %w", err) + } + defer connection.Close() + if deadline, ok := ctx.Deadline(); ok { + _ = connection.SetDeadline(deadline) + } + client := rpc.NewClient(connection) + defer client.Close() + call := client.Go("Control."+method, argument, reply, make(chan *rpc.Call, 1)) + select { + case result := <-call.Done: + return result.Error + case <-ctx.Done(): + return ctx.Err() + } +} diff --git a/internal/mountfs/windows_core_rpc.go b/internal/mountfs/windows_core_rpc.go new file mode 100644 index 0000000..9541dc2 --- /dev/null +++ b/internal/mountfs/windows_core_rpc.go @@ -0,0 +1,203 @@ +//go:build windows + +package mountfs + +import ( + "context" + "crypto/subtle" + "encoding/json" + "errors" + "io" + "net" + "net/rpc" + "os" + "strings" + "sync" + "syscall" + "time" + + "github.com/Microsoft/go-winio" + "github.com/samekind/codexfold/internal/buildid" + "golang.org/x/sys/windows" +) + +const WindowsCoreProtocol = 1 +const coreMaxIO = 8 << 20 + +type WindowsCoreBoot struct{ Pipe, Token string } +type CoreRequest struct { + Token, Operation, Name, Target string + Handle uint64 + Flags, Length int + Offset, Size int64 + Mode, UID uint32 + AccessTime, ModTime time.Time + Data []byte +} +type CoreResponse struct { + Errno syscall.Errno + Attr Attr + Entries []string + Handle uint64 + Count, Version, PID int + Build string + Data []byte +} + +func corePipeSecurity() (string, error) { + user, err := windows.GetCurrentProcessToken().GetTokenUser() + if err != nil { + return "", err + } + // Reject network logons even if they belong to the local Administrators + // group. The SYSTEM host exposes control only to SYSTEM/administrators. + return "D:P(D;;GA;;;NU)(A;;GA;;;SY)(A;;GA;;;BA)(A;;GA;;;" + user.User.Sid.String() + ")", nil +} + +func ReadWindowsCoreBoot(reader io.Reader) (WindowsCoreBoot, error) { + var boot WindowsCoreBoot + if err := json.NewDecoder(io.LimitReader(reader, 4096)).Decode(&boot); err != nil { + return boot, err + } + if !strings.HasPrefix(boot.Pipe, `\\.\pipe\codexfold-core-`) || len(boot.Token) < 32 || len(boot.Token) > 128 { + return boot, errors.New("invalid private engine launch binding") + } + return boot, nil +} + +type coreRPCServer struct { + fs *Filesystem + token, build string +} + +func (server *coreRPCServer) Execute(request CoreRequest, reply *CoreResponse) error { + if subtle.ConstantTimeCompare([]byte(request.Token), []byte(server.token)) != 1 { + return errors.New("engine authentication failed") + } + f := server.fs + switch request.Operation { + case "hello": + reply.Version, reply.PID, reply.Build = WindowsCoreProtocol, os.Getpid(), server.build + case "getattr": + reply.Attr, reply.Errno = f.Getattr(request.Name) + case "readdir": + reply.Entries, reply.Errno = f.ReadDir(request.Name) + case "open": + reply.Handle, reply.Errno = f.Open(request.Name, request.Flags) + case "read": + if request.Length < 0 || request.Length > coreMaxIO { + reply.Errno = syscall.EINVAL + break + } + reply.Data = make([]byte, request.Length) + reply.Count, reply.Errno = f.Read(request.Handle, reply.Data, request.Offset) + reply.Data = reply.Data[:reply.Count] + case "write": + if len(request.Data) > coreMaxIO { + reply.Errno = syscall.EINVAL + break + } + reply.Count, reply.Errno = f.Write(request.Handle, request.Data, request.Offset) + case "truncate": + reply.Errno = f.Truncate(request.Handle, request.Size) + case "truncate-path": + reply.Errno = f.TruncatePath(request.Name, request.Size) + case "flush": + reply.Errno = f.Flush(request.Handle) + case "fsync": + reply.Errno = f.Fsync(request.Handle) + case "release": + reply.Errno = f.Release(request.Handle) + case "mkdir": + reply.Errno = f.Mkdir(request.Name, request.Mode) + case "rename": + reply.Errno = f.Rename(request.Name, request.Target) + case "unlink": + reply.Errno = f.Unlink(request.Name) + case "chmod", "chown", "utimens": + path, managed, errno := f.metadataPath(request.Name) + reply.Errno = errno + if errno != 0 || managed { + break + } + var err error + switch request.Operation { + case "chmod": + err = os.Chmod(path, os.FileMode(request.Mode)&os.ModePerm) + case "chown": + err = os.Chown(path, int(request.Mode), int(request.UID)) + case "utimens": + err = os.Chtimes(path, request.AccessTime, request.ModTime) + } + reply.Errno = errnoFor(err) + default: + reply.Errno = syscall.ENOSYS + } + return nil +} + +// ServeWindowsCore reuses the existing storage engine and its recovery logic. +// Closing the parent lifeline cancels this context; RPC calls finish before the +// caller closes session owners and releases the exclusive service lock. +func ServeWindowsCore(ctx context.Context, fs *Filesystem, boot WindowsCoreBoot, onReady func()) error { + security, err := corePipeSecurity() + if err != nil { + return err + } + listener, err := winio.ListenPipe(boot.Pipe, &winio.PipeConfig{SecurityDescriptor: security, InputBufferSize: 65536, OutputBufferSize: 65536}) + if err != nil { + return err + } + defer listener.Close() + build, err := buildid.CurrentSHA256() + if err != nil { + return err + } + server := rpc.NewServer() + if err := server.RegisterName("Core", &coreRPCServer{fs: fs, token: boot.Token, build: build}); err != nil { + return err + } + var mu sync.Mutex + connections := map[net.Conn]bool{} + var calls sync.WaitGroup + done := make(chan struct{}) + defer close(done) + go func() { + select { + case <-ctx.Done(): + case <-done: + return + } + _ = listener.Close() + mu.Lock() + for connection := range connections { + _ = connection.Close() + } + mu.Unlock() + }() + if onReady != nil { + onReady() + } + for { + connection, err := listener.Accept() + if err != nil { + calls.Wait() + if ctx.Err() != nil { + return ctx.Err() + } + return err + } + mu.Lock() + connections[connection] = true + mu.Unlock() + calls.Add(1) + go func() { + defer calls.Done() + defer connection.Close() + server.ServeConn(connection) + mu.Lock() + delete(connections, connection) + mu.Unlock() + }() + } +} diff --git a/internal/mountfs/windows_core_test.go b/internal/mountfs/windows_core_test.go new file mode 100644 index 0000000..30cf107 --- /dev/null +++ b/internal/mountfs/windows_core_test.go @@ -0,0 +1,146 @@ +//go:build windows + +package mountfs + +import ( + "context" + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestWindowsCoreEngineProcess(t *testing.T) { + if len(os.Args) < 3 || os.Args[len(os.Args)-2] != "--core-fixture" { + return + } + root := os.Args[len(os.Args)-1] + boot, err := ReadWindowsCoreBoot(os.Stdin) + if err != nil { + os.Exit(2) + } + ctx, cancel := context.WithCancel(context.Background()) + go func() { _, _ = io.Copy(io.Discard, os.Stdin); cancel() }() + filesystem := NewCanonical() + filesystem.SetNativeRoot(root) + err = ServeWindowsCore(ctx, filesystem, boot, nil) + _ = filesystem.CloseSessions() + if err != nil && !errors.Is(err, context.Canceled) { + os.Exit(3) + } + os.Exit(0) +} + +func TestWindowsCoreUpdatePreservesDataAndRejectsOpenHandles(t *testing.T) { + root := t.TempDir() + native := filepath.Join(root, "native") + if err := os.MkdirAll(filepath.Join(native, "sessions"), 0700); err != nil { + t.Fatal(err) + } + binary, _ := os.Executable() + candidate := filepath.Join(root, "candidate.exe") + data, err := os.ReadFile(binary) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(candidate, append(data, []byte("core-update-candidate")...), 0700); err != nil { + t.Fatal(err) + } + paused := true + options := WindowsCoreOptions{Binary: binary, Root: filepath.Join(root, "images"), Store: root, + Arguments: []string{"-test.run=TestWindowsCoreEngineProcess", "--", "--core-fixture", native}, ReadyTimeout: 3 * time.Second, + BeforeUpdate: func() error { + if !paused { + return errors.New("worker active") + } + return nil + }} + host, err := NewWindowsCoreHost(context.Background(), options) + if err != nil { + t.Fatal(err) + } + defer host.Close() + ctx := context.Background() + stop, err := host.ServeControl(ctx) + if err != nil { + t.Fatal(err) + } + defer stop() + var before CoreInfo + if err := CallWindowsCoreControl(ctx, root, "Info", struct{}{}, &before); err != nil { + t.Fatal(err) + } + handle, errno := host.Open("/sessions/note.txt", os.O_CREATE|os.O_RDWR) + if errno != 0 { + t.Fatal(errno) + } + if count, errno := host.Write(handle, []byte("verified payload"), 0); errno != 0 || count != 16 { + t.Fatalf("write %d %v", count, errno) + } + if result, err := host.Update(ctx, CoreUpdateRequest{Candidate: candidate, Apply: true}); err == nil || result.OpenHandles != 1 { + t.Fatalf("open-handle guard: %+v %v", result, err) + } + if host.process.command.Process.Pid != before.EnginePID { + t.Fatal("busy update replaced engine") + } + if errno := host.Fsync(handle); errno != 0 { + t.Fatal(errno) + } + if errno := host.Release(handle); errno != 0 { + t.Fatal(errno) + } + paused = false + if _, err := host.Update(ctx, CoreUpdateRequest{Candidate: candidate, Apply: true}); err == nil || !strings.Contains(err.Error(), "worker active") { + t.Fatal("worker guard", err) + } + paused = true + result, err := host.Update(ctx, CoreUpdateRequest{Candidate: candidate, Apply: true}) + if err != nil { + t.Fatal(err) + } + if !result.MountPreserved || result.ReplacementPID == before.EnginePID || result.HostPID != before.HostPID { + t.Fatalf("cutover %+v", result) + } + handle, errno = host.Open("/sessions/note.txt", os.O_RDONLY) + if errno != 0 { + t.Fatal(errno) + } + buffer := make([]byte, 100) + count, errno := host.Read(handle, buffer, 0) + if errno != 0 || string(buffer[:count]) != "verified payload" { + t.Fatal("payload changed", errno) + } + if errno := host.Release(handle); errno != 0 { + t.Fatal(errno) + } + // An invalid candidate must roll back to the last verified engine without + // fabricating a successful update or replaying any writes. + bad := filepath.Join(root, "broken.exe") + if err := os.WriteFile(bad, []byte("bad executable"), 0700); err != nil { + t.Fatal(err) + } + if _, err := host.Update(ctx, CoreUpdateRequest{Candidate: bad, Apply: true}); err == nil { + t.Fatal("invalid executable accepted") + } + if !host.Healthy() || host.BuildSHA256() != result.Build { + t.Fatal("rollback failed") + } + if _, errno := host.Getattr("/sessions/note.txt"); errno != 0 { + t.Fatal(errno) + } + if err := host.Close(); err != nil { + t.Fatal(err) + } + // The committed image survives a restart of the resident host. + restarted, err := NewWindowsCoreHost(ctx, options) + if err != nil { + t.Fatal(err) + } + defer restarted.Close() + if restarted.BuildSHA256() != result.Build { + t.Fatal("committed image was not restored") + } +} diff --git a/internal/mountfs/windows_volume_prefix.go b/internal/mountfs/windows_volume_prefix.go new file mode 100644 index 0000000..b551829 --- /dev/null +++ b/internal/mountfs/windows_volume_prefix.go @@ -0,0 +1,19 @@ +package mountfs + +import ( + "crypto/sha256" + "fmt" + "path/filepath" + "strings" +) + +// This is a WinFsp local namespace, not an SMB share or a remote connection. +func windowsVolumePrefix(namespaceRoot, mountPoint string) string { + digest := sha256.Sum256([]byte(strings.ToLower(filepath.Clean(namespaceRoot) + "\x00" + filepath.Clean(mountPoint)))) + return fmt.Sprintf("/codexfold/%x", digest[:12]) +} + +// WindowsUNCPath is the stable local WinFsp alias used by Windows realpath. +func WindowsUNCPath(namespaceRoot, mountPoint string) string { + return `\` + strings.ReplaceAll(windowsVolumePrefix(namespaceRoot, mountPoint), "/", `\`) +} diff --git a/internal/pack/accounting.go b/internal/pack/accounting.go new file mode 100644 index 0000000..24bc485 --- /dev/null +++ b/internal/pack/accounting.go @@ -0,0 +1,88 @@ +package pack + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "path/filepath" + "strings" + + "github.com/samekind/codexfold/internal/fold" +) + +// PublishedLogicalBytes reads the immutable manifest archive associated with a +// published pack. Newly folded but not yet packed manifests must not inflate +// its apparent compression saving. This never opens a repairing resolver. +func PublishedLogicalBytes(ctx context.Context, store, generation string) (int64, error) { + if !safeGeneration(generation) { + return 0, errors.New("safe published generation is required") + } + directory := filepath.Join(store, "packs", generation) + if _, err := readPublishedGeneration(directory); err != nil { + return 0, err + } + catalog, err := ReadRecoveryCatalog(directory) + if err != nil { + return 0, err + } + wanted := make(map[string]RecoveryFile) + for _, file := range catalog.Files { + if strings.HasPrefix(file.Path, "manifests/") { + wanted[file.Path] = file + } + } + archive, closeArchive, err := openRecoveryArchive(directory) + if err != nil { + return 0, err + } + defer closeArchive() + var total int64 + seenIDs := make(map[string]bool) + for { + if err := ctx.Err(); err != nil { + return 0, err + } + header, err := archive.Next() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return 0, err + } + identity, ok := wanted[header.Name] + if !ok { + continue + } + if header.Size != identity.Bytes || header.Size < 0 || header.Size > 64<<20 { + return 0, errors.New("invalid accounting manifest size") + } + data, err := io.ReadAll(io.LimitReader(archive, header.Size+1)) + if err != nil { + return 0, err + } + digest := sha256.Sum256(data) + if int64(len(data)) != identity.Bytes || hex.EncodeToString(digest[:]) != identity.SHA256 { + return 0, errors.New("accounting manifest does not match publication proof") + } + manifest, err := fold.DecodeManifest(data) + if err != nil { + return 0, err + } + if seenIDs[manifest.Session.ID] || manifest.Source.Bytes < 0 || manifest.Source.Bytes > maxInt64()-total { + return 0, errors.New("invalid accounting session total") + } + seenIDs[manifest.Session.ID] = true + total += manifest.Source.Bytes + delete(wanted, header.Name) + if len(wanted) == 0 { + break + } + } + if len(wanted) != 0 { + return 0, fmt.Errorf("published archive is missing %d accounting manifests", len(wanted)) + } + return total, nil +} diff --git a/internal/pack/accounting_test.go b/internal/pack/accounting_test.go new file mode 100644 index 0000000..9dee5ea --- /dev/null +++ b/internal/pack/accounting_test.go @@ -0,0 +1,31 @@ +package pack + +import ( + "bytes" + "context" + "testing" +) + +func TestPublishedLogicalBytesExcludeUnpublishedWork(t *testing.T) { + store := t.TempDir() + first := bytes.Repeat([]byte("already-published-session"), 10000) + writeManifest(t, store, "first", putObjects(t, store, first)) + result, err := Build(context.Background(), store, BuildOptions{}) + if err != nil { + t.Fatal(err) + } + second := bytes.Repeat([]byte("still-being-folded"), 10000) + writeManifest(t, store, "second", putObjects(t, store, second)) + logical, err := PublishedLogicalBytes(context.Background(), store, result.Generation) + if err != nil || logical != int64(len(first)) { + t.Fatalf("published basis = %d, %v", logical, err) + } + result, err = Build(context.Background(), store, BuildOptions{}) + if err != nil { + t.Fatal(err) + } + logical, err = PublishedLogicalBytes(context.Background(), store, result.Generation) + if err != nil || logical != int64(len(first)+len(second)) { + t.Fatalf("next publication basis = %d, %v", logical, err) + } +} diff --git a/internal/pack/build.go b/internal/pack/build.go index a3eb284..5aa8fb2 100644 --- a/internal/pack/build.go +++ b/internal/pack/build.go @@ -16,6 +16,7 @@ import ( "time" "github.com/klauspost/compress/zstd" + "github.com/samekind/codexfold/internal/dirsync" "github.com/samekind/codexfold/internal/fold" "github.com/samekind/codexfold/internal/storage" _ "modernc.org/sqlite" @@ -714,7 +715,7 @@ func syncDirectory(path string) error { return fmt.Errorf("open directory for sync %s: %w", path, err) } defer directory.Close() - if err := directory.Sync(); err != nil { + if err := dirsync.Sync(directory); err != nil { return fmt.Errorf("sync directory %s: %w", path, err) } return nil diff --git a/internal/pack/recovery.go b/internal/pack/recovery.go index cc5538d..d09b53c 100644 --- a/internal/pack/recovery.go +++ b/internal/pack/recovery.go @@ -18,6 +18,7 @@ import ( "time" "github.com/klauspost/compress/zstd" + "github.com/samekind/codexfold/internal/dirsync" "github.com/samekind/codexfold/internal/fold" "github.com/samekind/codexfold/internal/storage" "github.com/samekind/codexfold/internal/vfs" @@ -211,10 +212,10 @@ func collectRecoverySources(storeDir string, generationDir string, meta indexV3M return nil } relative, err := filepath.Rel(filepath.Clean(storeDir), path) - if err != nil || !safeRecoveryPath(relative) || !strings.HasPrefix(filepath.ToSlash(relative), "manifests/") { + archivePath := filepath.ToSlash(relative) + if err != nil || !safeRecoveryPath(archivePath) || !strings.HasPrefix(archivePath, "manifests/") { return fmt.Errorf("unsafe recovery manifest path %q", path) } - archivePath := filepath.ToSlash(relative) manifest, loadErr := fold.LoadManifestPath(path) if loadErr != nil { return loadErr @@ -1023,7 +1024,7 @@ func syncRecoveryRootDirectory(root *os.Root, relative string) error { return err } defer directory.Close() - return directory.Sync() + return dirsync.Sync(directory) } func markGenerationPublished(generationDir string, sequence uint64, previousGeneration string) (PublishedGeneration, error) { diff --git a/internal/service/binary_update.go b/internal/service/binary_update.go index 562a914..1cd57e5 100644 --- a/internal/service/binary_update.go +++ b/internal/service/binary_update.go @@ -6,6 +6,7 @@ import ( "io" "os" "path/filepath" + "runtime" "github.com/samekind/codexfold/internal/buildid" ) @@ -34,7 +35,8 @@ func StageBinaryUpdate(candidate string, target string) (*BinaryUpdate, error) { if err != nil { return nil, err } - if !candidateInfo.Mode().IsRegular() || candidateInfo.Mode().Perm()&0o111 == 0 { + // Windows does not model executability through Unix permission bits. + if !candidateInfo.Mode().IsRegular() || runtime.GOOS != "windows" && candidateInfo.Mode().Perm()&0o111 == 0 { return nil, errors.New("candidate service binary must be a regular executable file") } root := filepath.Dir(target) diff --git a/internal/service/build_status.go b/internal/service/build_status.go index f320aaa..70a8c5e 100644 --- a/internal/service/build_status.go +++ b/internal/service/build_status.go @@ -28,6 +28,13 @@ func InspectBuild(platform Platform, definitionPath string, mountPoint string) B status.Error = err.Error() return status } + if platform == PlatformWindows { + binaryPath, err = configuredWindowsCoreImage(binaryPath) + if err != nil { + status.Error = err.Error() + return status + } + } status.ConfiguredBinaryPath = binaryPath status.ConfiguredBuildSHA256, err = buildid.FileSHA256(binaryPath) if err != nil { @@ -172,6 +179,9 @@ func DefinitionFSKitResource(platform Platform, definitionPath string) (string, // DefinitionMountPoint reports the --mount argument of a native-fskit launchd // definition. It returns an empty string for non-native-fskit definitions. func DefinitionMountPoint(platform Platform, definitionPath string) (string, error) { + if platform == PlatformWindows { + return windowsDefinitionPath(definitionPath, "--mount") + } frontend, err := DefinitionFrontend(platform, definitionPath) if err != nil { return "", err @@ -200,6 +210,9 @@ func DefinitionMountPoint(platform Platform, definitionPath string) (string, err } func DefinitionStore(platform Platform, definitionPath string) (string, error) { + if platform == PlatformWindows { + return windowsDefinitionPath(definitionPath, "--store") + } if !filepath.IsAbs(definitionPath) { return "", errors.New("absolute service definition path is required") } @@ -231,6 +244,9 @@ func DefinitionStore(platform Platform, definitionPath string) (string, error) { // native FSKit is the macOS frontend and other service definitions do not yet // expose an equivalent introspection contract. func DefinitionNativeRoot(platform Platform, definitionPath string) (string, error) { + if platform == PlatformWindows { + return windowsDefinitionPath(definitionPath, "--native-root") + } if !filepath.IsAbs(definitionPath) { return "", errors.New("absolute service definition path is required") } diff --git a/internal/service/mount_probe_windows.go b/internal/service/mount_probe_windows.go index 6b9597c..e66de46 100644 --- a/internal/service/mount_probe_windows.go +++ b/internal/service/mount_probe_windows.go @@ -3,13 +3,35 @@ package service import ( + "errors" + "fmt" "os" "path/filepath" + "strings" "github.com/samekind/codexfold/internal/mountid" + "golang.org/x/sys/windows" ) func defaultMountProbe(path string) error { + present, err := MountPresent(path) + if err != nil { + return err + } + if !present { + return errors.New("path is not a filesystem mount root") + } + name, err := windows.UTF16PtrFromString(filepath.Clean(path) + `\`) + if err != nil { + return err + } + var filesystem [256]uint16 + if err := windows.GetVolumeInformation(name, nil, 0, nil, nil, nil, &filesystem[0], uint32(len(filesystem))); err != nil { + return err + } + if !strings.EqualFold(windows.UTF16ToString(filesystem[:]), "FUSE") { + return errors.New("path is not a WinFsp FUSE mount") + } value, err := os.ReadFile(filepath.Join(path, mountid.Path)) if err != nil { return err @@ -17,4 +39,41 @@ func defaultMountProbe(path string) error { return mountid.Validate(value) } -func MountPresent(string) (bool, error) { return false, nil } +func MountPresent(path string) (bool, error) { + name, err := windows.UTF16PtrFromString(filepath.Clean(path)) + if err != nil { + return false, err + } + handle, err := windows.CreateFile(name, 0, windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, + nil, windows.OPEN_EXISTING, windows.FILE_FLAG_BACKUP_SEMANTICS, 0) + if errors.Is(err, windows.ERROR_FILE_NOT_FOUND) || errors.Is(err, windows.ERROR_PATH_NOT_FOUND) { + return false, nil + } + if err != nil { + return false, err + } + defer windows.CloseHandle(handle) + var resolved [32768]uint16 + const volumeNameNT = 2 + n, err := windows.GetFinalPathNameByHandle(handle, &resolved[0], uint32(len(resolved)), volumeNameNT) + if err != nil { + return false, err + } + if n >= uint32(len(resolved)) { + return false, fmt.Errorf("resolved mount path exceeds Windows path limit") + } + // NT volume roots resolve to \Device\\; a directory below that + // volume has additional components, even when reached through a junction. + return windowsMountRoot(windows.UTF16ToString(resolved[:n])), nil +} + +func windowsMountRoot(resolved string) bool { + parts := strings.Split(strings.Trim(resolved, `\`), `\`) + if len(parts) == 2 && strings.EqualFold(parts[0], "Device") { + return true + } + // WinFsp network volumes resolve through MUP to a UNC share root. The + // filesystem type and mount identity are checked separately. + return len(parts) == 4 && strings.EqualFold(parts[0], "Device") && + strings.EqualFold(parts[1], "Mup") && strings.EqualFold(parts[2], "codexfold") && parts[3] != "" +} diff --git a/internal/service/mount_probe_windows_test.go b/internal/service/mount_probe_windows_test.go new file mode 100644 index 0000000..44c19e0 --- /dev/null +++ b/internal/service/mount_probe_windows_test.go @@ -0,0 +1,40 @@ +//go:build windows + +package service + +import ( + "os" + "path/filepath" + "testing" + + "github.com/samekind/codexfold/internal/mountid" +) + +func TestWindowsMountProbeRejectsOrdinaryDirectory(t *testing.T) { + path := t.TempDir() + if present, err := MountPresent(path); err != nil || present { + t.Fatalf("ordinary directory: present=%v err=%v", present, err) + } + if present, err := MountPresent(filepath.Join(path, "missing")); err != nil || present { + t.Fatalf("missing directory: present=%v err=%v", present, err) + } + if err := os.WriteFile(filepath.Join(path, mountid.Path), []byte(`{"version":1}`), 0o600); err != nil { + t.Fatal(err) + } + if err := defaultMountProbe(path); err == nil { + t.Fatal("marker in an ordinary directory accepted as a mounted filesystem") + } +} + +func TestWindowsMountRootRecognizesLocalUNCAndRejectsSubdirectories(t *testing.T) { + for _, path := range []string{`\Device\Volume{test}\`, `\Device\Mup\codexfold\test\`} { + if !windowsMountRoot(path) { + t.Fatalf("mount root rejected: %s", path) + } + } + for _, path := range []string{`\Device\HarddiskVolume3\Users\guo`, `\Device\Mup\codexfold\test\sessions`, `\Device\Mup\other\test\`} { + if windowsMountRoot(path) { + t.Fatalf("ordinary path accepted as mount root: %s", path) + } + } +} diff --git a/internal/service/service_test.go b/internal/service/service_test.go index 650ecd8..1ab6f31 100644 --- a/internal/service/service_test.go +++ b/internal/service/service_test.go @@ -304,13 +304,14 @@ func TestWindowsManagerInstallsStartsStopsAndReportsSCMState(t *testing.T) { if err := manager.Start(context.Background(), "com.codexfold.fs"); err != nil { t.Fatalf("Start: %v", err) } - if err := manager.Stop(context.Background(), "com.codexfold.fs"); err != nil { - t.Fatalf("Stop: %v", err) - } status := manager.Status(context.Background(), "com.codexfold.fs", filepath.Join(t.TempDir(), "mount")) if !status.DaemonRunning || !status.MountHealthy { t.Fatalf("Windows service status = %#v", status) } + statusRunner.outputs["sc.exe queryex com.codexfold.fs"] = []byte("STATE : 1 STOPPED\n") + if err := manager.Stop(context.Background(), "com.codexfold.fs"); err != nil { + t.Fatalf("Stop: %v", err) + } } func TestStatusDoesNotTreatLoadedExitedJobAsRunning(t *testing.T) { diff --git a/internal/service/windows.go b/internal/service/windows.go index 3d67769..2dd1ee9 100644 --- a/internal/service/windows.go +++ b/internal/service/windows.go @@ -14,6 +14,7 @@ import ( const windowsConfigVersion = 1 var windowsRunningState = regexp.MustCompile(`(?m)STATE\s*:\s*4\s+RUNNING\b`) +var windowsStoppedState = regexp.MustCompile(`(?m)STATE\s*:\s*1\s+STOPPED\b`) type WindowsConfig struct { Version int `json:"version"` @@ -88,9 +89,38 @@ func (m WindowsManager) Install(ctx context.Context, name string, binaryPath str if output, err := m.runner().Run(ctx, "sc.exe", "description", name, "CodexFold transparent Codex session filesystem"); err != nil { return commandFailure("sc.exe description", output, err) } - if output, err := m.runner().Run(ctx, "sc.exe", "failure", name, "reset=", "86400", "actions=", "restart/5000/restart/15000/\"\"/0"); err != nil { + if output, err := m.runner().Run(ctx, "sc.exe", "failure", name, "reset=", "86400", "actions=", "restart/5000/restart/15000/restart/60000"); err != nil { return commandFailure("sc.exe failure", output, err) } + if output, err := m.runner().Run(ctx, "sc.exe", "failureflag", name, "1"); err != nil { + return commandFailure("sc.exe failureflag", output, err) + } + return nil +} + +func (m WindowsManager) InstallEnrollment(ctx context.Context, name, binary, definition string) error { + if !safeLabel(name) || !absoluteWindowsServicePath(binary) || !absoluteWindowsServicePath(definition) { + return errors.New("safe enrollment service name and absolute installation paths are required") + } + command := strings.Join([]string{quoteWindowsCommandLineArgument(binary), "fs", "enroll", "service", "run", "--definition", quoteWindowsCommandLineArgument(definition)}, " ") + _, existsErr := m.runner().Run(ctx, "sc.exe", "query", name) + operation := "create" + args := []string{operation, name, "binPath=", command, "start=", "delayed-auto", "DisplayName=", "CodexFold Automatic Enrollment"} + if existsErr == nil { + args[0] = "config" + } + if output, err := m.runner().Run(ctx, "sc.exe", args...); err != nil { + return commandFailure("sc.exe enrollment registration", output, err) + } + if output, err := m.runner().Run(ctx, "sc.exe", "description", name, "CodexFold persistent automatic session enrollment"); err != nil { + return commandFailure("sc.exe description", output, err) + } + if output, err := m.runner().Run(ctx, "sc.exe", "failure", name, "reset=", "86400", "actions=", "restart/5000/restart/15000/restart/60000"); err != nil { + return commandFailure("sc.exe failure", output, err) + } + if output, err := m.runner().Run(ctx, "sc.exe", "failureflag", name, "1"); err != nil { + return commandFailure("sc.exe failureflag", output, err) + } return nil } @@ -113,7 +143,24 @@ func (m WindowsManager) Stop(ctx context.Context, name string) error { if err != nil { return commandFailure("sc.exe stop", output, err) } - return nil + deadline := time.Now().Add(30 * time.Second) + for { + output, err = m.runner().Run(ctx, "sc.exe", "queryex", name) + if err != nil { + return commandFailure("sc.exe queryex", output, err) + } + if windowsStoppedState.Match(output) { + return nil + } + if time.Now().After(deadline) { + return errors.New("Windows filesystem service did not finish stopping") + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(50 * time.Millisecond): + } + } } func (m WindowsManager) Status(ctx context.Context, name string, mountPoint string) Status { diff --git a/internal/service/windows_core_image.go b/internal/service/windows_core_image.go new file mode 100644 index 0000000..aa0da94 --- /dev/null +++ b/internal/service/windows_core_image.go @@ -0,0 +1,67 @@ +package service + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + + "github.com/samekind/codexfold/internal/buildid" +) + +func windowsDefinitionPath(definitionPath, flag string) (string, error) { + if !filepath.IsAbs(definitionPath) { + return "", errors.New("absolute service definition path is required") + } + data, err := os.ReadFile(definitionPath) + if err != nil { + return "", err + } + config, err := ParseWindowsConfig(data) + if err != nil { + return "", err + } + value := "" + for index, argument := range config.Arguments { + if argument == flag && index+1 < len(config.Arguments) { + value = config.Arguments[index+1] + } + if strings.HasPrefix(argument, flag+"=") { + value = strings.TrimPrefix(argument, flag+"=") + } + } + if !filepath.IsAbs(value) && !absoluteWindowsServicePath(value) { + return "", errors.New("Windows definition requires an absolute " + flag + " argument") + } + return filepath.Clean(value), nil +} + +// The SCM binary owns the resident frontend. A verified, administrator-owned +// image binding selects the separately replaceable storage engine. +func configuredWindowsCoreImage(binary string) (string, error) { + root := filepath.Join(filepath.Dir(binary), "Core") + data, err := os.ReadFile(filepath.Join(root, "current.json")) + if errors.Is(err, os.ErrNotExist) { + return binary, nil + } + if err != nil { + return "", err + } + var image struct { + Version int + SHA256 string + } + if json.Unmarshal(data, &image) != nil || image.Version != 1 || !buildid.ValidSHA256(image.SHA256) { + return "", errors.New("configured Windows engine binding is invalid") + } + path := filepath.Join(root, image.SHA256+".exe") + info, err := os.Lstat(path) + if err != nil || !info.Mode().IsRegular() { + return "", errors.New("configured Windows engine is not an ordinary file") + } + if actual, err := buildid.FileSHA256(path); err != nil || actual != image.SHA256 { + return "", errors.New("configured Windows engine hash does not match its binding") + } + return path, nil +} diff --git a/internal/service/windows_core_image_test.go b/internal/service/windows_core_image_test.go new file mode 100644 index 0000000..64c1912 --- /dev/null +++ b/internal/service/windows_core_image_test.go @@ -0,0 +1,98 @@ +package service + +import ( + "encoding/json" + "github.com/samekind/codexfold/internal/buildid" + "os" + "path/filepath" + "testing" +) + +func TestWindowsCoreImageBinding(t *testing.T) { + root := t.TempDir() + base := filepath.Join(root, "codexfold.exe") + if err := os.WriteFile(base, []byte("frontend"), 0600); err != nil { + t.Fatal(err) + } + if path, err := configuredWindowsCoreImage(base); err != nil || path != base { + t.Fatal(path, err) + } + core := filepath.Join(root, "Core") + _ = os.Mkdir(core, 0700) + image := filepath.Join(root, "new.exe") + _ = os.WriteFile(image, []byte("new-engine"), 0600) + sha, _ := buildid.FileSHA256(image) + target := filepath.Join(core, sha+".exe") + _ = os.Rename(image, target) + binding, _ := json.Marshal(struct { + Version int + SHA256 string + }{1, sha}) + _ = os.WriteFile(filepath.Join(core, "current.json"), binding, 0600) + if path, err := configuredWindowsCoreImage(base); err != nil || path != target { + t.Fatal(path, err) + } + _ = os.WriteFile(target, []byte("changed"), 0600) + if _, err := configuredWindowsCoreImage(base); err == nil { + t.Fatal("unverified image accepted") + } +} + +func TestWindowsCoreDefinitionPaths(t *testing.T) { + root := t.TempDir() + definition := filepath.Join(root, "service.json") + data, _ := json.Marshal(WindowsConfig{Version: 1, ServiceName: "com.codexfold.fs", BinaryPath: filepath.Join(root, "core.exe"), + Arguments: []string{"fs", "serve", "--store=" + root, "--mount", filepath.Join(root, "mount"), "--native-root", filepath.Join(root, "native")}, + StdoutPath: filepath.Join(root, "out.log"), StderrPath: filepath.Join(root, "err.log")}) + if err := os.WriteFile(definition, data, 0600); err != nil { + t.Fatal(err) + } + for _, item := range []struct { + load func(Platform, string) (string, error) + want string + }{{DefinitionStore, root}, {DefinitionMountPoint, filepath.Join(root, "mount")}, {DefinitionNativeRoot, filepath.Join(root, "native")}} { + value, err := item.load(PlatformWindows, definition) + if err != nil || value != item.want { + t.Fatal(value, err) + } + } +} + +func TestWindowsCoreOfflineUpdateRestoresEngineBinding(t *testing.T) { + root := t.TempDir() + base := filepath.Join(root, "codexfold.exe") + core := filepath.Join(root, "Core") + _ = os.Mkdir(core, 0700) + _ = os.WriteFile(base, []byte("old-engine"), 0600) + oldSHA, _ := buildid.FileSHA256(base) + _ = os.WriteFile(filepath.Join(core, oldSHA+".exe"), []byte("old-engine"), 0600) + previous, _ := json.Marshal(struct { + Version int + SHA256 string + }{1, oldSHA}) + _ = os.WriteFile(filepath.Join(core, "current.json"), previous, 0600) + _ = os.WriteFile(base, []byte("new-engine"), 0600) + rollback, err := PrepareWindowsCoreOfflineUpdate(base) + if err != nil { + t.Fatal(err) + } + selected, err := configuredWindowsCoreImage(base) + if err != nil { + t.Fatal(err) + } + newSHA, _ := buildid.FileSHA256(selected) + if newSHA == oldSHA { + t.Fatal("offline update kept old engine") + } + if err := rollback(); err != nil { + t.Fatal(err) + } + selected, err = configuredWindowsCoreImage(base) + if err != nil { + t.Fatal(err) + } + restored, _ := buildid.FileSHA256(selected) + if restored != oldSHA { + t.Fatal("rollback changed old image") + } +} diff --git a/internal/service/windows_core_offline.go b/internal/service/windows_core_offline.go new file mode 100644 index 0000000..10ec6b0 --- /dev/null +++ b/internal/service/windows_core_offline.go @@ -0,0 +1,76 @@ +package service + +import ( + "encoding/json" + "errors" + "io" + "os" + "path/filepath" + + "github.com/samekind/codexfold/internal/buildid" +) + +// An offline frontend update must also select its new engine image. Retain the +// previous binding so the existing binary-update rollback can restore both. +// The caller has already stopped the owned Windows service. +func PrepareWindowsCoreOfflineUpdate(binary string) (func() error, error) { + root := filepath.Join(filepath.Dir(binary), "Core") + bindingPath := filepath.Join(root, "current.json") + previous, err := os.ReadFile(bindingPath) + if errors.Is(err, os.ErrNotExist) { + return func() error { return nil }, nil + } + if err != nil { + return nil, err + } + if _, err := configuredWindowsCoreImage(binary); err != nil { + return nil, err + } + sha, err := buildid.FileSHA256(binary) + if err != nil { + return nil, err + } + image := filepath.Join(root, sha+".exe") + if info, err := os.Lstat(image); err == nil { + if !info.Mode().IsRegular() { + return nil, errors.New("offline engine image is not an ordinary file") + } + if actual, err := buildid.FileSHA256(image); err != nil || actual != sha { + return nil, errors.New("offline engine image failed verification") + } + } else if errors.Is(err, os.ErrNotExist) { + source, err := os.Open(binary) + if err != nil { + return nil, err + } + defer source.Close() + target, err := os.CreateTemp(root, ".offline-engine-*.tmp") + if err != nil { + return nil, err + } + name := target.Name() + defer os.Remove(name) + _, copyErr := io.Copy(target, source) + syncErr := target.Sync() + closeErr := target.Close() + if err := errors.Join(copyErr, syncErr, closeErr); err != nil { + return nil, err + } + if actual, err := buildid.FileSHA256(name); err != nil || actual != sha { + return nil, errors.New("offline engine source changed") + } + if err := os.Rename(name, image); err != nil { + return nil, err + } + } else { + return nil, err + } + data, _ := json.Marshal(struct { + Version int + SHA256 string + }{1, sha}) + if _, err := WriteDefinition(bindingPath, data, true); err != nil { + return nil, err + } + return func() error { _, err := WriteDefinition(bindingPath, previous, true); return err }, nil +} diff --git a/internal/service/windows_enrollment.go b/internal/service/windows_enrollment.go new file mode 100644 index 0000000..0846398 --- /dev/null +++ b/internal/service/windows_enrollment.go @@ -0,0 +1,59 @@ +package service + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "path/filepath" + "strings" +) + +const WindowsEnrollmentServiceName = "com.codexfold.enroll" + +// This administrator-owned binding contains no executable arguments from the +// writable user policy. Every data location is explicit, even under SYSTEM. +type WindowsEnrollmentConfig struct { + Version int `json:"version"` + ServiceName string `json:"service_name"` + BinaryPath string `json:"binary_path"` + OwnerSID string `json:"owner_sid"` + CodexHome string `json:"codex_home"` + Store string `json:"store"` + Mount string `json:"mount"` + NativeRoot string `json:"native_root"` + StdoutPath string `json:"stdout_path"` + StderrPath string `json:"stderr_path"` +} + +func ParseWindowsEnrollmentConfig(data []byte) (WindowsEnrollmentConfig, error) { + var config WindowsEnrollmentConfig + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&config); err != nil { + return config, err + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + return config, errors.New("unexpected data after enrollment service binding") + } + if config.Version != 1 { + return config, errors.New("unsupported enrollment service binding version") + } + if !safeLabel(config.ServiceName) || (config.ServiceName != WindowsEnrollmentServiceName && !strings.HasPrefix(config.ServiceName, WindowsEnrollmentServiceName+".test-")) { + return config, errors.New("unexpected enrollment service name") + } + if config.OwnerSID == "" { + return config, errors.New("enrollment service owner SID is required") + } + for name, path := range map[string]string{"binary": config.BinaryPath, "home": config.CodexHome, "store": config.Store, "mount": config.Mount, "native": config.NativeRoot, "stdout": config.StdoutPath, "stderr": config.StderrPath} { + if !absoluteWindowsServicePath(path) { + return config, fmt.Errorf("absolute enrollment service %s path is required", name) + } + } + if !strings.EqualFold(filepath.Clean(config.Store), filepath.Join(filepath.Clean(config.CodexHome), "fold-store")) || + !strings.EqualFold(filepath.Clean(config.NativeRoot), filepath.Join(filepath.Clean(config.CodexHome), "fold-native")) { + return config, errors.New("enrollment service store and native root must belong to its bound home") + } + return config, nil +} diff --git a/internal/service/windows_enrollment_test.go b/internal/service/windows_enrollment_test.go new file mode 100644 index 0000000..d9e6bf7 --- /dev/null +++ b/internal/service/windows_enrollment_test.go @@ -0,0 +1,55 @@ +//go:build windows + +package service + +import ( + "context" + "encoding/json" + "errors" + "path/filepath" + "strings" + "testing" +) + +func TestWindowsEnrollmentBindingRejectsAmbiguousRoots(t *testing.T) { + home := filepath.Join(t.TempDir(), "home") + valid := WindowsEnrollmentConfig{Version: 1, ServiceName: WindowsEnrollmentServiceName, BinaryPath: filepath.Join(home, "bin.exe"), OwnerSID: "S-1-5-21-1-2-3-1001", CodexHome: home, Store: filepath.Join(home, "fold-store"), NativeRoot: filepath.Join(home, "fold-native"), Mount: `V:\`, StdoutPath: filepath.Join(home, "out.log"), StderrPath: filepath.Join(home, "err.log")} + for _, mutate := range []func(*WindowsEnrollmentConfig){nil, + func(c *WindowsEnrollmentConfig) { c.CodexHome = "" }, + func(c *WindowsEnrollmentConfig) { c.Store = filepath.Join(home, "other-store") }, + func(c *WindowsEnrollmentConfig) { c.NativeRoot = filepath.Dir(home) }, + func(c *WindowsEnrollmentConfig) { c.OwnerSID = "" }, + func(c *WindowsEnrollmentConfig) { c.ServiceName = "unrelated" }, + func(c *WindowsEnrollmentConfig) { c.Version = 2 }, + } { + candidate := valid + if mutate != nil { + mutate(&candidate) + } + data, err := json.Marshal(candidate) + if err != nil { + t.Fatal(err) + } + _, err = ParseWindowsEnrollmentConfig(data) + if (err == nil) != (mutate == nil) { + t.Fatalf("binding %#v: %v", candidate, err) + } + } +} + +func TestWindowsEnrollmentManagerRegistersRecoveryWithoutFilesystemRestart(t *testing.T) { + runner := &recordingRunner{errors: map[string]error{"sc.exe query " + WindowsEnrollmentServiceName: errors.New("not installed")}} + manager := WindowsManager{Runner: runner} + if err := manager.InstallEnrollment(context.Background(), WindowsEnrollmentServiceName, `C:\Program Files\CodexFold\Enrollment\codexfold-enroll.exe`, `C:\ProgramData\CodexFold\Enrollment\service.json`); err != nil { + t.Fatal(err) + } + actual := strings.Join(runner.calls, "\n") + for _, want := range []string{"fs enroll service run --definition", "start= delayed-auto", "actions= restart/5000/restart/15000/restart/60000", "failureflag " + WindowsEnrollmentServiceName + " 1"} { + if !strings.Contains(actual, want) { + t.Fatalf("missing %s: %s", want, actual) + } + } + if strings.Contains(actual, "com.codexfold.fs") { + t.Fatal("enrollment registration touched filesystem service") + } +} diff --git a/internal/sessionns/activation.go b/internal/sessionns/activation.go index a2bedb1..ebc63a7 100644 --- a/internal/sessionns/activation.go +++ b/internal/sessionns/activation.go @@ -6,6 +6,8 @@ import ( "fmt" "os" "path/filepath" + + "github.com/samekind/codexfold/internal/dirsync" ) const ( @@ -51,7 +53,7 @@ func Inspect(options Options) (Result, error) { if err != nil { return Result{}, fmt.Errorf("inspect %s: %w", homePath, err) } - if info.Mode()&os.ModeSymlink != 0 { + if isNamespaceLink(info) { target, err := os.Readlink(homePath) if err != nil || filepath.Clean(target) != filepath.Join(options.Mount, name) { return Result{}, fmt.Errorf("unexpected namespace link %s", homePath) @@ -132,7 +134,7 @@ func Activate(options Options) (Result, error) { if err := os.Rename(homePath, nativePath); err != nil { return rollbackAfterError(options, err) } - if err := os.Symlink(filepath.Join(options.Mount, name), homePath); err != nil { + if err := createNamespaceLink(filepath.Join(options.Mount, name), homePath); err != nil { return rollbackAfterError(options, err) } } @@ -200,7 +202,7 @@ func Recover(options Options) (Result, error) { for _, name := range sessionDirectories { homePath := filepath.Join(options.Home, name) nativePath := filepath.Join(options.NativeRoot, name) - if info, err := os.Lstat(homePath); err == nil && info.Mode()&os.ModeSymlink != 0 { + if info, err := os.Lstat(homePath); err == nil && isNamespaceLink(info) { if err := os.Remove(homePath); err != nil { return Result{}, err } @@ -327,5 +329,5 @@ func syncDirectory(path string) error { return err } defer directory.Close() - return directory.Sync() + return dirsync.Sync(directory) } diff --git a/internal/sessionns/link_unix.go b/internal/sessionns/link_unix.go new file mode 100644 index 0000000..f11909e --- /dev/null +++ b/internal/sessionns/link_unix.go @@ -0,0 +1,8 @@ +//go:build !windows + +package sessionns + +import "os" + +func isNamespaceLink(info os.FileInfo) bool { return info.Mode()&os.ModeSymlink != 0 } +func createNamespaceLink(target, link string) error { return os.Symlink(target, link) } diff --git a/internal/sessionns/link_windows.go b/internal/sessionns/link_windows.go new file mode 100644 index 0000000..50b4e27 --- /dev/null +++ b/internal/sessionns/link_windows.go @@ -0,0 +1,67 @@ +//go:build windows + +package sessionns + +import ( + "encoding/binary" + "errors" + "os" + "path/filepath" + + "golang.org/x/sys/windows" +) + +func isNamespaceLink(info os.FileInfo) bool { + // Go 1.23+ reports junctions as irregular name-surrogate reparse points. + // Readlink and exact target validation still reject unrelated reparse data. + return info.Mode()&(os.ModeSymlink|os.ModeIrregular) != 0 +} + +func createNamespaceLink(target, link string) (result error) { + if !filepath.IsAbs(target) || filepath.VolumeName(target) == "" { + return errors.New("absolute local Windows junction target is required") + } + substitute, err := windows.UTF16FromString(`\??\` + filepath.Clean(target)) + if err != nil { + return err + } + printed, err := windows.UTF16FromString(filepath.Clean(target)) + if err != nil { + return err + } + data := make([]byte, 16+2*(len(substitute)+len(printed))) + if len(data) > 16384 { + return errors.New("Windows junction target exceeds reparse buffer limit") + } + binary.LittleEndian.PutUint32(data[0:4], windows.IO_REPARSE_TAG_MOUNT_POINT) + binary.LittleEndian.PutUint16(data[4:6], uint16(len(data)-8)) + binary.LittleEndian.PutUint16(data[10:12], uint16(2*(len(substitute)-1))) + binary.LittleEndian.PutUint16(data[12:14], uint16(2*len(substitute))) + binary.LittleEndian.PutUint16(data[14:16], uint16(2*(len(printed)-1))) + for i, value := range append(substitute, printed...) { + binary.LittleEndian.PutUint16(data[16+2*i:18+2*i], value) + } + // Only create a fresh empty directory; never replace a pre-existing path. + if err := os.Mkdir(link, 0o700); err != nil { + return err + } + defer func() { + if result != nil { + _ = os.Remove(link) + } + }() + name, err := windows.UTF16PtrFromString(link) + if err != nil { + return err + } + handle, err := windows.CreateFile(name, windows.GENERIC_WRITE, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, + nil, windows.OPEN_EXISTING, windows.FILE_FLAG_OPEN_REPARSE_POINT|windows.FILE_FLAG_BACKUP_SEMANTICS, 0) + if err != nil { + return err + } + defer windows.CloseHandle(handle) + var returned uint32 + return windows.DeviceIoControl(handle, windows.FSCTL_SET_REPARSE_POINT, + &data[0], uint32(len(data)), nil, 0, &returned, nil) +} diff --git a/internal/sessionns/routing_guard.go b/internal/sessionns/routing_guard.go index 6a94797..b9fc49b 100644 --- a/internal/sessionns/routing_guard.go +++ b/internal/sessionns/routing_guard.go @@ -7,8 +7,11 @@ import ( "fmt" "os" "path/filepath" + "runtime" "strings" + "github.com/samekind/codexfold/internal/mountfs" + _ "modernc.org/sqlite" ) @@ -95,29 +98,46 @@ func normalizeExistingRoutesStatement(options Options) string { } func routeGuardCase(options Options, value string) string { - activeMount := filepath.Join(options.Mount, "sessions") + string(filepath.Separator) - archiveMount := filepath.Join(options.Mount, "archived_sessions") + string(filepath.Separator) - activeHome := filepath.Join(options.Home, "sessions") + string(filepath.Separator) - archiveHome := filepath.Join(options.Home, "archived_sessions") + string(filepath.Separator) - activeMountSQL := quoteSQLString(activeMount) - archiveMountSQL := quoteSQLString(archiveMount) - return fmt.Sprintf( - `case when substr(%s, 1, length(%s)) = %s then %s || substr(%s, length(%s) + 1) else %s || substr(%s, length(%s) + 1) end`, - value, activeMountSQL, activeMountSQL, quoteSQLString(activeHome), value, activeMountSQL, - quoteSQLString(archiveHome), value, archiveMountSQL, - ) + var result strings.Builder + result.WriteString("case ") + for _, prefix := range routePrefixes(options) { + from := quoteSQLString(prefix.mount) + fmt.Fprintf(&result, "when %s then %s || substr(%s, length(%s) + 1) ", routePrefixCondition(value, from), quoteSQLString(prefix.home), value, from) + } + fmt.Fprintf(&result, "else %s end", value) + return result.String() } func routeGuardCondition(options Options, value string) string { - activeMount := filepath.Join(options.Mount, "sessions") + string(filepath.Separator) - archiveMount := filepath.Join(options.Mount, "archived_sessions") + string(filepath.Separator) - activeMountSQL := quoteSQLString(activeMount) - archiveMountSQL := quoteSQLString(archiveMount) - return fmt.Sprintf( - `substr(%s, 1, length(%s)) = %s or substr(%s, 1, length(%s)) = %s`, - value, activeMountSQL, activeMountSQL, - value, archiveMountSQL, archiveMountSQL, - ) + var conditions []string + for _, prefix := range routePrefixes(options) { + conditions = append(conditions, routePrefixCondition(value, quoteSQLString(prefix.mount))) + } + return strings.Join(conditions, " or ") +} + +type routePrefix struct{ mount, home string } + +func routePrefixes(options Options) []routePrefix { + roots := []string{options.Mount} + if runtime.GOOS == "windows" && len(filepath.Clean(options.Mount)) == 3 && filepath.Dir(options.Mount) == options.Mount { + unc := mountfs.WindowsUNCPath(options.Home, options.Mount) + roots = append(roots, `\\?\`+filepath.Clean(options.Mount), unc, `\\?\UNC\`+strings.TrimPrefix(unc, `\\`)) + } + var prefixes []routePrefix + for _, root := range roots { + for _, namespace := range sessionDirectories { + prefixes = append(prefixes, routePrefix{filepath.Join(root, namespace) + string(filepath.Separator), filepath.Join(options.Home, namespace) + string(filepath.Separator)}) + } + } + return prefixes +} + +func routePrefixCondition(value, prefix string) string { + if runtime.GOOS == "windows" { + return fmt.Sprintf("lower(substr(%s, 1, length(%s))) = lower(%s)", value, prefix, prefix) + } + return fmt.Sprintf("substr(%s, 1, length(%s)) = %s", value, prefix, prefix) } func quoteSQLString(value string) string { diff --git a/internal/sessionns/routing_guard_windows_test.go b/internal/sessionns/routing_guard_windows_test.go new file mode 100644 index 0000000..02b3e30 --- /dev/null +++ b/internal/sessionns/routing_guard_windows_test.go @@ -0,0 +1,55 @@ +//go:build windows + +package sessionns + +import ( + "database/sql" + "path/filepath" + "testing" + + "github.com/samekind/codexfold/internal/mountfs" +) + +func TestWindowsRouteGuardNormalizesDriveAndUNCAliases(t *testing.T) { + home := t.TempDir() + options := Options{Home: home, Mount: `T:\`, NativeRoot: filepath.Join(home, "native")} + db, err := sql.Open("sqlite", filepath.Join(home, "state_5.sqlite")) + if err != nil { + t.Fatal(err) + } + defer db.Close() + if _, err := db.Exec(`create table threads (id text primary key, rollout_path text); insert into threads values ('thread','native')`); err != nil { + t.Fatal(err) + } + if err := installRouteGuard(options); err != nil { + t.Fatal(err) + } + unc := mountfs.WindowsUNCPath(home, options.Mount) + for _, root := range []string{`t:\`, `\\?\T:\`, unc, `\\?\UNC\` + unc[2:]} { + for _, namespace := range sessionDirectories { + alias := filepath.Join(root, namespace, "2026", "rollout.jsonl") + if _, err := db.Exec(`update threads set rollout_path=? where id='thread'`, alias); err != nil { + t.Fatal(err) + } + var actual string + if err := db.QueryRow(`select rollout_path from threads where id='thread'`).Scan(&actual); err != nil { + t.Fatal(err) + } + expected := filepath.Join(home, namespace, "2026", "rollout.jsonl") + if actual != expected { + t.Fatalf("alias %q normalized to %q, wanted %q", alias, actual, expected) + } + } + } + foreign := `\\unrelated\share\sessions\rollout.jsonl` + if _, err := db.Exec(`update threads set rollout_path=? where id='thread'`, foreign); err != nil { + t.Fatal(err) + } + var actual string + if err := db.QueryRow(`select rollout_path from threads where id='thread'`).Scan(&actual); err != nil { + t.Fatal(err) + } + if actual != foreign { + t.Fatal("unrelated UNC route changed") + } +} diff --git a/internal/storage/gc.go b/internal/storage/gc.go index 6498bf9..19a5dc5 100644 --- a/internal/storage/gc.go +++ b/internal/storage/gc.go @@ -15,6 +15,8 @@ import ( "strconv" "strings" "time" + + "github.com/samekind/codexfold/internal/dirsync" ) type CandidateKind string @@ -513,7 +515,7 @@ func syncGCRootDirectory(root *os.Root, relative string) error { return err } defer directory.Close() - return directory.Sync() + return dirsync.Sync(directory) } func (b *gcBuilder) add(path string, kind CandidateKind) error { diff --git a/internal/tray/accounting.go b/internal/tray/accounting.go new file mode 100644 index 0000000..7f6bd58 --- /dev/null +++ b/internal/tray/accounting.go @@ -0,0 +1,96 @@ +package tray + +import ( + "context" + "errors" + "path/filepath" + "sync" + "time" + + "github.com/samekind/codexfold/internal/pack" + "github.com/samekind/codexfold/internal/storage" +) + +type spaceAccounting struct { + Logical, Physical, CompressionLogical, Compressed, Pending int64 + UpdatedAt time.Time +} + +type accountingCache struct { + sync.Mutex + busy bool + next time.Time + value *spaceAccounting + err error +} + +func (m *Monitor) RequestAccountingRefresh() { + m.space.Lock() + defer m.space.Unlock() + if !m.space.busy { + m.space.next = time.Time{} + } +} + +func readSpaceAccounting(ctx context.Context, store string) (spaceAccounting, error) { + generation, err := pack.CurrentGeneration(store) + if err != nil { + return spaceAccounting{}, err + } + inventory, err := storage.Scan(ctx, storage.Options{StoreDir: store, AllowMetadataIssues: true}) + if err != nil { + return spaceAccounting{}, err + } + if inventory.IssueCount != 0 { + return spaceAccounting{}, errors.New("space inventory has unresolved metadata issues") + } + logical, err := pack.PublishedLogicalBytes(ctx, store, generation) + if err != nil { + return spaceAccounting{}, err + } + // Scan the published generation separately: reused pack parts can be hard + // linked from an older generation, and whole-store category attribution + // depends on traversal order. Compression cost must include the full pack. + core, err := storage.Scan(ctx, storage.Options{StoreDir: filepath.Join(store, "packs", generation)}) + if err != nil { + return spaceAccounting{}, err + } + current, err := pack.CurrentGeneration(store) + if err != nil || current != generation { + return spaceAccounting{}, errors.New("pack changed during space accounting") + } + // These retained representations are separate from current pack storage. + // Their removal remains subject to the worker's normal verification gates. + pending := max(int64(0), inventory.TotalPhysicalBytes-core.TotalPhysicalBytes-inventory.Metadata.PhysicalBytes-inventory.ActiveDeltas.PhysicalBytes-inventory.WritableBackings.PhysicalBytes) + return spaceAccounting{Logical: inventory.LogicalSessionBytes, Physical: inventory.TotalPhysicalBytes, + CompressionLogical: logical, Compressed: core.TotalPhysicalBytes, Pending: pending, UpdatedAt: time.Now().UTC()}, nil +} + +// File enumeration is off the UI/poll thread, bounded to one scan at a time. +// The tray reads metadata only; it never repairs, retires or deletes storage. +func (m *Monitor) accounting(now time.Time) (*spaceAccounting, error) { + cache := &m.space + cache.Lock() + defer cache.Unlock() + if !cache.busy && !now.Before(cache.next) { + cache.busy = true + cache.next = now.Add(time.Minute) + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + defer cancel() + value, err := readSpaceAccounting(ctx, m.Store) + cache.Lock() + defer cache.Unlock() + cache.busy = false + cache.err = err + if err == nil { + cache.value = &value + } + }() + } + if cache.value != nil && now.Sub(cache.value.UpdatedAt) <= 5*time.Minute { + copy := *cache.value + return ©, cache.err + } + return nil, cache.err +} diff --git a/internal/tray/accounting_test.go b/internal/tray/accounting_test.go new file mode 100644 index 0000000..1455caa --- /dev/null +++ b/internal/tray/accounting_test.go @@ -0,0 +1,70 @@ +package tray + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/samekind/codexfold/internal/fold" + "github.com/samekind/codexfold/internal/pack" + "github.com/samekind/codexfold/internal/storage" +) + +func TestSpaceAccountingSeparatesCompressionAndUnpublishedCopies(t *testing.T) { + oldLimits := storage.DefaultLimits + storage.DefaultLimits.FreeSpaceReserveBytes = 1 << 20 + t.Cleanup(func() { storage.DefaultLimits = oldLimits }) + store := t.TempDir() + native := t.TempDir() + objects := fold.NewObjectStore(store) + write := func(id string, payload []byte) { + t.Helper() + ref, _, err := objects.Put(payload, true) + if err != nil { + t.Fatal(err) + } + digest := sha256.Sum256(payload) + original := filepath.Join(native, id+".jsonl") + if err := os.WriteFile(original, payload, 0o600); err != nil { + t.Fatal(err) + } + manifest := fold.Manifest{Version: fold.ManifestVersion, Kind: fold.ManifestKind, Session: fold.ManifestSession{ID: id, RolloutPath: original}, Parts: []fold.Part{{Kind: fold.PartResidual, Object: ref}}} + manifest.Source.Bytes = int64(len(payload)) + manifest.Source.SHA256 = hex.EncodeToString(digest[:]) + data, err := json.Marshal(manifest) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(store, "manifests"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(store, "manifests", id+".json"), data, 0o600); err != nil { + t.Fatal(err) + } + } + first := bytes.Repeat([]byte("published-compressed-data"), 20000) + write("first", first) + if _, err := pack.Build(context.Background(), store, pack.BuildOptions{}); err != nil { + t.Fatal(err) + } + before, err := readSpaceAccounting(context.Background(), store) + if err != nil { + t.Fatal(err) + } + write("pending", bytes.Repeat([]byte("unpublished-extra-data"), 20000)) + after, err := readSpaceAccounting(context.Background(), store) + if err != nil { + t.Fatal(err) + } + if before.CompressionLogical != int64(len(first)) || after.CompressionLogical != before.CompressionLogical || after.Compressed != before.Compressed { + t.Fatalf("unpublished work changed compression: before=%+v after=%+v", before, after) + } + if after.Physical <= before.Physical || after.Pending <= before.Pending { + t.Fatalf("extra copies not exposed: before=%+v after=%+v", before, after) + } +} diff --git a/internal/tray/dashboard.html b/internal/tray/dashboard.html new file mode 100644 index 0000000..0fa8f14 --- /dev/null +++ b/internal/tray/dashboard.html @@ -0,0 +1,580 @@ + + + + + + +CodexFold + + + + +

+
+
+ + + +
+
+
+
+ +

CodexFold

未连接

+
+
+ + + +
+
+
+
+
+
+
压缩减少
—
+
压缩比例
—
+
会话数据
—
+
磁盘占用
—
+
+
+

自动折叠

未连接
+

正在读取后台状态…

+
+ + + + +
+

+ +
+
待回收占用—
+
当前净节省—
+
+
+
+
+

空间变化

+
+ + + + +
+
+
原始大小当前占用
+
+ + + + + +
暂无记录
+
+
24 小时前现在
+

+

+
+
+
+

读写速度

+
读取 —写入 —
+
+
+ + + + +
暂无记录
+
+
10 分钟前现在
+
+

故障记录

暂无持续故障记录

+
+

运行状态

托管会话:—
+
+
文件服务未连接
+
会话管理未连接
+
+
技术详情
+
+
+
+ +

诊断

+

+

+  

诊断包含运行状态、设置和汇总记录,不含会话内容。

+
+
+ + + diff --git a/internal/tray/diagnostics.go b/internal/tray/diagnostics.go new file mode 100644 index 0000000..15496cd --- /dev/null +++ b/internal/tray/diagnostics.go @@ -0,0 +1,42 @@ +package tray + +import ( + "encoding/json" + "runtime" + "runtime/debug" + "time" +) + +type DiagnosticReport struct { + Version int + GeneratedAt string + Platform string + GoVersion string + SourceRevision string + SourceModified bool + SessionContent bool + Snapshot View +} + +func Diagnostics(view View) DiagnosticReport { + report := DiagnosticReport{Version: 1, GeneratedAt: time.Now().UTC().Format(time.RFC3339Nano), + Platform: runtime.GOOS + "/" + runtime.GOARCH, GoVersion: runtime.Version(), Snapshot: view} + // An operation acknowledgement is transient UI state, not a diagnostic. + report.Snapshot.Operation = nil + if info, ok := debug.ReadBuildInfo(); ok { + for _, setting := range info.Settings { + switch setting.Key { + case "vcs.revision": + report.SourceRevision = setting.Value + case "vcs.modified": + report.SourceModified = setting.Value == "true" + } + } + } + return report +} + +func DiagnosticBytes(view View) ([]byte, error) { + return json.MarshalIndent(Diagnostics(view), "", " ") +} +func ExportDiagnostics(path string, view View) error { return writeJSONFile(path, Diagnostics(view)) } diff --git a/internal/tray/diagnostics_windows.go b/internal/tray/diagnostics_windows.go new file mode 100644 index 0000000..a98f475 --- /dev/null +++ b/internal/tray/diagnostics_windows.go @@ -0,0 +1,128 @@ +//go:build windows + +package tray + +import ( + "errors" + "fmt" + "time" + utf16codec "unicode/utf16" + "unsafe" + + "golang.org/x/sys/windows" +) + +type openFileName struct { + Size uint32 + Owner uintptr + Instance uintptr + Filter *uint16 + CustomFilter *uint16 + MaxCustom uint32 + FilterIndex uint32 + File *uint16 + MaxFile uint32 + FileTitle *uint16 + MaxFileTitle uint32 + InitialDir *uint16 + Title *uint16 + Flags uint32 + FileOffset uint16 + FileExtension uint16 + DefaultExt *uint16 + CustomData uintptr + Hook uintptr + Template *uint16 + Reserved uintptr + ReservedWord uint32 + FlagsEx uint32 +} + +func chooseDiagnosticFile(owner uintptr) (string, error) { + buffer := make([]uint16, 32768) + name, _ := windows.UTF16FromString("codexfold-diagnostics-" + time.Now().Format("20060102-150405") + ".json") + copy(buffer, name) + filter := utf16codec.Encode([]rune("JSON 诊断文件\x00*.json\x00所有文件\x00*.*\x00\x00")) + dialog := openFileName{Owner: owner, Filter: &filter[0], FilterIndex: 1, File: &buffer[0], MaxFile: uint32(len(buffer)), + Title: utf16("导出 CodexFold 诊断"), DefaultExt: utf16("json"), Flags: 0x2 | 0x8 | 0x800 | 0x80000} + dialog.Size = uint32(unsafe.Sizeof(dialog)) + common := windows.NewLazySystemDLL("comdlg32.dll") + if ok, _, _ := common.NewProc("GetSaveFileNameW").Call(uintptr(unsafe.Pointer(&dialog))); ok == 0 { + code, _, _ := common.NewProc("CommDlgExtendedError").Call() + if code != 0 { + return "", fmt.Errorf("保存窗口错误 0x%x", code) + } + return "", nil + } + return windows.UTF16ToString(buffer), nil +} + +func copyDiagnosticText(owner uintptr, value string) error { + text, err := windows.UTF16FromString(value) + if err != nil { + return err + } + kernel := windows.NewLazySystemDLL("kernel32.dll") + if ok, _, _ := user32.NewProc("OpenClipboard").Call(owner); ok == 0 { + return errors.New("剪贴板正被其他程序占用,请稍后重试") + } + defer user32.NewProc("CloseClipboard").Call() + memory, _, _ := kernel.NewProc("GlobalAlloc").Call(2, uintptr(len(text)*2)) + if memory == 0 { + return errors.New("无法分配剪贴板内存") + } + owned := true + defer func() { + if owned { + kernel.NewProc("GlobalFree").Call(memory) + } + }() + address, _, _ := kernel.NewProc("GlobalLock").Call(memory) + if address == 0 { + return errors.New("无法写入剪贴板") + } + moveMemory.Call(address, uintptr(unsafe.Pointer(&text[0])), uintptr(len(text)*2)) + kernel.NewProc("GlobalUnlock").Call(memory) + if ok, _, _ := user32.NewProc("EmptyClipboard").Call(); ok == 0 { + return errors.New("无法清空剪贴板") + } + if ok, _, _ := user32.NewProc("SetClipboardData").Call(13, memory); ok == 0 { // CF_UNICODETEXT + return errors.New("复制失败,请稍后重试") + } + owned = false // Windows owns the allocation after SetClipboardData. + return nil +} + +func (a *application) reply(result ActionResult) { + data, err := marshalActionResult(result) + if err == nil { + a.browser.Eval("window.showOperationResult && window.showOperationResult(" + string(data) + ")") + } +} + +func (a *application) exportDiagnostics() { + path, err := chooseDiagnosticFile(a.window) + if err == nil && path == "" { + return + } + if err == nil { + err = ExportDiagnostics(path, a.view) + } + result := ActionResult{ID: "export-diagnostics", OK: err == nil, Message: "诊断已导出:" + path} + if err != nil { + result.Message = "导出失败:" + err.Error() + } + a.reply(result) +} + +func (a *application) copyDiagnostics() { + data, err := DiagnosticBytes(a.view) + if err == nil { + err = copyDiagnosticText(a.window, string(data)) + } + result := ActionResult{ID: "copy-diagnostics", OK: err == nil, Message: "诊断已复制,不含会话内容。"} + if err != nil { + result.Message = "复制失败:" + err.Error() + } + a.reply(result) +} diff --git a/internal/tray/files_other.go b/internal/tray/files_other.go new file mode 100644 index 0000000..bb935d5 --- /dev/null +++ b/internal/tray/files_other.go @@ -0,0 +1,8 @@ +//go:build !windows + +package tray + +import "os" + +func openSharedFile(path string) (*os.File, error) { return os.Open(path) } +func replaceUIFile(source, destination string) error { return os.Rename(source, destination) } diff --git a/internal/tray/files_windows.go b/internal/tray/files_windows.go new file mode 100644 index 0000000..f0013ad --- /dev/null +++ b/internal/tray/files_windows.go @@ -0,0 +1,34 @@ +//go:build windows + +package tray + +import ( + "os" + + "golang.org/x/sys/windows" +) + +func openSharedFile(path string) (*os.File, error) { + name, err := windows.UTF16PtrFromString(path) + if err != nil { + return nil, err + } + handle, err := windows.CreateFile(name, windows.GENERIC_READ, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, nil, windows.OPEN_EXISTING, windows.FILE_ATTRIBUTE_NORMAL, 0) + if err != nil { + return nil, &os.PathError{Op: "open", Path: path, Err: err} + } + return os.NewFile(uintptr(handle), path), nil +} + +func replaceUIFile(source, destination string) error { + from, err := windows.UTF16PtrFromString(source) + if err != nil { + return err + } + to, err := windows.UTF16PtrFromString(destination) + if err != nil { + return err + } + return windows.MoveFileEx(from, to, windows.MOVEFILE_REPLACE_EXISTING|windows.MOVEFILE_WRITE_THROUGH) +} diff --git a/internal/tray/history.go b/internal/tray/history.go new file mode 100644 index 0000000..3b02e41 --- /dev/null +++ b/internal/tray/history.go @@ -0,0 +1,272 @@ +package tray + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "time" +) + +const ( + historyRetention = 30 * 24 * time.Hour + historyMaxBytes = 4 << 20 + incidentDelay = 10 * time.Second +) + +type HistorySample struct { + T int64 `json:"t"` + Logical *int64 `json:"l,omitempty"` + Physical *int64 `json:"p,omitempty"` + Read *float64 `json:"r,omitempty"` + Write *float64 `json:"w,omitempty"` + Health string `json:"health"` +} + +type Incident struct { + ID string `json:"id"` + Source string `json:"source"` + Reason string `json:"reason"` + Impact string `json:"impact"` + Recommendations []string `json:"recommendations"` + Detail string `json:"detail"` + StartedAt int64 `json:"started_at"` + RecoveredAt *int64 `json:"recovered_at,omitempty"` +} + +type runtimeIssue struct { + Source, Reason, Impact, Detail string + Recommendations []string +} + +type historyArchive struct { + Version int `json:"version"` + Samples []HistorySample `json:"samples"` + Incidents []Incident `json:"incidents"` +} + +type historyState struct { + archive historyArchive + path string + errorText string + readError bool + dirty bool + lastSave time.Time + pending *runtimeIssue + since time.Time +} + +func loadHistory(store string, now time.Time) historyState { + h := historyState{path: filepath.Join(store, "enrollment", "ui-history-v1.json"), archive: historyArchive{Version: 1}} + file, err := openSharedFile(h.path) + if errors.Is(err, os.ErrNotExist) { + return h + } + if err == nil { + defer file.Close() + var data []byte + data, err = io.ReadAll(io.LimitReader(file, historyMaxBytes+1)) + if err == nil && len(data) > historyMaxBytes { + err = errors.New("history file exceeds its size limit") + } + if err == nil { + err = json.Unmarshal(data, &h.archive) + } + if err == nil && (h.archive.Version != 1 || len(h.archive.Samples) > 5000 || len(h.archive.Incidents) > 200) { + err = errors.New("history version or record count is invalid") + } + } + if err != nil { + // Preserve an unreadable archive. An empty GUI history is not permission + // to replace existing evidence with a new empty file. + h.archive = historyArchive{Version: 1} + h.errorText, h.readError = "历史文件无法读取,原文件已保留;新记录暂未写入。", true + return h + } + h.compact(now) + return h +} + +func (h *historyState) record(view View, issue *runtimeIssue, now time.Time) { + if view.LogicalBytes != nil || view.PhysicalBytes != nil || view.ReadRate != nil || view.WriteRate != nil { + sample := HistorySample{T: now.UnixMilli(), Logical: view.LogicalBytes, Physical: view.PhysicalBytes, + Read: view.ReadRate, Write: view.WriteRate, Health: view.Health} + minute := sample.T / 60000 + count := len(h.archive.Samples) + if count > 0 && h.archive.Samples[count-1].T/60000 == minute { + h.archive.Samples[count-1] = sample + } else { + h.archive.Samples = append(h.archive.Samples, sample) + h.dirty = true + } + } + incidentChanged := h.recoverIncidents(view, now) + before := len(h.archive.Incidents) + h.recordIncident(issue, false, now) + incidentChanged = incidentChanged || len(h.archive.Incidents) != before + if h.dirty && (incidentChanged || h.lastSave.IsZero() || now.Sub(h.lastSave) >= time.Minute) { + h.flush(now) + } +} + +func (h *historyState) recoverIncidents(view View, now time.Time) bool { + changed := false + for index := range h.archive.Incidents { + item := &h.archive.Incidents[index] + if item.RecoveredAt != nil { + continue + } + healthy := item.Source == "filesystem" && view.DaemonState == "运行正常" || + item.Source == "managed" && view.ManagedState == "运行正常" + if item.Source == "enrollment" { + switch view.EnrollmentState { + case "等待下次检查", "正在检查", "正在折叠", "正在打包", "正在迁移", "正在回收", "等待回收", "已关闭": + healthy = view.Progress != nil && view.Progress.Fresh + } + } + if healthy { + at := now.UnixMilli() + item.RecoveredAt, h.dirty, changed = &at, true, true + } + } + return changed +} + +func (h *historyState) recordIncident(issue *runtimeIssue, healthy bool, now time.Time) { + if issue == nil { + h.pending, h.since = nil, time.Time{} + if healthy { + for index := range h.archive.Incidents { + if h.archive.Incidents[index].RecoveredAt == nil { + at := now.UnixMilli() + h.archive.Incidents[index].RecoveredAt = &at + h.dirty = true + } + } + } + return + } + if h.pending == nil || h.pending.Source != issue.Source { + copy := *issue + h.pending, h.since = ©, now + } + var active *Incident + for index := range h.archive.Incidents { + item := &h.archive.Incidents[index] + if item.RecoveredAt == nil && item.Source == issue.Source { + active = item + break + } + } + if active == nil && now.Sub(h.since) >= incidentDelay { + h.archive.Incidents = append(h.archive.Incidents, Incident{ID: fmt.Sprintf("%s-%d", issue.Source, h.since.UnixNano()), + Source: issue.Source, StartedAt: h.since.UnixMilli()}) + active = &h.archive.Incidents[len(h.archive.Incidents)-1] + h.dirty = true + } + if active != nil { + if active.Reason != issue.Reason || active.Detail != issue.Detail { + h.dirty = true + } + active.Reason, active.Impact, active.Detail = issue.Reason, issue.Impact, issue.Detail + active.Recommendations = append([]string(nil), issue.Recommendations...) + } +} + +func historyResolution(age time.Duration) time.Duration { + if age <= 6*time.Hour { + return time.Minute + } + if age <= 7*24*time.Hour { + return 15 * time.Minute + } + return time.Hour +} + +func (h *historyState) compact(now time.Time) { + cutoff, future := now.Add(-historyRetention).UnixMilli(), now.Add(time.Minute).UnixMilli() + buckets := make(map[[2]int64]HistorySample) + for _, sample := range h.archive.Samples { + if sample.T < cutoff || sample.T > future || sample.T <= 0 { + continue + } + resolution := historyResolution(now.Sub(time.UnixMilli(sample.T))).Milliseconds() + key := [2]int64{resolution, sample.T / resolution} + if previous, exists := buckets[key]; !exists || sample.T > previous.T { + buckets[key] = sample + } + } + h.archive.Samples = make([]HistorySample, 0, len(buckets)) + for _, sample := range buckets { + h.archive.Samples = append(h.archive.Samples, sample) + } + sort.Slice(h.archive.Samples, func(i, j int) bool { return h.archive.Samples[i].T < h.archive.Samples[j].T }) + incidents := h.archive.Incidents[:0] + for _, item := range h.archive.Incidents { + last := item.StartedAt + if item.RecoveredAt != nil { + last = *item.RecoveredAt + } + if last >= cutoff && item.StartedAt <= future { + incidents = append(incidents, item) + } + } + sort.Slice(incidents, func(i, j int) bool { return incidents[i].StartedAt > incidents[j].StartedAt }) + if len(incidents) > 200 { + incidents = incidents[:200] + } + h.archive.Incidents = incidents +} + +func (h *historyState) flush(now time.Time) { + if h.readError { + return + } + h.compact(now) + h.lastSave = now + if err := writeJSONFile(h.path, h.archive); err != nil { + h.errorText = "历史记录未保存,请检查目录权限;系统将稍后重试。" + return + } + h.dirty, h.errorText = false, "" +} + +func writeJSONFile(path string, value any) error { + data, err := json.MarshalIndent(value, "", " ") + if err != nil { + return err + } + data = append(data, '\n') + if len(data) > historyMaxBytes { + return errors.New("diagnostic/history payload exceeds its size limit") + } + directory := filepath.Dir(path) + if err := os.MkdirAll(directory, 0o700); err != nil { + return err + } + file, err := os.CreateTemp(directory, ".codexfold-ui-*.tmp") + if err != nil { + return err + } + temporary := file.Name() + defer os.Remove(temporary) + if err := file.Chmod(0o600); err != nil { + file.Close() + return err + } + if _, err := file.Write(data); err != nil { + file.Close() + return err + } + if err := file.Sync(); err != nil { + file.Close() + return err + } + if err := file.Close(); err != nil { + return err + } + return replaceUIFile(temporary, path) +} diff --git a/internal/tray/history_test.go b/internal/tray/history_test.go new file mode 100644 index 0000000..3e4d1fc --- /dev/null +++ b/internal/tray/history_test.go @@ -0,0 +1,108 @@ +package tray + +import ( + "bytes" + "os" + "path/filepath" + "testing" + "time" +) + +func TestHistorySurvivesRestartAndRetainsOnlyObservedThirtyDays(t *testing.T) { + store := t.TempDir() + now := time.Now().UTC().Truncate(time.Minute) + h := loadHistory(store, now) + logical, physical, read := int64(2000), int64(800), float64(32) + view := View{LogicalBytes: &logical, PhysicalBytes: &physical, ReadRate: &read, Health: "运行正常", DaemonState: "运行正常", ManagedState: "运行正常", Healthy: true} + for minute := 31 * 24 * 60; minute >= 0; minute-- { + h.archive.Samples = append(h.archive.Samples, HistorySample{T: now.Add(-time.Duration(minute) * time.Minute).UnixMilli(), Logical: &logical, Physical: &physical}) + } + h.record(view, nil, now) + h.flush(now) + if h.errorText != "" { + t.Fatal(h.errorText) + } + loaded := loadHistory(store, now) + if len(loaded.archive.Samples) == 0 || len(loaded.archive.Samples) > 2000 || loaded.archive.Samples[0].T < now.Add(-historyRetention).UnixMilli() { + t.Fatalf("history retention/downsampling failed: %d", len(loaded.archive.Samples)) + } + before := len(loaded.archive.Samples) + loaded.record(view, nil, now.Add(10*time.Second)) + if len(loaded.archive.Samples) != before { + t.Fatal("one minute created duplicate samples") + } + loaded.record(view, nil, now.Add(10*time.Minute)) + found := false + for _, sample := range loaded.archive.Samples { + if sample.T > now.Add(10*time.Second).UnixMilli() && sample.T < now.Add(10*time.Minute).UnixMilli() { + t.Fatal("unobserved gap was filled with invented samples") + } + if sample.T == now.Add(10*time.Minute).UnixMilli() { + found = true + } + } + if !found { + t.Fatal("new observation was not recorded") + } +} + +func TestIncidentsConfirmAfterTenSecondsAndRecoverAtObservation(t *testing.T) { + now := time.Now().UTC() + h := loadHistory(t.TempDir(), now) + issue := &runtimeIssue{Source: "filesystem", Reason: "心跳停止", Impact: "访问待确认", Recommendations: []string{"检查状态"}} + bad := View{Health: "需要关注", DaemonState: "状态不可用"} + h.record(bad, issue, now) + h.record(bad, issue, now.Add(9*time.Second)) + if len(h.archive.Incidents) != 0 { + t.Fatal("transient incident persisted") + } + h.record(bad, issue, now.Add(10*time.Second)) + if len(h.archive.Incidents) != 1 || h.archive.Incidents[0].StartedAt != now.UnixMilli() { + t.Fatal("sustained incident lost original observation") + } + loaded := loadHistory(filepath.Dir(filepath.Dir(h.path)), now.Add(time.Minute)) + if len(loaded.archive.Incidents) != 1 || loaded.archive.Incidents[0].RecoveredAt != nil { + t.Fatal("active incident did not survive restart") + } + // A different component can still be failing when this source recovers. + other := &runtimeIssue{Source: "managed", Reason: "管理状态中断"} + loaded.record(View{DaemonState: "运行正常", ManagedState: "状态不可用"}, other, now.Add(time.Minute)) + if at := loaded.archive.Incidents[0].RecoveredAt; at == nil || *at != now.Add(time.Minute).UnixMilli() { + t.Fatal("recovery was delayed behind unrelated failure") + } + loaded.flush(now.Add(time.Minute)) + again := loadHistory(filepath.Dir(filepath.Dir(h.path)), now.Add(2*time.Minute)) + if len(again.archive.Incidents) != 1 || again.archive.Incidents[0].RecoveredAt == nil { + t.Fatal("recovery did not persist") + } +} + +func TestUnreadableHistoryIsPreservedAndDiagnosticsAreAggregateOnly(t *testing.T) { + store := t.TempDir() + path := filepath.Join(store, "enrollment", "ui-history-v1.json") + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + original := []byte(`{"version":99,"session_text":"private sentinel"}`) + if err := os.WriteFile(path, original, 0o600); err != nil { + t.Fatal(err) + } + h := loadHistory(store, time.Now()) + h.flush(time.Now()) + data, err := os.ReadFile(path) + if err != nil || !bytes.Equal(data, original) || h.errorText == "" { + t.Fatal("unreadable history was overwritten") + } + view := NewMonitor(store).Refresh(time.Now()) + diagnostics, err := DiagnosticBytes(view) + if err != nil || bytes.Contains(diagnostics, []byte("private sentinel")) { + t.Fatal("diagnostics read or leaked session content") + } + output := filepath.Join(t.TempDir(), "diagnostics.json") + if err := ExportDiagnostics(output, view); err != nil { + t.Fatal(err) + } + if info, err := os.Stat(output); err != nil || info.Size() == 0 { + t.Fatal("diagnostics were not written") + } +} diff --git a/internal/tray/logs.go b/internal/tray/logs.go new file mode 100644 index 0000000..7ba42ba --- /dev/null +++ b/internal/tray/logs.go @@ -0,0 +1,42 @@ +package tray + +import ( + "io" + "os" + "path/filepath" + "strings" + + "github.com/samekind/codexfold/internal/service" +) + +func logDirectories(store string) map[string]string { + locations := map[string]string{"filesystem": filepath.Join(store, "fs"), "enrollment": filepath.Join(store, "enrollment")} + root := os.Getenv("ProgramData") + if root == "" { + return locations + } + root = filepath.Join(root, "CodexFold") + read := func(path string) []byte { + file, err := openSharedFile(path) + if err != nil { + return nil + } + defer file.Close() + data, err := io.ReadAll(io.LimitReader(file, 65537)) + if err != nil || len(data) > 65536 { + return nil + } + return data + } + if config, err := service.ParseWindowsConfig(read(filepath.Join(root, "service.json"))); err == nil && config.ServiceName == "com.codexfold.fs" { + for index, arg := range config.Arguments { + if arg == "--store" && index+1 < len(config.Arguments) && strings.EqualFold(filepath.Clean(config.Arguments[index+1]), filepath.Clean(store)) { + locations["filesystem"] = filepath.Dir(config.StderrPath) + } + } + } + if config, err := service.ParseWindowsEnrollmentConfig(read(filepath.Join(root, "Enrollment", "service.json"))); err == nil && config.ServiceName == "com.codexfold.enroll" && strings.EqualFold(filepath.Clean(config.Store), filepath.Clean(store)) { + locations["enrollment-service"] = filepath.Dir(config.StderrPath) + } + return locations +} diff --git a/internal/tray/policy.go b/internal/tray/policy.go new file mode 100644 index 0000000..364e124 --- /dev/null +++ b/internal/tray/policy.go @@ -0,0 +1,172 @@ +package tray + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "time" + + "github.com/samekind/codexfold/internal/enroll" +) + +type PolicySettings struct { + Available bool + Repairable bool + Revision string + Enabled bool + Interval string + StableFor string + ArchivedOnly bool + BatchSize int + Error string +} + +type PolicyChange struct { + Action string `json:"action"` + ID string `json:"id"` + Revision string `json:"revision"` + Field string `json:"field"` + Value json.RawMessage `json:"value"` +} + +type ActionResult struct { + ID string + OK bool + Message string +} + +func marshalActionResult(result ActionResult) ([]byte, error) { return json.Marshal(result) } + +func decodePolicyChange(message string) (PolicyChange, error) { + if len(message) > 4096 { + return PolicyChange{}, errors.New("设置请求过长") + } + var request PolicyChange + decoder := json.NewDecoder(bytes.NewBufferString(message)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&request); err != nil { + return request, errors.New("设置请求格式有误") + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) || request.Action != "set-policy" || request.ID == "" || len(request.ID) > 64 { + return request, errors.New("设置请求格式有误") + } + return request, nil +} + +func (m *Monitor) policy() (string, enroll.Control, PolicySettings, error) { + path := enroll.WorkerControlPath(m.Store) + control, revision, err := enroll.LoadControlRevision(path) + _, workerProgressErr := os.Lstat(enroll.WorkerProgressPath(m.Store)) + if revision == "" && err == nil && errors.Is(workerProgressErr, os.ErrNotExist) { + path = enroll.ControlPath(m.Store) + control, revision, err = enroll.LoadControlRevision(path) + } + settings := PolicySettings{Available: revision != "", Revision: revision, + Enabled: control.Enabled, Interval: control.Interval.String(), StableFor: control.StableFor.String(), + ArchivedOnly: control.ArchivedOnly, BatchSize: control.BatchSize} + if err != nil { + settings.Error = "自动折叠设置无法读取;可修复为暂停状态,再重新设置。" + settings.Repairable = revision != "" + } else if !settings.Available { + settings.Error = "尚未找到自动折叠设置,请先安装后台服务。" + progressPath := enroll.ProgressPath(m.Store) + if path == enroll.WorkerControlPath(m.Store) { + progressPath = enroll.WorkerProgressPath(m.Store) + } + if _, progressErr := os.Lstat(progressPath); progressErr == nil { + settings.Repairable = true + settings.Error = "自动折叠设置缺失,可修复为暂停状态。" + if path == enroll.ControlPath(m.Store) { + settings.Error = "后台使用启动参数;可建立策略文件并保持暂停。" + } + } + } + return path, control, settings, err +} + +// ApplyPolicy changes one selected field. Custom values in the other fields +// survive GUI edits, and a stale GUI cannot overwrite newly installed settings. +func (m *Monitor) ApplyPolicy(request PolicyChange) ActionResult { + result := ActionResult{ID: request.ID} + path, control, settings, _ := m.policy() + if request.Revision != settings.Revision || !(settings.Available && request.Revision != "" || request.Field == "reset" && settings.Repairable) { + result.Message = "设置已变化,请等待刷新后重试。" + return result + } + if settings.Error != "" && request.Field != "reset" { + result.Message = settings.Error + return result + } + var err error + if request.Field != "reset" && (len(request.Value) == 0 || bytes.Equal(bytes.TrimSpace(request.Value), []byte("null"))) { + result.Message = "未保存:设置值不能为空。" + return result + } + switch request.Field { + case "enabled": + err = json.Unmarshal(request.Value, &control.Enabled) + if err == nil && control.Enabled && control.Interval < 30*time.Second { + err = errors.New("请先把检查间隔设为至少 30 秒") + } + case "interval", "stable_for": + var raw string + var value time.Duration + err = json.Unmarshal(request.Value, &raw) + if err == nil { + value, err = time.ParseDuration(raw) + } + if request.Field == "interval" { + if err == nil && (value < 30*time.Second || value > 24*time.Hour) { + err = errors.New("检查间隔须在 30 秒到 24 小时之间") + } + control.Interval = value + } else { + if err == nil && (value < time.Minute || value > 30*24*time.Hour) { + err = errors.New("空闲时间须在 1 分钟到 30 天之间") + } + control.StableFor = value + } + case "archived_only": + err = json.Unmarshal(request.Value, &control.ArchivedOnly) + case "batch_size": + err = json.Unmarshal(request.Value, &control.BatchSize) + if err == nil && (control.BatchSize < 0 || control.BatchSize > 1000) { + err = errors.New("每批会话数须为自动或 1 到 1000") + } + case "reset": + if settings.Error == "" { + err = errors.New("当前设置有效,无需修复") + } else { + control = enroll.Control{Present: true, Interval: 5 * time.Minute, StableFor: time.Hour, ArchivedOnly: true} + } + default: + err = errors.New("不支持的设置项") + } + if err != nil { + result.Message = "未保存:" + err.Error() + return result + } + // Check once more immediately before the atomic replacement. The worker only + // reads this file; changes from another UI or installer remain visible here. + _, latestRevision, loadErr := enroll.LoadControlRevision(path) + if latestRevision != request.Revision || loadErr != nil && request.Field != "reset" { + result.Message = "设置已变化,请等待刷新后重试。" + return result + } + if err := enroll.SaveControl(path, control); err != nil { + result.Message = fmt.Sprintf("设置未保存,请检查目录权限:%v", err) + return result + } + result.OK = true + result.Message = "已保存,新设置将由后台自动应用。" + if request.Field == "enabled" && !control.Enabled { + result.Message = "已请求暂停,等待当前操作安全退出。" + } + if request.Field == "reset" { + result.Message = "设置已修复,自动折叠保持暂停。" + } + return result +} diff --git a/internal/tray/policy_test.go b/internal/tray/policy_test.go new file mode 100644 index 0000000..52371d4 --- /dev/null +++ b/internal/tray/policy_test.go @@ -0,0 +1,104 @@ +package tray + +import ( + "bytes" + "encoding/json" + "os" + "testing" + "time" + + "github.com/samekind/codexfold/internal/enroll" +) + +func TestPolicyEditsPreserveCustomValuesAndRejectStaleRevision(t *testing.T) { + store := t.TempDir() + path := enroll.WorkerControlPath(store) + original := enroll.Control{Enabled: true, Interval: 37 * time.Minute, StableFor: 2 * time.Hour, BatchSize: 17} + if err := enroll.SaveControl(path, original); err != nil { + t.Fatal(err) + } + m := NewMonitor(store) + _, _, settings, _ := m.policy() + result := m.ApplyPolicy(PolicyChange{ID: "one", Revision: settings.Revision, Field: "archived_only", Value: json.RawMessage(`true`)}) + if !result.OK { + t.Fatal(result.Message) + } + got, err := enroll.LoadControl(path) + if err != nil || !got.Enabled || !got.ArchivedOnly || got.BatchSize != 17 || got.Interval != original.Interval || got.StableFor != original.StableFor { + t.Fatalf("custom settings changed: %#v, %v", got, err) + } + before, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + result = m.ApplyPolicy(PolicyChange{ID: "stale", Revision: settings.Revision, Field: "enabled", Value: json.RawMessage(`false`)}) + if result.OK { + t.Fatal("stale page overwrote current settings") + } + after, err := os.ReadFile(path) + if err != nil || !bytes.Equal(before, after) { + t.Fatal("rejected request changed policy") + } + _, _, settings, _ = m.policy() + for _, value := range []string{`null`, `"true"`} { + if result := m.ApplyPolicy(PolicyChange{Revision: settings.Revision, Field: "enabled", Value: json.RawMessage(value)}); result.OK { + t.Fatalf("invalid value accepted: %s", value) + } + } +} + +func TestPolicyRepairIsExplicitPausedAndDoesNotEnableBuiltinLoop(t *testing.T) { + store := t.TempDir() + if err := enroll.SaveControl(enroll.ControlPath(store), enroll.Control{Interval: time.Minute, StableFor: time.Hour}); err != nil { + t.Fatal(err) + } + if err := enroll.SaveProgress(enroll.WorkerProgressPath(store), enroll.Progress{Phase: enroll.PhaseDisabled}); err != nil { + t.Fatal(err) + } + m := NewMonitor(store) + path, _, settings, _ := m.policy() + if path != enroll.WorkerControlPath(store) || !settings.Repairable || settings.Available { + t.Fatalf("missing worker policy fell back to builtin: %s, %#v", path, settings) + } + if result := m.ApplyPolicy(PolicyChange{Field: "enabled", Value: json.RawMessage(`true`)}); result.OK { + t.Fatal("missing policy revived defaults") + } + if result := m.ApplyPolicy(PolicyChange{Field: "reset", Revision: settings.Revision}); !result.OK { + t.Fatal(result.Message) + } + for _, path := range []string{enroll.WorkerControlPath(store), enroll.ControlPath(store)} { + control, err := enroll.LoadControl(path) + if err != nil || control.Enabled { + t.Fatalf("repair enabled policy %s: %#v, %v", path, control, err) + } + } + if err := os.WriteFile(enroll.WorkerControlPath(store), []byte(`broken`), 0o600); err != nil { + t.Fatal(err) + } + _, _, settings, _ = m.policy() + if !settings.Repairable || settings.Revision == "" { + t.Fatal("malformed file has no repair revision") + } + if result := m.ApplyPolicy(PolicyChange{Field: "reset", Revision: settings.Revision}); !result.OK { + t.Fatal(result.Message) + } + control, err := enroll.LoadControl(enroll.WorkerControlPath(store)) + if err != nil || control.Enabled || control.BatchSize != 0 || !control.ArchivedOnly { + t.Fatalf("unsafe repair: %#v, %v", control, err) + } +} + +func TestDashboardPolicyRequestOnlyAcceptsStructuredSettings(t *testing.T) { + for _, message := range []string{ + `{"action":"exec","id":"one","field":"enabled","value":true}`, + `{"action":"set-policy","id":"one","command":"calc.exe"}`, + `{"action":"set-policy","id":"one"} {}`, + } { + if _, err := decodePolicyChange(message); err == nil { + t.Fatalf("unsafe request accepted: %s", message) + } + } + if _, err := decodePolicyChange(`{"action":"set-policy","id":"one","revision":"r","field":"enabled","value":false}`); err != nil { + t.Fatal(err) + } +} diff --git a/internal/tray/status.go b/internal/tray/status.go new file mode 100644 index 0000000..9718777 --- /dev/null +++ b/internal/tray/status.go @@ -0,0 +1,316 @@ +// Package tray implements the Windows status and automatic-folding companion. +package tray + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/samekind/codexfold/internal/enroll" + "github.com/samekind/codexfold/internal/fskitstatus" +) + +type View struct { + Health, Storage, Activity, Sessions, Detail string + Healthy bool + StorePath string + LogicalBytes, PhysicalBytes *int64 + CompressionLogicalBytes, CompressedBytes, PendingBytes *int64 + ReadRate, WriteRate *float64 + DaemonState, ManagedState, EnrollmentState string + Settings PolicySettings + Progress *EnrollmentProgress + HistorySamples []HistorySample + Incidents []Incident + HistoryError string + Operation *ActionResult + LogDirectories map[string]string +} + +type EnrollmentProgress struct { + Phase string + ManagedCount, WaitingCount int + WaitingKnown bool + CycleDone, CycleTotal int + NextCheckAt, UpdatedAt string + ErrorKind, LastError string + Fresh bool +} + +type observation struct { + snapshot fskitstatus.Snapshot + advanced time.Time +} + +type Monitor struct { + Store string + last map[string]observation + connected bool + space accountingCache + history historyState + logs map[string]string +} + +func NewMonitor(store string) *Monitor { + return &Monitor{Store: store, last: make(map[string]observation), history: loadHistory(store, time.Now()), logs: logDirectories(store)} +} + +func (m *Monitor) read(component string, now time.Time, maxAge time.Duration) (fskitstatus.Snapshot, error) { + s, err := fskitstatus.Read(filepath.Join(m.Store, "fs", "status", component+".json")) + if err != nil { + return s, err + } + if s.Component != component { + return s, errors.New("状态组件不匹配") + } + updated, err := time.Parse(time.RFC3339Nano, s.UpdatedAt) + if err != nil || updated.After(now.Add(5*time.Second)) || now.Sub(updated) > maxAge { + return s, errors.New("状态已过期,等待后台更新") + } + if component != "storage" { + if s.PublisherInstanceID == "" || s.BackendID == "" || s.ObservationSequence == 0 { + return s, errors.New("状态缺少运行实例或心跳序号") + } + previous := m.last[component] + if s.PublisherInstanceID == previous.snapshot.PublisherInstanceID { + if s.BackendID != previous.snapshot.BackendID || s.ObservationSequence < previous.snapshot.ObservationSequence { + return s, errors.New("状态序号或后台标识发生异常变化") + } + if s.ObservationSequence == previous.snapshot.ObservationSequence { + if now.Sub(previous.advanced) > maxAge { + return s, errors.New("后台心跳已停止") + } + return s, nil + } + } + m.last[component] = observation{s, now} + } + return s, nil +} + +func (m *Monitor) Refresh(now time.Time) View { + return m.refresh(now, true) +} + +// DiagnosticSnapshot reads aggregate status without competing with the resident +// tray for ownership of the persistent history archive. +func (m *Monitor) DiagnosticSnapshot(now time.Time) View { + return m.refresh(now, false) +} + +func (m *Monitor) refresh(now time.Time, recordHistory bool) View { + v := View{Health: "未连接", Storage: "空间统计:暂无数据", Activity: "读取 — 写入 —", Sessions: "托管会话:—", StorePath: m.Store, LogDirectories: m.logs} + previous := m.last["daemon"].snapshot + daemon, daemonErr := m.read("daemon", now, 15*time.Second) + managed, managedErr := m.read("managed", now, 15*time.Second) + v.DaemonState = componentState(daemon, daemonErr) + v.ManagedState = componentState(managed, managedErr) + v.EnrollmentState = "未连接" + policyPath, policy, settings, policyErr := m.policy() + v.Settings = settings + usingWorker := policyPath == enroll.WorkerControlPath(m.Store) + if settings.Available || settings.Repairable || policyErr != nil || daemonErr == nil && daemon.State == "healthy" { + progressPath := enroll.ProgressPath(m.Store) + progressMaxAge := 15 * time.Minute + if usingWorker { + progressPath = enroll.WorkerProgressPath(m.Store) + progressMaxAge = 30 * time.Second + } + progress, err := enroll.LoadProgress(progressPath) + if policyErr != nil { + err = policyErr + } + fresh := err == nil && !progress.UpdatedAt.IsZero() && !progress.UpdatedAt.After(now.Add(5*time.Second)) && now.Sub(progress.UpdatedAt) <= progressMaxAge + v.Progress = &EnrollmentProgress{Phase: progress.Phase, ManagedCount: progress.ManagedCount, + WaitingCount: progress.WaitingCount, WaitingKnown: progress.WaitingKnown, CycleDone: progress.CycleDone, + CycleTotal: progress.CycleTotal, ErrorKind: progress.ErrorKind, LastError: progress.LastError, Fresh: fresh} + if !progress.UpdatedAt.IsZero() { + v.Progress.UpdatedAt = progress.UpdatedAt.Format(time.RFC3339Nano) + } + if !progress.NextCheckAt.IsZero() { + v.Progress.NextCheckAt = progress.NextCheckAt.Format(time.RFC3339Nano) + } + switch { + case policyErr != nil: + v.EnrollmentState = "设置有误" + case err != nil: + v.EnrollmentState = "状态不可用" + case progress.UpdatedAt.IsZero(): + v.EnrollmentState = "暂无状态" + case progress.UpdatedAt.After(now.Add(5*time.Second)) || now.Sub(progress.UpdatedAt) > progressMaxAge: + v.EnrollmentState = "状态已过期" + case progress.Phase == enroll.PhaseConfigInvalid: + v.EnrollmentState = "设置有误" + case progress.Phase == enroll.PhaseStopped: + v.EnrollmentState = "后台已停止" + case progress.Phase == enroll.PhaseWaitingFilesystem: + v.EnrollmentState = "等待文件系统" + case settings.Available && !policy.Enabled && progress.Enabled: + v.EnrollmentState = "正在暂停" + case progress.LastError != "": + v.EnrollmentState = "需要关注" + case !progress.Enabled: + v.EnrollmentState = "已关闭" + case daemonErr != nil || daemon.State != "healthy": + v.EnrollmentState = "等待文件系统" + default: + v.EnrollmentState = map[string]string{ + enroll.PhaseIdle: "等待下次检查", enroll.PhaseChecking: "正在检查", + enroll.PhaseFolding: "正在折叠", enroll.PhasePacking: "正在打包", + enroll.PhaseMigrating: "正在迁移", enroll.PhaseReclaiming: "正在回收", + enroll.PhaseWaitingReclaim: "等待回收", + }[progress.Phase] + if v.EnrollmentState == "" { + v.EnrollmentState = "状态未知" + } + } + } + var details []string + if errors.Is(daemonErr, os.ErrNotExist) && errors.Is(managedErr, os.ErrNotExist) && !m.connected { + details = append(details, "尚未发现后台状态。请先在隔离目录中启动 WinFsp 文件服务。") + } else { + m.connected = true + v.Health = "需要关注" + if daemonErr == nil && managedErr == nil && daemon.State == "healthy" && managed.State == "healthy" { + v.Health, v.Healthy = "运行正常", true + } else if daemonErr == nil && (daemon.State == "starting" || daemon.State == "stopped") { + v.Health = map[string]string{"starting": "正在启动", "stopped": "已停止"}[daemon.State] + } + for _, channel := range []struct { + name string + snapshot fskitstatus.Snapshot + err error + }{ + {"后台", daemon, daemonErr}, {"会话", managed, managedErr}, + } { + if channel.err != nil { + details = append(details, channel.name+":"+channel.err.Error()) + } else { + details = append(details, channel.name+":"+channel.snapshot.State) + if channel.snapshot.Detail != "" { + details = append(details, channel.snapshot.Detail) + } + } + } + } + if managedErr == nil && managed.ManagedSessions != nil { + v.Sessions = fmt.Sprintf("托管会话:%d", *managed.ManagedSessions) + } + if daemonErr == nil && daemon.State == "healthy" && previous.State == "healthy" && + previous.PublisherInstanceID == daemon.PublisherInstanceID && previous.BackendID == daemon.BackendID && + daemon.ObservationSequence > previous.ObservationSequence && + daemon.ReadBytesTotal != nil && daemon.WrittenBytesTotal != nil && previous.ReadBytesTotal != nil && previous.WrittenBytesTotal != nil && + *daemon.ReadBytesTotal >= *previous.ReadBytesTotal && *daemon.WrittenBytesTotal >= *previous.WrittenBytesTotal { + before, _ := time.Parse(time.RFC3339Nano, previous.UpdatedAt) + after, _ := time.Parse(time.RFC3339Nano, daemon.UpdatedAt) + if seconds := after.Sub(before).Seconds(); seconds > 0 && seconds <= 15 { + readRate := float64(*daemon.ReadBytesTotal-*previous.ReadBytesTotal) / seconds + writeRate := float64(*daemon.WrittenBytesTotal-*previous.WrittenBytesTotal) / seconds + v.ReadRate, v.WriteRate = &readRate, &writeRate + v.Activity = fmt.Sprintf("读取 %s/s 写入 %s/s", bytesText(readRate), bytesText(writeRate)) + } + } + storage, storageErr := m.read("storage", now, 15*time.Minute) + if storageErr == nil && storage.State == "healthy" && storage.LogicalBytes != nil && storage.PhysicalBytes != nil && *storage.LogicalBytes >= 0 && *storage.PhysicalBytes >= 0 { + logical, physical := float64(*storage.LogicalBytes), float64(*storage.PhysicalBytes) + v.LogicalBytes, v.PhysicalBytes = storage.LogicalBytes, storage.PhysicalBytes + saving := "暂无可比较数据" + if logical > 0 { + if physical <= logical { + saving = fmt.Sprintf("净节省 %.1f%%", (logical-physical)/logical*100) + } else { + saving = "物理占用高于逻辑数据量" + } + } + v.Storage = fmt.Sprintf("逻辑数据 %s 物理占用 %s\r\n%s", bytesText(logical), bytesText(physical), saving) + details = append(details, "空间统计包含存储、保留副本和恢复文件;更新时间:"+storage.UpdatedAt) + } else if storageErr != nil && !errors.Is(storageErr, os.ErrNotExist) { + details = append(details, "空间统计:"+storageErr.Error()) + } + if measured, err := m.accounting(now); measured != nil { + v.LogicalBytes, v.PhysicalBytes = &measured.Logical, &measured.Physical + v.CompressionLogicalBytes, v.CompressedBytes, v.PendingBytes = &measured.CompressionLogical, &measured.Compressed, &measured.Pending + details = append(details, "压缩统计依据已发布压缩包;待回收占用包含保留原件、散对象、旧版本及恢复文件,实际回收需通过校验。更新时间:"+measured.UpdatedAt.Format(time.RFC3339)) + if err != nil { + details = append(details, "空间明细正在重试,暂显示最近一次有效统计。") + } + } + if daemon.MountPoint != "" { + details = append(details, "挂载目录:"+daemon.MountPoint) + } + details = append(details, "存储目录:"+m.Store, "Windows 预览版 · 退出此界面后后台服务继续运行。") + v.Detail = strings.Join(details, "\r\n\r\n") + var issue *runtimeIssue + if m.connected || settings.Available || settings.Repairable { + switch { + case daemonErr != nil || daemon.State != "healthy": + issue = &runtimeIssue{Source: "filesystem", Reason: "文件服务尚未就绪", Impact: "托管会话的访问可能暂时受影响。", + Recommendations: []string{"等待 Windows 服务恢复后点击“检查状态”。", "持续未恢复时查看文件服务日志并导出诊断。"}} + if daemonErr != nil { + issue.Detail = daemonErr.Error() + } else { + issue.Detail = daemon.Detail + } + case managedErr != nil || managed.State != "healthy": + issue = &runtimeIssue{Source: "managed", Reason: "会话管理状态异常", Impact: "托管会话的当前健康状态尚未得到确认。", + Recommendations: []string{"稍后重新检查状态。", "持续异常时查看服务日志并导出诊断。"}, Detail: managed.Detail} + if managedErr != nil { + issue.Detail = managedErr.Error() + } + case policyErr != nil || usingWorker && settings.Repairable && settings.Error != "": + issue = &runtimeIssue{Source: "enrollment", Reason: "自动折叠设置无效", Impact: "新的自动折叠工作暂停。", + Recommendations: []string{"点击“修复设置”,然后选择所需参数。"}, Detail: settings.Error} + if policyErr != nil { + issue.Detail = policyErr.Error() + } + case settings.Enabled && (v.EnrollmentState == "状态已过期" || v.EnrollmentState == "后台已停止" || v.EnrollmentState == "需要关注" || v.EnrollmentState == "状态不可用" || v.EnrollmentState == "等待文件系统"): + issue = &runtimeIssue{Source: "enrollment", Reason: "自动折叠需要关注", Impact: "新会话的压缩或回收可能需要稍后重试。", + Recommendations: []string{"查看自动折叠日志。", "需要排查时复制或导出诊断。"}, Detail: v.EnrollmentState} + if v.Progress != nil { + issue.Detail += ";阶段:" + v.Progress.Phase + ";错误类型:" + v.Progress.ErrorKind + if v.Progress.ErrorKind == "budget" { + issue.Reason = "自动折叠的磁盘空间预算不足" + issue.Recommendations = []string{"释放磁盘空间后等待后台重试。", "原始数据和保留副本须在校验通过后才能回收。"} + } + } + } + } + if issue != nil && issue.Source == "enrollment" { + v.Health, v.Healthy = "需要关注", false + } + if recordHistory { + m.history.record(v, issue, now) + } + v.HistorySamples = append([]HistorySample(nil), m.history.archive.Samples...) + v.Incidents = append([]Incident(nil), m.history.archive.Incidents...) + v.HistoryError = m.history.errorText + return v +} + +func (m *Monitor) FlushHistory() { m.history.flush(time.Now()) } + +func componentState(snapshot fskitstatus.Snapshot, err error) string { + if errors.Is(err, os.ErrNotExist) { + return "未连接" + } + if err != nil { + return "状态不可用" + } + if text, ok := map[string]string{"healthy": "运行正常", "starting": "正在启动", "stopped": "已停止", "degraded": "需要关注", "error": "需要关注"}[snapshot.State]; ok { + return text + } + return "状态未知" +} + +func bytesText(value float64) string { + units := []string{"B", "KiB", "MiB", "GiB", "TiB"} + i := 0 + for value >= 1024 && i < len(units)-1 { + value /= 1024 + i++ + } + return fmt.Sprintf("%.1f %s", value, units[i]) +} diff --git a/internal/tray/status_test.go b/internal/tray/status_test.go new file mode 100644 index 0000000..bc9897c --- /dev/null +++ b/internal/tray/status_test.go @@ -0,0 +1,99 @@ +package tray + +import ( + "path/filepath" + "strings" + "testing" + "time" + + "github.com/samekind/codexfold/internal/enroll" + "github.com/samekind/codexfold/internal/fskitstatus" +) + +func TestMonitorMissingHealthyStaleAndRestart(t *testing.T) { + store := t.TempDir() + m := NewMonitor(store) + now := time.Now().UTC() + if got := m.Refresh(now); got.Health != "未连接" || got.Healthy || got.DaemonState != "未连接" || got.EnrollmentState != "未连接" { + t.Fatalf("missing: %+v", got) + } + write := func(component string, sequence, read uint64, instance string, at time.Time) { + s := fskitstatus.Snapshot{Component: component, State: "healthy", UpdatedAt: at.Format(time.RFC3339Nano), PublisherInstanceID: instance, BackendID: "test", ObservationSequence: sequence, MountPoint: store, ResourcePath: store, ReadBytesTotal: &read, WrittenBytesTotal: &read} + if err := fskitstatus.Write(filepath.Join(store, "fs", "status", component+".json"), s); err != nil { + t.Fatal(err) + } + } + write("daemon", 1, 0, "one", now) + write("managed", 1, 0, "one", now) + if err := enroll.SaveProgress(enroll.ProgressPath(store), enroll.Progress{StorePath: store, Enabled: true, Phase: enroll.PhaseFolding, UpdatedAt: now}); err != nil { + t.Fatal(err) + } + if got := m.Refresh(now); !got.Healthy || got.DaemonState != "运行正常" || got.ManagedState != "运行正常" || got.EnrollmentState != "正在折叠" { + t.Fatalf("healthy: %+v", got) + } + if err := enroll.SaveControl(enroll.WorkerControlPath(store), enroll.Control{Enabled: true, Interval: time.Minute, StableFor: time.Hour, BatchSize: 1}); err != nil { + t.Fatal(err) + } + if err := enroll.SaveProgress(enroll.WorkerProgressPath(store), enroll.Progress{StorePath: store, Enabled: true, Phase: enroll.PhasePacking, UpdatedAt: now}); err != nil { + t.Fatal(err) + } + if got := m.Refresh(now); got.EnrollmentState != "正在打包" { + t.Fatalf("worker status was not selected: %+v", got) + } + write("daemon", 2, 2048, "one", now.Add(2*time.Second)) + write("managed", 2, 0, "one", now.Add(2*time.Second)) + if got := m.Refresh(now.Add(2 * time.Second)); !strings.Contains(got.Activity, "1.0 KiB/s") { + t.Fatalf("rate: %+v", got) + } + if got := m.Refresh(now.Add(20 * time.Second)); got.Healthy || got.Health != "需要关注" || got.DaemonState != "状态不可用" || got.EnrollmentState != "等待文件系统" { + t.Fatalf("stale: %+v", got) + } + write("daemon", 1, 50, "two", now.Add(21*time.Second)) + write("managed", 1, 0, "two", now.Add(21*time.Second)) + if got := m.Refresh(now.Add(21 * time.Second)); !got.Healthy || got.Activity != "读取 — 写入 —" { + t.Fatalf("restart: %+v", got) + } +} + +func TestMonitorPersistentWorkerStatesWithoutMount(t *testing.T) { + store := t.TempDir() + now := time.Now() + if err := enroll.SaveControl(enroll.WorkerControlPath(store), enroll.Control{Enabled: true, Interval: time.Minute, StableFor: time.Hour, BatchSize: 5}); err != nil { + t.Fatal(err) + } + for _, state := range []struct { + phase string + enabled bool + lastError, want string + }{ + {enroll.PhaseWaitingFilesystem, true, "heartbeat is stale", "等待文件系统"}, + {enroll.PhaseStopped, true, "", "后台已停止"}, + {enroll.PhaseConfigInvalid, false, "invalid policy", "设置有误"}, + {enroll.PhaseDisabled, false, "", "已关闭"}, + } { + if err := enroll.SaveProgress(enroll.WorkerProgressPath(store), enroll.Progress{Enabled: state.enabled, Phase: state.phase, LastError: state.lastError, UpdatedAt: now}); err != nil { + t.Fatal(err) + } + if got := NewMonitor(store).Refresh(now); got.EnrollmentState != state.want { + t.Fatalf("%s: %+v", state.phase, got) + } + } +} + +func TestMonitorRejectsRewrittenTimestampWithoutProgress(t *testing.T) { + store := t.TempDir() + m := NewMonitor(store) + now := time.Now().UTC() + for _, at := range []time.Time{now, now.Add(20 * time.Second)} { + for _, component := range []string{"daemon", "managed"} { + s := fskitstatus.Snapshot{Component: component, State: "healthy", UpdatedAt: at.Format(time.RFC3339Nano), PublisherInstanceID: "one", BackendID: "one", ObservationSequence: 1, MountPoint: store, ResourcePath: store} + if err := fskitstatus.Write(filepath.Join(store, "fs", "status", component+".json"), s); err != nil { + t.Fatal(err) + } + } + got := m.Refresh(at) + if at.After(now) && got.Healthy { + t.Fatal("timestamp rewrite accepted as heartbeat") + } + } +} diff --git a/internal/tray/testdata/windows-smoke.cjs b/internal/tray/testdata/windows-smoke.cjs new file mode 100644 index 0000000..db590ff --- /dev/null +++ b/internal/tray/testdata/windows-smoke.cjs @@ -0,0 +1,231 @@ +// Run after building the tray: node internal/tray/testdata/windows-smoke.cjs [screenshots-dir] [live-store] +// Exercises the real embedded WebView2 through a temporary loopback debug port. +// No desktop automation, extra npm dependencies, or production store is used. +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const net = require('node:net'); +const path = require('node:path'); +const {spawn} = require('node:child_process'); + +const pause = ms => new Promise(resolve => setTimeout(resolve, ms)); +async function until(action, label) { + const deadline = Date.now() + 15000; + let last; + while (Date.now() < deadline) { + try { const result = await action(); if (result) return result; } catch (error) { last = error; } + await pause(150); + } + throw new Error(label + ' timed out' + (last ? ': ' + last.message : '')); +} +async function freePort() { + const server = net.createServer(); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const port = server.address().port; + await new Promise(resolve => server.close(resolve)); + return port; +} +async function connect(port) { + const target = await until(async () => { + const response = await fetch('http://127.0.0.1:' + port + '/json/list', {signal: AbortSignal.timeout(1000)}); + return (await response.json()).find(item => item.title === 'CodexFold'); + }, 'WebView2 page'); + const socket = new WebSocket(target.webSocketDebuggerUrl); + await new Promise((resolve, reject) => { socket.onopen = resolve; socket.onerror = reject; }); + let serial = 0; + const pending = new Map(), exceptions = []; + socket.onmessage = event => { + const result = JSON.parse(event.data); + if (result.method === 'Runtime.exceptionThrown') exceptions.push(result.params.exceptionDetails); + const request = pending.get(result.id); + if (!request) return; + pending.delete(result.id); clearTimeout(request.timer); + result.error ? request.reject(new Error(JSON.stringify(result.error))) : request.resolve(result.result); + }; + const call = (method, params = {}) => new Promise((resolve, reject) => { + const id = ++serial; + const timer = setTimeout(() => { pending.delete(id); reject(new Error(method + ' timed out')); }, 8000); + pending.set(id, {resolve, reject, timer}); socket.send(JSON.stringify({id, method, params})); + }); + const evaluate = async expression => { + const result = await call('Runtime.evaluate', {expression, returnByValue: true, awaitPromise: true}); + if (result.exceptionDetails) throw new Error(JSON.stringify(result.exceptionDetails)); + return result.result.value; + }; + await call('Runtime.enable'); + return {call, evaluate, exceptions, close: () => socket.close()}; +} +async function main() { + assert.equal(process.platform, 'win32', 'This test requires Windows and WebView2 Runtime'); + assert.ok(process.argv[2], 'Provide the built tray executable'); + const executable = path.resolve(process.argv[2]); + const screenshots = process.argv[3] && path.resolve(process.argv[3]); + const temporaryRoot = path.resolve(process.env.CODEXFOLD_TEST_TEMP || path.join(process.cwd(), '.tmp')); + await fs.mkdir(temporaryRoot, {recursive:true}); + const root = await fs.mkdtemp(path.join(temporaryRoot, 'codexfold-tray-smoke-')); + const port = await freePort(); + const liveStore = process.argv[4] && path.resolve(process.argv[4]); + const store = liveStore || path.join(root, 'store'); + if (!liveStore) { + await fs.mkdir(path.join(store,'enrollment'), {recursive:true}); + await fs.writeFile(path.join(store,'enrollment/ui-history-v1.json'), JSON.stringify({version:1,samples:[{t:Date.now()-20*86400000,l:10000000,p:6000000,health:'运行正常'}],incidents:[]})); + } + const env = {...process.env, WEBVIEW2_USER_DATA_FOLDER: path.join(root, 'webview'), WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS: '--remote-debugging-port=' + port}; + // windowsHide supplies STARTF_USESHOWWINDOW/SW_HIDE, the failing launcher condition. + const child = spawn(executable, ['--store', store], {env, windowsHide: true, stdio: 'ignore'}); + let client; + try { + child.on('error', error => { console.error(error); }); + client = await connect(port); + if (liveStore) { + await until(() => client.evaluate("document.visibilityState === 'visible' && document.getElementById('health')?.textContent === '运行正常'"), 'Live filesystem dashboard'); + const status = await client.evaluate("({health:document.getElementById('health').textContent,sessions:document.getElementById('session-count').textContent,logical:document.getElementById('logical').textContent,physical:document.getElementById('physical').textContent,daemon:document.getElementById('daemon-state').textContent,managed:document.getElementById('managed-state').textContent,enrollment:document.getElementById('fold-status').textContent,overflow:document.documentElement.scrollWidth > innerWidth})"); + assert.equal(status.daemon, '运行正常'); + assert.equal(status.managed, '运行正常'); + assert.match(status.sessions, /托管会话:\d+/); + assert.notEqual(status.logical, '—'); + assert.notEqual(status.physical, '—'); + assert.equal(status.overflow, false); + if (process.env.CODEXFOLD_EXPECT_ACCOUNTING === '1') { + const end = Date.now()+60000; + while (await client.evaluate("document.getElementById('saved').textContent === '—'") && Date.now() child.exitCode !== null, 'Live tray exit'); + assert.equal(child.exitCode, 0); + return; + } + await until(() => client.evaluate("document.readyState === 'complete' && document.visibilityState === 'visible' && document.getElementById('health')?.textContent === '未连接'"), 'First-launch visible dashboard'); + console.log('PASS: first launch renders with a hidden launcher; native status bridge responds'); + const enrollment = path.join(store, 'enrollment'); + await fs.mkdir(enrollment, {recursive:true}); + await fs.writeFile(path.join(enrollment,'worker-policy.json'),JSON.stringify({version:1,enabled:true,interval:'1m',stable_for:'1h',archived_only:false,batch_size:5})); + for (const [phase,enabled,label] of [['waiting-filesystem',true,'等待文件系统'],['stopped',true,'后台已停止'],['config-invalid',false,'设置有误'],['disabled',false,'已关闭']]) { + await fs.writeFile(path.join(enrollment,'worker-status.json'),JSON.stringify({version:1,store_path:store,enabled,phase,interval:'1m',stable_for:'1h',archived_only:false,managed_count:0,waiting_count:0,waiting_known:false,cycle_total:0,cycle_done:0,updated_at:new Date().toISOString()})); + await client.evaluate("window.chrome.webview.postMessage('refresh')"); + await until(()=>client.evaluate("document.getElementById('fold-status').textContent === "+JSON.stringify(label)),label); + } + // Keep the longer waiting label visible in all existing size checks. + await fs.writeFile(path.join(enrollment,'worker-status.json'),JSON.stringify({version:1,store_path:store,enabled:true,phase:'waiting-filesystem',managed_count:0,waiting_count:0,waiting_known:false,cycle_total:0,cycle_done:0,updated_at:new Date().toISOString()})); + await client.evaluate("window.chrome.webview.postMessage('refresh')"); + await until(()=>client.evaluate("document.getElementById('fold-status').textContent === '等待文件系统'"),'Waiting label'); + console.log('PASS: persistent worker waiting, stopped, invalid and user-paused states render without a mount'); + assert.ok(await client.evaluate('samples.some(item=>Date.now()-item.t>7*86400000)'), 'Persisted history must survive loading'); + assert.equal(await client.evaluate("getComputedStyle(document.getElementById('policy-enabled')).width"), '64px'); + await until(()=>client.evaluate("getComputedStyle(document.querySelector('.glass')).backdropFilter.includes('url(')"), 'First visible glass lens'); + await client.evaluate("document.getElementById('policy-batch').value='25';document.getElementById('policy-batch').dispatchEvent(new Event('change',{bubbles:true}))"); + await until(()=>client.evaluate('!pendingPolicy'), 'Native policy save'); + let savedPolicy=JSON.parse(await fs.readFile(path.join(enrollment,'worker-policy.json'),'utf8')); + assert.equal(savedPolicy.batch_size,25);assert.equal(savedPolicy.interval,'1m0s');assert.equal(savedPolicy.stable_for,'1h0m0s'); + await client.evaluate("document.getElementById('policy-enabled').click()"); + await until(()=>client.evaluate('!pendingPolicy && !document.getElementById("policy-enabled").checked'), 'Pause setting'); + savedPolicy=JSON.parse(await fs.readFile(path.join(enrollment,'worker-policy.json'),'utf8'));assert.equal(savedPolicy.enabled,false); + console.log('PASS: policy edits preserve other fields; pause persists; 30-day history loads; glass lens and switch proportions render'); + // Only this isolated test page uses metric fixtures. Shipping status still + // arrives from native read-only accounting; no production files are changed. + const metrics = {LogicalBytes:916715330,PhysicalBytes:1698632426,CompressionLogicalBytes:916715330,CompressedBytes:604913040,PendingBytes:1087218322}; + await client.evaluate(`(() => { + const nativeUpdate = window.updateStatus; + window.metricFixture = ${JSON.stringify(metrics)}; + window.nativeUpdates = 0; + window.updateStatus = view => { window.nativeUpdates++; nativeUpdate({...view,...window.metricFixture}); }; + })()`); + await client.evaluate("window.chrome.webview.postMessage('refresh')"); + await until(()=>client.evaluate("document.getElementById('saved').textContent === '311.8 MB'"),'Compression saving'); + let metricState = await client.evaluate("({saved:document.getElementById('saved').textContent,pending:document.getElementById('pending').textContent,label:document.getElementById('net-label').textContent,net:document.getElementById('net-saved').textContent})"); + assert.deepEqual(metricState,{saved:'311.8 MB',pending:'1.1 GB',label:'暂多占',net:'781.9 MB'}); + await client.evaluate("window.metricFixture.PhysicalBytes = 650000000; window.chrome.webview.postMessage('refresh')"); + await until(()=>client.evaluate("document.getElementById('net-label').textContent === '当前净节省'"),'Positive net saving'); + assert.equal(await client.evaluate("document.getElementById('saved').textContent"),'311.8 MB'); + await client.evaluate("window.metricFixture.PhysicalBytes = 1698632426; window.chrome.webview.postMessage('refresh')"); + await until(()=>client.evaluate("document.getElementById('net-label').textContent === '暂多占'"),'Negative net saving'); + console.log('PASS: compression stays visible while temporary storage changes net saving'); + for (const [width, height] of [[1060, 730], [540, 420], [390, 730]]) { + await client.call('Emulation.setDeviceMetricsOverride', {width, height, deviceScaleFactor: 1, mobile: false}); + const layout = await client.evaluate(`({width:innerWidth,scroll:document.documentElement.scrollWidth,columns:getComputedStyle(document.querySelector('.metrics')).gridTemplateColumns.split(' ').length,heading:document.querySelector('h1').textContent})`); + assert.ok(layout.scroll <= layout.width, 'Horizontal overflow at ' + width); + assert.equal(layout.columns, width > 720 ? 4 : 2); + assert.equal(layout.heading, 'CodexFold'); + const scroll = await client.evaluate(`(() => { + const content = document.getElementById('content-scroll'); + const headerTop = document.querySelector('.toolbar').getBoundingClientRect().top; + content.scrollTo(0, content.scrollHeight); + const result = {top:content.scrollTop,root:document.scrollingElement.scrollTop,headerTop,headerAfter:document.querySelector('.toolbar').getBoundingClientRect().top,scrollbar:getComputedStyle(content,'::-webkit-scrollbar').width,overflow:content.scrollWidth > content.clientWidth}; + content.scrollTo(0,0); return result; + })()`); + assert.ok(scroll.top > 0, 'Content must remain scrollable'); + assert.equal(scroll.root, 0, 'Window itself must not scroll'); + assert.equal(scroll.headerAfter, scroll.headerTop, 'Toolbar moved with content'); + assert.equal(scroll.scrollbar, '10px'); + assert.equal(scroll.overflow, false, 'Horizontal content overflow'); + if (screenshots && width !== 540) { + await fs.mkdir(screenshots, {recursive: true}); + const capture = await client.call('Page.captureScreenshot', {format: 'png', captureBeyondViewport: false}); + await fs.writeFile(path.join(screenshots, 'dashboard-' + width + '.png'), Buffer.from(capture.data, 'base64')); + } + } + await client.call('Emulation.clearDeviceMetricsOverride'); + const wheelPoint = await client.evaluate("({x:innerWidth/2,y:document.querySelector('.toolbar').getBoundingClientRect().bottom+80})"); + await client.call('Input.dispatchMouseEvent', {type:'mouseWheel', ...wheelPoint, deltaX:0, deltaY:360}); + await until(() => client.evaluate("document.getElementById('content-scroll').scrollTop > 0"), 'Mouse wheel scrolling'); + await client.evaluate("document.getElementById('content-scroll').scrollTo(0,0); document.getElementById('content-scroll').focus()"); + await client.call('Input.dispatchKeyEvent', {type:'rawKeyDown', key:'PageDown', code:'PageDown', windowsVirtualKeyCode:34}); + await client.call('Input.dispatchKeyEvent', {type:'keyUp', key:'PageDown', code:'PageDown', windowsVirtualKeyCode:34}); + await until(() => client.evaluate("document.getElementById('content-scroll').scrollTop > 0"), 'Keyboard scrolling'); + await client.evaluate("document.getElementById('content-scroll').scrollTo(0,0)"); + console.log('PASS: actual mouse wheel and PageDown scroll the content region'); + assert.equal(await client.evaluate("document.querySelector('[data-hours=\"168\"]').click(); document.getElementById('range-start').textContent"), '7 天前'); + assert.equal(await client.evaluate("document.getElementById('show-diagnostics').click(); document.getElementById('diagnostics').open"), true); + assert.equal(await client.evaluate("document.getElementById('close-diagnostics').click(); document.getElementById('diagnostics').open"), false); + assert.equal(await client.evaluate("document.getElementById('more').click(); document.getElementById('menu').hidden"), false); + assert.equal(await client.evaluate("document.querySelector('main').click(); document.getElementById('menu').hidden"), true); + console.log('PASS: desktop/narrow/short layout, fixed toolbar, slim content scrollbar, time range, diagnostics and menu'); + const before = await client.evaluate('window.nativeUpdates'); + await client.evaluate("document.querySelector('[data-command=\"refresh\"]').click()"); + await until(async () => await client.evaluate('window.nativeUpdates') > before, 'Native refresh'); + assert.deepEqual(client.exceptions, []); + console.log('PASS: refresh reaches native host; no JavaScript exceptions'); + await client.evaluate("window.chrome.webview.postMessage('exit')"); + await until(() => child.exitCode !== null, 'Native exit'); + assert.equal(child.exitCode, 0); + client.close(); client = null; + console.log('PASS: exit button shuts down the tray process'); + + // Also check a background start, followed by exactly one existing-instance open. + const background = spawn(executable, ['--background', '--store', store], {env, windowsHide: true, stdio: 'ignore'}); + try { + client = await connect(port); + assert.equal(await client.evaluate('document.visibilityState'), 'hidden'); + const opener = spawn(executable, ['--store', store], {env, windowsHide: true, stdio: 'ignore'}); + await until(() => opener.exitCode !== null, 'Existing-instance opener'); + assert.equal(opener.exitCode, 0); + await until(() => client.evaluate("document.visibilityState === 'visible'"), 'Background window restore'); + const quitter = spawn(executable, ['--quit', '--store', store], {env, windowsHide: true, stdio: 'ignore'}); + await until(() => quitter.exitCode !== null, 'Tray-only quit command'); + assert.equal(quitter.exitCode, 0); + await until(() => background.exitCode !== null, 'Background process exit'); + assert.equal(background.exitCode, 0); + console.log('PASS: background start stays hidden; one open restores the dashboard'); + } finally { if (background.exitCode === null) background.kill(); } + } finally { + client?.close(); + if (child.exitCode === null) child.kill(); + // Remove only the exact temporary directory created by this test. + assert.equal(path.dirname(path.resolve(root)), temporaryRoot); + assert.ok(path.basename(root).startsWith('codexfold-tray-smoke-')); + await fs.rm(root, {recursive: true, force: true, maxRetries: 20, retryDelay: 250}); + } +} +main().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/internal/tray/window_windows.go b/internal/tray/window_windows.go new file mode 100644 index 0000000..c3426ad --- /dev/null +++ b/internal/tray/window_windows.go @@ -0,0 +1,642 @@ +//go:build windows + +package tray + +import ( + "crypto/sha256" + _ "embed" + "encoding/json" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "syscall" + "time" + "unsafe" + + "github.com/jchv/go-webview2/pkg/edge" + "golang.org/x/sys/windows" +) + +//go:embed dashboard.html +var dashboardHTML string + +var ( + user32 = windows.NewLazySystemDLL("user32.dll") + shell32 = windows.NewLazySystemDLL("shell32.dll") + registerClass = user32.NewProc("RegisterClassExW") + createWindow = user32.NewProc("CreateWindowExW") + defWindowProc = user32.NewProc("DefWindowProcW") + showWindow = user32.NewProc("ShowWindow") + setForeground = user32.NewProc("SetForegroundWindow") + postMessage = user32.NewProc("PostMessageW") + notifyIcon = shell32.NewProc("Shell_NotifyIconW") + moveMemory = windows.NewLazySystemDLL("kernel32.dll").NewProc("RtlMoveMemory") +) + +const ( + wmTray = 0x8001 + wmShow = 0x8002 + wmRefresh = 0x8003 + idOpen = 100 + idStore = 101 + idLogs = 102 + idRefresh = 103 + idExit = 104 + idWorkerLogs = 105 + idCopyDiagnostics = 106 + idExportDiagnostics = 107 + idWindowMinimize = 108 + idWindowMaximize = 109 + idWindowHide = 110 + idWindowDrag = 111 + // rsrc emits the manifest as 1, then each group followed by its nine images. + appIconResource = 2 + attentionIconResource = 12 +) + +type point struct{ X, Y int32 } +type rect struct{ Left, Top, Right, Bottom int32 } +type message struct { + Window uintptr + ID uint32 + WParam, LParam uintptr + Time uint32 + Point point + Private uint32 +} +type windowClass struct { + Size, Style uint32 + Procedure uintptr + ClassExtra, WindowExtra int32 + Instance, Icon, Cursor, Background uintptr + MenuName, ClassName *uint16 + SmallIcon uintptr +} +type iconData struct { + Size uint32 + Window uintptr + ID, Flags, Callback uint32 + Icon uintptr + Tip [128]uint16 + State, StateMask uint32 + Info [256]uint16 + Timeout uint32 + InfoTitle [64]uint16 + InfoFlags uint32 + GUID windows.GUID + BalloonIcon uintptr +} + +type application struct { + window uintptr + monitor *Monitor + browser *edge.Chromium + icon iconData + largeIcon, smallIcon, warningIcon uintptr + instance windows.Handle + taskbarMessage uint32 + scale float64 + view View + results chan View + refresh chan struct{} + stop chan struct{} + pollDone chan struct{} + actions chan PolicyChange + notifiedIncidents map[string]bool +} + +func utf16(value string) *uint16 { + p, _ := windows.UTF16PtrFromString(strings.ReplaceAll(value, "\x00", "")) + return p +} +func pointer(value *uint16) uintptr { return uintptr(unsafe.Pointer(value)) } +func (a *application) px(value int) uintptr { return uintptr(int(float64(value) * a.scale)) } + +func ShowError(err error) { + user32.NewProc("MessageBoxW").Call(0, pointer(utf16(err.Error())), pointer(utf16("CodexFold")), 0x10) +} + +func RequestExit(store string) error { + key := fmt.Sprintf("CodexFold.Tray.%x", sha256.Sum256([]byte(strings.ToLower(filepath.Clean(store))))) + window, _, _ := user32.NewProc("FindWindowW").Call(pointer(utf16(key)), 0) + if window == 0 { + return nil + } + if sent, _, err := postMessage.Call(window, 0x111, idExit, 0); sent == 0 { + return fmt.Errorf("request tray exit: %w", err) + } + return nil +} + +func Run(store string, background bool) error { + runtime.LockOSThread() + defer runtime.UnlockOSThread() + // The executable manifest sets PerMonitorV2 before any COM windows exist. + // Keep a fallback for hosts that call this package without that manifest. + if proc := user32.NewProc("SetProcessDpiAwarenessContext"); proc.Find() == nil { + proc.Call(^uintptr(3)) // DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 (-4) + } else { + user32.NewProc("SetProcessDPIAware").Call() + } + key := fmt.Sprintf("CodexFold.Tray.%x", sha256.Sum256([]byte(strings.ToLower(filepath.Clean(store))))) + mutex, err := windows.CreateMutex(nil, false, utf16("Local\\"+key)) + if mutex != 0 { + defer windows.CloseHandle(mutex) + } + if err == windows.ERROR_ALREADY_EXISTS { + window, _, _ := user32.NewProc("FindWindowW").Call(pointer(utf16(key)), 0) + if window != 0 { + postMessage.Call(window, wmShow, 0, 0) + } + return nil + } + if err != nil { + return err + } + a := &application{monitor: NewMonitor(store), scale: 1, results: make(chan View, 1), refresh: make(chan struct{}, 1), stop: make(chan struct{}), pollDone: make(chan struct{}), actions: make(chan PolicyChange, 1), notifiedIncidents: make(map[string]bool)} + if proc := user32.NewProc("GetDpiForSystem"); proc.Find() == nil { + if dpi, _, _ := proc.Call(); dpi != 0 { + a.scale = float64(dpi) / 96 + } + } + cursor, _, _ := user32.NewProc("LoadCursorW").Call(0, 32512) + var instance windows.Handle + err = windows.GetModuleHandleEx(0, nil, &instance) + if err != nil { + return err + } + a.instance = instance + if err := a.loadIcons(); err != nil { + return err + } + class := windowClass{Procedure: syscall.NewCallback(a.procedure), Instance: uintptr(instance), Icon: a.largeIcon, Cursor: cursor, Background: 16, ClassName: utf16(key), SmallIcon: a.smallIcon} + class.Size = uint32(unsafe.Sizeof(class)) + if value, _, err := registerClass.Call(uintptr(unsafe.Pointer(&class))); value == 0 { + return fmt.Errorf("register status window: %w", err) + } + // Clip child surfaces so repainting the native frame cannot cover WebView2. + bounds := fitWindow(primaryWorkArea(), a.scale) + window, _, err := createWindow.Call(0, pointer(class.ClassName), pointer(utf16("CodexFold")), 0x02cf0000, uintptr(bounds.Left), uintptr(bounds.Top), uintptr(bounds.Right-bounds.Left), uintptr(bounds.Bottom-bounds.Top), 0, 0, uintptr(instance), 0) + if window == 0 { + return fmt.Errorf("create status window: %w", err) + } + a.window = window + defer user32.NewProc("DestroyWindow").Call(window) + a.browser = edge.NewChromium() + cache, err := os.UserCacheDir() + if err != nil { + return err + } + a.browser.DataPath = filepath.Join(cache, "CodexFold", "WebView2") + a.browser.SetGlobalPermission(edge.CoreWebView2PermissionStateDeny) + a.browser.MessageCallback = func(action string) { + // Only fixed actions from the embedded page cross the native boundary. + // No path, command line, or arbitrary code is accepted from JavaScript. + switch action { + case "ready": + a.update(a.view) + case "store": + postMessage.Call(window, 0x111, idStore, 0) + case "logs": + postMessage.Call(window, 0x111, idLogs, 0) + case "refresh": + postMessage.Call(window, 0x111, idRefresh, 0) + case "logs-worker": + postMessage.Call(window, 0x111, idWorkerLogs, 0) + case "copy-diagnostics": + postMessage.Call(window, 0x111, idCopyDiagnostics, 0) + case "export-diagnostics": + postMessage.Call(window, 0x111, idExportDiagnostics, 0) + case "exit": + postMessage.Call(window, 0x111, idExit, 0) + case "window-minimize": + postMessage.Call(window, 0x111, idWindowMinimize, 0) + case "window-maximize": + postMessage.Call(window, 0x111, idWindowMaximize, 0) + case "window-hide": + postMessage.Call(window, 0x111, idWindowHide, 0) + case "window-drag": + postMessage.Call(window, 0x111, idWindowDrag, 0) + default: + if strings.HasPrefix(action, "{") { + request, err := decodePolicyChange(action) + if err != nil { + a.reply(ActionResult{ID: request.ID, Message: err.Error()}) + return + } + select { + case a.actions <- request: + default: + a.reply(ActionResult{ID: request.ID, Message: "正在保存上一项设置,请稍后重试。"}) + } + } + } + } + if !a.browser.Embed(window) { + return fmt.Errorf("无法初始化 Microsoft Edge WebView2 Runtime,请安装后重新打开") + } + settings, err := a.browser.GetSettings() + if err != nil { + return err + } + if err := settings.PutAreDefaultContextMenusEnabled(false); err != nil { + return err + } + if err := settings.PutAreDevToolsEnabled(false); err != nil { + return err + } + a.browser.NavigateToString(dashboardHTML) + // Use the Windows 11 rounded frame when available; older Windows ignores it. + dwm := windows.NewLazySystemDLL("dwmapi.dll").NewProc("DwmSetWindowAttribute") + corner, border := uint32(2), uint32(0xfffffffe) + dwm.Call(window, 33, uintptr(unsafe.Pointer(&corner)), 4) + dwm.Call(window, 34, uintptr(unsafe.Pointer(&border)), 4) + // Recalculate the client area after the custom glass caption is ready. + user32.NewProc("SetWindowPos").Call(window, 0, 0, 0, 0, 0, 0x37) // FRAMECHANGED | NOMOVE | NOSIZE | NOZORDER | NOACTIVATE + registered, _, _ := user32.NewProc("RegisterWindowMessageW").Call(pointer(utf16("TaskbarCreated"))) + a.taskbarMessage = uint32(registered) + a.icon = iconData{Window: window, ID: 1, Flags: 7, Callback: wmTray, Icon: a.smallIcon} + a.icon.Size = uint32(unsafe.Sizeof(a.icon)) + a.setTip("CodexFold · 正在读取状态") + if result, _, err := notifyIcon.Call(0, uintptr(unsafe.Pointer(&a.icon))); result == 0 { + return fmt.Errorf("add notification icon: %w", err) + } + defer notifyIcon.Call(2, uintptr(unsafe.Pointer(&a.icon))) + a.layout() + a.update(View{Health: "正在读取状态", Storage: "空间统计:暂无数据", Activity: "读取 — 写入 —", Sessions: "托管会话:—", Detail: "正在读取本地后台状态…"}) + go a.poll() + defer func() { + close(a.stop) + select { + case <-a.pollDone: + case <-time.After(3 * time.Second): + } + }() + if !background { + a.show() + } + var msg message + for { + result, _, err := user32.NewProc("GetMessageW").Call(uintptr(unsafe.Pointer(&msg)), 0, 0, 0) + if int32(result) == -1 { + return err + } + if result == 0 { + return nil + } + if handled, _, _ := user32.NewProc("IsDialogMessageW").Call(window, uintptr(unsafe.Pointer(&msg))); handled != 0 { + continue + } + user32.NewProc("TranslateMessage").Call(uintptr(unsafe.Pointer(&msg))) + user32.NewProc("DispatchMessageW").Call(uintptr(unsafe.Pointer(&msg))) + } +} + +func (a *application) poll() { + defer close(a.pollDone) + defer a.monitor.FlushHistory() + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + var operation *ActionResult + for { + view := a.monitor.Refresh(time.Now()) + view.Operation = operation + select { + case a.results <- view: + default: + select { + case <-a.results: + default: + } + select { + case a.results <- view: + default: + } + } + postMessage.Call(a.window, wmRefresh, 0, 0) + select { + case <-a.stop: + return + case <-ticker.C: + case <-a.refresh: + case request := <-a.actions: + result := a.monitor.ApplyPolicy(request) + operation = &result + } + } +} + +func (a *application) update(view View) { + a.view = view + if a.browser != nil { + payload, err := json.Marshal(view) + if err == nil { + a.browser.Eval("window.updateStatus && window.updateStatus(" + string(payload) + ")") + } + } + a.icon.Icon = a.smallIcon + if view.Health == "需要关注" { + a.icon.Icon = a.warningIcon + } + a.setTip("CodexFold · " + view.Health + "\n" + view.Activity) + notifyIcon.Call(1, uintptr(unsafe.Pointer(&a.icon))) + for _, incident := range view.Incidents { + if incident.RecoveredAt == nil && !a.notifiedIncidents[incident.ID] { + a.notifiedIncidents[incident.ID] = true + a.icon.Flags = 0x10 // NIF_INFO: request one native notification per incident. + a.icon.InfoTitle, a.icon.Info = [64]uint16{}, [256]uint16{} + copy(a.icon.InfoTitle[:], windows.StringToUTF16("CodexFold · 需要关注")) + copy(a.icon.Info[:], windows.StringToUTF16(incident.Reason+"。打开状态窗口查看详情。")) + a.icon.InfoFlags = 2 + notifyIcon.Call(1, uintptr(unsafe.Pointer(&a.icon))) + a.icon.Flags = 7 + break + } + } +} + +func (a *application) setTip(value string) { + a.icon.Tip = [128]uint16{} + encoded, _ := windows.UTF16FromString(value) + if len(encoded) > 127 { + encoded = encoded[:127] + if encoded[126] >= 0xd800 && encoded[126] <= 0xdbff { + encoded = encoded[:126] + } + } + copy(a.icon.Tip[:], encoded) +} + +func (a *application) layout() { + if a.browser != nil && a.browser.GetController() != nil { + a.browser.Resize() + } +} + +func primaryWorkArea() rect { + var work rect + if ok, _, _ := user32.NewProc("SystemParametersInfoW").Call(0x30, 0, uintptr(unsafe.Pointer(&work)), 0); ok != 0 { + return work + } + width, _, _ := user32.NewProc("GetSystemMetrics").Call(0) + height, _, _ := user32.NewProc("GetSystemMetrics").Call(1) + return rect{Right: int32(width), Bottom: int32(height)} +} + +func windowWorkArea(window uintptr) rect { + var info struct { + Size uint32 + Monitor, Work rect + Flags uint32 + } + info.Size = uint32(unsafe.Sizeof(info)) + monitor, _, _ := user32.NewProc("MonitorFromWindow").Call(window, 2) + if ok, _, _ := user32.NewProc("GetMonitorInfoW").Call(monitor, uintptr(unsafe.Pointer(&info))); ok != 0 { + return info.Work + } + return primaryWorkArea() +} + +func fitWindow(work rect, scale float64) rect { + margin := int32(16 * scale) + width := min(int32(1000*scale), max(1, work.Right-work.Left-2*margin)) + height := min(int32(780*scale), max(1, work.Bottom-work.Top-2*margin)) + left := work.Left + (work.Right-work.Left-width)/2 + top := work.Top + (work.Bottom-work.Top-height)/2 + return rect{left, top, left + width, top + height} +} + +func frameResizeHit(bounds rect, cursor point, edge int32) uintptr { + left, right := cursor.X < bounds.Left+edge, cursor.X >= bounds.Right-edge + top, bottom := cursor.Y < bounds.Top+edge, cursor.Y >= bounds.Bottom-edge + switch { + case top && left: + return 13 + case top && right: + return 14 + case bottom && left: + return 16 + case bottom && right: + return 17 + case left: + return 10 + case right: + return 11 + case top: + return 12 + case bottom: + return 15 + default: + return 0 + } +} + +func (a *application) loadIcons() error { + load := func(resource uintptr, size int) (uintptr, error) { + icon, _, err := user32.NewProc("LoadImageW").Call(uintptr(a.instance), resource, 1, a.px(size), a.px(size), 0x8000) // IMAGE_ICON, LR_SHARED + if icon == 0 { + return 0, fmt.Errorf("load application icon %d: %w", resource, err) + } + return icon, nil + } + var err error + if a.largeIcon, err = load(appIconResource, 32); err != nil { + return err + } + if a.smallIcon, err = load(appIconResource, 16); err != nil { + return err + } + a.warningIcon, err = load(attentionIconResource, 16) + return err +} + +func (a *application) show() { + showWindow.Call(a.window, 9) // restore a minimized window + // STARTF_USESHOWWINDOW can override the first ShowWindow call with SW_HIDE. + // SetWindowPos applies our explicit open request even for a hidden launcher. + user32.NewProc("SetWindowPos").Call(a.window, 0, 0, 0, 0, 0, 0x47) // NOSIZE | NOMOVE | NOZORDER | SHOWWINDOW + a.syncBrowserVisibility(true) + a.layout() + if a.browser != nil { + _ = a.browser.NotifyParentWindowPositionChanged() + a.browser.Focus() + } + user32.NewProc("UpdateWindow").Call(a.window) + setForeground.Call(a.window) +} + +func (a *application) syncBrowserVisibility(visible bool) { + if a.browser == nil || a.browser.GetController() == nil { + return + } + if visible { + _ = a.browser.Show() + } else { + _ = a.browser.Hide() + } +} + +func (a *application) openFolder(path string) { + if info, err := os.Stat(path); err != nil || !info.IsDir() { + user32.NewProc("MessageBoxW").Call(a.window, pointer(utf16("该目录尚未创建:\n"+path)), pointer(utf16("CodexFold")), 0x40) + return + } + result, _, _ := shell32.NewProc("ShellExecuteW").Call(a.window, pointer(utf16("open")), pointer(utf16(path)), 0, 0, 1) + if result <= 32 { + ShowError(fmt.Errorf("无法打开目录 (%d): %s", result, path)) + } +} + +func (a *application) command(id uintptr) { + switch id { + case idOpen: + a.show() + case idStore: + a.openFolder(a.monitor.Store) + case idLogs: + path := a.view.LogDirectories["filesystem"] + if path == "" { + path = filepath.Join(a.monitor.Store, "fs") + } + a.openFolder(path) + case idWorkerLogs: + a.openFolder(filepath.Join(a.monitor.Store, "enrollment")) + case idCopyDiagnostics: + a.copyDiagnostics() + case idExportDiagnostics: + a.exportDiagnostics() + case idRefresh: + a.monitor.RequestAccountingRefresh() + select { + case a.refresh <- struct{}{}: + default: + } + case idExit: + user32.NewProc("PostQuitMessage").Call(0) + case idWindowMinimize: + showWindow.Call(a.window, 6) + case idWindowMaximize: + if maximized, _, _ := user32.NewProc("IsZoomed").Call(a.window); maximized != 0 { + showWindow.Call(a.window, 9) + } else { + showWindow.Call(a.window, 3) + } + case idWindowHide: + postMessage.Call(a.window, 0x10, 0, 0) + case idWindowDrag: + user32.NewProc("ReleaseCapture").Call() + postMessage.Call(a.window, 0xa1, 2, 0) // WM_NCLBUTTONDOWN / HTCAPTION + } +} + +func (a *application) popup() { + a.show() + if a.browser != nil { + a.browser.Eval("window.openGlassMenu && window.openGlassMenu()") + } +} + +func (a *application) procedure(window uintptr, msg uint32, wparam, lparam uintptr) uintptr { + if a.taskbarMessage != 0 && msg == a.taskbarMessage { + notifyIcon.Call(0, uintptr(unsafe.Pointer(&a.icon))) + return 0 + } + switch msg { + case 0x83: // WM_NCCALCSIZE: use the web caption while retaining the native sizing frame. + if wparam != 0 { + var bounds rect + // LPARAM belongs to Windows. Copy through the native API rather + // than retaining an unmanaged address as a Go pointer. + moveMemory.Call(uintptr(unsafe.Pointer(&bounds)), lparam, unsafe.Sizeof(bounds)) + if maximized, _, _ := user32.NewProc("IsZoomed").Call(window); maximized != 0 { + bounds = windowWorkArea(window) + } else { + // Keep a native sizing rim outside the WebView child so the + // child cannot intercept pointer hit tests at the window edges. + rim := int32(a.px(6)) + bounds.Left += rim + bounds.Top += rim + bounds.Right -= rim + bounds.Bottom -= rim + } + moveMemory.Call(lparam, uintptr(unsafe.Pointer(&bounds)), unsafe.Sizeof(bounds)) + return 0 + } + case 0x84: // WM_NCHITTEST: signed coordinates also work on left-hand monitors. + if maximized, _, _ := user32.NewProc("IsZoomed").Call(window); maximized == 0 { + var bounds rect + user32.NewProc("GetWindowRect").Call(window, uintptr(unsafe.Pointer(&bounds))) + cursor := point{int32(int16(lparam & 0xffff)), int32(int16((lparam >> 16) & 0xffff))} + if hit := frameResizeHit(bounds, cursor, int32(a.px(6))); hit != 0 { + return hit + } + } + return 1 // HTCLIENT: the caption drag regions are sent explicitly by the page. + case wmShow: + a.show() + return 0 + case wmRefresh: + select { + case view := <-a.results: + a.update(view) + default: + } + return 0 + case wmTray: + switch uint32(lparam) { + case 0x202, 0x203: + a.show() + case 0x205: + a.popup() + } + return 0 + case 0x111: + a.command(wparam & 0xffff) + return 0 + case 0x10: + showWindow.Call(window, 0) + return 0 + case 0x18: // WM_SHOWWINDOW: keep the controller in sync with the tray window + a.syncBrowserVisibility(wparam != 0) + return 0 + case 0x5: + visible, _, _ := user32.NewProc("IsWindowVisible").Call(window) + a.syncBrowserVisibility(wparam != 1 && visible != 0) // SIZE_MINIMIZED + a.layout() + return 0 + case 0x3, 0x216: // WM_MOVE, WM_MOVING + if a.browser != nil { + _ = a.browser.NotifyParentWindowPositionChanged() + } + case 0x2e0: // WM_DPICHANGED + a.scale = float64(wparam&0xffff) / 96 + if a.instance != 0 { + if err := a.loadIcons(); err == nil { + user32.NewProc("SendMessageW").Call(window, 0x80, 1, a.largeIcon) + user32.NewProc("SendMessageW").Call(window, 0x80, 0, a.smallIcon) + if a.icon.Size != 0 { + a.update(a.view) + } + } + } + var bounds rect + moveMemory.Call(uintptr(unsafe.Pointer(&bounds)), lparam, unsafe.Sizeof(bounds)) + user32.NewProc("SetWindowPos").Call(window, 0, uintptr(bounds.Left), uintptr(bounds.Top), uintptr(bounds.Right-bounds.Left), uintptr(bounds.Bottom-bounds.Top), 0x14) // NOZORDER | NOACTIVATE + a.layout() + return 0 + case 0x24: // minimum size, in physical pixels + var limits [5]point + moveMemory.Call(uintptr(unsafe.Pointer(&limits)), lparam, unsafe.Sizeof(limits)) + work := windowWorkArea(window) + limits[3] = point{min(int32(a.px(540)), work.Right-work.Left), min(int32(a.px(420)), work.Bottom-work.Top)} + moveMemory.Call(lparam, uintptr(unsafe.Pointer(&limits)), unsafe.Sizeof(limits)) + return 0 + case 0x2: + user32.NewProc("PostQuitMessage").Call(0) + return 0 + } + result, _, _ := defWindowProc.Call(window, uintptr(msg), wparam, lparam) + return result +} diff --git a/internal/tray/window_windows_test.go b/internal/tray/window_windows_test.go new file mode 100644 index 0000000..0142d0d --- /dev/null +++ b/internal/tray/window_windows_test.go @@ -0,0 +1,31 @@ +//go:build windows + +package tray + +import "testing" + +func TestInitialWindowFitsWorkArea(t *testing.T) { + for _, work := range []rect{{0, 0, 1920, 1040}, {0, 0, 1366, 728}, {-1280, 0, 0, 984}} { + for _, scale := range []float64{1, 1.25, 1.5, 2, 3} { + bounds := fitWindow(work, scale) + if bounds.Left < work.Left || bounds.Top < work.Top || bounds.Right > work.Right || bounds.Bottom > work.Bottom || bounds.Left >= bounds.Right || bounds.Top >= bounds.Bottom { + t.Fatalf("work=%+v scale=%v: window %+v does not fit", work, scale, bounds) + } + } + } +} + +func TestGlassFrameResizeOnNegativeMonitorCoordinates(t *testing.T) { + bounds := rect{-1280, 100, -280, 880} + for _, test := range []struct { + cursor point + want uintptr + }{ + {point{-1279, 101}, 13}, {point{-281, 101}, 14}, {point{-1279, 879}, 16}, {point{-281, 879}, 17}, + {point{-1279, 400}, 10}, {point{-281, 400}, 11}, {point{-800, 101}, 12}, {point{-800, 879}, 15}, {point{-800, 400}, 0}, + } { + if got := frameResizeHit(bounds, test.cursor, 6); got != test.want { + t.Fatalf("cursor=%+v got=%d want=%d", test.cursor, got, test.want) + } + } +} diff --git a/internal/vfs/initial_publish.go b/internal/vfs/initial_publish.go index d7af9fa..2e9ffcc 100644 --- a/internal/vfs/initial_publish.go +++ b/internal/vfs/initial_publish.go @@ -88,7 +88,7 @@ func publishInitialSession(ctx context.Context, options SessionOptions, view *Vi var reservedLease *os.File if reserveWriter { - reservedLease, err = acquireWriterLease(filepath.Join(staging, "writer.lease")) + reservedLease, err = acquireInitialWriterLease(staging, options.Manifest.Session.ID) if err != nil { return SessionState{}, nil, err } diff --git a/internal/vfs/initial_publish_windows_test.go b/internal/vfs/initial_publish_windows_test.go new file mode 100644 index 0000000..ea111c1 --- /dev/null +++ b/internal/vfs/initial_publish_windows_test.go @@ -0,0 +1,99 @@ +//go:build windows + +package vfs + +import ( + "errors" + "os" + "os/exec" + "path/filepath" + "testing" +) + +func TestInitialWriterLeaseSurvivesWindowsPublication(t *testing.T) { + if path := os.Getenv("CODEXFOLD_TEST_PUBLISHED_LEASE"); path != "" { + lease, err := acquireWriterLease(path) + if !errors.Is(err, ErrWriterBusy) { + if lease != nil { + _ = unlockWriterFile(lease) + _ = lease.Close() + } + t.Fatalf("published writer lease did not exclude child process: %v", err) + } + return + } + parent := t.TempDir() + staging := filepath.Join(parent, "staging") + if err := os.Mkdir(staging, 0o700); err != nil { + t.Fatal(err) + } + lease, err := acquireInitialWriterLease(staging, "session-1") + if err != nil { + t.Fatal(err) + } + defer lease.Close() + final := filepath.Join(parent, "session-1") + if err := os.Rename(staging, final); err != nil { + t.Fatal(err) + } + leasePath := filepath.Join(final, "writer.lease") + child := exec.Command(os.Args[0], "-test.run=^TestInitialWriterLeaseSurvivesWindowsPublication$") + child.Env = append(os.Environ(), "CODEXFOLD_TEST_PUBLISHED_LEASE="+leasePath) + if output, err := child.CombinedOutput(); err != nil { + t.Fatalf("child writer exclusion: %v\n%s", err, output) + } + if err := unlockWriterFile(lease); err != nil { + t.Fatal(err) + } + if err := lease.Close(); err != nil { + t.Fatal(err) + } + reopened, err := acquireWriterLease(leasePath) + if err != nil { + t.Fatalf("writer after release: %v", err) + } + _ = unlockWriterFile(reopened) + _ = reopened.Close() + if _, err := os.Stat(filepath.Join(parent, initialSessionLockName("session-1")+".writer")); !os.IsNotExist(err) { + t.Fatalf("temporary writer name remains: %v", err) + } +} + +func TestPublishedWriterLeaseSurvivesWindowsRetirement(t *testing.T) { + parent := t.TempDir() + directory := filepath.Join(parent, "session") + if err := os.Mkdir(directory, 0o700); err != nil { + t.Fatal(err) + } + path := filepath.Join(directory, "writer.lease") + lease, err := acquireWriterLease(path) + if err != nil { + t.Fatal(err) + } + defer lease.Close() + retired := filepath.Join(parent, "retired-session") + if err := os.Rename(directory, retired); err != nil { + t.Fatalf("retire locked session: %v", err) + } + guard, acquired, err := TryAcquireWriterLeaseGuardAtPath(filepath.Join(retired, "writer.lease")) + if err != nil || acquired { + if guard != nil { + _ = guard.Close() + } + t.Fatalf("retired lease lost exclusion: acquired=%v err=%v", acquired, err) + } + if err := unlockWriterFile(lease); err != nil { + t.Fatal(err) + } + if err := lease.Close(); err != nil { + t.Fatal(err) + } + guard, acquired, err = TryAcquireWriterLeaseGuardAtPath(filepath.Join(retired, "writer.lease")) + if err != nil || !acquired { + t.Fatalf("guard after retirement: acquired=%v err=%v", acquired, err) + } + defer guard.Close() + if err := os.Rename(retired, directory); err != nil { + t.Fatalf("restore guarded session: %v", err) + } +} diff --git a/internal/vfs/native_retirement.go b/internal/vfs/native_retirement.go index 5916d62..81046ae 100644 --- a/internal/vfs/native_retirement.go +++ b/internal/vfs/native_retirement.go @@ -13,6 +13,8 @@ import ( "slices" "sort" "time" + + "github.com/samekind/codexfold/internal/dirsync" ) const ( @@ -421,7 +423,7 @@ func syncNativeRetirementDirectory(root *os.Root, relative string) error { if err != nil { return err } - if err := directory.Sync(); err != nil { + if err := dirsync.Sync(directory); err != nil { _ = directory.Close() return err } diff --git a/internal/vfs/session.go b/internal/vfs/session.go index 81c5489..a83c77c 100644 --- a/internal/vfs/session.go +++ b/internal/vfs/session.go @@ -346,7 +346,7 @@ func (s *Session) OpenWriter() (*WriteHandle, error) { } func acquireWriterLease(leasePath string) (*os.File, error) { - lease, err := os.OpenFile(leasePath, os.O_CREATE|os.O_RDWR, 0o600) + lease, err := openWriterLeaseFile(leasePath, true) if err != nil { return nil, fmt.Errorf("create writer lease: %w", err) } @@ -385,7 +385,7 @@ func TryAcquireWriterLeaseGuard(root string, sessionID string) (*WriterLeaseGuar return nil, false, errors.New("session root and safe session ID are required") } leasePath := filepath.Join(filepath.Clean(root), "fs", "sessions", sessionID, "writer.lease") - file, err := os.OpenFile(leasePath, os.O_CREATE|os.O_RDWR, 0o600) + file, err := openWriterLeaseFile(leasePath, true) if err != nil { return nil, false, fmt.Errorf("open writer lease guard: %w", err) } diff --git a/internal/vfs/session_deletion_lease_other.go b/internal/vfs/session_deletion_lease_other.go new file mode 100644 index 0000000..09faaf7 --- /dev/null +++ b/internal/vfs/session_deletion_lease_other.go @@ -0,0 +1,9 @@ +//go:build !windows + +package vfs + +import "os" + +func openDeletionWriterLease(store *sessionDeletionStoreRoot, relative, _ string) (*os.File, error) { + return store.root.OpenFile(relative, os.O_RDWR, 0) +} diff --git a/internal/vfs/session_deletion_lease_windows.go b/internal/vfs/session_deletion_lease_windows.go new file mode 100644 index 0000000..a9dcb9d --- /dev/null +++ b/internal/vfs/session_deletion_lease_windows.go @@ -0,0 +1,11 @@ +//go:build windows + +package vfs + +import "os" + +func openDeletionWriterLease(_ *sessionDeletionStoreRoot, _ string, path string) (*os.File, error) { + // Pin the same verified file through its store-side hard-link alias; + // a locked handle opened inside the state tree blocks NTFS directory moves. + return openWriterLeaseFile(path, false) +} diff --git a/internal/vfs/session_deletion_object_other.go b/internal/vfs/session_deletion_object_other.go index d4bfa4a..931a0b4 100644 --- a/internal/vfs/session_deletion_object_other.go +++ b/internal/vfs/session_deletion_object_other.go @@ -1,4 +1,4 @@ -//go:build !darwin && !linux +//go:build !darwin && !linux && !windows package vfs diff --git a/internal/vfs/session_deletion_object_windows.go b/internal/vfs/session_deletion_object_windows.go new file mode 100644 index 0000000..624c450 --- /dev/null +++ b/internal/vfs/session_deletion_object_windows.go @@ -0,0 +1,111 @@ +//go:build windows + +package vfs + +import ( + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "errors" + "fmt" + "os" + "runtime" + "strings" + "unsafe" + + "golang.org/x/sys/windows" +) + +var deletionVolumeInformation = windows.NewLazySystemDLL("kernel32.dll").NewProc("GetVolumeInformationByHandleW") +var deletionQueryEA = windows.NewLazySystemDLL("ntdll.dll").NewProc("NtQueryEaFile") + +func sessionDeletionObjectFromFile(file *os.File, info os.FileInfo) (SessionDeletionPurgeObject, error) { + if file == nil || info == nil { + return SessionDeletionPurgeObject{}, errors.New("missing Windows deletion object") + } + var filesystem [32]uint16 + result, _, callErr := deletionVolumeInformation.Call(file.Fd(), 0, 0, 0, 0, 0, uintptr(unsafe.Pointer(&filesystem[0])), uintptr(len(filesystem))) + if result == 0 { + return SessionDeletionPurgeObject{}, fmt.Errorf("identify deletion filesystem: %w", callErr) + } + if !strings.EqualFold(windows.UTF16ToString(filesystem[:]), "NTFS") { + return SessionDeletionPurgeObject{}, errors.New("exact Windows session deletion requires NTFS") + } + var native windows.ByHandleFileInformation + if err := windows.GetFileInformationByHandle(windows.Handle(file.Fd()), &native); err != nil { + return SessionDeletionPurgeObject{}, err + } + reference := uint64(native.FileIndexHigh)<<32 | uint64(native.FileIndexLow) + if native.VolumeSerialNumber == 0 || reference == 0 || reference>>48 == 0 || native.CreationTime.Nanoseconds() <= 0 { + return SessionDeletionPurgeObject{}, errors.New("NTFS object has no proven sequence and creation time") + } + descriptor, err := windows.GetSecurityInfo(windows.Handle(file.Fd()), windows.SE_FILE_OBJECT, windows.OWNER_SECURITY_INFORMATION|windows.GROUP_SECURITY_INFORMATION|windows.DACL_SECURITY_INFORMATION) + if err != nil { + return SessionDeletionPurgeObject{}, fmt.Errorf("capture deletion security descriptor: %w", err) + } + security := sha256.Sum256([]byte(descriptor.String())) + return SessionDeletionPurgeObject{IdentityProof: sessionDeletionObjectProofWindows, GenerationSource: sessionDeletionGenerationSourceWindows, BirthtimeSource: sessionDeletionBirthtimeSourceWindows, Device: uint64(native.VolumeSerialNumber), Inode: reference, Generation: reference >> 48, BirthtimeUnixNano: native.CreationTime.Nanoseconds(), SecuritySHA256: hex.EncodeToString(security[:]), Attributes: native.FileAttributes}, nil +} + +func captureSessionDeletionXattrs(file *os.File, info os.FileInfo, path string) (SessionDeletionPurgeXattrs, error) { + var native windows.ByHandleFileInformation + if err := windows.GetFileInformationByHandle(windows.Handle(file.Fd()), &native); err != nil { + return SessionDeletionPurgeXattrs{}, err + } + const ordinaryAttributes = windows.FILE_ATTRIBUTE_ARCHIVE | windows.FILE_ATTRIBUTE_DIRECTORY | windows.FILE_ATTRIBUTE_READONLY | windows.FILE_ATTRIBUTE_NORMAL | windows.FILE_ATTRIBUTE_NOT_CONTENT_INDEXED + if native.FileAttributes&^uint32(ordinaryAttributes) != 0 { + return SessionDeletionPurgeXattrs{}, fmt.Errorf("unproved Windows attributes prevent deletion purge: %s", path) + } + if err := requireNoDeletionStreams(file); err != nil { + return SessionDeletionPurgeXattrs{}, err + } + buffer := make([]byte, 65536) + var status windows.IO_STATUS_BLOCK + result, _, _ := deletionQueryEA.Call(file.Fd(), uintptr(unsafe.Pointer(&status)), uintptr(unsafe.Pointer(&buffer[0])), uintptr(len(buffer)), 0, 0, 0, 0, 1) + runtime.KeepAlive(file) + runtime.KeepAlive(buffer) + const statusNoEAsOnFile = uint32(0xc0000052) + if uint32(result) != statusNoEAsOnFile { + return SessionDeletionPurgeXattrs{}, fmt.Errorf("unproved Windows extended attributes prevent deletion purge (status=%#x): %s", uint32(result), path) + } + empty := sha256.Sum256(nil) + return SessionDeletionPurgeXattrs{SHA256: hex.EncodeToString(empty[:])}, nil +} + +func requireNoDeletionStreams(file *os.File) error { + buffer := make([]byte, 65536) + const fileStreamInfo = 7 + if err := windows.GetFileInformationByHandleEx(windows.Handle(file.Fd()), fileStreamInfo, &buffer[0], uint32(len(buffer))); err != nil { + if errors.Is(err, windows.ERROR_HANDLE_EOF) { + return nil + } + return fmt.Errorf("enumerate deletion object streams: %w", err) + } + for offset := 0; ; { + if offset < 0 || offset+24 > len(buffer) { + return errors.New("invalid deletion stream metadata") + } + next := int(binary.LittleEndian.Uint32(buffer[offset:])) + length := int(binary.LittleEndian.Uint32(buffer[offset+4:])) + if length == 0 && next == 0 { + return nil + } + if length <= 0 || length%2 != 0 || offset+24+length > len(buffer) { + return errors.New("invalid deletion stream name") + } + name := make([]uint16, length/2) + for index := range name { + name[index] = binary.LittleEndian.Uint16(buffer[offset+24+index*2:]) + } + if windows.UTF16ToString(name) != "::$DATA" { + return errors.New("alternate streams prevent exact session deletion purge") + } + if next == 0 { + return nil + } + if next < 24+length { + return errors.New("invalid deletion stream offset") + } + offset += next + } +} diff --git a/internal/vfs/session_deletion_object_windows_test.go b/internal/vfs/session_deletion_object_windows_test.go new file mode 100644 index 0000000..ad3ba49 --- /dev/null +++ b/internal/vfs/session_deletion_object_windows_test.go @@ -0,0 +1,82 @@ +//go:build windows + +package vfs + +import ( + "os" + "path/filepath" + "testing" + + "golang.org/x/sys/windows" +) + +func TestWindowsDeletionObjectRejectsAlternateStreams(t *testing.T) { + path := filepath.Join(t.TempDir(), "file.jsonl") + if err := os.WriteFile(path, []byte("{}\n"), 0600); err != nil { + t.Fatal(err) + } + file, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + defer file.Close() + info, err := file.Stat() + if err != nil { + t.Fatal(err) + } + if _, err := captureSessionDeletionXattrs(file, info, path); err != nil { + t.Fatal(err) + } + object, err := sessionDeletionObjectFromFile(file, info) + if err != nil || !validSessionDeletionPurgeObject(object) { + t.Fatalf("invalid NTFS proof: %+v, %v", object, err) + } + if err := os.WriteFile(path+":extra", []byte("keep this"), 0600); err != nil { + t.Fatal(err) + } + if _, err := captureSessionDeletionXattrs(file, info, path); err == nil { + t.Fatal("alternate stream was eligible for purge") + } +} + +func TestWindowsDeletionObjectCapturesPermissionChanges(t *testing.T) { + path := filepath.Join(t.TempDir(), "file.jsonl") + if err := os.WriteFile(path, []byte("{}\n"), 0600); err != nil { + t.Fatal(err) + } + file, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + defer file.Close() + info, err := file.Stat() + if err != nil { + t.Fatal(err) + } + before, err := sessionDeletionObjectFromFile(file, info) + if err != nil { + t.Fatal(err) + } + tokenUser, err := windows.GetCurrentProcessToken().GetTokenUser() + if err != nil { + t.Fatal(err) + } + descriptor, err := windows.SecurityDescriptorFromString("D:P(A;;FA;;;" + tokenUser.User.Sid.String() + ")") + if err != nil { + t.Fatal(err) + } + dacl, _, err := descriptor.DACL() + if err != nil { + t.Fatal(err) + } + if err := windows.SetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION, nil, nil, dacl, nil); err != nil { + t.Fatal(err) + } + after, err := sessionDeletionObjectFromFile(file, info) + if err != nil { + t.Fatal(err) + } + if before.Inode != after.Inode || before.SecuritySHA256 == after.SecuritySHA256 { + t.Fatal("changed ACL was not distinguished from the original deletion object") + } +} diff --git a/internal/vfs/session_deletion_purge.go b/internal/vfs/session_deletion_purge.go index c794530..361325b 100644 --- a/internal/vfs/session_deletion_purge.go +++ b/internal/vfs/session_deletion_purge.go @@ -22,13 +22,16 @@ const ( sessionDeletionPurgeVersion = 3 sessionDeletionPurgeKind = "managed-session-deletion-purge" - sessionDeletionObjectProofDarwin = "darwin-stat-generation-birthtime-v1" - sessionDeletionObjectProofLinux = "linux-statx-btime-fs-ioc-getversion-v1" + sessionDeletionObjectProofDarwin = "darwin-stat-generation-birthtime-v1" + sessionDeletionObjectProofLinux = "linux-statx-btime-fs-ioc-getversion-v1" + sessionDeletionObjectProofWindows = "windows-ntfs-file-reference-creation-time-v1" - sessionDeletionGenerationSourceDarwin = "stat.st_gen" - sessionDeletionBirthtimeSourceDarwin = "stat.st_birthtimespec" - sessionDeletionGenerationSourceLinux = "FS_IOC_GETVERSION" - sessionDeletionBirthtimeSourceLinux = "statx.AT_EMPTY_PATH.STATX_BTIME" + sessionDeletionGenerationSourceDarwin = "stat.st_gen" + sessionDeletionBirthtimeSourceDarwin = "stat.st_birthtimespec" + sessionDeletionGenerationSourceLinux = "FS_IOC_GETVERSION" + sessionDeletionBirthtimeSourceLinux = "statx.AT_EMPTY_PATH.STATX_BTIME" + sessionDeletionGenerationSourceWindows = "NTFS.file-reference.sequence" + sessionDeletionBirthtimeSourceWindows = "BY_HANDLE_FILE_INFORMATION.CreationTime" sessionDeletionPurgePrepared = "prepared" sessionDeletionPurgeRenamed = "renamed" @@ -66,6 +69,8 @@ type SessionDeletionPurgeObject struct { BirthtimeUnixNano int64 `json:"birthtime_unix_nano"` UID uint32 `json:"uid"` GID uint32 `json:"gid"` + SecuritySHA256 string `json:"security_sha256,omitempty"` + Attributes uint32 `json:"attributes,omitempty"` } type SessionDeletionPurgeXattrs struct { @@ -1181,6 +1186,9 @@ func validSessionDeletionPurgeObject(object SessionDeletionPurgeObject) bool { if object.Device == 0 || object.Inode == 0 || object.BirthtimeUnixNano <= 0 { return false } + if object.IdentityProof != sessionDeletionObjectProofWindows && (object.SecuritySHA256 != "" || object.Attributes != 0) { + return false + } switch object.IdentityProof { case sessionDeletionObjectProofDarwin: return object.GenerationSource == sessionDeletionGenerationSourceDarwin && @@ -1189,13 +1197,17 @@ func validSessionDeletionPurgeObject(object SessionDeletionPurgeObject) bool { return object.Generation != 0 && object.GenerationSource == sessionDeletionGenerationSourceLinux && object.BirthtimeSource == sessionDeletionBirthtimeSourceLinux + case sessionDeletionObjectProofWindows: + return object.Generation != 0 && object.Generation == object.Inode>>48 && + object.GenerationSource == sessionDeletionGenerationSourceWindows && + object.BirthtimeSource == sessionDeletionBirthtimeSourceWindows && validStateSHA256(object.SecuritySHA256) default: return false } } func deletionPurgeObjectHashFields(object SessionDeletionPurgeObject) string { - return object.IdentityProof + "\x00" + + fields := object.IdentityProof + "\x00" + object.GenerationSource + "\x00" + object.BirthtimeSource + "\x00" + strconv.FormatUint(object.Device, 10) + "\x00" + @@ -1204,6 +1216,10 @@ func deletionPurgeObjectHashFields(object SessionDeletionPurgeObject) string { strconv.FormatInt(object.BirthtimeUnixNano, 10) + "\x00" + strconv.FormatUint(uint64(object.UID), 10) + "\x00" + strconv.FormatUint(uint64(object.GID), 10) + if object.IdentityProof == sessionDeletionObjectProofWindows { + fields += "\x00" + object.SecuritySHA256 + "\x00" + strconv.FormatUint(uint64(object.Attributes), 10) + } + return fields } func validSessionDeletionPurgeXattrs(xattrs SessionDeletionPurgeXattrs) bool { diff --git a/internal/vfs/session_deletion_rename_other.go b/internal/vfs/session_deletion_rename_other.go index 082f89c..603f03e 100644 --- a/internal/vfs/session_deletion_rename_other.go +++ b/internal/vfs/session_deletion_rename_other.go @@ -1,4 +1,4 @@ -//go:build !darwin && !linux +//go:build !darwin && !linux && !windows package vfs diff --git a/internal/vfs/session_deletion_rename_windows.go b/internal/vfs/session_deletion_rename_windows.go new file mode 100644 index 0000000..93c944c --- /dev/null +++ b/internal/vfs/session_deletion_rename_windows.go @@ -0,0 +1,86 @@ +//go:build windows + +package vfs + +import ( + "fmt" + "path/filepath" + "unsafe" + + "golang.org/x/sys/windows" +) + +type deletionRenameInformation struct { + ReplaceIfExists uint32 + RootDirectory windows.Handle + FileNameLength uint32 + FileName [1]uint16 +} + +const deletionFileRenameInformationEx = 65 + +func sessionDeletionRenameNoReplace(store *sessionDeletionStoreRoot, source, target string) error { + sourceRelative, err := store.relative(source) + if err != nil { + return fmt.Errorf("open deletion rename source path: %w", err) + } + targetRelative, err := store.relative(target) + if err != nil { + return err + } + sourceParent, err := store.root.Open(filepath.Dir(sourceRelative)) + if err != nil { + return err + } + defer sourceParent.Close() + targetParent, err := store.root.Open(filepath.Dir(targetRelative)) + if err != nil { + return err + } + defer targetParent.Close() + directoryName, err := windows.NewNTUnicodeString("") + if err != nil { + return err + } + parentAttributes := windows.OBJECT_ATTRIBUTES{RootDirectory: windows.Handle(targetParent.Fd()), ObjectName: directoryName} + parentAttributes.Length = uint32(unsafe.Sizeof(parentAttributes)) + var writableParent windows.Handle + var parentStatus windows.IO_STATUS_BLOCK + err = windows.NtCreateFile(&writableParent, windows.FILE_GENERIC_READ|windows.FILE_GENERIC_WRITE|windows.FILE_TRAVERSE, &parentAttributes, &parentStatus, nil, 0, windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, windows.FILE_OPEN, windows.FILE_DIRECTORY_FILE|windows.FILE_SYNCHRONOUS_IO_NONALERT, 0, 0) + if err != nil { + return fmt.Errorf("open writable quarantine parent: %w", err) + } + defer windows.CloseHandle(writableParent) + name, err := windows.NewNTUnicodeString(filepath.Base(sourceRelative)) + if err != nil { + return err + } + attributes := windows.OBJECT_ATTRIBUTES{RootDirectory: windows.Handle(sourceParent.Fd()), ObjectName: name} + attributes.Length = uint32(unsafe.Sizeof(attributes)) + var handle windows.Handle + var status windows.IO_STATUS_BLOCK + err = windows.NtCreateFile(&handle, windows.DELETE|windows.FILE_READ_ATTRIBUTES|windows.SYNCHRONIZE, &attributes, &status, nil, 0, windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, windows.FILE_OPEN, windows.FILE_OPEN_REPARSE_POINT|windows.FILE_OPEN_FOR_BACKUP_INTENT|windows.FILE_SYNCHRONOUS_IO_NONALERT, 0, 0) + if err != nil { + return err + } + defer windows.CloseHandle(handle) + newName, err := windows.UTF16FromString(filepath.Base(targetRelative)) + if err != nil { + return err + } + var layout deletionRenameInformation + header := int(unsafe.Offsetof(layout.FileName)) + length := (len(newName) - 1) * 2 + buffer := make([]byte, header+length) + information := (*deletionRenameInformation)(unsafe.Pointer(&buffer[0])) + information.ReplaceIfExists = windows.FILE_RENAME_POSIX_SEMANTICS + information.RootDirectory = writableParent + information.FileNameLength = uint32(length) + copy(unsafe.Slice((*uint16)(unsafe.Pointer(&buffer[header])), len(newName)-1), newName[:len(newName)-1]) + // FILE_RENAME_REPLACE_IF_EXISTS stays clear. Source and destination parents are + // open handles anchored in os.Root, not freshly resolved absolute paths. + if err := windows.NtSetInformationFile(handle, &status, &buffer[0], uint32(len(buffer)), deletionFileRenameInformationEx); err != nil { + return fmt.Errorf("rename deletion quarantine: %w", err) + } + return nil +} diff --git a/internal/vfs/session_deletion_rename_windows_test.go b/internal/vfs/session_deletion_rename_windows_test.go new file mode 100644 index 0000000..4803ab6 --- /dev/null +++ b/internal/vfs/session_deletion_rename_windows_test.go @@ -0,0 +1,47 @@ +//go:build windows + +package vfs + +import ( + "os" + "path/filepath" + "testing" +) + +func TestWindowsDeletionRenameNoReplaceKeepsCollision(t *testing.T) { + root := t.TempDir() + store, err := openSessionDeletionStoreRoot(root) + if err != nil { + t.Fatal(err) + } + defer store.Close() + source := filepath.Join(root, "source") + target := filepath.Join(root, "quarantine", "target") + if err := os.Mkdir(source, 0700); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Dir(target), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(source, "data"), []byte("source"), 0600); err != nil { + t.Fatal(err) + } + if err := sessionDeletionRenameNoReplace(store, source, target); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(source, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(source, "data"), []byte("new source"), 0600); err != nil { + t.Fatal(err) + } + if err := sessionDeletionRenameNoReplace(store, source, target); err == nil { + t.Fatal("existing quarantine replaced") + } + for path, expected := range map[string]string{filepath.Join(source, "data"): "new source", filepath.Join(target, "data"): "source"} { + data, err := os.ReadFile(path) + if err != nil || string(data) != expected { + t.Fatalf("collision changed %s: %q, %v", path, data, err) + } + } +} diff --git a/internal/vfs/session_deletion_safety.go b/internal/vfs/session_deletion_safety.go index 5480b18..c79535a 100644 --- a/internal/vfs/session_deletion_safety.go +++ b/internal/vfs/session_deletion_safety.go @@ -227,7 +227,7 @@ func (s *sessionDeletionStoreRoot) acquireWriterLease(path string) (*os.File, er if err := s.requireSameDevice(before, path); err != nil { return nil, err } - file, err := s.root.OpenFile(relative, os.O_RDWR, 0) + file, err := openDeletionWriterLease(s, relative, path) if err != nil { return nil, err } diff --git a/internal/vfs/writer_lease_guard.go b/internal/vfs/writer_lease_guard.go index 95312ec..e740958 100644 --- a/internal/vfs/writer_lease_guard.go +++ b/internal/vfs/writer_lease_guard.go @@ -22,7 +22,7 @@ func TryAcquireWriterLeaseGuardAtPath(leasePath string) (*WriterLeaseGuard, bool if !before.Mode().IsRegular() { return nil, false, errors.New("writer lease guard is not a regular file") } - file, err := os.OpenFile(leasePath, os.O_RDWR, 0) + file, err := openWriterLeaseFile(leasePath, false) if err != nil { return nil, false, fmt.Errorf("open writer lease guard: %w", err) } diff --git a/internal/vfs/writer_lock_unix.go b/internal/vfs/writer_lock_unix.go index a65c726..0fab72a 100644 --- a/internal/vfs/writer_lock_unix.go +++ b/internal/vfs/writer_lock_unix.go @@ -5,10 +5,23 @@ package vfs import ( "errors" "os" + "path/filepath" "golang.org/x/sys/unix" ) +func acquireInitialWriterLease(staging, _ string) (*os.File, error) { + return acquireWriterLease(filepath.Join(staging, "writer.lease")) +} + +func openWriterLeaseFile(path string, create bool) (*os.File, error) { + flags := os.O_RDWR + if create { + flags |= os.O_CREATE + } + return os.OpenFile(path, flags, 0o600) +} + func tryLockWriterFile(file *os.File) (bool, error) { err := unix.Flock(int(file.Fd()), unix.LOCK_EX|unix.LOCK_NB) if errors.Is(err, unix.EWOULDBLOCK) { diff --git a/internal/vfs/writer_lock_windows.go b/internal/vfs/writer_lock_windows.go index ce721e7..fb28bfd 100644 --- a/internal/vfs/writer_lock_windows.go +++ b/internal/vfs/writer_lock_windows.go @@ -4,11 +4,110 @@ package vfs import ( "errors" + "fmt" "os" + "path/filepath" "golang.org/x/sys/windows" ) +// Sharing delete access lets initial publication unlink the temporary lease +// name while retaining its lock through the canonical hard link. +func openWriterLeaseFile(path string, create bool) (*os.File, error) { + if filepath.Base(path) != "writer.lease" { + return openSharedWriterLeaseFile(path, create) + } + // Published sessions can be moved into retired storage while their writer + // lease is held. Open through an external alias so that the lock does not + // pin the containing directory on Windows. + file, err := openSharedWriterLeaseFile(path, create) + if err != nil { + return nil, err + } + before, statErr := file.Stat() + closeErr := file.Close() + if err := errors.Join(statErr, closeErr); err != nil { + return nil, err + } + if !before.Mode().IsRegular() { + return nil, errors.New("writer lease is not a regular file") + } + aliasFile, err := os.CreateTemp(filepath.Dir(filepath.Dir(path)), ".codexfold-writer-*.lease") + if err != nil { + return nil, err + } + alias := aliasFile.Name() + if err := aliasFile.Close(); err != nil { + _ = os.Remove(alias) + return nil, err + } + if err := os.Remove(alias); err != nil { + return nil, err + } + if err := os.Link(path, alias); err != nil { + return nil, fmt.Errorf("link published writer lease: %w", err) + } + defer os.Remove(alias) + file, err = openSharedWriterLeaseFile(alias, false) + if err != nil { + return nil, err + } + opened, statErr := file.Stat() + after, pathErr := os.Lstat(path) + if statErr != nil || pathErr != nil || !os.SameFile(before, opened) || !os.SameFile(opened, after) { + _ = file.Close() + return nil, errors.Join(statErr, pathErr, errors.New("writer lease changed while opening its external alias")) + } + if err := os.Remove(alias); err != nil { + _ = file.Close() + return nil, err + } + return file, nil +} + +func openSharedWriterLeaseFile(path string, create bool) (*os.File, error) { + name, err := windows.UTF16PtrFromString(path) + if err != nil { + return nil, err + } + disposition := uint32(windows.OPEN_EXISTING) + if create { + disposition = windows.OPEN_ALWAYS + } + handle, err := windows.CreateFile(name, windows.GENERIC_READ|windows.GENERIC_WRITE, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, + nil, disposition, windows.FILE_ATTRIBUTE_NORMAL, 0) + if err != nil { + return nil, &os.PathError{Op: "open", Path: path, Err: err} + } + return os.NewFile(uintptr(handle), path), nil +} + +// Windows cannot rename a directory containing an open child handle, even +// with FILE_SHARE_DELETE. Open the lease through a name outside staging, then +// hard-link it into staging before publishing. Both names identify the same +// locked file, so no writer can race the directory publication. The caller +// holds the session initialization lease while using this temporary name. +func acquireInitialWriterLease(staging, sessionID string) (*os.File, error) { + external := filepath.Join(filepath.Dir(staging), initialSessionLockName(sessionID)+".writer") + lease, err := acquireWriterLease(external) + if err != nil { + return nil, err + } + if err := os.Link(external, filepath.Join(staging, "writer.lease")); err != nil { + _ = unlockWriterFile(lease) + _ = lease.Close() + _ = os.Remove(external) + return nil, fmt.Errorf("link initial writer lease (store requires hard-link support): %w", err) + } + if err := os.Remove(external); err != nil { + _ = unlockWriterFile(lease) + _ = lease.Close() + return nil, fmt.Errorf("unlink temporary writer lease: %w", err) + } + return lease, nil +} + func tryLockWriterFile(file *os.File) (bool, error) { overlapped := new(windows.Overlapped) err := windows.LockFileEx(windows.Handle(file.Fd()), windows.LOCKFILE_EXCLUSIVE_LOCK|windows.LOCKFILE_FAIL_IMMEDIATELY, 0, 1, 0, overlapped) @@ -23,7 +122,7 @@ func unlockWriterFile(file *os.File) error { } func cleanupStaleWriterLease(path string) error { - file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600) + file, err := openWriterLeaseFile(path, true) if err != nil { return err } diff --git a/scripts/enable-windows-enrollment-service.ps1 b/scripts/enable-windows-enrollment-service.ps1 new file mode 100644 index 0000000..4e189d8 --- /dev/null +++ b/scripts/enable-windows-enrollment-service.ps1 @@ -0,0 +1,33 @@ +param( + [string]$CodexHome, + [ValidatePattern('^[D-Zd-z]$')][string]$MountDrive = 'V', + [switch]$Apply +) +. (Join-Path $PSScriptRoot 'windows-local-common.ps1') +$paths = Get-LocalFoldPaths $CodexHome $MountDrive +$candidate = Join-Path $paths.Repo 'dist\codexfold-enroll.exe' +if (-not (Test-Path -LiteralPath $candidate)) { throw 'Build dist\codexfold-enroll.exe first.' } +$resultPath = Join-Path $paths.Repo ('.tmp\enrollment-install-' + [Guid]::NewGuid().ToString('N') + '.json') +New-Item -ItemType Directory -Path (Split-Path -Parent $resultPath) -Force | Out-Null +$arguments = 'fs enroll service install --codex-home "'+$paths.Home+'" --mount "'+$paths.Mount+'" --result "'+$resultPath+'"' +if ($Apply) { $arguments += ' --apply' } +$installerPrincipal = New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent()) +if ($Apply -and -not $installerPrincipal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + $process = Start-Process -FilePath $candidate -ArgumentList $arguments -Verb RunAs -WindowStyle Hidden -PassThru +} else { + $process = Start-Process -FilePath $candidate -ArgumentList $arguments -WindowStyle Hidden -PassThru +} +if (-not $process.WaitForExit(120000)) { throw "Enrollment installation is still running; inspect $resultPath" } +$process.Refresh() +if (-not (Test-Path -LiteralPath $resultPath)) { throw 'Enrollment installer did not return a result.' } +$result = [IO.File]::ReadAllText($resultPath) | ConvertFrom-Json +if ($process.ExitCode -ne 0 -or $result.error) { throw "Enrollment installation failed ($($process.ExitCode)): $($result.error)" } +if ($Apply) { + $startup = Join-Path ([Environment]::GetFolderPath('Startup')) 'CodexFold Enrollment.lnk' + if (Test-Path -LiteralPath $startup) { + $link = (New-Object -ComObject WScript.Shell).CreateShortcut($startup) + $legacy = Join-Path $env:LOCALAPPDATA 'CodexFold\bin\codexfold-enroll.exe' + if ($link.TargetPath -eq $legacy -and $link.Arguments.Contains($paths.Store)) { Remove-Item -LiteralPath $startup } + } +} +$result | ConvertTo-Json -Depth 5 diff --git a/scripts/enable-windows-enrollment-worker.ps1 b/scripts/enable-windows-enrollment-worker.ps1 new file mode 100644 index 0000000..0c85916 --- /dev/null +++ b/scripts/enable-windows-enrollment-worker.ps1 @@ -0,0 +1,7 @@ +# Kept as the existing entry point; enrollment now uses a persistent SCM service. +param( + [string]$CodexHome, + [ValidatePattern('^[D-Zd-z]$')][string]$MountDrive = 'V', + [switch]$Apply +) +& (Join-Path $PSScriptRoot 'enable-windows-enrollment-service.ps1') -CodexHome $CodexHome -MountDrive $MountDrive -Apply:$Apply diff --git a/scripts/enable-windows-local.ps1 b/scripts/enable-windows-local.ps1 new file mode 100644 index 0000000..8ab2774 --- /dev/null +++ b/scripts/enable-windows-local.ps1 @@ -0,0 +1,71 @@ +param( + [string]$CodexHome, + [ValidatePattern('^[D-Zd-z]$')][string]$MountDrive = 'V', + [switch]$Apply +) +. (Join-Path $PSScriptRoot 'windows-local-common.ps1') +$paths = Get-LocalFoldPaths $CodexHome $MountDrive +foreach ($path in @($paths.Candidate,$paths.TrayCandidate,$paths.BackupTool,(Join-Path $paths.Repo 'dist\codexfold-enroll.exe'))) { + if (-not (Test-Path -LiteralPath $path)) { throw "Build the Windows binaries first: $path" } +} +if (-not $Apply) { + [PSCustomObject]@{Apply=$false;Home=$paths.Home;Store=$paths.Store;Native=$paths.Native;Mount=$paths.Mount;Binary=$paths.Binary;Definition=$paths.Definition;BackupBase=$paths.BackupBase;Clients=@(Get-CimInstance Win32_Process -Filter "Name='codex.exe'").Count} | ConvertTo-Json + return +} +Assert-LocalFoldOffline +$namespace = Invoke-LocalFold $paths.Candidate @('fs','namespace','status','--codex-home',$paths.Home,'--mount',$paths.Mount,'--native-root',$paths.Native,'--json') +if ($namespace.active) { throw 'The home is already activated. Use the installed service start command or the restore script.' } +if (Test-Path -LiteralPath $paths.Mount) { throw 'The selected drive is already in use.' } +Stop-LocalFoldEnrollmentWorker $paths + +# Freeze one consistent recovery copy before changing the real namespace. +New-Item -ItemType Directory -Path $paths.BackupBase -Force | Out-Null +$backup = Join-Path $paths.BackupBase ((Get-Date -Format 'yyyyMMdd-HHmmss') + '-' + [guid]::NewGuid().ToString('N').Substring(0,8)) +& $paths.BackupTool -backup-only -source-home $paths.Home -root $backup +if ($LASTEXITCODE -ne 0) { throw "Backup failed; the original namespace was not changed. Inspect $backup" } +$userSid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value +& icacls.exe $backup /inheritance:r /grant:r ('*'+$userSid+':(OI)(CI)F') '*S-1-5-18:(OI)(CI)F' '*S-1-5-32-544:(OI)(CI)F' | Out-Null +if ($LASTEXITCODE -ne 0) { throw 'Could not protect the recovery copy.' } +Assert-LocalFoldOffline + +# This service label is shared with the owned preview; stop it before replacing +# its binary/configuration, preserving the preview data in its original folder. +$existing = Get-CimInstance Win32_Service -Filter "Name='com.codexfold.fs'" +if ($existing) { + if (-not ($existing.PathName.Contains($paths.Repo + '\.tmp\windows-use\') -or $existing.PathName.Contains($paths.Binary))) { throw 'An unrelated service uses com.codexfold.fs; it was left unchanged.' } + Stop-Service -Name 'com.codexfold.fs' -ErrorAction SilentlyContinue + (Get-Service -Name 'com.codexfold.fs').WaitForStatus('Stopped',[TimeSpan]::FromSeconds(30)) +} +Protect-LocalFoldProgramDirectory (Split-Path -Parent $paths.Binary) +Protect-LocalFoldProgramDirectory (Split-Path -Parent $paths.Definition) +Copy-Item -LiteralPath $paths.Candidate -Destination $paths.Binary -Force +Copy-Item -LiteralPath $paths.TrayCandidate -Destination $paths.Tray -Force +Install-LocalFoldService $paths '0' +try { + Invoke-LocalFold $paths.Binary @('fs','namespace','activate','--apply','--codex-home',$paths.Home,'--mount',$paths.Mount,'--native-root',$paths.Native,'--json') | Out-Null + Invoke-LocalFold $paths.Binary @('fs','validate-native','--native-root',$paths.Native,'--json') | Out-Null + Assert-LocalFoldOffline + Install-LocalFoldService $paths '0' + $status = Invoke-LocalFold $paths.Binary @('fs','service','status','--definition',$paths.Definition,'--codex-home',$paths.Home,'--mount',$paths.Mount,'--json') + if (-not ($status.daemon_running -and $status.mount_healthy -and $status.build.healthy)) { throw 'The installed service did not become healthy.' } + & (Join-Path $PSScriptRoot 'enable-windows-enrollment-service.ps1') -CodexHome $paths.Home -MountDrive $MountDrive -Apply | Out-Null +} catch { + # Enrollment stays disabled until activation/preflight succeeds. Rollback + # can restore the original directories while there are no managed sessions. + $failure = $_ + Stop-LocalFoldEnrollmentWorker $paths + Stop-Service -Name 'com.codexfold.fs' -ErrorAction SilentlyContinue + (Get-Service -Name 'com.codexfold.fs').WaitForStatus('Stopped',[TimeSpan]::FromSeconds(30)) + & $paths.Binary fs namespace deactivate --apply --codex-home $paths.Home --store $paths.Store --mount $paths.Mount --native-root $paths.Native --json + throw $failure +} +$startup = Join-Path ([Environment]::GetFolderPath('Startup')) 'CodexFold.lnk' +$shortcut = (New-Object -ComObject WScript.Shell).CreateShortcut($startup) +$shortcut.TargetPath = $paths.Tray +$shortcut.Arguments = '--store "' + $paths.Store + '"' +$shortcut.WorkingDirectory = Split-Path -Parent $paths.Tray +$shortcut.IconLocation = $paths.Tray + ',0' +$shortcut.Save() +Start-Process -FilePath $paths.Tray -ArgumentList ('--store "'+$paths.Store+'"') -WindowStyle Hidden | Out-Null +Write-Host 'CodexFold is enabled. You can reopen Codex now.' +Write-Host "Recovery copy: $backup" diff --git a/scripts/launch-windows-local.ps1 b/scripts/launch-windows-local.ps1 new file mode 100644 index 0000000..fb5ae56 --- /dev/null +++ b/scripts/launch-windows-local.ps1 @@ -0,0 +1,53 @@ +param( + [ValidateSet('Enable','Restore')][string]$Action = 'Enable' +) +$ErrorActionPreference = 'Stop' +$repoRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) +$identity = [Security.Principal.WindowsIdentity]::GetCurrent() +$principal = New-Object Security.Principal.WindowsPrincipal($identity) +$result = 0 + +try { + if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + $hostPath = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' + $arguments = '-NoProfile -File "' + $PSCommandPath + '" -Action ' + $Action + # This is the user's interactive entry point. UAC and the elevated + # console remain visible so the user can review success or failure. + Start-Process -FilePath $hostPath -ArgumentList $arguments -WorkingDirectory $repoRoot -Verb RunAs | Out-Null + return + } + + $clients = @(Get-CimInstance Win32_Process -Filter "Name='codex.exe'") + if ($clients.Count -ne 0) { + Write-Host 'Close Codex Desktop and every Codex CLI before switching storage.' + Write-Host 'If the managed daemon remains after exiting, stop it in another terminal:' + $codexBinary = Join-Path $env:LOCALAPPDATA 'Programs\OpenAI\Codex\bin\codex.exe' + if (Test-Path -LiteralPath $codexBinary) { + Write-Host (' & "' + $codexBinary + '" app-server daemon stop') + } else { + Write-Host ' codex app-server daemon stop' + } + Write-Host 'Then run this launcher again. No session directory has been changed.' + throw "$($clients.Count) Codex processes are still running." + } + + $logDirectory = Join-Path $env:LOCALAPPDATA 'CodexFold\logs' + New-Item -ItemType Directory -Path $logDirectory -Force | Out-Null + $logPath = Join-Path $logDirectory ($Action.ToLowerInvariant() + '-' + (Get-Date -Format 'yyyyMMdd-HHmmss') + '.log') + Start-Transcript -LiteralPath $logPath | Out-Null + try { + $scriptName = if ($Action -eq 'Enable') { 'enable-windows-local.ps1' } else { 'restore-windows-local.ps1' } + & (Join-Path $PSScriptRoot $scriptName) -Apply + Write-Host "Operation completed. Log: $logPath" + } finally { + Stop-Transcript | Out-Null + } +} catch { + Write-Host ('Operation stopped: ' + $_.Exception.Message) -ForegroundColor Red + $result = 1 +} finally { + if ($principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + Read-Host 'Press Enter to close this window' | Out-Null + } +} +exit $result diff --git a/scripts/restore-windows-local.ps1 b/scripts/restore-windows-local.ps1 new file mode 100644 index 0000000..2e8617f --- /dev/null +++ b/scripts/restore-windows-local.ps1 @@ -0,0 +1,29 @@ +param( + [string]$CodexHome, + [ValidatePattern('^[D-Zd-z]$')][string]$MountDrive = 'V', + [switch]$Apply +) +. (Join-Path $PSScriptRoot 'windows-local-common.ps1') +$paths = Get-LocalFoldPaths $CodexHome $MountDrive +if (-not $Apply) { + [PSCustomObject]@{Apply=$false;Home=$paths.Home;Store=$paths.Store;Native=$paths.Native;Mount=$paths.Mount;Definition=$paths.Definition} | ConvertTo-Json + return +} +Assert-LocalFoldOffline +$definition = [IO.File]::ReadAllText($paths.Definition) | ConvertFrom-Json +if ($definition.binary_path -ne $paths.Binary -or -not ($definition.arguments -contains $paths.Home) -or -not ($definition.arguments -contains $paths.Store)) { throw 'The installed service belongs to another home.' } +Stop-LocalFoldEnrollmentWorker $paths +Install-LocalFoldService $paths '0' +$stateRoot = Join-Path $paths.Store 'fs\sessions' +if (Test-Path -LiteralPath $stateRoot) { + foreach ($session in @(Get-ChildItem -LiteralPath $stateRoot -Directory)) { + if (-not (Test-Path -LiteralPath (Join-Path $session.FullName 'state.json'))) { continue } + Invoke-LocalFold $paths.Binary @('fs','rollback',$session.Name,'--apply','--canonical-namespace','--codex-home',$paths.Home,'--store',$paths.Store,'--mount',$paths.Mount,'--native-root',$paths.Native,'--json') | Out-Null + } +} +Invoke-LocalFold $paths.Binary @('fs','service','stop','--apply','--definition',$paths.Definition,'--json') | Out-Null +Invoke-LocalFold $paths.Binary @('fs','namespace','deactivate','--apply','--codex-home',$paths.Home,'--store',$paths.Store,'--mount',$paths.Mount,'--native-root',$paths.Native,'--json') | Out-Null +Set-Service -Name 'com.codexfold.fs' -StartupType Disabled +$startup = Join-Path ([Environment]::GetFolderPath('Startup')) 'CodexFold.lnk' +if (Test-Path -LiteralPath $startup) { Remove-Item -LiteralPath $startup } +Write-Host 'Ordinary session directories are restored with the latest saved contents. Recovery copies and the fold store are retained.' diff --git a/scripts/stop-windows-test.ps1 b/scripts/stop-windows-test.ps1 new file mode 100644 index 0000000..dc8689f --- /dev/null +++ b/scripts/stop-windows-test.ps1 @@ -0,0 +1,34 @@ +param([string]$RunRoot) +$ErrorActionPreference = 'Stop' +$repoRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) +$testBase = Join-Path $repoRoot '.tmp\windows-use' +$record = if ($RunRoot) { Join-Path $RunRoot 'running.json' } else { Join-Path $testBase 'current.json' } +$running = [IO.File]::ReadAllText($record) | ConvertFrom-Json +$resolvedRoot = [IO.Path]::GetFullPath($running.root) +if (-not $resolvedRoot.StartsWith($testBase + '\', [StringComparison]::OrdinalIgnoreCase)) { + throw 'The process record is outside this workspace test directory.' +} +if ($running.mode -eq 'service') { + $definitionPath = [IO.Path]::GetFullPath($running.definition) + if (-not $definitionPath.StartsWith($resolvedRoot + '\', [StringComparison]::OrdinalIgnoreCase)) { throw 'Service definition is outside this fixture.' } + $definition = [IO.File]::ReadAllText($definitionPath) | ConvertFrom-Json + $actualService = Get-CimInstance Win32_Service -Filter "Name='com.codexfold.fs'" + if (-not $actualService -or -not $actualService.PathName.Contains($definitionPath) -or -not $definition.binary_path.StartsWith($resolvedRoot + '\bin\', [StringComparison]::OrdinalIgnoreCase)) { throw 'The service no longer belongs to this test; it was left unchanged.' } + $stopper = Start-Process -FilePath $definition.binary_path -ArgumentList @('fs','service','stop','--apply','--definition',$definitionPath,'--json') -Verb RunAs -WindowStyle Hidden -PassThru + $stopper.WaitForExit() + if ($stopper.ExitCode -ne 0) { throw 'The isolated service did not stop.' } + Write-Host "Stopped the isolated Windows service: $resolvedRoot" + return +} +$expectedExe = [IO.Path]::GetFullPath($running.executable) +$workspaceExe = [IO.Path]::GetFullPath((Join-Path $repoRoot 'dist\codexfold.exe')) +$runExe = [IO.Path]::GetFullPath((Join-Path $resolvedRoot 'bin\codexfold.exe')) +if ($expectedExe -ne $workspaceExe -and $expectedExe -ne $runExe) { throw 'Unexpected executable in the test record.' } +$process = Get-CimInstance Win32_Process -Filter "ProcessId=$($running.pid)" +if (-not $process) { Write-Host 'Test filesystem is already stopped.'; return } +if ($process.ExecutablePath -ne $expectedExe -or -not $process.CommandLine.Contains($resolvedRoot) -or -not $process.CommandLine.Contains('serve')) { + throw 'The recorded PID now belongs to another process; it was left running.' +} +Stop-Process -Id $process.ProcessId +Write-Host "Stopped the isolated filesystem: $resolvedRoot" +Write-Host 'Snapshots and logs are retained. The tray may be closed separately.' diff --git a/scripts/test-windows-use.ps1 b/scripts/test-windows-use.ps1 new file mode 100644 index 0000000..71961dc --- /dev/null +++ b/scripts/test-windows-use.ps1 @@ -0,0 +1,172 @@ +param( + [ValidateSet('Storage', 'Mount')][string]$Mode = 'Storage', + [switch]$KeepRunning, + [switch]$RealSessionCopies +) +$ErrorActionPreference = 'Stop' +$repoRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) +$testBase = Join-Path $repoRoot '.tmp\windows-use' +$cli = Join-Path $repoRoot 'dist\codexfold.exe' +$tray = Join-Path $repoRoot 'dist\codexfold-tray.exe' + +function Invoke-CodexFold { + param([string[]]$CommandArguments) + $result = & $cli @CommandArguments + if ($LASTEXITCODE -ne 0) { throw "codexfold failed: $($CommandArguments -join ' ')" } + $logName = 'command-' + [guid]::NewGuid().ToString('N').Substring(0, 8) + '.json' + $result | Set-Content -LiteralPath (Join-Path $runRoot $logName) -Encoding UTF8 + Write-Host ('OK: ' + ($CommandArguments[0..([Math]::Min(1, $CommandArguments.Length-1))] -join ' ')) +} +function Assert-Hash { + param([string]$Path, [string]$Expected) + $actual = (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash + if ($actual -ne $Expected) { throw "Byte verification failed: $Path" } +} +function Wait-Until { + param([scriptblock]$Condition, [string]$Description) + $deadline = [DateTime]::UtcNow.AddSeconds(30) + while ([DateTime]::UtcNow -lt $deadline) { + if (& $Condition) { return } + Start-Sleep -Milliseconds 200 + } + throw "Timed out: $Description" +} +function Start-IsolatedService { + param([string]$Suffix) + $stdoutLog = Join-Path $runRoot ("service$Suffix.stdout.log") + $stderrLog = Join-Path $runRoot ("service$Suffix.stderr.log") + $serveArgs = @('fs', 'serve', '--apply', '--foreground', '--codex-home', $fixture.home, '--store', $fixture.store, '--mount', $fixture.mount, '--operation-trace', (Join-Path $runRoot "operations$Suffix.log")) + $quotedServe = ($serveArgs | ForEach-Object { '"' + $_ + '"' }) -join ' ' + $process = Start-Process -FilePath $cli -ArgumentList $quotedServe -WindowStyle Hidden -RedirectStandardOutput $stdoutLog -RedirectStandardError $stderrLog -PassThru + try { + Wait-Until { if ($process.HasExited) { throw "Filesystem exited. See $stderrLog" }; Test-Path -LiteralPath (Join-Path $fixture.mount '.codexfold-health') } 'WinFsp mount' + } catch { + if (-not $process.HasExited) { Stop-Process -Id $process.Id } + throw + } + return $process +} + +if ($env:OS -ne 'Windows_NT') { throw 'This script requires Windows.' } +if ($KeepRunning -and $Mode -ne 'Mount') { throw '-KeepRunning requires -Mode Mount.' } +if ($Mode -eq 'Mount') { + $winfsp = Get-ItemProperty -LiteralPath 'HKLM:\SOFTWARE\WOW6432Node\WinFsp' -ErrorAction SilentlyContinue + if (-not $winfsp) { $winfsp = Get-ItemProperty -LiteralPath 'HKLM:\SOFTWARE\WinFsp' -ErrorAction SilentlyContinue } + if (-not $winfsp) { throw 'WinFsp is not installed. Install the verified official prerequisite before mounting.' } +} +New-Item -ItemType Directory -Path $testBase -Force | Out-Null +New-Item -ItemType Directory -Path (Join-Path $repoRoot 'dist') -Force | Out-Null +$runPrefix = if ($RealSessionCopies) { 'run-local-' } else { 'run-' } +$runName = $runPrefix + (Get-Date -Format 'yyyyMMdd-HHmmss') + '-' + [guid]::NewGuid().ToString('N').Substring(0, 6) +$runRoot = [IO.Path]::GetFullPath((Join-Path $testBase $runName)) +if (-not $runRoot.StartsWith($testBase + '\', [StringComparison]::OrdinalIgnoreCase)) { throw 'Test directory escaped its workspace.' } +$serviceProcess = $null +$passed = $false +Push-Location -LiteralPath $repoRoot +try { + if ($RealSessionCopies) { + $fixtureJSON = & go run ./scripts/windows-local-fixture.go --root $runRoot + } else { + $fixtureJSON = & go run ./scripts/windows-fixture.go $runRoot + } + if ($LASTEXITCODE -ne 0) { throw 'Isolated fixture setup failed.' } + $fixture = ($fixtureJSON -join "`n") | ConvertFrom-Json + # Each run has its own binary, so rebuilding never replaces a running daemon. + $binaryDirectory = Join-Path $runRoot 'bin' + New-Item -ItemType Directory -Path $binaryDirectory -Force | Out-Null + $cli = Join-Path $binaryDirectory 'codexfold.exe' + & go build -tags winfsp -o $cli ./cmd/codexfold + if ($LASTEXITCODE -ne 0) { throw 'Windows filesystem CLI build failed.' } + $common = @('--codex-home', $fixture.home, '--store', $fixture.store) + Write-Host "Isolated test: $runRoot" + if ($RealSessionCopies) { + Write-Host "Local inventory: $($fixture.inventory.present_files) files; $($fixture.inventory.source_bytes) bytes; $($fixture.inventory.stable_candidates) stable candidates." + } + foreach ($session in $fixture.sessions) { + Invoke-CodexFold (@('fold', $session.id, '--apply', '--json') + $common) + $restored = Join-Path $runRoot ($session.id + '.restored.jsonl') + Invoke-CodexFold (@('unfold', $session.id, '--to', $restored, '--json') + $common) + Assert-Hash $restored $session.sha256 + Assert-Hash $session.path $session.sha256 + if ($RealSessionCopies) { Assert-Hash $session.source_path $session.sha256 } + } + Invoke-CodexFold (@('pack', 'build', '--json') + $common) + Invoke-CodexFold (@('pack', 'doctor', '--json') + $common) + Invoke-CodexFold (@('doctor', '--json') + $common) + Write-Host 'PASS: fold, restore, packed storage and SHA-256 verification (TF-004, TF-012, TF-020).' + + if ($Mode -eq 'Mount') { + $serviceProcess = Start-IsolatedService '' + $mountedHashes = @{} + foreach ($session in $fixture.sessions) { + Invoke-CodexFold (@('fs', 'migrate', $session.id, '--apply', '--mount', $fixture.mount, '--cli', 'none', '--desktop-app', 'none', '--json') + $common) + $mounted = Join-Path $fixture.mount ($session.id + '.jsonl') + Assert-Hash $mounted $session.sha256 + $append = [Text.Encoding]::UTF8.GetBytes("{`"type`":`"event_msg`",`"payload`":{`"type`":`"agent_message`",`"message`":`"Windows mounted append test`"}}`n") + $stream = [IO.File]::Open($mounted, [IO.FileMode]::Append, [IO.FileAccess]::Write, [IO.FileShare]::ReadWrite) + try { $stream.Write($append, 0, $append.Length); $stream.Flush($true) } finally { $stream.Dispose() } + $mountedBytes = [IO.File]::ReadAllBytes($mounted) + if ($mountedBytes.Length -ne ($session.bytes + $append.Length)) { throw 'Mounted append length differs.' } + $appendedHash = (Get-FileHash -LiteralPath $mounted -Algorithm SHA256).Hash + $expectedBytes = New-Object byte[] ($session.bytes + $append.Length) + [IO.File]::ReadAllBytes($session.path).CopyTo($expectedBytes, 0) + $append.CopyTo($expectedBytes, $session.bytes) + $hasher = [Security.Cryptography.SHA256]::Create() + try { $expectedHash = [BitConverter]::ToString($hasher.ComputeHash($expectedBytes)).Replace('-', '') } finally { $hasher.Dispose() } + if ($appendedHash -ne $expectedHash) { throw 'Mounted append changed existing bytes.' } + $mountedHashes[$session.id] = $appendedHash + Assert-Hash $session.path $session.sha256 + } + # Simulate termination only after Flush(true) has acknowledged each append. + Stop-Process -Id $serviceProcess.Id + $serviceProcess.WaitForExit(5000) | Out-Null + Wait-Until { -not (Test-Path -LiteralPath $fixture.mount) } 'mount removed after service termination' + $unexpectedWrite = Join-Path $fixture.mount 'unexpected-write.jsonl' + $writeRejected = $false + try { [IO.File]::WriteAllText($unexpectedWrite, '{}') } catch [IO.DirectoryNotFoundException] { $writeRejected = $true } + if (-not $writeRejected) { throw 'Unmounted path accepted an ordinary write.' } + $serviceProcess = Start-IsolatedService '-restart' + foreach ($session in $fixture.sessions) { + Assert-Hash (Join-Path $fixture.mount ($session.id + '.jsonl')) $mountedHashes[$session.id] + Assert-Hash $session.path $session.sha256 + } + Write-Host 'PASS: acknowledged appends survive service termination and remount; unmounted writes fail.' + # Roll back one session, preserving a second managed session for optional manual use. + $rollbackSession = $fixture.sessions[1] + $rollbackTarget = Join-Path $runRoot 'rollback.jsonl' + $beforeRollback = (Get-FileHash -LiteralPath (Join-Path $fixture.mount ($rollbackSession.id + '.jsonl')) -Algorithm SHA256).Hash + Invoke-CodexFold (@('fs', 'rollback', $rollbackSession.id, '--apply', '--mount', $fixture.mount, '--to', $rollbackTarget, '--json') + $common) + Assert-Hash $rollbackTarget $beforeRollback + Write-Host 'PASS: real WinFsp reads, flushed append, independent sources and byte-identical rollback.' + # Reconstruct this preview namespace after retirement before manual use. + # The complete live retirement/status handoff is a separate release gate. + Stop-Process -Id $serviceProcess.Id + $serviceProcess.WaitForExit(5000) | Out-Null + Wait-Until { -not (Test-Path -LiteralPath $fixture.mount) } 'mount removed after rollback' + $serviceProcess = Start-IsolatedService '-manual' + Assert-Hash (Join-Path $fixture.mount ($fixture.sessions[0].id + '.jsonl')) $mountedHashes[$fixture.sessions[0].id] + if (Test-Path -LiteralPath (Join-Path $fixture.mount ($rollbackSession.id + '.jsonl'))) { throw 'Rolled-back session remained mounted after restart.' } + if ($KeepRunning) { + if (Test-Path -LiteralPath $tray) { Start-Process -FilePath $tray -ArgumentList ('--store "' + $fixture.store + '"') -WindowStyle Hidden | Out-Null } + $running = [pscustomobject]@{root=$runRoot;store=$fixture.store;mount=$fixture.mount;pid=$serviceProcess.Id;executable=$cli} | ConvertTo-Json + $running | Set-Content -LiteralPath (Join-Path $runRoot 'running.json') -Encoding UTF8 + $running | Set-Content -LiteralPath (Join-Path $testBase 'current.json') -Encoding UTF8 + Write-Host "Ready for manual file tests: $($fixture.mount)" + $sourceDescription = if ($RealSessionCopies) { 'independent copies of stable local sessions' } else { 'synthetic sessions' } + Write-Host "Filesystem PID: $($serviceProcess.Id). This run uses $sourceDescription." + } + } + if ($RealSessionCopies) { + foreach ($session in $fixture.sessions) { Assert-Hash $session.source_path $session.sha256 } + Write-Host 'PASS: both original local session hashes are unchanged.' + } + $passed = $true +} finally { + if ($serviceProcess -and (-not $KeepRunning -or -not $passed) -and -not $serviceProcess.HasExited) { + # Only this run's child process is stopped. All snapshots and logs are retained. + Stop-Process -Id $serviceProcess.Id + $serviceProcess.WaitForExit(5000) | Out-Null + } + Pop-Location +} +Write-Host "Test artifacts retained: $runRoot" diff --git a/scripts/upgrade-windows-core.ps1 b/scripts/upgrade-windows-core.ps1 new file mode 100644 index 0000000..47b9feb --- /dev/null +++ b/scripts/upgrade-windows-core.ps1 @@ -0,0 +1,63 @@ +param( + [string]$CodexHome, + [ValidatePattern('^[D-Zd-z]$')][string]$MountDrive = 'V', + [switch]$Apply +) +. (Join-Path $PSScriptRoot 'windows-local-common.ps1') +$paths = Get-LocalFoldPaths $CodexHome $MountDrive +foreach ($path in @($paths.Candidate,$paths.TrayCandidate)) { + if (-not (Test-Path -LiteralPath $path)) { throw "Build the Windows binaries first: $path" } +} +$definition = [IO.File]::ReadAllText($paths.Definition) | ConvertFrom-Json +if ($definition.binary_path -ne $paths.Binary -or $definition.service_name -ne 'com.codexfold.fs') { throw 'The filesystem definition belongs to another installation.' } +foreach ($expected in @(@('--codex-home',$paths.Home),@('--store',$paths.Store),@('--mount',$paths.Mount),@('--native-root',$paths.Native))) { + $configuredValue = $null + for ($index=0; $index -lt $definition.arguments.Count; $index++) { + $argument = $definition.arguments[$index] + if ($argument -eq $expected[0] -and $index+1 -lt $definition.arguments.Count) { $configuredValue = $definition.arguments[$index+1] } + if ($argument.StartsWith($expected[0]+'=')) { $configuredValue = $argument.Substring($expected[0].Length+1) } + } + if (-not $configuredValue -or $configuredValue.Replace('/','\').TrimEnd('\') -ne $expected[1].Replace('/','\').TrimEnd('\')) { throw ('The filesystem binding does not match '+$expected[0]) } +} +$currentService = Get-CimInstance Win32_Service -Filter "Name='com.codexfold.fs'" +if (-not $currentService -or -not $currentService.PathName.Contains($paths.Definition) -or -not $currentService.PathName.Contains($paths.Binary)) { throw 'The filesystem service belongs to another installation.' } +$preview = Invoke-LocalFold $paths.Candidate @('fs','service','update-binary',$paths.Candidate,'--definition',$paths.Definition,'--codex-home',$paths.Home,'--mount',$paths.Mount,'--json') +if (-not $Apply) { + [PSCustomObject]@{Apply=$false;Home=$paths.Home;Mount=$paths.Mount;Candidate=$paths.Candidate;Target=$paths.Binary;RequiresClientsClosed=$true;Update=$preview} | ConvertTo-Json -Depth 5 + return +} +Assert-LocalFoldOffline +# Stop only the owned worker, leaving the user's persisted policy untouched. +# This lets an interrupted retirement finish through the existing recovery +# proofs during the subsequent engine startup. +$worker = Get-CimInstance Win32_Service -Filter "Name='com.codexfold.enroll'" +$workerWasRunning = $false +try { +if ($worker) { + $workerDefinition = Join-Path $env:ProgramData 'CodexFold\Enrollment\service.json' + $binding = [IO.File]::ReadAllText($workerDefinition) | ConvertFrom-Json + if ($binding.codex_home -ne $paths.Home -or $binding.store -ne $paths.Store -or -not $worker.PathName.Contains($workerDefinition)) { throw 'The enrollment service belongs to another installation.' } + $workerWasRunning = $worker.State -eq 'Running' + if ($worker.State -ne 'Stopped') { + Stop-Service -Name 'com.codexfold.enroll' + (Get-Service -Name 'com.codexfold.enroll').WaitForStatus('Stopped',[TimeSpan]::FromSeconds(40)) + } +} +if (-not $worker -and (Test-Path -LiteralPath (Join-Path $paths.Store 'enrollment\worker-policy.json'))) { + $rawPolicy = [IO.File]::ReadAllText((Join-Path $paths.Store 'enrollment\worker-policy.json')) | ConvertFrom-Json + if ($rawPolicy.enabled) { throw 'Pause the non-service folding worker before this offline upgrade.' } +} + Assert-LocalFoldOffline + $updated = Invoke-LocalFold $paths.Candidate @('fs','service','update-binary',$paths.Candidate,'--definition',$paths.Definition,'--codex-home',$paths.Home,'--mount',$paths.Mount,'--apply','--json') + $status = Invoke-LocalFold $paths.Candidate @('fs','service','status','--definition',$paths.Definition,'--codex-home',$paths.Home,'--mount',$paths.Mount,'--json') + if (-not ($status.daemon_running -and $status.mount_healthy -and $status.build.healthy)) { throw 'The upgraded filesystem did not pass its health check.' } + & $paths.TrayCandidate --quit --store $paths.Store + Copy-Item -LiteralPath $paths.TrayCandidate -Destination $paths.Tray -Force + $localTray = Join-Path $env:LOCALAPPDATA 'CodexFold\bin\codexfold-tray.exe' + if (Test-Path -LiteralPath $localTray) { Copy-Item -LiteralPath $paths.TrayCandidate -Destination $localTray -Force } + Start-Process -FilePath $paths.Tray -ArgumentList ('--background --store "'+$paths.Store+'"') -WindowStyle Hidden | Out-Null + $updated | ConvertTo-Json -Depth 5 +} finally { + if ($workerWasRunning) { Start-Service -Name 'com.codexfold.enroll' } +} +Write-Host 'The resident Windows mount is ready. You can reopen Codex.' diff --git a/scripts/windows-client-smoke.cjs b/scripts/windows-client-smoke.cjs new file mode 100644 index 0000000..ecf3bd1 --- /dev/null +++ b/scripts/windows-client-smoke.cjs @@ -0,0 +1,124 @@ +// Unmodified local Codex app-server against an explicitly isolated Windows fixture. +// This stage sends no model requests and never copies login credentials. +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const path = require('node:path'); +const readline = require('node:readline'); +const {spawn} = require('node:child_process'); + +async function main() { + assert.equal(process.platform, 'win32'); + const executable = path.resolve(process.argv[2] || ''); + const root = path.resolve(process.argv[3] || ''); + const testBase = path.resolve(__dirname, '..', '.tmp', 'windows-use') + path.sep; + assert.ok(root.startsWith(testBase), 'Only workspace-owned isolated fixtures are accepted'); + let full = true; + try { await fs.access(path.join(root, 'full-fixture.json')); } catch { full = false; } + const fixture = JSON.parse(await fs.readFile(path.join(root, full ? 'full-fixture.json' : 'fixture.json'), 'utf8')); + const deletionCanary = process.argv[4] === 'delete-fork'; + const forkFixture = deletionCanary && JSON.parse(await fs.readFile(path.join(root, 'client-fork.json'), 'utf8')); + const session = deletionCanary ? {id:forkFixture.id,rollout_path:forkFixture.path} : full ? fixture.session : fixture.sessions[process.argv[4] === 'native' ? 1 : 0]; + const managed = process.argv[4] === 'managed' || deletionCanary; + let mounted = full ? session.rollout_path : path.join(fixture.mount, session.id + '.jsonl'); + // Let the real client create its own complete schema, rather than using the + // small engine-only fixture database. No production database is copied. + const clientHome = full ? fixture.home : path.join(root, 'client-home'); + assert.ok(path.resolve(clientHome).toLowerCase().startsWith(root.toLowerCase() + path.sep), 'The test home must remain inside its owned fixture'); + if (full) assert.ok(path.resolve(mounted).toLowerCase().startsWith(path.resolve(clientHome).toLowerCase() + path.sep), 'The selected rollout must belong to the copied home'); + if (managed) { + assert.ok(full, 'Managed client validation requires a complete fixture'); + const native = path.join(root, 'native', path.relative(clientHome, mounted)); + await assert.rejects(fs.stat(native), error => error.code === 'ENOENT', 'The managed rollout must be served from compressed storage'); + } + await fs.mkdir(clientHome, {recursive:true}); + if (!full && process.argv[4] === 'native') { + const date = path.basename(session.source_path).match(/^rollout-(\d{4})-(\d{2})-(\d{2})T/); + assert.ok(date, 'The native baseline must preserve the original rollout filename'); + const directory = path.join(clientHome, 'sessions', date[1], date[2], date[3]); + await fs.mkdir(directory, {recursive:true}); + mounted = path.join(directory, path.basename(session.source_path)); + await fs.copyFile(session.path, mounted); + } + const child = spawn(executable, ['app-server', '--stdio'], { + env: {...process.env, CODEX_HOME: clientHome}, cwd: root, + windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'], + }); + const pending = new Map(); + let serial = 0, stderr = ''; + child.stderr.on('data', chunk => { stderr += chunk.toString(); }); + const lines = readline.createInterface({input: child.stdout}); + lines.on('line', line => { + let message; + try { message = JSON.parse(line); } catch { return; } + if (message.id !== undefined && message.method) { + child.stdin.write(JSON.stringify({id:message.id, error:{code:-32601,message:'No test approval or external action handler'}}) + '\n'); + return; + } + const request = pending.get(message.id); + if (!request) return; + clearTimeout(request.timer); pending.delete(message.id); + if (message.error) { + fs.writeFile(path.join(root, 'client-error.json'), JSON.stringify(message.error, null, 2)).catch(() => {}); + request.reject(new Error(request.method + ' failed with code ' + message.error.code)); + } else request.resolve(message.result); + }); + child.on('exit', () => { + for (const request of pending.values()) { clearTimeout(request.timer); request.reject(new Error('Owned test app-server exited')); } + pending.clear(); + }); + const call = (method, params) => new Promise((resolve, reject) => { + const id = ++serial; + const timer = setTimeout(() => { pending.delete(id); reject(new Error(method + ' timed out')); }, 30000); + pending.set(id, {method, resolve, reject, timer}); + child.stdin.write(JSON.stringify({id, method, params}) + '\n'); + }); + try { + await call('initialize', {clientInfo:{name:'codexfold_windows_smoke',version:'1.0.0'},capabilities:{experimentalApi:true}}); + child.stdin.write(JSON.stringify({method:'initialized',params:{}}) + '\n'); + const resumed = await call('thread/resume', { + threadId:session.id, path:mounted, cwd:root, + modelProvider:'openai', approvalPolicy:'never', sandbox:'read-only', + }); + assert.equal(resumed.thread.id, session.id); + const label = process.argv[4] === 'native' ? 'native' : managed ? 'managed' : 'mounted'; + console.log('PASS: local Codex resumes the copied ' + label + ' thread'); + const read = await call('thread/read', {threadId:session.id, includeTurns:true}); + assert.equal(read.thread.id, session.id); + console.log('PASS: local Codex reads copied ' + label + ' history; turns=' + read.thread.turns.length); + if (deletionCanary) { + await call('thread/delete', {threadId:session.id}); + await fs.writeFile(path.join(root, 'client-deletion-result.json'), JSON.stringify({id:session.id,deleted:true}, null, 2)); + console.log('PASS: local Codex deletes the compressed test fork'); + return; + } + if (full && process.argv[4] !== 'native') { + const fork = await call('thread/fork', {threadId:session.id,cwd:root,modelProvider:'openai',approvalPolicy:'never',sandbox:'read-only'}); + assert.notEqual(fork.thread.id, session.id); + console.log('PASS: local Codex forks the mounted thread'); + await call('thread/archive', {threadId:fork.thread.id}); + await call('thread/unarchive', {threadId:fork.thread.id}); + console.log('PASS: local Codex archives and unarchives its new fork'); + if (process.argv[4] === 'keep-fork') { + await fs.writeFile(path.join(root, 'client-fork.json'), JSON.stringify({id:fork.thread.id,path:fork.thread.path}, null, 2)); + } else { + await call('thread/delete', {threadId:fork.thread.id}); + console.log('PASS: local Codex deletes its new native fork'); + } + if (managed) { + await call('thread/archive', {threadId:session.id}); + await call('thread/unarchive', {threadId:session.id}); + console.log('PASS: local Codex archives and unarchives the compressed managed thread'); + } + } + } finally { + await fs.writeFile(path.join(root, 'client.stderr.log'), stderr); + child.stdin.end(); + await new Promise(resolve => { + if (child.exitCode !== null) return resolve(); + const timer = setTimeout(() => { child.kill(); resolve(); }, 5000); + child.once('exit', () => { clearTimeout(timer); resolve(); }); + }); + lines.close(); + } +} +main().catch(error => { console.error(error.message); process.exitCode = 1; }); diff --git a/scripts/windows-fixture.go b/scripts/windows-fixture.go new file mode 100644 index 0000000..828d24e --- /dev/null +++ b/scripts/windows-fixture.go @@ -0,0 +1,98 @@ +//go:build ignore + +// Creates synthetic sessions for scripts/test-windows-use.ps1. Never reads the real Codex home. +package main + +import ( + "bytes" + "crypto/sha256" + "database/sql" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + _ "modernc.org/sqlite" +) + +func main() { + if err := create(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func create() error { + if len(os.Args) != 2 { + return fmt.Errorf("usage: go run scripts/windows-fixture.go ") + } + root, err := filepath.Abs(os.Args[1]) + if err != nil { + return err + } + // Refuse every existing directory: a fixture can never overwrite user data. + if err := os.Mkdir(root, 0700); err != nil { + return err + } + home := filepath.Join(root, "home") + if err := os.MkdirAll(filepath.Join(home, "sessions"), 0700); err != nil { + return err + } + db, err := sql.Open("sqlite", filepath.Join(home, "state_5.sqlite")) + if err != nil { + return err + } + defer db.Close() + _, err = db.Exec(`create table threads (id text primary key, title text, cwd text, rollout_path text, model_provider text, model text, updated_at integer, archived integer, git_branch text)`) + if err != nil { + return err + } + type session struct { + ID string `json:"id"` + Path string `json:"path"` + SHA256 string `json:"sha256"` + Bytes int `json:"bytes"` + } + var sessions []session + for i, id := range []string{"a1111111-1111-4111-8111-111111111111", "b2222222-2222-4222-8222-222222222222"} { + var content bytes.Buffer + encoder := json.NewEncoder(&content) + if err := encoder.Encode(map[string]any{"timestamp": "2026-10-01T00:00:00Z", "type": "session_meta", "payload": map[string]any{"id": id, "cwd": root, "originator": "codexfold-windows-test", "cli_version": "fixture"}}); err != nil { + return err + } + for record := 0; record < 192; record++ { + payload := map[string]any{"type": "agent_message", "message": strings.Repeat("CodexFold Windows byte-preserving test. 中文会话内容。\n", 180)} + if err := encoder.Encode(map[string]any{"timestamp": "2026-10-01T00:00:01Z", "type": "event_msg", "payload": payload}); err != nil { + return err + } + } + if err := encoder.Encode(map[string]any{"type": "event_msg", "payload": map[string]any{"type": "agent_message", "message": fmt.Sprintf("Independent session %d", i)}}); err != nil { + return err + } + rollout := filepath.Join(home, "sessions", "rollout-2026-10-01T00-00-00-"+id+".jsonl") + if err := os.WriteFile(rollout, content.Bytes(), 0600); err != nil { + return err + } + digest := sha256.Sum256(content.Bytes()) + if _, err := db.Exec(`insert into threads values (?, ?, ?, ?, 'fixture', 'fixture', ?, 0, '')`, id, "Windows isolated test", root, rollout, time.Now().Add(-2*time.Hour).Unix()); err != nil { + return err + } + sessions = append(sessions, session{id, rollout, hex.EncodeToString(digest[:]), content.Len()}) + } + if err := db.Close(); err != nil { + return err + } + metadata := map[string]any{"root": root, "home": home, "store": filepath.Join(root, "store"), "mount": filepath.Join(root, "mount"), "sessions": sessions} + encoded, err := json.MarshalIndent(metadata, "", " ") + if err != nil { + return err + } + if err := os.WriteFile(filepath.Join(root, "fixture.json"), encoded, 0600); err != nil { + return err + } + fmt.Println(string(encoded)) + return nil +} diff --git a/scripts/windows-full-fixture.go b/scripts/windows-full-fixture.go new file mode 100644 index 0000000..6d15064 --- /dev/null +++ b/scripts/windows-full-fixture.go @@ -0,0 +1,278 @@ +//go:build ignore + +// A byte-preserving full session-tree and SQLite snapshot for real-client tests. +// Opens the production database read-only; does not copy credentials or config. +package main + +import ( + "bufio" + "crypto/sha256" + "database/sql" + "encoding/hex" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/samekind/codexfold/internal/codex" +) + +type snapshotFile struct { + Source string `json:"source"` + Path string `json:"path"` + Bytes int64 `json:"bytes"` + SHA256 string `json:"sha256"` +} + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run() error { + rootArg := flag.String("root", "", "New workspace-owned fixture directory") + homeArg := flag.String("source-home", "", "Codex home to open read-only") + backupOnly := flag.Bool("backup-only", false, "Preserve original SQLite routes for an offline recovery backup") + flag.Parse() + home, err := codex.ResolveHome(*homeArg) + if err != nil { + return err + } + home, err = filepath.Abs(home) + if err != nil { + return err + } + root, err := filepath.Abs(*rootArg) + if err != nil || *rootArg == "" { + return errors.New("a new absolute fixture root is required") + } + relative, err := filepath.Rel(home, root) + if err == nil && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) && !filepath.IsAbs(relative) { + return errors.New("fixture must be outside the production Codex home") + } + if err := os.Mkdir(root, 0700); err != nil { + return err + } + clone := filepath.Join(root, "home") + if err := os.Mkdir(clone, 0700); err != nil { + return err + } + var files []snapshotFile + var skipped int + for _, namespace := range []string{"sessions", "archived_sessions"} { + sourceBase := filepath.Join(home, namespace) + if *backupOnly { + info, err := os.Lstat(sourceBase) + if err != nil || !info.IsDir() || info.Mode()&(os.ModeSymlink|os.ModeIrregular) != 0 { + return fmt.Errorf("backup requires ordinary session directories: %s", sourceBase) + } + } + if err := os.MkdirAll(filepath.Join(clone, namespace), 0700); err != nil { + return err + } + err := filepath.WalkDir(sourceBase, func(source string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.IsDir() { + return nil + } + info, err := entry.Info() + if err != nil { + return err + } + if !info.Mode().IsRegular() { + skipped++ + return nil + } + rel, err := filepath.Rel(home, source) + if err != nil { + return err + } + target := filepath.Join(clone, rel) + if err := os.MkdirAll(filepath.Dir(target), 0700); err != nil { + return err + } + digest, err := copySnapshot(source, target, info) + if err != nil { + skipped++ + _ = os.Remove(target) // exact file created by this copy only + return nil + } + files = append(files, snapshotFile{source, target, info.Size(), digest}) + return nil + }) + if err != nil { + return err + } + } + if *backupOnly && skipped != 0 { + return fmt.Errorf("backup is incomplete: %d changed or non-regular files", skipped) + } + // VACUUM INTO makes one consistent snapshot, including committed WAL data, + // while the source connection remains mode=ro. + sourceDB, err := sql.Open("sqlite", "file:"+filepath.ToSlash(filepath.Join(home, "state_5.sqlite"))+"?mode=ro") + if err != nil { + return err + } + _, err = sourceDB.Exec(`vacuum into ?`, filepath.Join(clone, "state_5.sqlite")) + closeErr := sourceDB.Close() + if err := errors.Join(err, closeErr); err != nil { + return err + } + if *backupOnly { + var bytes int64 + for _, file := range files { + bytes += file.Bytes + } + metadata := map[string]any{"root": root, "home": clone, "source_home": home, "files": files, "copied_bytes": bytes, "copied_files": len(files), "database_routes_preserved": true} + encoded, err := json.MarshalIndent(metadata, "", " ") + if err != nil { + return err + } + if err := os.WriteFile(filepath.Join(root, "backup.json"), encoded, 0600); err != nil { + return err + } + return json.NewEncoder(os.Stdout).Encode(map[string]any{"root": root, "copied_files": len(files), "copied_bytes": bytes, "database_routes_preserved": true}) + } + db, err := sql.Open("sqlite", filepath.Join(clone, "state_5.sqlite")) + if err != nil { + return err + } + defer db.Close() + rows, err := db.Query(`select id, rollout_path from threads`) + if err != nil { + return err + } + type route struct{ id, path string } + var routes []route + for rows.Next() { + var r route + if err := rows.Scan(&r.id, &r.path); err != nil { + rows.Close() + return err + } + routes = append(routes, r) + } + if err := errors.Join(rows.Err(), rows.Close()); err != nil { + return err + } + for _, r := range routes { + rel, err := filepath.Rel(home, r.path) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) { + // Never let a test client route writes to an external original file. + if _, err := db.Exec(`delete from threads where id=?`, r.id); err != nil { + return err + } + continue + } + if _, err := db.Exec(`update threads set rollout_path=? where id=?`, filepath.Join(clone, rel), r.id); err != nil { + return err + } + } + if err := db.Close(); err != nil { + return err + } + sessions, err := codex.LoadSessions(clone) + if err != nil { + return err + } + var candidates []codex.Session + for _, s := range sessions { + info, err := os.Stat(s.RolloutPath) + if err != nil || info.Size() == 0 || s.UpdatedAt > time.Now().Add(-time.Hour).Unix() { + continue + } + file, err := os.Open(s.RolloutPath) + if err != nil { + continue + } + first, readErr := bufio.NewReader(file).ReadBytes('\n') + file.Close() + var header struct { + Type string `json:"type"` + Payload struct { + ID string `json:"id"` + Source json.RawMessage `json:"source"` + } `json:"payload"` + } + if readErr != nil || json.Unmarshal(first, &header) != nil || header.Type != "session_meta" || header.Payload.ID != s.ID { + continue + } + var source string + if json.Unmarshal(header.Payload.Source, &source) != nil { + continue + } // multi-agent child histories need their parent + candidates = append(candidates, s) + } + if len(candidates) == 0 { + return errors.New("no stable main thread is available for isolated real-client testing") + } + sort.Slice(candidates, func(i, j int) bool { return candidates[i].UpdatedAt > candidates[j].UpdatedAt }) + var bytes int64 + for _, f := range files { + bytes += f.Bytes + } + metadata := map[string]any{"root": root, "home": clone, "store": filepath.Join(root, "store"), "native_root": filepath.Join(root, "native"), "mount": "T:\\", "files": files, "source_home": home, "copied_bytes": bytes, "copied_files": len(files), "skipped_files": skipped, "session": candidates[0], "source": "full-local-session-copy"} + encoded, err := json.MarshalIndent(metadata, "", " ") + if err != nil { + return err + } + if err := os.WriteFile(filepath.Join(root, "full-fixture.json"), encoded, 0600); err != nil { + return err + } + return json.NewEncoder(os.Stdout).Encode(map[string]any{"root": root, "copied_files": len(files), "copied_bytes": bytes, "skipped_files": skipped, "threads": len(sessions), "main_thread": candidates[0].ID}) +} + +func copySnapshot(sourcePath, target string, expected os.FileInfo) (string, error) { + source, err := os.Open(sourcePath) + if err != nil { + return "", err + } + defer source.Close() + before, err := source.Stat() + if err != nil { + return "", err + } + if !os.SameFile(before, expected) || before.Size() != expected.Size() || !before.ModTime().Equal(expected.ModTime()) { + return "", errors.New("source changed") + } + destination, err := os.OpenFile(target, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) + if err != nil { + return "", err + } + hash := sha256.New() + n, copyErr := io.Copy(io.MultiWriter(destination, hash), source) + err = errors.Join(copyErr, destination.Sync(), destination.Close()) + if err != nil { + return "", err + } + after, err := source.Stat() + if err != nil { + return "", err + } + if n != before.Size() || after.Size() != before.Size() || !after.ModTime().Equal(before.ModTime()) { + return "", errors.New("source changed") + } + digest := hex.EncodeToString(hash.Sum(nil)) + if _, err := source.Seek(0, io.SeekStart); err != nil { + return "", err + } + hash.Reset() + if _, err := io.Copy(hash, source); err != nil { + return "", err + } + if hex.EncodeToString(hash.Sum(nil)) != digest { + return "", errors.New("source digest changed") + } + return digest, os.Chtimes(target, before.ModTime(), before.ModTime()) +} diff --git a/scripts/windows-local-common.ps1 b/scripts/windows-local-common.ps1 new file mode 100644 index 0000000..b629913 --- /dev/null +++ b/scripts/windows-local-common.ps1 @@ -0,0 +1,82 @@ +$ErrorActionPreference = 'Stop' + +function Get-LocalFoldPaths { + param([string]$CodexHome, [string]$MountDrive) + $repoRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) + if (-not $CodexHome) { + $CodexHome = if ($env:CODEX_HOME) { $env:CODEX_HOME } else { Join-Path ([Environment]::GetFolderPath('UserProfile')) '.codex' } + } + $foldHome = [IO.Path]::GetFullPath($CodexHome) + if (-not (Test-Path -LiteralPath (Join-Path $foldHome 'state_5.sqlite'))) { throw 'Codex state_5.sqlite was not found.' } + [PSCustomObject]@{ + Home = $foldHome + Store = Join-Path $foldHome 'fold-store' + Native = Join-Path $foldHome 'fold-native' + Mount = $MountDrive.ToUpperInvariant() + ':/' + Binary = Join-Path $env:ProgramFiles 'CodexFold\codexfold.exe' + Tray = Join-Path $env:ProgramFiles 'CodexFold\codexfold-tray.exe' + Definition = Join-Path $env:ProgramData 'CodexFold\service.json' + Logs = Join-Path $env:ProgramData 'CodexFold\logs' + Candidate = Join-Path $repoRoot 'dist\codexfold.exe' + TrayCandidate = Join-Path $repoRoot 'dist\codexfold-tray.exe' + BackupTool = Join-Path $repoRoot 'dist\codexfold-home-backup.exe' + BackupBase = Join-Path $env:LOCALAPPDATA 'CodexFold\backups' + Repo = $repoRoot + } +} + +function Assert-LocalFoldOffline { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = New-Object Security.Principal.WindowsPrincipal($identity) + if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw 'Run this script in an administrator PowerShell window.' } + $clients = @(Get-CimInstance Win32_Process -Filter "Name='codex.exe'") + if ($clients.Count -ne 0) { throw "Close all Codex Desktop and CLI clients first ($($clients.Count) processes remain). This script does not close clients." } +} + +function Invoke-LocalFold { + param([string]$Binary, [string[]]$CommandArguments) + $output = & $Binary @CommandArguments + if ($LASTEXITCODE -ne 0) { throw "CodexFold failed: $($CommandArguments[0..([Math]::Min(2, $CommandArguments.Length-1))] -join ' ')" } + if ($output) { ($output -join "`n") | ConvertFrom-Json } +} + +function Install-LocalFoldService { + param($Paths, [string]$Interval) + Invoke-LocalFold $Paths.Binary @('fs','service','install','--apply','--binary',$Paths.Binary,'--codex-home',$Paths.Home,'--store',$Paths.Store,'--mount',$Paths.Mount,'--canonical-namespace','--native-root',$Paths.Native,'--definition',$Paths.Definition,'--log-dir',$Paths.Logs,'--enrollment-interval',$Interval,'--enrollment-stable-for','1h','--enrollment-batch-size','1','--json') | Out-Null +} + +function Protect-LocalFoldProgramDirectory { + param([string]$Path) + New-Item -ItemType Directory -Path $Path -Force | Out-Null + & icacls.exe $Path /inheritance:r /grant:r '*S-1-5-18:(OI)(CI)F' '*S-1-5-32-544:(OI)(CI)F' '*S-1-5-32-545:(OI)(CI)RX' | Out-Null + if ($LASTEXITCODE -ne 0) { throw 'Could not protect the installed program directory.' } +} + +function Stop-LocalFoldEnrollmentWorker { + param($Paths) + $enrollmentService = Get-CimInstance Win32_Service -Filter "Name='com.codexfold.enroll'" + if ($enrollmentService) { + $enrollmentDefinition = Join-Path $env:ProgramData 'CodexFold\Enrollment\service.json' + $binding = [IO.File]::ReadAllText($enrollmentDefinition) | ConvertFrom-Json + if ($binding.codex_home -ne $Paths.Home -or $binding.store -ne $Paths.Store -or -not $enrollmentService.PathName.Contains($enrollmentDefinition) -or -not $enrollmentService.PathName.Contains($binding.binary_path)) { throw 'The enrollment service belongs to another installation.' } + if ($enrollmentService.State -ne 'Stopped') { + Stop-Service -Name 'com.codexfold.enroll' + (Get-Service -Name 'com.codexfold.enroll').WaitForStatus('Stopped',[TimeSpan]::FromSeconds(40)) + } + Set-Service -Name 'com.codexfold.enroll' -StartupType Disabled + } + $policy = Join-Path $Paths.Store 'enrollment\worker-policy.json' + if (-not (Test-Path -LiteralPath $policy)) { return } + $workerBinary = Join-Path $env:LOCALAPPDATA 'CodexFold\bin\codexfold-enroll.exe' + if (-not $enrollmentService) { + if (-not (Test-Path -LiteralPath $workerBinary)) { throw 'The separate enrollment worker binary is missing; stop it before switching storage.' } + $arguments = 'fs enroll stop --apply --store "'+$Paths.Store+'" --codex-home "'+$Paths.Home+'"' + $stopRequest = Start-Process -FilePath $workerBinary -ArgumentList $arguments -WindowStyle Hidden -PassThru + if (-not $stopRequest.WaitForExit(40000) -or $stopRequest.ExitCode -ne 0) { throw 'The separate enrollment worker did not finish stopping.' } + } + $startup = Join-Path ([Environment]::GetFolderPath('Startup')) 'CodexFold Enrollment.lnk' + if (Test-Path -LiteralPath $startup) { + $shortcut = (New-Object -ComObject WScript.Shell).CreateShortcut($startup) + if ($shortcut.TargetPath -eq $workerBinary -and $shortcut.Arguments.Contains($Paths.Store)) { Remove-Item -LiteralPath $startup } + } +} diff --git a/scripts/windows-local-fixture.go b/scripts/windows-local-fixture.go new file mode 100644 index 0000000..074b8ab --- /dev/null +++ b/scripts/windows-local-fixture.go @@ -0,0 +1,204 @@ +//go:build ignore + +// Copies stable local sessions into an independent test home. Source access is +// read-only; no credentials, configuration, or live SQLite database are copied. +package main + +import ( + "crypto/sha256" + "database/sql" + "encoding/hex" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/samekind/codexfold/internal/codex" +) + +type localCandidate struct { + session codex.Session + info os.FileInfo +} +type copiedSession struct { + ID string `json:"id"` + Path string `json:"path"` + SourcePath string `json:"source_path"` + SHA256 string `json:"sha256"` + Bytes int64 `json:"bytes"` +} + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run() error { + sourceHome := flag.String("source-home", "", "Local Codex home, opened read-only") + rootArg := flag.String("root", "", "New test directory; omitted reports only inventory") + flag.Parse() + realHome, err := codex.ResolveHome(*sourceHome) + if err != nil { + return err + } + realHome, err = filepath.Abs(realHome) + if err != nil { + return err + } + sessions, err := codex.LoadSessions(realHome) + if err != nil { + return err + } + var candidates []localCandidate + var present, missing, excluded int + var totalBytes int64 + cutoff := time.Now().Add(-time.Hour) + for _, s := range sessions { + relative, err := filepath.Rel(realHome, s.RolloutPath) + if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) || filepath.IsAbs(relative) { + excluded++ + continue + } + info, err := os.Lstat(s.RolloutPath) + if os.IsNotExist(err) { + missing++ + continue + } + if err != nil || !info.Mode().IsRegular() { + excluded++ + continue + } + present++ + totalBytes += info.Size() + if info.Size() > 0 && info.Size() <= 64<<20 && info.ModTime().Before(cutoff) && s.UpdatedAt < cutoff.Unix() && filepath.Base(s.ID) == s.ID && !strings.ContainsAny(s.ID, "/\\\x00") { + candidates = append(candidates, localCandidate{s, info}) + } + } + sort.Slice(candidates, func(i, j int) bool { return candidates[i].info.Size() > candidates[j].info.Size() }) + inventory := map[string]any{"source_home": realHome, "database_sessions": len(sessions), "present_files": present, "missing_files": missing, "excluded_files": excluded, "source_bytes": totalBytes, "stable_candidates": len(candidates)} + if *rootArg == "" { + return printJSON(inventory) + } + if len(candidates) < 2 { + return errors.New("at least two stable local sessions are required") + } + root, err := filepath.Abs(*rootArg) + if err != nil { + return err + } + if root == realHome || strings.HasPrefix(strings.ToLower(root), strings.ToLower(realHome)+string(filepath.Separator)) { + return errors.New("test directory must be outside the real Codex home") + } + if err := os.Mkdir(root, 0o700); err != nil { + return err + } + home := filepath.Join(root, "home") + if err := os.MkdirAll(filepath.Join(home, "sessions"), 0o700); err != nil { + return err + } + db, err := sql.Open("sqlite", filepath.Join(home, "state_5.sqlite")) + if err != nil { + return err + } + defer db.Close() + if _, err = db.Exec(`create table threads (id text primary key, title text, cwd text, rollout_path text, model_provider text, model text, updated_at integer, archived integer, git_branch text)`); err != nil { + return err + } + var copies []copiedSession + for _, candidate := range candidates { + if len(copies) == 2 { + break + } + s := candidate.session + target := filepath.Join(home, "sessions", "rollout-local-"+s.ID+".jsonl") + digest, err := copyStable(candidate, target) + if err != nil { + fmt.Fprintln(os.Stderr, "Skipped changed/unavailable source:", s.ID, err) + continue + } + if _, err = db.Exec(`insert into threads values (?, ?, ?, ?, ?, ?, ?, 0, '')`, s.ID, "Local session copy", root, target, s.ModelProvider, s.Model, s.UpdatedAt); err != nil { + return err + } + copies = append(copies, copiedSession{s.ID, target, s.RolloutPath, digest, candidate.info.Size()}) + } + if len(copies) != 2 { + return errors.New("could not capture two stable local sessions") + } + if err := db.Close(); err != nil { + return err + } + metadata := map[string]any{"root": root, "home": home, "store": filepath.Join(root, "store"), "mount": filepath.Join(root, "mount"), "sessions": copies, "inventory": inventory, "source": "local-session-copies"} + encoded, err := json.MarshalIndent(metadata, "", " ") + if err != nil { + return err + } + if err := os.WriteFile(filepath.Join(root, "fixture.json"), encoded, 0o600); err != nil { + return err + } + fmt.Println(string(encoded)) + return nil +} + +func copyStable(candidate localCandidate, target string) (string, error) { + source, err := os.Open(candidate.session.RolloutPath) + if err != nil { + return "", err + } + defer source.Close() + before, err := source.Stat() + if err != nil { + return "", err + } + if !os.SameFile(candidate.info, before) || before.Size() != candidate.info.Size() || !before.ModTime().Equal(candidate.info.ModTime()) { + return "", errors.New("source changed before copy") + } + destination, err := os.OpenFile(target, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + if err != nil { + return "", err + } + hash := sha256.New() + n, copyErr := io.Copy(io.MultiWriter(destination, hash), source) + syncErr := destination.Sync() + closeErr := destination.Close() + if err := errors.Join(copyErr, syncErr, closeErr); err != nil { + return "", err + } + after, err := source.Stat() + if err != nil { + return "", err + } + if n != before.Size() || after.Size() != before.Size() || !after.ModTime().Equal(before.ModTime()) { + return "", errors.New("source changed during copy") + } + if _, err := source.Seek(0, io.SeekStart); err != nil { + return "", err + } + verification := sha256.New() + if _, err := io.Copy(verification, source); err != nil { + return "", err + } + digest := hex.EncodeToString(hash.Sum(nil)) + if digest != hex.EncodeToString(verification.Sum(nil)) { + return "", errors.New("source hash changed during copy") + } + if err := os.Chtimes(target, before.ModTime(), before.ModTime()); err != nil { + return "", err + } + return digest, nil +} + +func printJSON(value any) error { + encoded, err := json.MarshalIndent(value, "", " ") + if err == nil { + fmt.Println(string(encoded)) + } + return err +} diff --git a/scripts/windows-model-smoke.cjs b/scripts/windows-model-smoke.cjs new file mode 100644 index 0000000..de65ff2 --- /dev/null +++ b/scripts/windows-model-smoke.cjs @@ -0,0 +1,149 @@ +// A real, short model conversation in the owned Windows fixture. Authentication +// is supplied over stdin in memory; production history and credentials stay put. +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const path = require('node:path'); +const readline = require('node:readline'); +const {spawn} = require('node:child_process'); +const {randomBytes} = require('node:crypto'); + +async function main() { + assert.equal(process.platform, 'win32'); + const executable = path.resolve(process.argv[2] || ''); + const root = path.resolve(process.argv[3] || ''); + const mode = process.argv[4] || 'read'; + assert.ok(['start', 'resume', 'recovered', 'live-recovery', 'read'].includes(mode)); + assert.ok(root.startsWith(path.resolve(__dirname, '..', '.tmp', 'windows-use') + path.sep)); + const fixture = JSON.parse(await fs.readFile(path.join(root, 'full-fixture.json'), 'utf8')); + const home = path.resolve(fixture.home); + assert.ok(home.startsWith(root + path.sep)); + const recordPath = path.join(root, 'model-canary.json'); + const cwd = path.join(root, 'model-work'); + await fs.mkdir(cwd, {recursive:true}); + await assert.rejects(fs.stat(path.join(home, 'auth.json')), e => e.code === 'ENOENT'); + let record = mode === 'start' ? {code:'CF_' + randomBytes(10).toString('hex'), phases:[]} : JSON.parse(await fs.readFile(recordPath, 'utf8')); + const auth = mode === 'read' ? null : JSON.parse(await fs.readFile(path.join(process.env.USERPROFILE, '.codex', 'auth.json'), 'utf8')); + if (auth) assert.ok(auth.tokens?.access_token && auth.tokens?.account_id, 'Existing ChatGPT login is required'); + const child = spawn(executable, ['app-server', '--stdio'], { + cwd, env:{...process.env, CODEX_HOME:home}, windowsHide:true, stdio:['pipe','pipe','pipe'], + }); + const pending = new Map(), completed = new Map(), waiters = new Map(); + let serial = 0, tools = 0, stderr = ''; + const send = message => child.stdin.write(JSON.stringify(message) + '\n'); + child.stderr.on('data', chunk => { stderr += chunk.toString(); }); + const lines = readline.createInterface({input:child.stdout}); + lines.on('line', line => { + let m; try { m = JSON.parse(line); } catch { return; } + if (m.id !== undefined && m.method) { + send({id:m.id,error:{code:-32601,message:'Test client declines external actions and token refresh'}}); + return; + } + if (m.method === 'item/started' && !['userMessage','agentMessage','reasoning'].includes(m.params?.item?.type)) tools++; + if (m.method === 'turn/completed') { + const key = m.params.threadId; + completed.set(key, m.params.turn); + const waiter = waiters.get(key); + if (waiter) { clearTimeout(waiter.timer); waiters.delete(key); waiter.resolve(m.params.turn); } + } + const request = pending.get(m.id); + if (!request) return; + clearTimeout(request.timer); pending.delete(m.id); + if (m.error) request.reject(new Error(request.method + ': ' + String(m.error.message))); + else request.resolve(m.result); + }); + child.on('exit', () => { + for (const request of [...pending.values(), ...waiters.values()]) { clearTimeout(request.timer); request.reject(new Error('Owned model test client exited')); } + pending.clear(); waiters.clear(); + }); + const call = (method, params) => new Promise((resolve,reject) => { + const id = ++serial; + const timer = setTimeout(() => { pending.delete(id); reject(new Error(method + ' timed out')); }, 60000); + pending.set(id,{method,resolve,reject,timer}); send({id,method,params}); + }); + const waitTurn = threadId => new Promise((resolve,reject) => { + if (completed.has(threadId)) return resolve(completed.get(threadId)); + const timer = setTimeout(() => { waiters.delete(threadId); reject(new Error('Real model turn timed out')); },180000); + waiters.set(threadId,{resolve,reject,timer}); + }); + const options = {cwd,model:'gpt-6.1-sol',modelProvider:'openai',approvalPolicy:'never',sandbox:'read-only', + baseInstructions:'You are a storage compatibility test. Answer only the requested short text. Do not use any tools, access files, or delegate.', + config:{model_reasoning_effort:'medium'},environments:[]}; + try { + await call('initialize',{clientInfo:{name:'codexfold_windows_model_canary',version:'1.0.0'},capabilities:{experimentalApi:true}}); + send({method:'initialized',params:{}}); + if (auth) { + await call('account/login/start',{type:'chatgptAuthTokens',accessToken:auth.tokens.access_token,chatgptAccountId:auth.tokens.account_id}); + console.log('PASS: isolated test client authenticated in memory'); + } + let thread; + if (mode === 'start') thread = (await call('thread/start',options)).thread; + else { + assert.ok(record.id && record.path); + const canaryPath = path.resolve(String(record.path).replace(/^\\\\\?\\/,'')); + assert.ok(canaryPath.toLowerCase().startsWith(home.toLowerCase() + path.sep), 'Resume only the isolated canary'); + record.path = canaryPath; + if (mode !== 'read') await assert.rejects(fs.stat(path.join(root,'native',path.relative(home,record.path))), e => e.code === 'ENOENT'); + thread = (await call('thread/resume',{...options,threadId:record.id,path:record.path})).thread; + } + if (mode === 'live-recovery') { + const trigger = path.join(root,'model-recovery-trigger.json'); + await fs.rm(trigger,{force:true}); + await fs.writeFile(path.join(root,'model-recovery-ready.json'),JSON.stringify({threadId:thread.id,pid:child.pid})); + console.log('READY: owned model test client remains open during filesystem recovery'); + const deadline = Date.now() + 180000; + while (true) { + try { await fs.access(trigger); break; } catch (error) { if (error.code !== 'ENOENT') throw error; } + assert.ok(Date.now() < deadline,'Filesystem recovery signal timed out'); + await new Promise(resolve => setTimeout(resolve,250)); + } + } + if (mode !== 'read') { + const prompt = mode === 'start' ? 'Remember the code ' + record.code + '. Reply with exactly SAVED.' : + mode === 'resume' ? 'What code did I ask you to remember? Reply only with that code.' : + 'Reply with the code I asked you to remember, followed by a space and RECOVERED.'; + completed.delete(thread.id); + const waiting = waitTurn(thread.id); + // Attach a handler immediately, including when turn/start itself fails. + waiting.catch(() => {}); + const started = await call('turn/start',{threadId:thread.id,input:[{type:'text',text:prompt}],effort:'medium',environments:[]}); + console.log('Started real model turn: ' + mode); + const turn = await waiting; + assert.equal(turn.id,started.turn.id); + assert.equal(turn.status,'completed', 'The real model turn must finish successfully'); + const text = turn.items.filter(item => item.type === 'agentMessage').map(item => item.text).join('\n').trim(); + assert.equal(text, mode === 'start' ? 'SAVED' : mode === 'resume' ? record.code : record.code + ' RECOVERED'); + assert.equal(tools,0,'The model must not invoke tools'); + record.phases.push({mode,turnId:turn.id,completed:true}); + console.log('PASS: real model replied correctly without tool actions (' + mode + ')'); + } + const read = (await call('thread/read',{threadId:thread.id,includeTurns:true})).thread; + record.id = read.id; record.path = read.path; + // Paths from the app-server may be verbatim drive paths. Validate their + // resolved namespace before persisting the record or reading any bytes. + const localPath = path.resolve(String(record.path).replace(/^\\\\\?\\/,'')); + assert.ok(localPath.toLowerCase().startsWith(home.toLowerCase() + path.sep), 'The canary must remain in the isolated home'); + record.path = localPath; + const messages = read.turns.flatMap(turn => turn.items).filter(item => item.type === 'agentMessage').map(item => item.text.trim()); + for (const phase of record.phases) { + const expected = phase.mode === 'start' ? 'SAVED' : phase.mode === 'resume' ? record.code : record.code + ' RECOVERED'; + assert.ok(messages.includes(expected),'Previously completed replies must survive reopening'); + } + record.visibleTurns = read.turns.length; + await fs.writeFile(recordPath,JSON.stringify(record,null,2)); + console.log('PASS: persisted history contains every completed reply; turns=' + read.turns.length); + } finally { + child.stdin.end(); + await new Promise(resolve => { + if (child.exitCode !== null) return resolve(); + const timer = setTimeout(() => { child.kill(); resolve(); },5000); + child.once('exit', () => { clearTimeout(timer); resolve(); }); + }); + lines.close(); + for (const request of [...pending.values(), ...waiters.values()]) clearTimeout(request.timer); + // Keep diagnostic text private and remove token values before writing it. + if (auth) for (const token of Object.values(auth.tokens)) if (typeof token === 'string' && token) stderr = stderr.split(token).join('[REDACTED]'); + await fs.writeFile(path.join(root,'model-client.stderr.log'),stderr); + await assert.rejects(fs.stat(path.join(home,'auth.json')), e => e.code === 'ENOENT','External-token authentication must not create a credential file'); + } +} +main().catch(error => { console.error(error.message); process.exitCode = 1; }); diff --git "a/\345\220\257\347\224\250\346\255\243\345\274\217\344\275\277\347\224\250.cmd" "b/\345\220\257\347\224\250\346\255\243\345\274\217\344\275\277\347\224\250.cmd" new file mode 100644 index 0000000..69c0c46 --- /dev/null +++ "b/\345\220\257\347\224\250\346\255\243\345\274\217\344\275\277\347\224\250.cmd" @@ -0,0 +1,4 @@ +@echo off +setlocal +"%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -File "%~dp0scripts\launch-windows-local.ps1" -Action Enable +exit /b %errorlevel% diff --git "a/\346\201\242\345\244\215\346\231\256\351\200\232\345\255\230\345\202\250.cmd" "b/\346\201\242\345\244\215\346\231\256\351\200\232\345\255\230\345\202\250.cmd" new file mode 100644 index 0000000..232e5da --- /dev/null +++ "b/\346\201\242\345\244\215\346\231\256\351\200\232\345\255\230\345\202\250.cmd" @@ -0,0 +1,4 @@ +@echo off +setlocal +"%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -File "%~dp0scripts\launch-windows-local.ps1" -Action Restore +exit /b %errorlevel% From 0361d19dbcd2ae61adcce81044b92d2ff3b1e79f Mon Sep 17 00:00:00 2001 From: luohui1 <3053763193@qq.com> Date: Sat, 3 Oct 2026 02:58:03 -0700 Subject: [PATCH 2/4] ci: use native Go Windows loader and check deletion adapters --- .github/workflows/ci.yml | 5 ++++- docs/windows-preview.md | 5 +++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d5970d1..fd343b4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,6 +72,9 @@ jobs: windows-compile: name: windows-preview-checks runs-on: windows-latest + env: + # Use cgofuse's native Go Windows loader, matching the preview build. + CGO_ENABLED: "0" steps: - uses: actions/checkout@v7.0.1 - uses: actions/setup-go@v7.0.0 @@ -81,7 +84,7 @@ jobs: - name: Compile all test packages without claiming runtime validation run: go test ./... -run '^$' -count=1 - name: Test Windows adapters, worker lifecycle and tray state - run: go test ./internal/tray ./internal/enroll ./internal/cli ./internal/service ./internal/mountfs ./internal/pack ./internal/dirsync -run 'TestMonitor|TestPolicy|TestDashboard|TestHistory|TestIncidents|TestUnreadable|TestPeriodicEnrollment|TestEnrollmentWorker|TestPersistentEnrollment|TestWindows|TestFuse|TestPublishedLogicalBytes' -count=1 -timeout=120s + run: go test ./internal/tray ./internal/enroll ./internal/cli ./internal/service ./internal/mountfs ./internal/pack ./internal/dirsync ./internal/sessionns ./internal/vfs -run 'TestMonitor|TestPolicy|TestDashboard|TestHistory|TestIncidents|TestUnreadable|TestPeriodicEnrollment|TestEnrollmentWorker|TestPersistentEnrollment|TestWindows|TestFuse|TestPublishedLogicalBytes' -count=1 -timeout=120s - name: Test the WinFsp frontend and engine handover run: go test -tags winfsp ./internal/mountfs -run 'TestWindowsCore|TestWindowsFuseErrorMapping|TestFuse' -count=1 -timeout=120s - run: go build ./cmd/codexfold diff --git a/docs/windows-preview.md b/docs/windows-preview.md index a9eb909..266ea07 100644 --- a/docs/windows-preview.md +++ b/docs/windows-preview.md @@ -5,11 +5,16 @@ WinFsp, and the tray needs Microsoft WebView2 Runtime. Keep the store on a local NTFS volume: Windows writer leases rely on hard links. ```powershell +$env:CGO_ENABLED='0' go build -tags winfsp -o dist/codexfold.exe ./cmd/codexfold go build -ldflags='-H=windowsgui' -o dist/codexfold-tray.exe ./cmd/codexfold-tray ./scripts/test-windows-use.ps1 -Mode Mount -KeepRunning ``` +The Windows preview uses cgofuse's native Go loader. The setting above avoids +a build-time dependency on WinFsp's C SDK headers; WinFsp Runtime remains required +to mount the drive. + For the isolated native tray smoke check, use Node.js 22 or newer and the same WebView2 Runtime. It verifies settings, persistent history, material rendering, window restoration and controls at desktop, narrow and short sizes: From 783e23107e68845d05fb7b0ca19ac51d54504767 Mon Sep 17 00:00:00 2001 From: King Star Date: Sat, 17 Oct 2026 23:55:07 +0800 Subject: [PATCH 3/4] fix: consolidate local session recovery and space-safe notifications Preserve canonical replacement names, foreground-priority maintenance, deletion isolation, bounded transport health checks, native rollout repair and disk-full append recovery. Keep quarantine bytes when replacement completion is uncertain. Use quiet macOS incident notifications and read-only disk-pressure monitoring. Add regression coverage and dated validation reports; retain local raw evidence outside version control. This commit does not reinstall any production runtime. --- .gitignore | 1 + CHANGELOG.md | 41 ++ README.md | 13 + docs/incident-recovery-fix-2026-10-16.md | 58 ++ ...otification-space-safety-fix-2026-10-17.md | 65 ++ docs/rebind-compatibility-2026-10-16.md | 58 ++ internal/cli/fs.go | 138 +++- internal/cli/fs_deferred_open.go | 8 +- internal/cli/fs_deletion_isolation_test.go | 70 ++ internal/cli/fs_enroll.go | 24 + internal/cli/fs_maintenance_yield_test.go | 26 + internal/cli/fs_managed_status.go | 15 +- internal/cli/fs_managed_status_test.go | 25 +- internal/cli/fs_migrate_scope_test.go | 91 +++ internal/cli/fs_namespace_live_darwin_test.go | 305 ++++++++ internal/cli/fs_namespace_retirement.go | 86 +++ internal/cli/fs_namespace_retirement_test.go | 151 ++++ internal/cli/fs_reload_cancellation_test.go | 19 + internal/cli/fs_service.go | 9 + internal/cli/fs_service_live_update_darwin.go | 5 +- internal/cli/fs_state_recovery.go | 10 + internal/cli/fs_test.go | 4 +- internal/fold/fold.go | 6 +- internal/fold/fold_test.go | 22 + internal/fskitproto/client.go | 76 +- internal/fskitproto/client_timeout_test.go | 98 +++ internal/fskitstatus/status.go | 11 + internal/mountfs/filesystem.go | 103 ++- internal/mountfs/filesystem_test.go | 7 +- .../mountfs/foreground_maintenance_test.go | 59 ++ internal/mountfs/managed_namespace.go | 674 ++++++++++++++++++ .../managed_namespace_live_darwin_test.go | 171 +++++ internal/mountfs/managed_namespace_test.go | 480 +++++++++++++ internal/mountfs/native_clone_darwin.go | 9 + internal/mountfs/native_clone_other.go | 9 + internal/mountfs/native_fskit_diagnostics.go | 65 ++ .../mountfs/native_fskit_diagnostics_test.go | 37 + .../mountfs/native_fskit_request_health.go | 51 ++ .../native_fskit_request_health_test.go | 45 ++ internal/mountfs/native_fskit_server.go | 40 +- internal/mountfs/native_preflight.go | 18 + internal/mountfs/native_preflight_cache.go | 166 +++-- internal/mountfs/native_repair.go | 367 ++++++++++ internal/mountfs/native_repair_test.go | 245 +++++++ .../read_accounting_diagnostic_test.go | 4 +- internal/pack/foreground_maintenance_test.go | 158 ++++ internal/pack/gc_guard.go | 20 +- internal/pack/recovery.go | 8 +- internal/pack/retire.go | 6 +- .../service/native_fskit_operations_darwin.go | 6 +- internal/storage/copy_context.go | 24 + internal/storage/foreground.go | 144 ++++ internal/storage/foreground_test.go | 102 +++ internal/storage/gc.go | 2 +- internal/storage/guard.go | 123 ++++ internal/storage/guard_shared_test.go | 123 ++++ internal/storage/lease.go | 12 + internal/storage/proof.go | 37 +- internal/storage/read_reserve.go | 88 +++ internal/storage/read_reserve_test.go | 36 + internal/vfs/append_no_space_test.go | 145 ++++ internal/vfs/compact.go | 11 +- internal/vfs/foreground_maintenance_test.go | 51 ++ internal/vfs/handles.go | 32 +- internal/vfs/session.go | 28 +- internal/vfs/session_deletion.go | 2 +- .../vfs/session_deletion_object_darwin.go | 2 +- ...session_deletion_provenance_darwin_test.go | 99 +++ internal/vfs/session_deletion_purge.go | 54 +- platform/darwin/fskit/Host/Host.swift | 50 ++ .../fskit/Host/IncidentMonitorMain.swift | 2 +- .../fskit/Host/IncidentPresentation.swift | 141 +++- platform/darwin/fskit/Host/Localization.swift | 16 + platform/darwin/fskit/Host/StatusModel.swift | 80 ++- .../Tests/IncidentPresentationTests.swift | 71 ++ .../fskit/Host/Tests/StatusModelTests.swift | 80 +++ 76 files changed, 5560 insertions(+), 178 deletions(-) create mode 100644 docs/incident-recovery-fix-2026-10-16.md create mode 100644 docs/notification-space-safety-fix-2026-10-17.md create mode 100644 docs/rebind-compatibility-2026-10-16.md create mode 100644 internal/cli/fs_deletion_isolation_test.go create mode 100644 internal/cli/fs_maintenance_yield_test.go create mode 100644 internal/cli/fs_migrate_scope_test.go create mode 100644 internal/cli/fs_namespace_live_darwin_test.go create mode 100644 internal/cli/fs_namespace_retirement.go create mode 100644 internal/cli/fs_namespace_retirement_test.go create mode 100644 internal/cli/fs_reload_cancellation_test.go create mode 100644 internal/fskitproto/client_timeout_test.go create mode 100644 internal/mountfs/foreground_maintenance_test.go create mode 100644 internal/mountfs/managed_namespace.go create mode 100644 internal/mountfs/managed_namespace_live_darwin_test.go create mode 100644 internal/mountfs/managed_namespace_test.go create mode 100644 internal/mountfs/native_clone_darwin.go create mode 100644 internal/mountfs/native_clone_other.go create mode 100644 internal/mountfs/native_fskit_diagnostics.go create mode 100644 internal/mountfs/native_fskit_diagnostics_test.go create mode 100644 internal/mountfs/native_fskit_request_health.go create mode 100644 internal/mountfs/native_fskit_request_health_test.go create mode 100644 internal/mountfs/native_repair.go create mode 100644 internal/mountfs/native_repair_test.go create mode 100644 internal/pack/foreground_maintenance_test.go create mode 100644 internal/storage/copy_context.go create mode 100644 internal/storage/foreground.go create mode 100644 internal/storage/foreground_test.go create mode 100644 internal/storage/guard_shared_test.go create mode 100644 internal/storage/read_reserve.go create mode 100644 internal/storage/read_reserve_test.go create mode 100644 internal/vfs/append_no_space_test.go create mode 100644 internal/vfs/foreground_maintenance_test.go create mode 100644 internal/vfs/session_deletion_provenance_darwin_test.go diff --git a/.gitignore b/.gitignore index c3254d8..9fa9f28 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ /*.sqlite-shm /*.sqlite-wal *.prof +*.profraw *.test .DS_Store .worktrees/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 67839d2..1568926 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,47 @@ pre-1.0, so a minor version may contain compatibility changes. ## [Unreleased] +### Fixed + +- Foreground session opens can ask registered background maintenance to yield + and retry without bypassing writer locks. Copy-on-write budget checks reuse + a shared inventory instead of scanning the store while holding file locks; + current free space is still checked for each operation. +- A failed deletion cleanup no longer blocks unrelated managed-session reloads. + macOS provenance-only metadata changes are accepted only when the original + deletion proof recorded that metadata form; unknown materials remain intact. +- Transport health probes have one bounded deadline. Slow inventory refreshes + no longer manufacture a session-access outage; actual outstanding FSKit file + requests retain the ten-second incident threshold and bounded diagnostics. +- Failed or short appends roll back only that append, and storage budget errors + map to ENOSPC. A verified 256 KiB read-lease reserve can be released under + disk pressure without bypassing reader/collector coordination. +- macOS incidents use quiet, deduplicated system notifications instead of + automatically activating a fault window. Recovery removes notifications; + denied permission does not fall back to a modal window. Read-only disk-space + monitoring still works when status files cannot be written. +- Daemon live updates accept a bounded `--takeover-timeout` for slower stores; + the default remains nine seconds and failed takeovers retain rollback. +- Canonical managed rollouts now support same-namespace backup renames and + native temporary-file replacement used by `jstarctl codex rebind`. Durable + names preserve backups and old readers across replacement/reload; stable + replacements fold in the background, respect the automatic-folding policy, + and do not get enrolled again under their logical Codex thread ID. +- A damaged active rollout no longer keeps the mount down. After the disk + filled, about 10 KB of binary memory was left inside one rollout; the + startup preflight rejected the file, `fs serve` exited, and launchd restarted + it every two seconds with no mount, so Codex hung on a dead + `~/.codex/sessions`. The startup preflight now repairs such files: damaged + records and an unfinished final record are moved to + `/.codexfold-native-quarantine/` with a manifest that records + their offsets and SHA-256, and the rollout is rewritten through an atomic + rename of a clone (APFS clones share unchanged blocks, so this needs little + free space). Mode and modification time are kept. A complete final record + that only lacks its newline gets the newline. Anything that cannot be + repaired is logged and served unchanged instead of aborting startup. +- `fs validate-native --repair` applies the same repair by hand while the + filesystem service is stopped. Without the flag the command stays read-only. + ## [0.4.0-beta.2] - 2026-10-01 ### Added diff --git a/README.md b/README.md index 92f1570..52ca578 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,19 @@ The transparent filesystem preview has these explicit boundaries: See [the Linux FUSE3 validation](docs/validation-linux-fuse3.md) and [the macOS canary validation](docs/validation-macos-canary.md) for the evidence boundary. The default build remains storage-only; platform mounts require explicit build tags and installed host prerequisites. +### Unreleased local fixes + +Development since the release adds foreground-priority storage access, +durable backup/replacement names, isolated deletion cleanup, native-rollout +repair, bounded transport health checks, disk-full append recovery and quiet +macOS system notifications. These changes are not in the published archives. +The dated [replacement compatibility](docs/rebind-compatibility-2026-10-16.md), +[incident recovery](docs/incident-recovery-fix-2026-10-16.md), and +[notification and disk-space](docs/notification-space-safety-fix-2026-10-17.md) +reports distinguish the exact locally installed subsets from the complete +development source. Combining those changes does not by itself validate a new +production binary; this source update does not reinstall the running service. + ## Install Install the versioned preview with Go: diff --git a/docs/incident-recovery-fix-2026-10-16.md b/docs/incident-recovery-fix-2026-10-16.md new file mode 100644 index 0000000..aed9fde --- /dev/null +++ b/docs/incident-recovery-fix-2026-10-16.md @@ -0,0 +1,58 @@ +# 2026-10-16 删除清理阻断与健康检查超时修复 + +## 生产结果 + +2026-10-16 15:25(Asia/Taipei)完成 daemon 热更新,15:26 完成 supervisor 更新。没有退出/重启 Codex、ChatGPT 或它们的 app-server,没有卸载原挂载,没有修改自动折叠策略。 + +- 版本:`v0.4.0-beta.2+recovery-fix`。 +- 安装及运行 daemon SHA256:`1b435bfde05365408f192d144d78b35721ded1fcff5fac339bf489da750c0670`。 +- 安装 helper 有 Apple Development 签名,identifier 为 `vip.jstar.codexfold.helper`,TeamIdentifier 为 `Y987FUR837`。 +- daemon PID 从 1140 换为 39946;supervisor 从 2445 换为 40517。新 supervisor 的 executable 映射为已更新的 helper。 +- 原挂载 device/inode 仍为 `805306372 / 2`;热更新命令也验证了 FSID 保持。 +- 三条卡在 `renamed` 的删除清理均已自然推进到 `complete`,各自 staging 子目录为空。没有重写生产 receipt,没有强行删除 staging。 +- `status/managed.json` 从 14:46 起持续 `recovering` 恢复为 `healthy`,新 registry 已在 15:25:40 发布;多轮后续观察仍健康。 +- 自动折叠保持 `enabled=false`,policy 文件 SHA256 与更新前一致。 + +## 已修复的机制 + +1. 删除 cleanup 的失败不再使整个 managed reload 提前返回。解除已删会话路由失败仍阻止发布;后续磁盘清理失败单独记录,不再阻断无关会话刷新。 +2. macOS 的 `com.apple.provenance` 变化不再被误报为对象类型或权限变化。只允许此前采集器已确认的 provenance 元数据形态;inode、mode、内容 SHA256、未知文件、其他 xattr、ACL、BSD flags 等保护仍保留。原 receipt 自身的整体摘要验证没有取消。 +3. 健康检查的 Unix socket 连接、hello 和 Ping 共用两秒 context;完整帧读写有 deadline。超时后关闭连接,防止迟到响应与后续请求错配。 +4. 真实 FSKit 请求超过十秒会保存限频现场 goroutine 栈,写到 `~/.codex/fold-store/service/logs/blocked-io.txt`。最多每分钟一次、固定文件覆盖、最多约 4MiB,不记录请求正文。 + +## 验证 + +精确候选以此前真实生产源码 `.tmp/rename-compat-20261015/source/` 为基底,只叠加本次十三个文件的差异。没有把工作区其他贡献者尚未部署的 native repair 等改动混入此次生产候选。 + +- 候选源码 `go test ./... -count=1 -timeout=240s` 全部通过。 +- 新增 timeout、provenance、cleanup isolation、stall diagnostic 测试的 race 检查通过。 +- 正确签名后的候选在真实隔离 FSKit 挂载上通过两个测试:原命名空间 replacement/reload/后台 fold/后端接管,以及未知删除材料保留时其他会话仍能 reload。 +- 生产五条重点会话的首 64KiB 和更新前固定 EOF 前 64KiB SHA256 均保持一致;允许活跃会话追加,不把追加误判为损坏。末轮读取耗时 1–74ms。这是固定区域内容验证,不是全库全字节一致性证明。 +- 曾报告 busy 的 `019ec5b9-44d4-7c82-805b-88df6e731993` 额外从生产路径顺序读到 EOF,230,591,060 字节,无文件系统错误。 +- app 内置会话读取接口成功取回 `01a03c6c-f052-76b3-bfe4-0ea30aa82904` 和 `01a07688-b3e6-7a20-802a-63fda3916afa` 的最近一轮。前一条 busy 会话通过该接口返回 `thread/items/list is not supported yet`,因此不把它记作 Desktop UI 打开成功,也不把该接口不支持误说成 FSKit busy。 +- 客户端与两个 app-server PID 前后完全一致。未占用鼠标,未向上述会话发消息。 + +## 证据位置 + +本地 `.tmp/incident-20261016/`: + +- `findings.md`:部署前只读诊断及故障时间线。 +- `regression.log`、`race.log`、`signed-mounted.log`:候选测试。 +- `source/`、`codexfold`:本次精确候选源码与签名二进制。 +- `live-update.json`:热更新结果与旧二进制恢复路径。 +- `service-before.json`、`service-after.json`:生产 build 与后台 PID。 +- `production-before.json`、`production-after-final.json`:会话固定区域和客户端进程对照。 +- `busy-session-full-read.json`:busy 会话全文件顺序读取结果。 +- `deletion-receipts-before.tar`:三条原 purge receipt 的小型本地证据副本,不含整份会话备份。 + +## 边界与恢复 + +14:17 那次最早的客户端阻塞发生在上述三条删除任务之前,旧进程也没有现场栈,不能声称已证明它与后来 cleanup 错误是同一个原因。本次修复了可复现的健康检查无限等待和生产持续 reload 失败;不能据此保证所有未知阻塞永不出现。 + +若再次阻塞,优先保留 `blocked-io.txt`、当前状态文件及日志,再判断具体卡住的调用。不要通过重写删除凭据、无条件移除 staging、重启客户端来代替诊断。 + +本次旧 helper 保存在: + +`~/Library/Application Support/CodexFold/Recovery/live-update-20261016-072532/codexfold-previous-1556979473` + +旧版 SHA256 为 `50c491156e673a9ad291f653b55f8150e416f99acedfd5601d7aa39bf327d101`。它保留当前 namespace 兼容能力,可作为此次变更的紧急回退候选,但包含此次修复前的健康检查和 cleanup 缺陷。回退使用现有 `fs service update-daemon-live --apply --json`,先确认自动折叠闲置、当前 daemon/supervisor/mount 可识别且健康;不要覆盖正在执行的二进制或降到不识别当前 namespace 的旧版。需要 supervisor 回退时只终止经 lock 和命令行核实的那个 supervisor,由原 host 接管,不能使用会卸载挂载的 stop/restart 命令。 diff --git a/docs/notification-space-safety-fix-2026-10-17.md b/docs/notification-space-safety-fix-2026-10-17.md new file mode 100644 index 0000000..d20660d --- /dev/null +++ b/docs/notification-space-safety-fix-2026-10-17.md @@ -0,0 +1,65 @@ +# CodexFold 故障通知与磁盘满修复(2026-10-17) + +## 本机行为 + +- 故障默认使用 macOS 系统通知,无声音,不自动打开故障窗口、不抢焦点、不让 Dock 紧急跳动。只有用户点击对应通知才打开详情。 +- 通知首次使用安静的 provisional 授权,不主动弹权限对话框;通知权限被拒绝时仅保留状态栏和历史,不回退为模态弹窗。 +- 同一故障只发送一次;故障恢复后移除通知。UI 重启后观察到可信健康状态,也会清理上一轮已记录的通知。点击旧通知不会打开另一场故障。 +- 这是本机静默通知策略:provisional 通知通常进入通知中心,不保证出现横幅。最终显示仍受系统权限、勿扰和系统通知服务影响。 + +## 修复内容 + +1. 完整状态刷新超过 20 秒,不再直接被当作会话访问故障;实际错误仍保留到成功观测,不能由心跳抹掉。 +2. 跟踪实际未完成的 FSKit 文件请求,排除 Hello/Ping;请求持续超过 10 秒仍会报告问题,完成后报告恢复。 +3. 磁盘满时追加失败/短写,在独占写入锁内回退到本次追加前的 EOF,保留之前成功写入的内容,并向客户端返回失败供其重试。 +4. 每个存储目录仅保留 256 KiB 应急预留。读取所需锁文件遇到 ENOSPC 时释放预留并有界重试,不绕过锁,不删除未知材料;它不是会话备份。 +5. 存储预算不足向文件系统返回 ENOSPC;磁盘满造成的状态写失败日志按 30 秒限频。 +6. UI 通过只读 statfs 检查空间,即使状态文件无法写入也能识别空间耗尽;低于 16 MiB 进入故障判断,恢复到 64 MiB 后退出,避免低位反复闪动。 +7. 正常停止引发的 context.Canceled 不再发布新的 reload 故障。 + +物理空间耗尽时,不能承诺新写入成功;本次保证的是已验证场景中的原有内容保护、错误准确返回、读取应急能力以及释放空间后可重试。系统通知服务自己也可能因权限或系统故障无法展示,程序不会因此重新抢焦点弹窗。 + +## 已运行的验证 + +本地证据目录:仓库内 `.tmp/pressure-fix-20261017/`(原始运行材料不随公开源码上传)。 + +- `regression-final.log`:候选后台全量 Go 回归通过;后续小改另有 `release-cli.log` 与 `reload-final.log`。 +- `race.log`:涉及请求健康、状态、追加、预留空间的 race 测试通过。 +- `release-mounted.log`:签名后台真实隔离挂载、reload、fold、热接管和 cleanup 失败场景通过。 +- `full-volume-vfs.log`:真实 APFS 写满至 ENOSPC,已有/新读取、失败追加回退、释放空间后重试/重开通过。 +- `full-volume-mounted.log`:真实 FSKit 挂载 + 满 APFS;原生和已折叠文件可读,失败写入未改变已有落盘内容,释放空间后通过挂载路径追加成功。 +- `notification-tests-final.log`:104 项测试全部通过,0 failures / 0 skipped。覆盖通知去重、点击才打开、旧通知隔离、重启后恢复清理,以及真实小 APFS 满盘时无状态写入仍生成通知。 +- `notification-channel.log` 与 `notification-status.json`:本机生产 App 的系统通知通道。系统 usernoted 已记录 provisional 授权、接收请求和投递至通知中心;不是把 mock 测试当作系统投递证明。 +- `notification-status-after-cleanup.json`:测试通知已撤回,delivered_requests 为空。临时 256 MiB APFS 镜像已卸载并删除。 + +这不等于所有未知性能问题都已永久消失。后台此前仍有偶发连接超时记录;本次移除了慢扫描制造的假故障,并保留真实长请求的检测。 + +## 上线与可恢复性 + +后台从上一版实际生产源码 `.tmp/route-follow-20261016/source` 派生,只叠加本次修复;精确候选在 `.tmp/pressure-fix-20261017/source`。没有把工作区其他未上线变更一起部署。 + +当前后台版本:`v0.4.0-beta.2+space-safe`,SHA256: + +`3faef9d1eb28b9a8d28c448a7c0c2be9586c5e96bc1ecf7499c3c4798a672353` + +第一次使用默认 9 秒热接管未确认成功,已自动回退;第二次用 30 秒接管成功。该参数不改变 10 秒故障通知阈值。部署证据见 `live-update-30s.json`。 + +上一个后台保存在: + +`~/Library/Application Support/CodexFold/Recovery/live-update-20261017-150638/codexfold-previous-1431727526` + +UI 仅替换主程序、常驻监测程序及匹配签名资源,不改变 FSKit 扩展。监测程序须使用与 SMAppService 登记一致的 signing identifier:`vip.jstar.codexfold.fskitprofileprobe.incident-monitor`,Team `Y987FUR837`。首次刷新后系统曾保留旧身份,再次刷新自身注册后已实际运行;不能仅凭 register() 成功判断常驻正常。 + +更新前后生产挂载 device/inode 均为 `805306372 / 2`,扩展 SHA256 保持: + +`2a897911ffb411fd12002193848af503ab7ce5703c7ec55ab4c6116c9dff704d` + +未退出、重启或信号 Codex/ChatGPT/app-server,未卸载生产挂载。五条重点会话的首 64 KiB 与更新前 EOF 处 64 KiB 校验一致;活跃会话可继续追加。这是定点连续性检查,不是对全部历史数据逐字节重新认证。 + +自动折叠开关仍为关闭,策略文件 SHA256 未变: + +`61866193bc35953e23b957371184eb7fc4813fd5d1cb1503c18b38c619c74d74` + +UI 恢复材料位于 `Recovery/ui-pressure-20261017-*`,本轮最终替换前备份为 `Recovery/ui-pressure-20261017-notifications-final`。恢复必须成组恢复主程序、监测程序和 CodeResources,不能只换其中一份签名文件。后台恢复或挂载生命周期操作不在本文中自动执行,仍需当次明确授权。 + +最终生产检查:后台 PID 51231、supervisor PID 51931、菜单栏 PID 75240、monitor PID 75244 均实际运行;service status 的 daemon_running / supervisor_running / mount_healthy / build.healthy 均为 true。PID 是本次快照,不作为下次操作目标。客户端原 PID 2782 和 3316 及启动时间保持;进程清单另出现 computer-use 的 `cua_node/bin/node_repl` 派生的一个 app-server,因此 `processes_unchanged` 为 false,不能把完整清单相同作为结论,`original_processes_intact` 才是原进程未变的定点检查。 diff --git a/docs/rebind-compatibility-2026-10-16.md b/docs/rebind-compatibility-2026-10-16.md new file mode 100644 index 0000000..e29d69c --- /dev/null +++ b/docs/rebind-compatibility-2026-10-16.md @@ -0,0 +1,58 @@ +# CodexFold 与 jstarctl rebind 兼容修复记录 + +2026-10-16 00:14(Asia/Taipei)已在线安装到本机生产 daemon。未重启、退出或重新启动 Codex/ChatGPT,未卸载生产挂载,未修改真实会话的 provider 或 SQLite 行。 + +## 原因与修复 + +原实现把托管 rollout 的改名限制为 `.jsonl → .jsonl`,不支持 rebind 使用的完整流程:原文件改名为带时间戳的 `.bak`,临时文件再替换原路径。失败不是 OAuth 或 API 配置问题。 + +本次增加独立、持久化的文件路径记录: + +- 托管文件改名为 `.bak` 只改路径,不展开旧内容,也不修改旧内容;旧文件描述符继续指向原文件。 +- 新临时文件先通过原生 rename 快速替换,不在前台 rename 内做全文件折叠。 +- 新文件关闭后,后台折叠准备完成、源文件身份及大小/时间仍匹配、且没有新打开的 native handle,才切换为托管文件。准备期间发生追加、删除、改名或恢复备份,旧准备结果不生效。 +- 自动折叠开关仍控制新增后台折叠;原有压缩/回收证明不放宽。 +- 文件替换后的内部文件身份与 Codex 的逻辑 thread ID 分开。索引重载、重复纳管判断、状态恢复不再把备份或替换文件误认为未完成的 SQLite 迁移。 +- 改名意图写入后进程中断,启动按原生文件身份恢复,不猜测内容,也不覆盖有歧义的材料。 + +## 实际验证 + +所有证据位于仓库 `.tmp/rename-compat-20261015/`。 + +| 验证 | 结果 / 证据 | +| --- | --- | +| 最终生产候选源码 `go test ./... -count=1 -timeout=300s` | 全部通过;`candidate-regression-final.log` | +| mountfs / CLI 新行为与恢复路径的 `-race` | 通过;`race-final.log` | +| 真实 FSKit 隔离挂载 + 本机已安装的 `jstarctl codex rebind` | 最终 64 MiB 样本通过;rebind 4.165 秒,完整测试 13.06 秒;`mounted-rebind-final.log` | +| 真实挂载上的替换后追加、后台折叠切换后读回/再追加、备份字节不变、旧 FD 不变 | 同一最终 mounted 测试通过 | +| 签名候选 daemon + 真实 FSKit 隔离挂载 + 真实索引重载、后台折叠、daemon 重新连接 | 通过;12.85 秒;`signed-daemon-reload-final.log`;无 reload deferred / worker cleanup / enrollment cycle failed 报错 | +| 生产在线更新 | 成功;`live-update.json`;挂载身份保持不变 | +| 5 个相关真实会话的只读核对 | 更新前后文件大小、首尾各 64 KiB SHA-256 和 provider 均一致;`production-before.json` / `production-after.json` | +| 客户端进程 | ChatGPT PID 15490、app-server PID 15697 均保持不变 | +| 自动折叠设置 | 临时暂停后原样恢复;原/现 policy SHA-256 均为 `49c4e5bbf5f4bc93065df48f1c88caa3fea1dde4358e3a3c1530c61e2afa71b6` | + +此前 256 MiB 的异步 rename 版本也通过了实际 rebind,但不代替上述最终候选测试。更早同步压缩设计的 1 GiB 测试曾超时,该设计已放弃;这次没有声称最终版本通过了 1 GiB rebind。失败测试的独立挂载与临时原件已清理,生产挂载未动。 + +## 安装身份 + +- 安装位置:`~/Library/Application Support/CodexFold/codexfold` +- 版本:`v0.4.0-beta.2+rename-compat` +- 运行/安装 SHA-256:`50c491156e673a9ad291f653b55f8150e416f99acedfd5601d7aa39bf327d101` +- 签名 identifier:`vip.jstar.codexfold.helper`;与现有启动 App 相同 Team,严格签名校验通过。 +- daemon:973 → 59586;supervisor:3435 不变。 +- 生产挂载:`~/.codex/fold-fs`,健康。 +- 自动折叠:enabled;30 分钟;stable_for 1 小时。未调整用户参数。 + +生产候选基于上一轮已上线的 foreground-priority 源码,只叠加本轮兼容修复。工作区里另一个贡献者的 native 自动修复改动保留原样,未混入这个生产二进制。精确生产源码保留在 `.tmp/rename-compat-20261015/source/`。 + +## 使用与恢复边界 + +原来的 `jstarctl codex rebind` 命令无需改成“只改 SQLite”或“强制原地写”。本次没有代替用户执行真实会话的 provider 切换;实际 rebind 验收使用独立 HOME、SQLite 和合成测试数据,但文件操作经过本机真实 FSKit 模块和相同核心代码。 + +备份仍是备份:未显式删除的 `.bak` 保留原字节,不会自动当垃圾清除。新内容在前台替换后短暂以 native 文件存在;后台切为托管后,私有恢复 snapshot 仍由既有 pack-only 证明与回收流程退役,不承诺 rename 返回即回收全部磁盘空间。 + +恢复 `.bak` 覆盖一个仍有 native descriptor/追加事务的目标时,会安全拒绝这次覆盖,避免让原 writer 误写另一个文件;这不妨碍当前会话正常读写,也不是后台折叠时拒绝用户打开会话。 + +**不要在发生备份/替换后盲目降级到不认识 `fs/namespace.json` 的旧 daemon。** 旧 binary 已由更新命令保存在 Recovery,但旧版忽略新的路径映射,不能保证显示正确的当前文件。后续修复和恢复必须使用支持该路径格式的版本,并保留 namespace、manifest、托管状态与 pack/loose 数据。此次换版接管校验时生产尚无本轮改名记录,更新命令的即时失败回退不会跨越已使用的新格式。 + +这里证明的是本次 rebind 文件操作兼容性、真实挂载读写、后台切换与重载恢复,不把它扩大为“所有可能的外部文件操作均无条件支持”或长期性能验收。 diff --git a/internal/cli/fs.go b/internal/cli/fs.go index 26f6353..8083d9c 100644 --- a/internal/cli/fs.go +++ b/internal/cli/fs.go @@ -148,12 +148,19 @@ func newFSRetireNativeCommand() *cobra.Command { Use: "retire-native ", Short: "Retire a canonical native snapshot after pack-only recovery proof", Args: cobra.ExactArgs(1), - RunE: func(command *cobra.Command, args []string) error { + RunE: func(command *cobra.Command, args []string) (runErr error) { home, err := codex.ResolveHome(codexHome) if err != nil { return err } store := resolveFoldStore(home, storeDir) + maintenance, err := storage.BeginMaintenance(command.Context(), store) + if err != nil { + return err + } + defer maintenance.Close() + defer func() { runErr = maintenance.YieldError(runErr) }() + command.SetContext(maintenance.Context()) state, err := nativeRetirementState(command.Context(), store, args[0]) if err != nil { return err @@ -290,6 +297,7 @@ func newFSValidateNativeCommand() *cobra.Command { var codexHome string var nativeRoot string var auditAll bool + var repairDamaged bool var jsonOutput bool command := &cobra.Command{ Use: "validate-native", @@ -316,10 +324,19 @@ func newFSValidateNativeCommand() *cobra.Command { } else { filesystem := mountfs.NewCanonical() filesystem.SetNativeRoot(root) - result.Report, err = filesystem.ValidateNativeWriterRollouts(command.Context()) - if err != nil { - result.Healthy = false - result.Issues = []mountfs.NativePreflightIssue{{Message: err.Error()}} + if repairDamaged { + result.Report, err = filesystem.RepairNativeWriterRollouts(command.Context()) + result.Issues = result.Report.Issues + result.Healthy = err == nil && len(result.Issues) == 0 + if err != nil { + result.Issues = []mountfs.NativePreflightIssue{{Message: err.Error()}} + } + } else { + result.Report, err = filesystem.ValidateNativeWriterRollouts(command.Context()) + if err != nil { + result.Healthy = false + result.Issues = []mountfs.NativePreflightIssue{{Message: err.Error()}} + } } } if jsonOutput { @@ -340,6 +357,7 @@ func newFSValidateNativeCommand() *cobra.Command { command.Flags().StringVar(&codexHome, "codex-home", "", "Codex home directory; defaults to CODEX_HOME or ~/.codex") command.Flags().StringVar(&nativeRoot, "native-root", "", "Native rollout root; defaults to /fold-native") command.Flags().BoolVar(&auditAll, "audit-all", false, "Bypass the cache and report every invalid active rollout") + command.Flags().BoolVar(&repairDamaged, "repair", false, "Quarantine damaged records and rewrite the rollout; only while the filesystem service is stopped") command.Flags().BoolVar(&jsonOutput, "json", false, "Emit JSON output") return command } @@ -690,6 +708,9 @@ func newFSServeCommand() *cobra.Command { if canonicalNamespace { filesystem = mountfs.NewCanonical() filesystem.SetNativeRoot(nativeRoot) + if err := filesystem.SetManagedStore(store); err != nil { + return fmt.Errorf("recover managed file names: %w", err) + } filesystem.SetSessionDeletionPublisher(func(state vfs.SessionState, route string) error { _, err := vfs.PublishSessionDeletion(store, state, route) return err @@ -705,9 +726,19 @@ func newFSServeCommand() *cobra.Command { if err := filesystem.RecoverNativeAppendTransactions(); err != nil { return fmt.Errorf("recover native append transactions: %w", err) } - if _, err := filesystem.ValidateNativeWriterRollouts(command.Context()); err != nil { + // A damaged rollout must never keep the mount down: repair it, + // and report anything that cannot be repaired rather than exit. + preflight, err := filesystem.RepairNativeWriterRollouts(command.Context()) + if err != nil { return fmt.Errorf("validate native writer rollouts: %w", err) } + for _, repair := range preflight.Repairs { + _, _ = fmt.Fprintf(command.ErrOrStderr(), "native writer preflight repaired %s: removed %d damaged range(s), %d byte(s) from offset %d, added_final_newline=%t; original bytes kept in %s\n", + repair.Path, repair.RemovedRanges, repair.RemovedBytes, repair.FirstDamagedAt, repair.AddedFinalLF, repair.QuarantinePath) + } + for _, issue := range preflight.Issues { + _, _ = fmt.Fprintf(command.ErrOrStderr(), "native writer preflight could not fix %s: %s; serving it unchanged\n", issue.Path, issue.Message) + } } // Session-load incidents are the only place a client I/O error can be // correlated with the store transition that caused it, so they are @@ -778,9 +809,13 @@ func newFSServeCommand() *cobra.Command { knownPacks := make(map[string]string) lastStateIssueSignature := "" lastReloadError := "" + lastDeletionCleanupError := "" lastMissingKnownSignature := "" var managedReportMu sync.Mutex reportManagedReloadSafe := func(err error) { + if ctx.Err() != nil && errors.Is(err, ctx.Err()) { + return + } managedReportMu.Lock() defer managedReportMu.Unlock() reportManagedReload(command.ErrOrStderr(), err, &lastReloadError) @@ -793,6 +828,7 @@ func newFSServeCommand() *cobra.Command { _, _ = fmt.Fprintf(command.ErrOrStderr(), "write managed session status: %v\n", err) }) defer func() { _ = managedStatus.Close(500 * time.Millisecond) }() + storage.PrepareReadReserve(store) var loadMu sync.Mutex inspectionCache := &vfs.StateInspectionCache{} var managedObservationSequence uint64 @@ -801,7 +837,7 @@ func newFSServeCommand() *cobra.Command { return managedObservationSequence } openState := func(state vfs.SessionState) (*vfs.Session, *pack.Resolver, error) { - managed, resolver, err := openManagedSession(ctx, store, state) + managed, resolver, err := openManagedNamespaceSession(ctx, store, state) if err != nil { return nil, nil, err } @@ -941,11 +977,13 @@ func newFSServeCommand() *cobra.Command { mergeManagedSessionRegistryGenerations(expectedManaged, registry) registryMissing = false } - deleted, err := reconcileManagedSessionDeletionSet(store, filesystem, canonicalNamespace, deletions, known, knownRoutes, knownPacks) + deleted, err, cleanupErr := reconcileManagedSessionDeletionSet(store, filesystem, canonicalNamespace, deletions, known, knownRoutes, knownPacks) if err != nil { observation.Fatal = err return observation } + observation.CleanupError = cleanupErr + reportManagedDeletionCleanup(command.ErrOrStderr(), cleanupErr, &lastDeletionCleanupError) for sessionID := range deleted { delete(expectedManaged, sessionID) registryRemovals[sessionID] = struct{}{} @@ -1118,6 +1156,9 @@ func newFSServeCommand() *cobra.Command { if _, exists := routes[state.SessionID]; !exists { return false } + if routes[state.SessionID] == "" { + return false + } _, retiring, err := readRetirementRequest(store, state.SessionID) return err == nil && !retiring }, openState, 12) @@ -1136,6 +1177,9 @@ func newFSServeCommand() *cobra.Command { } } route, exists := routes[state.SessionID] + if exists && route == "" { + continue + } if !exists { missingRoutes = append(missingRoutes, state.SessionID) } @@ -1234,6 +1278,9 @@ func newFSServeCommand() *cobra.Command { break } } + if ctx.Err() != nil { + return ctx.Err() + } managedStatus.Observe(observation) return observation.Err() } @@ -1265,6 +1312,9 @@ func newFSServeCommand() *cobra.Command { activityCounter = &mountfs.IOActivityCounter{} } runtimeMemoryMaintenanceDone := startRuntimeMemoryMaintenance(ctx, filesystem) + namespaceMaintenanceDone := startNamespaceRetirement(ctx, filesystem, store, func() bool { + return resolveEnrollmentControl(flags, enrollmentInterval).Enabled + }, command.ErrOrStderr()) watcherDone := make(chan struct{}) go func() { defer close(watcherDone) @@ -1278,6 +1328,7 @@ func newFSServeCommand() *cobra.Command { SocketPath: nativeFSKitSocket, ResourcePath: nativeFSKitResource, StatusPath: service.FSKitStatusPath(nativeFSKitResource, "daemon"), MountPoint: mount, Recorder: operationRecorder, + StallDiagnosticPath: filepath.Join(store, "service", "logs", "blocked-io.txt"), Activity: activityCounter, PrewarmSharedMemoryWindows: 4, OnReady: startEnrollment, @@ -1294,6 +1345,7 @@ func newFSServeCommand() *cobra.Command { <-storageStatusDone } <-runtimeMemoryMaintenanceDone + <-namespaceMaintenanceDone <-enrollmentDone var nativeWatcherErr error if nativeWatcherDone != nil { @@ -1434,7 +1486,8 @@ func reconcileManagedSessionDeletions( if err != nil { return nil, err } - return reconcileManagedSessionDeletionSet(store, filesystem, canonicalNamespace, deletions, known, knownRoutes, knownPacks) + deleted, detachErr, cleanupErr := reconcileManagedSessionDeletionSet(store, filesystem, canonicalNamespace, deletions, known, knownRoutes, knownPacks) + return deleted, errors.Join(detachErr, cleanupErr) } func reconcileManagedSessionDeletionSet( @@ -1445,27 +1498,43 @@ func reconcileManagedSessionDeletionSet( known map[string]uint64, knownRoutes map[string]string, knownPacks map[string]string, -) (map[string]struct{}, error) { +) (map[string]struct{}, error, error) { deleted := deletedSessionIDs(deletions) - var replayErrors []error + var detachErrors, cleanupErrors []error for _, deletion := range deletions { if err := filesystem.RemoveSession(deletion.SessionID); err != nil && !errors.Is(err, os.ErrNotExist) { - replayErrors = append(replayErrors, fmt.Errorf("detach deleted managed session %s: %w", deletion.SessionID, err)) + detachErrors = append(detachErrors, fmt.Errorf("detach deleted managed session %s: %w", deletion.SessionID, err)) } delete(known, deletion.SessionID) delete(knownRoutes, deletion.SessionID) delete(knownPacks, deletion.SessionID) if canonicalNamespace { if err := filesystem.HideDeletedSessionAt(deletion.SessionID, deletion.Route); err != nil { - replayErrors = append(replayErrors, err) + detachErrors = append(detachErrors, err) continue } } if _, err := vfs.AdvanceSessionDeletion(store, deletion); err != nil && !errors.Is(err, vfs.ErrSessionDeletionBusy) { - replayErrors = append(replayErrors, fmt.Errorf("replay managed session deletion %s: %w", deletion.SessionID, err)) + cleanupErrors = append(cleanupErrors, fmt.Errorf("replay managed session deletion %s: %w", deletion.SessionID, err)) } } - return deleted, errors.Join(replayErrors...) + return deleted, errors.Join(detachErrors...), errors.Join(cleanupErrors...) +} + +func reportManagedDeletionCleanup(writer io.Writer, err error, previous *string) { + current := "" + if err != nil { + current = err.Error() + } + if current == *previous { + return + } + if current == "" { + _, _ = fmt.Fprintln(writer, "managed session deletion cleanup recovered") + } else { + _, _ = fmt.Fprintf(writer, "managed session deletion cleanup deferred; unrelated sessions continue to refresh: %v\n", err) + } + *previous = current } func reportManagedStateIssues(writer io.Writer, issues []vfs.SessionStateIssue, previous *string) { @@ -1628,6 +1697,9 @@ func runManagedReloadLoopWithDelay(ctx context.Context, delayState managedReload case <-timer.C: } err := load() + if ctx.Err() != nil { + return + } report(err) delay = delayState.next(err, time.Now()) } @@ -1878,7 +1950,9 @@ func newFSMigrateCommand() *cobra.Command { if _, err := mountfs.ValidateNativeRollout(command.Context(), sourcePath); err != nil { return fmt.Errorf("native rollout is not eligible for transparent routing: %w", err) } - shadow, err := fsctl.Shadow(command.Context(), sourcePath, view, fsctl.ShadowOptions{RandomReads: 10000, Seed: 1}) + // Shadow compares every byte and re-hashes the source afterwards. + // Random-access stress belongs in filesystem tests, not every cutover. + shadow, err := fsctl.Shadow(command.Context(), sourcePath, view, fsctl.ShadowOptions{}) if err != nil { return err } @@ -1899,9 +1973,10 @@ func newFSMigrateCommand() *cobra.Command { if currentNative.Bytes != native.Bytes || currentNative.SHA256 != native.SHA256 { return errors.New("native session changed during writer probe; migration was not applied") } - if err := requireStorageHealth(command.Context(), store); err != nil { - return err - } + // This session has a complete byte-for-byte shadow proof above. + // Its native snapshot remains until retirement proves pack-only + // recovery. Auditing the whole store here repeats that work for + // every session in a batch without strengthening this cutover. if compatibilityCanary { userHome, err := os.UserHomeDir() if err != nil { @@ -2614,6 +2689,15 @@ func recoverInterruptedCanonicalMigrationWithOptions( if filepath.Clean(state.NativeSnapshot.Path) != filepath.Clean(retainedPath) { return false, nil } + // Explicit filesystem names (backups and replacement inodes) are not + // interrupted SQLite migrations. Their durable namespace is authoritative. + routes, err := mountfs.ManagedNamespaceRoutes(store) + if err != nil { + return false, err + } + if _, named := routes[state.SessionID]; named { + return false, nil + } deletionLock, err := storage.AcquireOperationLock(store, "session-deletions") if err != nil { return false, fmt.Errorf("serialize interrupted migration recovery with explicit deletion: %w", err) @@ -2895,6 +2979,14 @@ func openFoldView(home string, store string, sessionID string) (codex.Session, f } func openManagedSession(ctx context.Context, store string, state vfs.SessionState) (*vfs.Session, *pack.Resolver, error) { + return openManagedSessionWithNamespace(ctx, store, state, false) +} + +func openManagedNamespaceSession(ctx context.Context, store string, state vfs.SessionState) (*vfs.Session, *pack.Resolver, error) { + return openManagedSessionWithNamespace(ctx, store, state, true) +} + +func openManagedSessionWithNamespace(ctx context.Context, store string, state vfs.SessionState, namespace bool) (*vfs.Session, *pack.Resolver, error) { manifest, err := fold.LoadManifestPath(state.ManifestPath) if err != nil { return nil, nil, err @@ -2903,7 +2995,11 @@ func openManagedSession(ctx context.Context, store string, state vfs.SessionStat if err != nil { return nil, nil, err } - managed, err := vfs.OpenSession(ctx, vfs.SessionOptions{Root: store, ManifestPath: state.ManifestPath, Manifest: manifest, Reader: resolver, NativeSnapshot: state.NativeSnapshot}) + var reader vfs.ObjectReader = resolver + if namespace && strings.HasPrefix(state.SessionID, "file-") { + reader = mountfs.NamespaceReader(store, resolver) + } + managed, err := vfs.OpenSession(ctx, vfs.SessionOptions{Root: store, ManifestPath: state.ManifestPath, Manifest: manifest, Reader: reader, NativeSnapshot: state.NativeSnapshot}) if err != nil { _ = resolver.Close() return nil, nil, err @@ -3134,7 +3230,7 @@ func startStorageMaintenance( defer close(done) defer debug.FreeOSMemory() result, ran, err := run(ctx, store) - if err != nil && !errors.Is(err, context.Canceled) { + if err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, storage.ErrManagedSessionBusy) { _, _ = fmt.Fprintf(diagnostics, "storage maintenance failed: %v\n", err) } else if ran && result.RetainedUnprovedCount != 0 { _, _ = fmt.Fprintf(diagnostics, "storage maintenance retained %d candidate(s) (%d apparent bytes) without exact durable deletion proof\n", result.RetainedUnprovedCount, result.RetainedUnprovedApparentBytes) diff --git a/internal/cli/fs_deferred_open.go b/internal/cli/fs_deferred_open.go index e534d50..1847d53 100644 --- a/internal/cli/fs_deferred_open.go +++ b/internal/cli/fs_deferred_open.go @@ -2,8 +2,10 @@ package cli import ( "context" + "strings" "github.com/samekind/codexfold/internal/fold" + "github.com/samekind/codexfold/internal/mountfs" "github.com/samekind/codexfold/internal/pack" "github.com/samekind/codexfold/internal/vfs" ) @@ -19,8 +21,12 @@ func openManagedSessionDeferred(ctx context.Context, store string, state vfs.Ses if err != nil { return nil, nil, err } + var reader vfs.ObjectReader = resolver + if strings.HasPrefix(state.SessionID, "file-") { + reader = mountfs.NamespaceReader(store, resolver) + } managed, err := vfs.OpenSession(ctx, vfs.SessionOptions{ - Root: store, ManifestPath: state.ManifestPath, Manifest: manifest, Reader: resolver, + Root: store, ManifestPath: state.ManifestPath, Manifest: manifest, Reader: reader, NativeSnapshot: state.NativeSnapshot, DeferRecovery: true, }) if err != nil { diff --git a/internal/cli/fs_deletion_isolation_test.go b/internal/cli/fs_deletion_isolation_test.go new file mode 100644 index 0000000..1d4e4de --- /dev/null +++ b/internal/cli/fs_deletion_isolation_test.go @@ -0,0 +1,70 @@ +package cli + +import ( + "errors" + "os" + "path/filepath" + "strings" + "syscall" + "testing" + "time" + + "github.com/samekind/codexfold/internal/mountfs" + "github.com/samekind/codexfold/internal/vfs" +) + +func TestManagedDeletionCleanupFailureDoesNotBlockOtherSessions(t *testing.T) { + store, native := t.TempDir(), t.TempDir() + if err := os.MkdirAll(filepath.Join(native, "sessions"), 0700); err != nil { + t.Fatal(err) + } + for _, name := range []string{"deleted.jsonl", "other.jsonl"} { + if err := os.WriteFile(filepath.Join(native, "sessions", name), []byte("kept\n"), 0600); err != nil { + t.Fatal(err) + } + } + filesystem := mountfs.NewCanonical() + filesystem.SetNativeRoot(native) + known := map[string]uint64{"deleted": 1, "other": 2} + routes := map[string]string{"deleted": "/sessions/deleted.jsonl", "other": "/sessions/other.jsonl"} + packs := map[string]string{"deleted": "gen", "other": "gen"} + // Invalid physical-cleanup authority is deliberately not repaired or trusted. + // Its durable hide barrier still must not block an unrelated valid route. + deleted, detachErr, cleanupErr := reconcileManagedSessionDeletionSet(store, filesystem, true, []vfs.SessionDeletion{{SessionID: "deleted", Route: "/sessions/deleted.jsonl"}}, known, routes, packs) + if detachErr != nil || cleanupErr == nil { + t.Fatalf("detach=%v cleanup=%v", detachErr, cleanupErr) + } + if _, ok := deleted["deleted"]; !ok || known["other"] != 2 || routes["other"] == "" || packs["other"] != "gen" { + t.Fatal("unrelated state was lost") + } + if _, errno := filesystem.Getattr("/sessions/deleted.jsonl"); errno != syscall.ENOENT { + t.Fatalf("deleted source revived: %v", errno) + } + h, errno := filesystem.Open("/sessions/other.jsonl", os.O_RDONLY) + if errno != 0 { + t.Fatal(errno) + } + defer filesystem.Release(h) + b := make([]byte, 5) + if n, errno := filesystem.Read(h, b, 0); errno != 0 || n != 5 || string(b) != "kept\n" { + t.Fatalf("other read n=%d errno=%v bytes=%q", n, errno, b) + } + observation := managedReloadObservation{CleanupError: cleanupErr, ManagedSessions: 1} + if observation.Err() != nil { + t.Fatal("cleanup error still aborts reload") + } + snapshot := newManagedStatusTracker().snapshot(observation, time.Now()) + if snapshot.State != "healthy" || !strings.Contains(snapshot.Detail, "cleanup") { + t.Fatalf("cleanup not separately reported: %+v", snapshot) + } + if _, err := os.Stat(filepath.Join(native, "sessions", "deleted.jsonl")); err != nil { + t.Fatalf("uncertain material was removed: %v", err) + } +} + +func TestManagedDeletionHideFailureRemainsFatal(t *testing.T) { + _, detachErr, _ := reconcileManagedSessionDeletionSet(t.TempDir(), mountfs.NewCanonical(), true, []vfs.SessionDeletion{{SessionID: "bad/id", Route: "/sessions/deleted.jsonl"}}, map[string]uint64{}, map[string]string{}, map[string]string{}) + if detachErr == nil || errors.Is(detachErr, os.ErrNotExist) { + t.Fatalf("unsafe hide failure ignored: %v", detachErr) + } +} diff --git a/internal/cli/fs_enroll.go b/internal/cli/fs_enroll.go index 89ef991..6aaa584 100644 --- a/internal/cli/fs_enroll.go +++ b/internal/cli/fs_enroll.go @@ -267,6 +267,13 @@ func applyEnrollmentCycle(ctx context.Context, flags enrollmentFlags, hooks enro for _, state := range states { managed[state.SessionID] = struct{}{} } + sessions, err := codex.LoadSessions(home) + if err != nil { + return FSEnrollmentApplyResult{Plan: plan}, err + } + if err := addNamespaceManagedSessions(store, home, mount, sessions, managed); err != nil { + return FSEnrollmentApplyResult{Plan: plan}, err + } selected, applied, err := enroll.Revalidate(ctx, plan, enroll.RevalidateOptions{ Limit: flags.batchSize, IsManaged: func(_ context.Context, sessionID string) (bool, error) { @@ -900,6 +907,9 @@ func buildEnrollmentPlan(ctx context.Context, flags enrollmentFlags) (enroll.Pla for _, state := range states { managed[state.SessionID] = struct{}{} } + if err := addNamespaceManagedSessions(store, home, mount, sessions, managed); err != nil { + return enroll.Plan{}, "", err + } observations, err := enroll.LoadObservations(enrollmentObservationPath(store)) if err != nil { return enroll.Plan{}, "", err @@ -964,6 +974,20 @@ func buildEnrollmentPlan(ctx context.Context, flags enrollmentFlags) (enroll.Pla return plan, store, err } +func addNamespaceManagedSessions(store, home, mount string, sessions []codex.Session, managed map[string]struct{}) error { + paths, err := mountfs.ManagedNamespacePaths(store) + if err != nil { + return err + } + for _, session := range sessions { + route, err := canonicalNamespaceRoute(home, mount, session.RolloutPath) + if err == nil && paths[route] { + managed[session.ID] = struct{}{} + } + } + return nil +} + func requireEnrollmentStorageHealth(ctx context.Context, store string) error { foldReport, err := doctorFoldStore(ctx, store) if err != nil { diff --git a/internal/cli/fs_maintenance_yield_test.go b/internal/cli/fs_maintenance_yield_test.go new file mode 100644 index 0000000..d6fb31a --- /dev/null +++ b/internal/cli/fs_maintenance_yield_test.go @@ -0,0 +1,26 @@ +package cli + +import ( + "bytes" + "context" + "fmt" + "testing" + "time" + + "github.com/samekind/codexfold/internal/storage" +) + +func TestStorageMaintenanceYieldDoesNotReportFailure(t *testing.T) { + var diagnostics bytes.Buffer + done := startStorageMaintenance(context.Background(), &diagnostics, t.TempDir(), func(context.Context, string) (storage.StorageGCResult, bool, error) { + return storage.StorageGCResult{}, true, fmt.Errorf("foreground access: %w", storage.ErrManagedSessionBusy) + }) + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("yielded maintenance did not finish") + } + if diagnostics.Len() != 0 { + t.Fatalf("ordinary foreground priority was reported as a maintenance failure: %s", diagnostics.String()) + } +} diff --git a/internal/cli/fs_managed_status.go b/internal/cli/fs_managed_status.go index d73621c..2033b21 100644 --- a/internal/cli/fs_managed_status.go +++ b/internal/cli/fs_managed_status.go @@ -24,6 +24,7 @@ const managedObservationStallDeadline = 20 * time.Second type managedReloadObservation struct { Sequence uint64 Fatal error + CleanupError error // Deleted objects remain hidden; unrelated reloads can proceed. NeedsReload bool StateIssues []vfs.SessionStateIssue MissingState []string @@ -105,6 +106,9 @@ func (t *managedStatusTracker) snapshot(observation managedReloadObservation, no t.observationSequence = snapshot.ObservationSequence fault := observation.Err() if fault == nil { + if observation.CleanupError != nil { + snapshot.Detail = "Deleted-session cleanup is deferred; session access and refresh remain available. " + observation.CleanupError.Error() + } if t.active != nil { t.recovered = t.active t.active = nil @@ -265,14 +269,13 @@ func (r *managedStatusReporter) Heartbeat() { } now := r.now() observation := r.lastObserved + snapshot := r.tracker.snapshot(observation, now) + // Completing a namespace scan is not an availability probe. Keep actual + // failures until Observe supplies recovery evidence, but never invent one + // merely because a large/background refresh is taking longer than usual. if now.Sub(r.lastObservedAt) > managedObservationStallDeadline { - observation = managedReloadObservation{ - Fatal: errors.New("managed session refresh has stalled"), - ManagedSessions: r.lastObserved.ManagedSessions, - FailureStartedAt: r.lastObservedAt.Add(managedRecoveryDeadline), - } + snapshot.Detail = strings.TrimSpace(snapshot.Detail + "\nBackground session refresh is taking longer than usual; the last completed observation is from " + r.lastObservedAt.UTC().Format(time.RFC3339) + ". Existing mounted routes remain in place; file access is monitored separately.") } - snapshot := r.tracker.snapshot(observation, now) snapshot.MountPoint = r.mountPoint snapshot.ResourcePath = r.resourcePath if r.publish != nil { diff --git a/internal/cli/fs_managed_status_test.go b/internal/cli/fs_managed_status_test.go index 5fa055a..5ad65bd 100644 --- a/internal/cli/fs_managed_status_test.go +++ b/internal/cli/fs_managed_status_test.go @@ -240,7 +240,7 @@ func TestManagedStatusHeartbeatPreservesIncidentWithoutReloading(t *testing.T) { } } -func TestManagedStatusHeartbeatDoesNotMaskStalledReload(t *testing.T) { +func TestManagedStatusHeartbeatDoesNotInventFailureForSlowReload(t *testing.T) { reporter := newManagedStatusReporterForStore("", "/store", "/mount", "/resource", nil) now := time.Unix(100, 0) reporter.now = func() time.Time { return now } @@ -249,8 +249,27 @@ func TestManagedStatusHeartbeatDoesNotMaskStalledReload(t *testing.T) { reporter.Observe(managedReloadObservation{Sequence: 1, ManagedSessions: 7}) now = now.Add(managedObservationStallDeadline + time.Second) reporter.Heartbeat() - if len(snapshots) != 2 || snapshots[1].State != "recovering" || snapshots[1].ManagedSessions == nil || *snapshots[1].ManagedSessions != 7 { - t.Fatalf("stalled reload was reported healthy: %#v", snapshots) + if len(snapshots) != 2 || snapshots[1].State != "healthy" || snapshots[1].IncidentID != "" || snapshots[1].Detail == "" || snapshots[1].ManagedSessions == nil || *snapshots[1].ManagedSessions != 7 { + t.Fatalf("slow refresh invented an availability failure or lost diagnostics: %#v", snapshots) + } +} + +func TestManagedStatusSlowRefreshKeepsActualFailureUntilObservedRecovery(t *testing.T) { + reporter := newManagedStatusReporterForStore("", "/store", "/mount", "/resource", nil) + now := time.Unix(100, 0) + reporter.now = func() time.Time { return now } + var snapshot fskitstatus.Snapshot + reporter.publish = func(s fskitstatus.Snapshot) { snapshot = s } + reporter.Observe(managedReloadObservation{Sequence: 1, Fatal: errors.New("actual route failure"), ManagedSessions: 7}) + id := snapshot.IncidentID + now = now.Add(time.Minute) + reporter.Heartbeat() + if snapshot.State != "recovering" || snapshot.IncidentID != id { + t.Fatalf("slow refresh masked a real failure: %+v", snapshot) + } + reporter.Observe(managedReloadObservation{Sequence: 2, ManagedSessions: 7}) + if snapshot.State != "healthy" || snapshot.IncidentID != id { + t.Fatalf("fresh observation did not recover: %+v", snapshot) } } diff --git a/internal/cli/fs_migrate_scope_test.go b/internal/cli/fs_migrate_scope_test.go new file mode 100644 index 0000000..53d4241 --- /dev/null +++ b/internal/cli/fs_migrate_scope_test.go @@ -0,0 +1,91 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/samekind/codexfold/internal/codex" + "github.com/samekind/codexfold/internal/fold" + "github.com/samekind/codexfold/internal/pack" +) + +func TestFSMigrateApplyUsesTargetProofWithUnrelatedUnpackedManifest(t *testing.T) { + allowFixtureMount(t) + home, store, native := fsFixture(t, true) + otherPath := filepath.Join(home, "unrelated.jsonl") + otherBytes := []byte("{\"type\":\"session_meta\"}\n{\"value\":\"unrelated-unique-unpacked-content\"}\n") + if err := os.WriteFile(otherPath, otherBytes, 0o600); err != nil { + t.Fatal(err) + } + if _, err := fold.Fold(context.Background(), fold.Session{ID: "unrelated", RolloutPath: otherPath, Archived: true}, fold.FoldOptions{StoreDir: store, Apply: true, FieldThreshold: 8}); err != nil { + t.Fatal(err) + } + report, err := pack.Doctor(context.Background(), store) + if err != nil || report.IssueCount == 0 { + t.Fatalf("fixture must have an unrelated unpacked manifest: issues=%d err=%v", report.IssueCount, err) + } + mount := filepath.Join(home, "mount") + if err := os.MkdirAll(mount, 0o700); err != nil { + t.Fatal(err) + } + source, err := os.ReadFile(native) + if err != nil { + t.Fatal(err) + } + target := filepath.Join(mount, "session.jsonl") + if err := os.WriteFile(target, source, 0o600); err != nil { + t.Fatal(err) + } + var output bytes.Buffer + root := NewRootCommand() + root.SetOut(&output) + root.SetErr(&bytes.Buffer{}) + root.SetArgs([]string{"fs", "migrate", "session", "--apply", "--json", "--codex-home", home, "--store", store, "--mount", mount, "--cli", approvedCLIContract(t, store, "1.2.3"), "--desktop-app", "none"}) + if err := root.Execute(); err != nil { + t.Fatalf("target migration should use its own complete proof: %v", err) + } + var result FSMigrateResult + if err := json.Unmarshal(output.Bytes(), &result); err != nil { + t.Fatal(err) + } + if !result.Routed || !result.Shadow.Verified || result.Shadow.ComparedBytes != int64(len(source)) || result.Shadow.RandomReads != 0 { + t.Fatalf("target was not completely verified: %+v", result) + } + if got, err := os.ReadFile(otherPath); err != nil || !bytes.Equal(got, otherBytes) { + t.Fatalf("unrelated source changed: %v", err) + } +} + +func TestFSMigrateRejectsSameSizeTargetMismatchBeforeMutation(t *testing.T) { + home, store, native := fsFixture(t, true) + source, err := os.ReadFile(native) + if err != nil { + t.Fatal(err) + } + changed := bytes.Replace(source, []byte("repeated"), []byte("modified"), 1) + if len(changed) != len(source) || bytes.Equal(changed, source) { + t.Fatal("fixture must change bytes without changing the size") + } + if err := os.WriteFile(native, changed, 0o600); err != nil { + t.Fatal(err) + } + root := NewRootCommand() + root.SetOut(&bytes.Buffer{}) + root.SetErr(&bytes.Buffer{}) + root.SetArgs([]string{"fs", "migrate", "session", "--apply", "--codex-home", home, "--store", store, "--mount", filepath.Join(home, "mount")}) + if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "shadow byte mismatch") { + t.Fatalf("same-size source mismatch must stop migration: %v", err) + } + sessions, err := codex.LoadSessions(home) + if err != nil || len(sessions) != 1 || sessions[0].RolloutPath != native { + t.Fatalf("rejected migration changed the route: sessions=%+v err=%v", sessions, err) + } + if _, err := os.Stat(filepath.Join(store, "fs", "sessions", "session", "state.json")); !os.IsNotExist(err) { + t.Fatalf("rejected migration published managed state: %v", err) + } +} diff --git a/internal/cli/fs_namespace_live_darwin_test.go b/internal/cli/fs_namespace_live_darwin_test.go new file mode 100644 index 0000000..9fb6828 --- /dev/null +++ b/internal/cli/fs_namespace_live_darwin_test.go @@ -0,0 +1,305 @@ +//go:build darwin + +package cli + +import ( + "bytes" + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "testing" + "time" + + "github.com/samekind/codexfold/internal/enroll" + "github.com/samekind/codexfold/internal/fold" + "github.com/samekind/codexfold/internal/mountfs" + "github.com/samekind/codexfold/internal/vfs" +) + +// Runs the actual signed daemon, including index reload and background fold, +// with isolated data and the installed FSKit module. No Codex client is started. +func TestNamespaceReplacementSignedDaemonReload(t *testing.T) { + testNamespaceReplacementSignedDaemonReload(t, false) +} + +func TestSignedDaemonDeletionCleanupDoesNotBlockReload(t *testing.T) { + testNamespaceReplacementSignedDaemonReload(t, true) +} + +func TestSignedDaemonActualFullVolumeReadAndWriteRecovery(t *testing.T) { + volume := os.Getenv("CODEXFOLD_TEST_FULL_VOLUME") + if !strings.HasPrefix(volume, "/private/tmp/codexfold-pressure-") { + t.Skip("explicit disposable full-volume fixture required") + } + t.Setenv("TMPDIR", volume) + testNamespaceReplacementSignedDaemonReload(t, false, true) +} + +func testNamespaceReplacementSignedDaemonReload(t *testing.T, pendingDeletion bool, fullVolume ...bool) { + candidate := os.Getenv("CODEXFOLD_NAMESPACE_CANDIDATE") + if candidate == "" { + t.Skip("explicit signed candidate required") + } + filesystem, store, native, want := namespaceReplacementFixture(t) + filesystem.CloseSessions() + var foreign string + if pendingDeletion { + foreign = signedDaemonPendingDeletionFixture(t, store) + } + home := t.TempDir() + user, err := os.UserHomeDir() + if err != nil { + t.Fatal(err) + } + resource, err := os.MkdirTemp(filepath.Join(user, "Library", "Group Containers", "group.vip.jstar.codexfold"), "r-") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(resource) + mount, err := os.MkdirTemp("/private/tmp", "cfd-daemon-") + if err != nil { + t.Fatal(err) + } + defer os.Remove(mount) + route := filepath.Join(mount, "sessions", "rollout.jsonl") + writeStateFixture(t, home, route) + if out, err := exec.Command("/usr/bin/sqlite3", filepath.Join(home, "state_5.sqlite"), "UPDATE threads SET id='logical';").CombinedOutput(); err != nil { + t.Fatalf("fixture index: %v %s", err, out) + } + // Disable ordinary enrollment so this test isolates replacement folding. + if err := enroll.SaveControl(enroll.ControlPath(store), enroll.Control{Enabled: false, Interval: time.Minute, StableFor: time.Hour}); err != nil { + t.Fatal(err) + } + var daemonLog bytes.Buffer + var cmd *exec.Cmd + var done chan error + start := func() { + cmd = exec.Command(candidate, "fs", "serve", "--apply", "--canonical-namespace", "--frontend", "native-fskit", "--codex-home", home, "--store", store, "--mount", mount, "--native-root", native, "--fskit-resource", resource) + cmd.Stdout, cmd.Stderr = &daemonLog, &daemonLog + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + done = make(chan error, 1) + go func(c *exec.Cmd, d chan error) { d <- c.Wait() }(cmd, done) + } + stop := func() { + if cmd == nil { + return + } + _ = cmd.Process.Signal(os.Interrupt) + select { + case err := <-done: + if err != nil { + t.Logf("isolated daemon exit: %v", err) + } + case <-time.After(10 * time.Second): + _ = cmd.Process.Kill() + <-done + t.Error("isolated daemon failed to stop") + } + cmd = nil + } + defer stop() + start() + deadline := time.Now().Add(15 * time.Second) + for { + if _, err := os.Stat(filepath.Join(resource, "daemon.sock")); err == nil { + break + } + select { + case err := <-done: + cmd = nil + t.Fatalf("isolated daemon exited: %v\n%s", err, daemonLog.String()) + default: + } + if time.Now().After(deadline) { + t.Fatal("isolated daemon startup timed out") + } + time.Sleep(20 * time.Millisecond) + } + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + if out, err := exec.CommandContext(ctx, "/sbin/mount", "-F", "-t", "codexfoldnative", resource, mount).CombinedOutput(); err != nil { + t.Fatalf("isolated mount: %v %s", err, out) + } + mounted := true + unmount := func() { + if !mounted { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + if out, err := exec.CommandContext(ctx, "/sbin/umount", mount).CombinedOutput(); err != nil { + t.Errorf("isolated unmount: %v %s", err, out) + } + mounted = false + } + defer unmount() + check := func() { + got, err := os.ReadFile(route) + if err != nil || !bytes.Equal(got, want) { + t.Fatalf("signed daemon current bytes: %v %q", err, got) + } + got, err = os.ReadFile(route + ".bak") + if err != nil || !bytes.Contains(got, []byte("original")) { + t.Fatalf("signed daemon backup: %v %q", err, got) + } + } + check() + if len(fullVolume) != 0 && fullVolume[0] { + filler := filepath.Join(os.Getenv("CODEXFOLD_TEST_FULL_VOLUME"), "test-only-mount-filler") + f, err := os.Create(filler) + if err != nil { + t.Fatal(err) + } + defer os.Remove(filler) + block := bytes.Repeat([]byte("x"), 1<<20) + var fillErr error + for i := 0; i < 300; i++ { + if _, fillErr = f.Write(block); fillErr != nil { + break + } + } + _ = f.Close() + if !errors.Is(fillErr, syscall.ENOSPC) { + t.Fatalf("did not fill disposable volume: %v", fillErr) + } + check() // Native replacement and folded .bak through the real FSKit mount. + writer, err := os.OpenFile(route, os.O_WRONLY|os.O_APPEND, 0) + if err != nil { + t.Fatal(err) + } + payload := append(append([]byte("{\"full_disk\":\""), block...), []byte("\"}\n")...) + _, writeErr := writer.Write(payload) + syncErr := writer.Sync() + closeErr := writer.Close() + if !errors.Is(errors.Join(writeErr, syncErr, closeErr), syscall.ENOSPC) { + t.Fatalf("full-volume write was not rejected: write=%v sync=%v close=%v", writeErr, syncErr, closeErr) + } + got, err := os.ReadFile(filepath.Join(native, "sessions", "rollout.jsonl")) + if err != nil || !bytes.Equal(got, want) { + t.Fatalf("failed write changed durable original: bytes=%d err=%v", len(got), err) + } + if err := os.Remove(filler); err != nil { + t.Fatal(err) + } + writer, err = os.OpenFile(route, os.O_WRONLY|os.O_APPEND, 0) + if err != nil { + t.Fatal(err) + } + appendix := []byte("{\"recovered\":true}\n") + if _, err := writer.Write(appendix); err != nil { + t.Fatal(err) + } + if err := writer.Sync(); err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + got, err = os.ReadFile(route) + if err != nil || !bytes.Equal(got, append(append([]byte{}, want...), appendix...)) { + t.Fatalf("mounted retry after freeing space differs: bytes=%d err=%v", len(got), err) + } + t.Log("actual full APFS volume: native/folded reads, ENOSPC without durable-byte changes, and mounted retry passed") + return + } + // Let the real reload loop poll the original logical ID before cutover. + time.Sleep(1200 * time.Millisecond) + check() + if err := enroll.SaveControl(enroll.ControlPath(store), enroll.Control{Enabled: true, Interval: time.Hour, StableFor: 24 * time.Hour}); err != nil { + t.Fatal(err) + } + deadline = time.Now().Add(25 * time.Second) + for { + _, err := os.Stat(filepath.Join(native, "sessions", "rollout.jsonl")) + if errors.Is(err, os.ErrNotExist) { + break + } + if time.Now().After(deadline) { + t.Fatal("signed daemon did not finish replacement folding") + } + time.Sleep(100 * time.Millisecond) + } + check() + if err := enroll.SaveControl(enroll.ControlPath(store), enroll.Control{Enabled: false, Interval: time.Minute, StableFor: time.Hour}); err != nil { + t.Fatal(err) + } + stop() + start() + // The mounted frontend reconnects to the new daemon without unmounting. + deadline = time.Now().Add(20 * time.Second) + for { + got, err := os.ReadFile(route) + if err == nil && bytes.Equal(got, want) { + break + } + if time.Now().After(deadline) { + t.Fatalf("signed daemon restart did not recover current: %v", err) + } + time.Sleep(100 * time.Millisecond) + } + check() + if pendingDeletion { + if _, err := os.Stat(foreign); err != nil { + t.Fatalf("unproved deletion object was removed: %v", err) + } + } + unmount() + stop() + for _, failure := range []string{"replaced file cleanup:", "reload deferred", "enrollment cycle failed"} { + if strings.Contains(daemonLog.String(), failure) { + t.Fatalf("signed daemon diagnostics:\n%s", daemonLog.String()) + } + } + if pendingDeletion && !strings.Contains(daemonLog.String(), "unrelated sessions continue to refresh") { + t.Fatalf("missing separate cleanup diagnostic: %s", daemonLog.String()) + } + t.Logf("signed daemon reload and mounted reconnect passed; %s", daemonLog.String()) +} + +func signedDaemonPendingDeletionFixture(t *testing.T, store string) string { + t.Helper() + source := filepath.Join(t.TempDir(), "deleted.jsonl") + if err := os.WriteFile(source, []byte("{\"deleted-fixture\":true}\n"), 0600); err != nil { + t.Fatal(err) + } + result, err := fold.Fold(context.Background(), fold.Session{ID: "deleted", RolloutPath: source}, fold.FoldOptions{StoreDir: store, Apply: true}) + if err != nil { + t.Fatal(err) + } + manifest, err := fold.LoadManifest(store, "deleted") + if err != nil { + t.Fatal(err) + } + snapshot, err := retainCanonicalSnapshot(context.Background(), store, "deleted", vfs.NativeFile{Path: source, Bytes: result.SourceBytes, SHA256: result.SourceSHA256}, nil) + if err != nil { + t.Fatal(err) + } + session, err := vfs.OpenSession(context.Background(), vfs.SessionOptions{Root: store, ManifestPath: fold.ManifestPath(store, "deleted"), Manifest: manifest, Reader: mountfs.NamespaceReader(store, nil), NativeSnapshot: snapshot}) + if err != nil { + t.Fatal(err) + } + tombstone, err := vfs.PublishSessionDeletion(store, session.State(), "/sessions/deleted.jsonl") + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(tombstone.RetiredSessionPath), 0700); err != nil { + t.Fatal(err) + } + if err := os.Rename(tombstone.SessionPath, tombstone.RetiredSessionPath); err != nil { + t.Fatal(err) + } + if err := os.Rename(tombstone.ManifestPath, tombstone.RetiredManifestPath); err != nil { + t.Fatal(err) + } + foreign := filepath.Join(tombstone.RetiredSessionPath, "foreign-do-not-delete") + if err := os.WriteFile(foreign, []byte("unproved material"), 0600); err != nil { + t.Fatal(err) + } + return foreign +} diff --git a/internal/cli/fs_namespace_retirement.go b/internal/cli/fs_namespace_retirement.go new file mode 100644 index 0000000..4ebce08 --- /dev/null +++ b/internal/cli/fs_namespace_retirement.go @@ -0,0 +1,86 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "io" + "time" + + "github.com/samekind/codexfold/internal/fold" + "github.com/samekind/codexfold/internal/mountfs" + "github.com/samekind/codexfold/internal/pack" + "github.com/samekind/codexfold/internal/storage" + "github.com/samekind/codexfold/internal/vfs" +) + +func startNamespaceRetirement(ctx context.Context, filesystem *mountfs.Filesystem, store string, enabled func() bool, diagnostics io.Writer) <-chan struct{} { + done := make(chan struct{}) + go func() { + defer close(done) + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + previous := "" + for { + err := filesystem.RetireReplacedFiles(ctx) + if err == nil && enabled() { + err = filesystem.FoldNativeReplacements(ctx, func(ctx context.Context, source, id string) (*vfs.Session, io.Closer, error) { + return prepareNamespaceReplacement(ctx, store, source, id) + }) + } + if errors.Is(err, storage.ErrOperationLockHeld) || errors.Is(err, storage.ErrManagedSessionBusy) { + err = nil + } + if err != nil && !errors.Is(err, context.Canceled) && err.Error() != previous { + fmt.Fprintf(diagnostics, "replaced file cleanup: %v\n", err) + previous = err.Error() + } + if err == nil { + previous = "" + } + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + } + }() + return done +} + +func prepareNamespaceReplacement(ctx context.Context, store, source, id string) (session *vfs.Session, closer io.Closer, err error) { + maintenance, err := storage.BeginMaintenance(ctx, store) + if err != nil { + return nil, nil, err + } + defer func() { + err = maintenance.YieldError(err) + maintenance.Close() + }() + ctx = maintenance.Context() + resolver, err := pack.Open(store, pack.OpenOptions{}) + if err != nil { + return nil, nil, err + } + result, err := fold.Fold(ctx, fold.Session{ID: id, RolloutPath: source}, fold.FoldOptions{StoreDir: store, Apply: true, Opaque: true, ExistingReader: resolver}) + if err != nil { + resolver.Close() + return nil, nil, err + } + snapshot, err := retainCanonicalSnapshot(ctx, store, id, vfs.NativeFile{Path: source, Bytes: result.SourceBytes, SHA256: result.SourceSHA256}, nil) + if err != nil { + resolver.Close() + return nil, nil, err + } + manifest, err := fold.LoadManifestPath(result.ManifestPath) + if err != nil { + resolver.Close() + return nil, nil, err + } + session, err = vfs.OpenSession(ctx, vfs.SessionOptions{Root: store, ManifestPath: result.ManifestPath, Manifest: manifest, Reader: mountfs.NamespaceReader(store, resolver), NativeSnapshot: snapshot}) + if err != nil { + resolver.Close() + return nil, nil, err + } + return session, resolver, nil +} diff --git a/internal/cli/fs_namespace_retirement_test.go b/internal/cli/fs_namespace_retirement_test.go new file mode 100644 index 0000000..64cb06f --- /dev/null +++ b/internal/cli/fs_namespace_retirement_test.go @@ -0,0 +1,151 @@ +package cli + +import ( + "bytes" + "context" + "io" + "os" + "path/filepath" + "testing" + "time" + + "github.com/samekind/codexfold/internal/codex" + "github.com/samekind/codexfold/internal/enroll" + "github.com/samekind/codexfold/internal/fold" + "github.com/samekind/codexfold/internal/mountfs" + "github.com/samekind/codexfold/internal/pack" + "github.com/samekind/codexfold/internal/vfs" +) + +func namespaceReplacementFixture(t *testing.T) (*mountfs.Filesystem, string, string, []byte) { + t.Helper() + store, native := t.TempDir(), t.TempDir() + route := "/sessions/rollout.jsonl" + source := filepath.Join(t.TempDir(), "original") + if err := os.WriteFile(source, []byte("{\"original\":true}\n"), 0600); err != nil { + t.Fatal(err) + } + result, err := fold.Fold(context.Background(), fold.Session{ID: "logical", RolloutPath: source}, fold.FoldOptions{StoreDir: store, Apply: true}) + if err != nil { + t.Fatal(err) + } + if _, err := pack.Build(context.Background(), store, pack.BuildOptions{}); err != nil { + t.Fatal(err) + } + manifest, err := fold.LoadManifest(store, "logical") + if err != nil { + t.Fatal(err) + } + snapshot, err := retainCanonicalSnapshot(context.Background(), store, "logical", vfs.NativeFile{Path: source, Bytes: result.SourceBytes, SHA256: result.SourceSHA256}, nil) + if err != nil { + t.Fatal(err) + } + session, err := vfs.OpenSession(context.Background(), vfs.SessionOptions{Root: store, ManifestPath: fold.ManifestPath(store, "logical"), Manifest: manifest, Reader: mountfs.NamespaceReader(store, nil), NativeSnapshot: snapshot}) + if err != nil { + t.Fatal(err) + } + filesystem := mountfs.NewCanonical() + filesystem.SetNativeRoot(native) + if err := filesystem.SetManagedStore(store); err != nil { + t.Fatal(err) + } + if err := filesystem.AddSessionAt("logical", route, session); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { filesystem.CloseSessions() }) + if err := os.MkdirAll(filepath.Join(native, "sessions"), 0700); err != nil { + t.Fatal(err) + } + want := []byte("{\"replacement\":true}\n") + if err := os.WriteFile(filepath.Join(native, "sessions", ".rebind"), want, 0600); err != nil { + t.Fatal(err) + } + if errno := filesystem.Rename(route, route+".bak"); errno != 0 { + t.Fatal(errno) + } + if errno := filesystem.Rename("/sessions/.rebind", route); errno != 0 { + t.Fatal(errno) + } + return filesystem, store, native, want +} + +func TestNamespaceReplacementUsesRetainedSnapshotAndLooseReader(t *testing.T) { + filesystem, store, native, want := namespaceReplacementFixture(t) + if err := filesystem.FoldNativeReplacements(context.Background(), func(ctx context.Context, source, id string) (*vfs.Session, io.Closer, error) { + return prepareNamespaceReplacement(ctx, store, source, id) + }); err != nil { + t.Fatal(err) + } + routes, err := mountfs.ManagedNamespaceRoutes(store) + if err != nil { + t.Fatal(err) + } + var current string + for id, route := range routes { + if route == "/sessions/rollout.jsonl" { + current = id + } + } + if current == "" || current == "logical" { + t.Fatalf("replacement identity: %s", current) + } + state, err := managedState(store, current) + if err != nil { + t.Fatal(err) + } + if changed, err := recoverInterruptedCanonicalMigration(t.TempDir(), store, native, state); err != nil || changed { + t.Fatalf("namespace inode was mistaken for a SQLite migration: %t %v", changed, err) + } + if state.NativeSnapshot.Path == filepath.Join(native, "sessions", "rollout.jsonl") { + t.Fatal("cutover reused mutable native source as proof") + } + if got, err := os.ReadFile(state.NativeSnapshot.Path); err != nil || !bytes.Equal(got, want) { + t.Fatalf("retained snapshot mismatch: %v", err) + } + if _, err := os.Stat(filepath.Join(native, "sessions", "rollout.jsonl")); !os.IsNotExist(err) { + t.Fatal("native source was not removed") + } + reopened, resolver, err := openManagedNamespaceSession(context.Background(), store, state) + if err != nil { + t.Fatal(err) + } + defer resolver.Close() + reader, err := reopened.OpenReader() + if err != nil { + t.Fatal(err) + } + defer reader.Close() + got := make([]byte, len(want)) + if n, err := reader.ReadAt(context.Background(), got, 0); err != nil || n != len(want) || !bytes.Equal(got, want) { + t.Fatalf("reopened replacement: n=%d err=%v", n, err) + } + + managed := make(map[string]struct{}) + home, mount := t.TempDir(), t.TempDir() + sessions := []codex.Session{{ID: "logical", RolloutPath: filepath.Join(home, "sessions", "rollout.jsonl")}} + if err := addNamespaceManagedSessions(store, home, mount, sessions, managed); err != nil { + t.Fatal(err) + } + if _, ok := managed["logical"]; !ok { + t.Fatal("replacement lost logical enrollment identity") + } +} + +func TestNamespaceReplacementHonorsDisabledPolicy(t *testing.T) { + filesystem, store, native, want := namespaceReplacementFixture(t) + if err := enroll.SaveControl(enroll.ControlPath(store), enroll.Control{Enabled: false, Interval: time.Minute, StableFor: time.Hour}); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + var diagnostics bytes.Buffer + done := startNamespaceRetirement(ctx, filesystem, store, func() bool { return resolveEnrollmentControl(enrollmentFlags{storeDir: store}, time.Minute).Enabled }, &diagnostics) + time.Sleep(30 * time.Millisecond) + cancel() + <-done + if got, err := os.ReadFile(filepath.Join(native, "sessions", "rollout.jsonl")); err != nil || !bytes.Equal(got, want) { + t.Fatalf("disabled policy folded/replaced native source: %v", err) + } + if diagnostics.Len() != 0 { + t.Fatal(diagnostics.String()) + } +} diff --git a/internal/cli/fs_reload_cancellation_test.go b/internal/cli/fs_reload_cancellation_test.go new file mode 100644 index 0000000..18a2f6f --- /dev/null +++ b/internal/cli/fs_reload_cancellation_test.go @@ -0,0 +1,19 @@ +package cli + +import ( + "context" + "testing" + "time" +) + +func TestManagedReloadCancellationDoesNotPublishOutage(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + loads, reports := 0, 0 + runManagedReloadLoop(ctx, time.Millisecond, time.Millisecond, func() error { + loads++ + cancel() + return context.Canceled + }, func(error) { reports++ }) + if loads != 1 || reports != 0 { t.Fatalf("normal shutdown reported an outage: loads=%d reports=%d", loads, reports) } +} diff --git a/internal/cli/fs_service.go b/internal/cli/fs_service.go index bded328..d2a7d58 100644 --- a/internal/cli/fs_service.go +++ b/internal/cli/fs_service.go @@ -1844,6 +1844,15 @@ func canonicalSessionRoutes(home string, mount string, store string, states []vf } routes[session.ID] = route } + names, err := mountfs.ManagedNamespaceRoutes(store) + if err != nil { + return nil, err + } + for id, route := range names { + if _, exists := managed[id]; exists { + routes[id] = route + } + } return routes, nil } diff --git a/internal/cli/fs_service_live_update_darwin.go b/internal/cli/fs_service_live_update_darwin.go index 0f59c88..ea05b07 100644 --- a/internal/cli/fs_service_live_update_darwin.go +++ b/internal/cli/fs_service_live_update_darwin.go @@ -47,11 +47,13 @@ func addLiveDaemonUpdateCommand(parent *cobra.Command) { func newFSServiceUpdateDaemonLiveCommand() *cobra.Command { var definitionPath, mountPoint string var apply, jsonOutput bool + var takeoverTimeout time.Duration command := &cobra.Command{ Use: "update-daemon-live ", Short: "Replace only the native FSKit daemon while keeping Codex and its mount running", Args: cobra.ExactArgs(1), RunE: func(command *cobra.Command, args []string) error { + if takeoverTimeout <= 0 || takeoverTimeout > 2*time.Minute { return errors.New("takeover timeout must be greater than zero and no longer than 2m") } candidate, err := filepath.Abs(args[0]) if err != nil { return err @@ -160,7 +162,7 @@ func newFSServiceUpdateDaemonLiveCommand() *cobra.Command { } oldDaemonSignaled = true } - replacement, err = waitForLiveDaemon(command.Context(), definition, mount, expectedSHA, before.DaemonPID, before.SupervisorPID, mountID, liveDaemonTakeoverTimeout) + replacement, err = waitForLiveDaemon(command.Context(), definition, mount, expectedSHA, before.DaemonPID, before.SupervisorPID, mountID, takeoverTimeout) return err } restore := func(expectedSHA string) error { @@ -195,6 +197,7 @@ func newFSServiceUpdateDaemonLiveCommand() *cobra.Command { addServiceDefinitionFlags(command, &definitionPath) command.Flags().StringVar(&mountPoint, "mount", "", "Native FSKit mount path from the service definition") command.Flags().BoolVar(&apply, "apply", false, "Replace only the daemon while preserving the active mount") + command.Flags().DurationVar(&takeoverTimeout, "takeover-timeout", liveDaemonTakeoverTimeout, "Maximum backend takeover wait before rollback; does not delay the ten-second outage alert") command.Flags().BoolVar(&jsonOutput, "json", false, "Emit JSON output") return command } diff --git a/internal/cli/fs_state_recovery.go b/internal/cli/fs_state_recovery.go index a4cc8de..49c42f3 100644 --- a/internal/cli/fs_state_recovery.go +++ b/internal/cli/fs_state_recovery.go @@ -6,6 +6,7 @@ import ( "path/filepath" "github.com/samekind/codexfold/internal/codex" + "github.com/samekind/codexfold/internal/mountfs" "github.com/samekind/codexfold/internal/storage" "github.com/samekind/codexfold/internal/vfs" ) @@ -41,6 +42,15 @@ func recoverManagedSessionStateIssues( current[session.ID] = struct{}{} } } + names, err := mountfs.ManagedNamespaceRoutes(store) + if err != nil { + return false, err + } + for id, route := range names { + if route != "" { + current[id] = struct{}{} + } + } recovered := false seen := make(map[string]struct{}, len(issues)) for _, issue := range issues { diff --git a/internal/cli/fs_test.go b/internal/cli/fs_test.go index 279a67b..7304a66 100644 --- a/internal/cli/fs_test.go +++ b/internal/cli/fs_test.go @@ -385,10 +385,12 @@ func TestManagedReloadLoopRetriesErrorsWithoutCancelingFileService(t *testing.T) if attempts < 3 { return errors.New("temporary metadata failure") } - cancel() return nil }, func(err error) { reports <- err + if err == nil { + cancel() + } }) }() select { diff --git a/internal/fold/fold.go b/internal/fold/fold.go index 51f9dab..fa2ba81 100644 --- a/internal/fold/fold.go +++ b/internal/fold/fold.go @@ -21,6 +21,8 @@ import ( ) type FoldOptions struct { + // Opaque imports arbitrary bytes with CDC, without JSON parsing. + Opaque bool StoreDir string ManifestPathOverride string Apply bool @@ -313,7 +315,7 @@ func Fold(ctx context.Context, session Session, options FoldOptions) (FoldResult reader := bufio.NewReaderSize(io.LimitReader(file, before.Size()), 1024*1024) var line bytes.Buffer - oversized := false + oversized := options.Opaque for { if err := ctx.Err(); err != nil { return FoldResult{}, err @@ -348,7 +350,7 @@ func Fold(ctx context.Context, session Session, options FoldOptions) (FoldResult } } line.Reset() - oversized = false + oversized = options.Opaque case errors.Is(readErr, bufio.ErrBufferFull): continue case errors.Is(readErr, io.EOF): diff --git a/internal/fold/fold_test.go b/internal/fold/fold_test.go index 4d1ab3c..ce5a537 100644 --- a/internal/fold/fold_test.go +++ b/internal/fold/fold_test.go @@ -480,6 +480,28 @@ func TestFoldHonorsCanceledContextWithoutManifest(t *testing.T) { } } +func TestFoldOpaquePreservesExactBytes(t *testing.T) { + for _, data := range [][]byte{nil, []byte("not json\nlast line"), {0, 255, 128, '\n', 0}, []byte("{ \"x\" : 1 }\r\n")} { + root := t.TempDir() + source := filepath.Join(root, "source") + store := filepath.Join(root, "store") + if err := os.WriteFile(source, data, 0600); err != nil { + t.Fatal(err) + } + if _, err := Fold(context.Background(), Session{ID: "opaque", RolloutPath: source}, FoldOptions{StoreDir: store, Apply: true, Opaque: true}); err != nil { + t.Fatal(err) + } + target := filepath.Join(root, "restored") + if _, err := Unfold(context.Background(), store, "opaque", target, false); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(target) + if err != nil || !bytes.Equal(got, data) { + t.Fatalf("opaque roundtrip: %x != %x (%v)", got, data, err) + } + } +} + func TestFoldCancellationAtCommitBoundaryRecoversOnRetry(t *testing.T) { root := t.TempDir() sourcePath := filepath.Join(root, "source.jsonl") diff --git a/internal/fskitproto/client.go b/internal/fskitproto/client.go index 335d0cf..bd6c5fa 100644 --- a/internal/fskitproto/client.go +++ b/internal/fskitproto/client.go @@ -1,6 +1,7 @@ package fskitproto import ( + "context" "errors" "fmt" "net" @@ -29,9 +30,17 @@ type Client struct { generation uint64 maxPayload uint32 nextID uint64 + timeout time.Duration } func DialResource(resourcePath string, timeout time.Duration) (*Client, error) { + return DialResourceContext(context.Background(), resourcePath, timeout) +} + +func DialResourceContext(ctx context.Context, resourcePath string, timeout time.Duration) (*Client, error) { + if err := ctx.Err(); err != nil { + return nil, err + } descriptorPath, err := ResourceDescriptorPath(resourcePath) if err != nil { return nil, err @@ -44,7 +53,7 @@ func DialResource(resourcePath string, timeout time.Duration) (*Client, error) { if err != nil { return nil, err } - return Dial(descriptor, timeout) + return DialContext(ctx, descriptor, timeout) } func ResourceDescriptorPath(resourcePath string) (string, error) { @@ -70,17 +79,23 @@ func UsesDirectoryResource(resourcePath string) bool { } func Dial(descriptor Descriptor, timeout time.Duration) (*Client, error) { + return DialContext(context.Background(), descriptor, timeout) +} + +func DialContext(ctx context.Context, descriptor Descriptor, timeout time.Duration) (*Client, error) { if timeout <= 0 { timeout = 5 * time.Second } - connection, err := net.DialTimeout("unix", descriptor.SocketPath, timeout) + dialCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + connection, err := (&net.Dialer{}).DialContext(dialCtx, "unix", descriptor.SocketPath) if err != nil { return nil, fmt.Errorf("connect to FSKit daemon: %w", err) } - client := &Client{connection: connection, generation: descriptor.Generation, maxPayload: DefaultMaxPayload, nextID: 1} + client := &Client{connection: connection, generation: descriptor.Generation, maxPayload: DefaultMaxPayload, nextID: 1, timeout: timeout} encoder := NewEncoder(len(descriptor.Token) + 4) encoder.Bytes(descriptor.Token) - response, err := client.callLocked(OpHello, 0, encoder.Data()) + response, err := client.callContextLocked(dialCtx, OpHello, 0, encoder.Data()) if err != nil { _ = connection.Close() return nil, err @@ -111,9 +126,60 @@ func (c *Client) Close() error { } func (c *Client) Call(operation Op, payload []byte) ([]byte, error) { + return c.CallContext(context.Background(), operation, payload) +} + +func (c *Client) CallContext(ctx context.Context, operation Op, payload []byte) ([]byte, error) { c.mu.Lock() defer c.mu.Unlock() - return c.callLocked(operation, c.generation, payload) + return c.callContextLocked(ctx, operation, c.generation, payload) +} + +func (c *Client) callContextLocked(ctx context.Context, operation Op, generation uint64, payload []byte) ([]byte, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + if c.connection == nil { + return nil, net.ErrClosed + } + timeout := c.timeout + if timeout <= 0 { + timeout = 5 * time.Second + } + deadline := time.Now().Add(timeout) + if limit, ok := ctx.Deadline(); ok && limit.Before(deadline) { + deadline = limit + } + connection := c.connection + if err := connection.SetDeadline(deadline); err != nil { + _ = connection.Close() + c.connection = nil + return nil, err + } + canceled := make(chan struct{}) + stop := context.AfterFunc(ctx, func() { + _ = connection.SetDeadline(time.Now()) + close(canceled) + }) + response, err := c.callLocked(operation, generation, payload) + if !stop() { + <-canceled + } + if ctx.Err() != nil { + err = ctx.Err() + } + var status StatusError + if err != nil && !errors.As(err, &status) { + // A partial frame cannot be reused: a late response would otherwise be + // mistaken for the next request. Closing also releases blocked peers. + _ = connection.Close() + c.connection = nil + } else if clearErr := connection.SetDeadline(time.Time{}); clearErr != nil { + _ = connection.Close() + c.connection = nil + return nil, clearErr + } + return response, err } func (c *Client) callLocked(operation Op, generation uint64, payload []byte) ([]byte, error) { diff --git a/internal/fskitproto/client_timeout_test.go b/internal/fskitproto/client_timeout_test.go new file mode 100644 index 0000000..13ee58f --- /dev/null +++ b/internal/fskitproto/client_timeout_test.go @@ -0,0 +1,98 @@ +package fskitproto + +import ( + "context" + "errors" + "net" + "os" + "path/filepath" + "testing" + "time" +) + +func TestDialTimeoutIncludesUnansweredHello(t *testing.T) { + root, err := os.MkdirTemp("", "cfd-dial-") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(root) + listener, err := net.Listen("unix", filepath.Join(root, "d.sock")) + if err != nil { + t.Fatal(err) + } + defer listener.Close() + peer := make(chan net.Conn, 1) + go func() { + c, err := listener.Accept() + if err == nil { + peer <- c + } + }() + started := time.Now() + _, err = Dial(Descriptor{SocketPath: listener.Addr().String(), Generation: 1, Token: make([]byte, 16)}, 40*time.Millisecond) + c := <-peer + defer c.Close() + if err == nil || time.Since(started) > time.Second { + t.Fatalf("unanswered hello elapsed=%s err=%v", time.Since(started), err) + } +} + +func TestClientCallTimeoutClosesDesynchronizedConnection(t *testing.T) { + a, b := net.Pipe() + defer b.Close() + client := &Client{connection: a, generation: 1, maxPayload: DefaultMaxPayload, nextID: 1, timeout: 40 * time.Millisecond} + defer client.Close() + go func() { _, _ = ReadFrame(b, 0) }() + started := time.Now() + if _, err := client.Call(OpPing, nil); err == nil || time.Since(started) > time.Second { + t.Fatalf("unanswered ping elapsed=%s err=%v", time.Since(started), err) + } + if _, err := client.Call(OpPing, nil); !errors.Is(err, net.ErrClosed) { + t.Fatalf("timed-out transport reused: %v", err) + } +} + +func TestClientCallContextCancelsBlockedWriteAndRead(t *testing.T) { + for _, consume := range []bool{false, true} { + t.Run(map[bool]string{false: "write", true: "read"}[consume], func(t *testing.T) { + a, b := net.Pipe() + defer b.Close() + client := &Client{connection: a, generation: 1, maxPayload: DefaultMaxPayload, nextID: 1, timeout: time.Minute} + defer client.Close() + if consume { + go func() { _, _ = ReadFrame(b, 0) }() + } + ctx, cancel := context.WithCancel(context.Background()) + timer := time.AfterFunc(30*time.Millisecond, cancel) + defer timer.Stop() + defer cancel() + started := time.Now() + if _, err := client.CallContext(ctx, OpPing, nil); !errors.Is(err, context.Canceled) || time.Since(started) > time.Second { + t.Fatalf("cancel elapsed=%s err=%v", time.Since(started), err) + } + }) + } +} + +func TestClientSuccessfulCallsDoNotInheritOldDeadline(t *testing.T) { + a, b := net.Pipe() + defer b.Close() + client := &Client{connection: a, generation: 1, maxPayload: DefaultMaxPayload, nextID: 1, timeout: 100 * time.Millisecond} + defer client.Close() + go func() { + for i := 0; i < 2; i++ { + request, err := ReadFrame(b, 0) + if err != nil { + return + } + _ = WriteFrame(b, Frame{Kind: KindResponse, Op: request.Op, Generation: 1, RequestID: request.RequestID}, 0) + } + }() + if _, err := client.Call(OpPing, nil); err != nil { + t.Fatal(err) + } + time.Sleep(120 * time.Millisecond) + if _, err := client.Call(OpPing, nil); err != nil { + t.Fatalf("old deadline leaked: %v", err) + } +} diff --git a/internal/fskitstatus/status.go b/internal/fskitstatus/status.go index bcbea0b..055dfac 100644 --- a/internal/fskitstatus/status.go +++ b/internal/fskitstatus/status.go @@ -11,6 +11,7 @@ import ( "os" "path/filepath" "sync" + "syscall" "time" ) @@ -205,8 +206,18 @@ func newPublisher(path string, onError func(error), write func(string, Snapshot) publisher := &Publisher{updates: make(chan Snapshot, 1), done: make(chan struct{})} go func() { defer close(publisher.done) + var lastNoSpaceReport time.Time for snapshot := range publisher.updates { if err := write(path, snapshot); err != nil && onError != nil { + if errors.Is(err, syscall.ENOSPC) { + // Random temporary filenames make string-based deduplication + // ineffective. Do not consume the final free blocks on a log + // storm while the UI's read-only space probe explains the fault. + if !lastNoSpaceReport.IsZero() && time.Since(lastNoSpaceReport) < 30*time.Second { + continue + } + lastNoSpaceReport = time.Now() + } onError(err) } } diff --git a/internal/mountfs/filesystem.go b/internal/mountfs/filesystem.go index 0ebff9e..cb33224 100644 --- a/internal/mountfs/filesystem.go +++ b/internal/mountfs/filesystem.go @@ -21,6 +21,7 @@ import ( "unicode/utf8" "github.com/samekind/codexfold/internal/fskitproto" + "github.com/samekind/codexfold/internal/storage" "github.com/samekind/codexfold/internal/vfs" ) @@ -142,8 +143,11 @@ type directoryState struct { } type Filesystem struct { - mu sync.RWMutex - loadMu sync.Mutex + mu sync.RWMutex + loadMu sync.Mutex + renameMu sync.RWMutex + namespaceStore string + managedNamespace managedNamespace // loadRetry bounds how long an unclassified session-load failure is retried // before it reaches Codex. A managed route stays registered while // enrollment swaps pack generations underneath it, so an access inside that @@ -553,10 +557,15 @@ func (f *Filesystem) AddSessionAt(sessionID string, name string, session *vfs.Se func (f *Filesystem) AddSessionAtOwned(sessionID string, name string, session *vfs.Session, closer io.Closer) error { owner := newSessionOwner(closer) cleaned := cleanPath(name) - if !f.canonical || !safeSessionID(sessionID) || session == nil || !canonicalSessionPath(cleaned) { + if !f.canonical || !safeSessionID(sessionID) || session == nil || !namespaceFilePath(cleaned) { return errors.Join(errors.New("canonical filesystem, safe session ID, path, and session are required"), owner.retire()) } f.mu.Lock() + cleaned, visible := f.namespaceRouteLocked(sessionID, cleaned) + if !visible { + f.mu.Unlock() + return owner.retire() + } if _, deleted := f.deletedManagedSessions[sessionID]; deleted { f.mu.Unlock() return errors.Join(errors.New("managed session has a durable deletion tombstone"), owner.retire()) @@ -589,10 +598,15 @@ func (f *Filesystem) UpsertSessionAt(sessionID string, name string, session *vfs func (f *Filesystem) UpsertSessionAtOwned(sessionID string, name string, session *vfs.Session, closer io.Closer) error { owner := newSessionOwner(closer) cleaned := cleanPath(name) - if !f.canonical || !safeSessionID(sessionID) || session == nil || !canonicalSessionPath(cleaned) { + if !f.canonical || !safeSessionID(sessionID) || session == nil || !namespaceFilePath(cleaned) { return errors.Join(errors.New("canonical filesystem, safe session ID, path, and session are required"), owner.retire()) } f.mu.Lock() + cleaned, visible := f.namespaceRouteLocked(sessionID, cleaned) + if !visible { + f.mu.Unlock() + return owner.retire() + } if _, deleted := f.deletedManagedSessions[sessionID]; deleted { f.mu.Unlock() return errors.Join(errors.New("managed session has a durable deletion tombstone"), owner.retire()) @@ -636,11 +650,15 @@ func (f *Filesystem) UpsertSessionAtOwned(sessionID string, name string, session func (f *Filesystem) MoveSessionAt(sessionID string, name string) error { cleaned := cleanPath(name) - if !f.canonical || !safeSessionID(sessionID) || !canonicalSessionPath(cleaned) { + if !f.canonical || !safeSessionID(sessionID) || !namespaceFilePath(cleaned) { return errors.New("canonical filesystem, safe session ID, and path are required") } f.mu.Lock() defer f.mu.Unlock() + cleaned, visible := f.namespaceRouteLocked(sessionID, cleaned) + if !visible { + return nil + } if _, publishing := f.publishingManagedDeletions[sessionID]; publishing { return errors.New("managed session deletion publication is in progress") } @@ -826,7 +844,7 @@ func (f *Filesystem) SetSessionDeletionPublisher(publisher func(vfs.SessionState // durable managed-session deletion, including after a daemon restart. func (f *Filesystem) HideDeletedSessionAt(sessionID string, name string) error { cleaned := cleanPath(name) - if !f.canonical || !safeSessionID(sessionID) || !canonicalSessionPath(cleaned) { + if !f.canonical || !safeSessionID(sessionID) || !namespaceFilePath(cleaned) { return errors.New("canonical filesystem, safe session ID, and path are required") } f.mu.Lock() @@ -873,7 +891,14 @@ func (f *Filesystem) ReadDir(name string) ([]string, syscall.Errno) { hiddenEntries := make(map[string]struct{}) for retained := range f.retained { if path.Dir(retained) == cleaned { - hiddenEntries[path.Base(retained)] = struct{}{} + if !f.managedNamespace.Native[retained] { + hiddenEntries[path.Base(retained)] = struct{}{} + } + } + } + for pending := range f.managedNamespace.Cleanup { + if path.Dir(pending) == cleaned { + hiddenEntries[path.Base(pending)] = struct{}{} } } for directory := range f.directories { @@ -1112,6 +1137,12 @@ func attrFromFileInfo(info os.FileInfo, size int64) Attr { } func (f *Filesystem) Open(name string, flags int) (uint64, syscall.Errno) { + // Serialize namespace cutover against native descriptor creation, while + // allowing unrelated foreground opens to proceed in parallel. + f.renameMu.RLock() + defer f.renameMu.RUnlock() + endIO := f.beginIO() + defer endIO() session, owner, errno := f.acquireSessionForPath(name) if errno != 0 { if !f.canonical { @@ -1170,7 +1201,7 @@ func (f *Filesystem) Open(name string, flags int) (uint64, syscall.Errno) { handle.read = reader } if access == os.O_WRONLY || access == os.O_RDWR { - writer, err := session.OpenWriter() + writer, err := session.OpenForegroundWriter() if err != nil { if handle.read != nil { _ = handle.read.Close() @@ -1571,7 +1602,7 @@ func (f *Filesystem) TruncatePath(name string, size int64) syscall.Errno { } return 0 } - writer, err := session.OpenWriter() + writer, err := session.OpenForegroundWriter() if err != nil { return errnoFor(err) } @@ -1755,14 +1786,23 @@ func (f *Filesystem) Mkdir(name string, _ uint32) syscall.Errno { } func (f *Filesystem) Rename(oldName string, newName string) syscall.Errno { + f.renameMu.Lock() + defer f.renameMu.Unlock() + endIO := f.beginIO() + defer endIO() if !f.canonical { return syscall.EPERM } oldPath, newPath := cleanPath(oldName), cleanPath(newName) openUnlinkRename := canonicalSessionPath(oldPath) && fskitOpenUnlinkPath(newPath) && path.Dir(oldPath) == path.Dir(newPath) - if !canonicalSessionPath(oldPath) || (!canonicalSessionPath(newPath) && !openUnlinkRename) { + if !namespaceFilePath(oldPath) || !namespaceFilePath(newPath) || + (fskitOpenUnlinkPath(newPath) && !openUnlinkRename) { return syscall.EPERM } + if oldPath == newPath { + _, errno := f.Getattr(oldPath) + return errno + } f.mu.Lock() sessionID, exists := f.paths[oldPath] if !exists { @@ -1774,10 +1814,15 @@ func (f *Filesystem) Rename(oldName string, newName string) syscall.Errno { oldNative := nativePathFromRoot(root, oldPath) newNative := nativePathFromRoot(root, newPath) appendState := f.nativeAppends[filepath.Clean(oldNative)] + _, managedTarget := f.paths[newPath] + importTarget := managedTarget || f.namespaceReplacementTargetLocked(newPath) || f.managedNamespace.Native[oldPath] f.mu.Unlock() if root == "" { return syscall.ENOENT } + if importTarget && namespaceFilePath(newPath) { + return f.importNativeRename(oldPath, newPath) + } var renameErr error if appendState != nil { renameErr = appendState.Relocate(newNative) @@ -1800,6 +1845,8 @@ func (f *Filesystem) Rename(oldName string, newName string) syscall.Errno { handle.nativePath = newNative } } + f.bumpDirectoryGenerationLocked(path.Dir(oldPath), time.Now()) + f.bumpDirectoryGenerationLocked(path.Dir(newPath), time.Now()) f.mu.Unlock() f.bumpNamespaceVersion() return 0 @@ -1816,9 +1863,8 @@ func (f *Filesystem) Rename(oldName string, newName string) syscall.Errno { return syscall.ENOENT } } - if _, exists := f.paths[newPath]; exists { - f.mu.Unlock() - return syscall.EEXIST + if !openUnlinkRename { + return f.renameManagedLocked(sessionID, oldPath, newPath) } if openUnlinkRename { publisher := f.deletionPublisher @@ -1876,6 +1922,8 @@ func (f *Filesystem) Rename(oldName string, newName string) syscall.Errno { } func (f *Filesystem) Unlink(name string) syscall.Errno { + f.renameMu.Lock() + defer f.renameMu.Unlock() if !f.canonical { return syscall.EPERM } @@ -1922,6 +1970,17 @@ func (f *Filesystem) Unlink(name string) syscall.Errno { delete(f.sessions, sessionID) delete(f.owners, sessionID) delete(f.nativeFirst, sessionID) + if _, ok := f.managedNamespace.Names[sessionID]; ok { + n := f.cloneManagedNamespaceLocked() + entry := n.Names[sessionID] + entry.Path = "" + n.Names[sessionID] = entry + // The deletion publisher is already durable. A metadata-sync error + // cannot authorize rediscovery of that deleted inode. + if err := f.publishManagedNamespaceLocked(n); err != nil { + f.managedNamespace = n + } + } for route, currentID := range f.paths { if currentID == sessionID { delete(f.paths, route) @@ -1945,6 +2004,14 @@ func (f *Filesystem) Unlink(name string) syscall.Errno { if err == nil { f.mu.Lock() delete(f.nativeAppends, filepath.Clean(nativePath)) + if f.managedNamespace.Native[cleaned] { + n := f.cloneManagedNamespaceLocked() + delete(n.Native, cleaned) + if publishErr := f.publishManagedNamespaceLocked(n); publishErr != nil { + f.managedNamespace = n + err = publishErr + } + } f.mu.Unlock() f.bumpNamespaceVersion() } @@ -2281,7 +2348,7 @@ func moveManagedMetadata(root string, oldPath string, newPath string) error { } func moveManagedXattrCarrier(root string, oldPath string, newPath string) error { - if root == "" || oldPath == "" || newPath == "" || !canonicalSessionPath(oldPath) || !canonicalSessionPath(newPath) { + if root == "" || !namespaceFilePath(oldPath) || !namespaceFilePath(newPath) { return nil } oldCarrier := managedXattrCarrier(root, oldPath) @@ -2341,8 +2408,12 @@ func (f *Filesystem) nativePath(name string) (string, bool) { f.mu.RLock() root := f.nativeRoot _, retained := f.retained[name] + if f.managedNamespace.Native[name] { + retained = false + } + _, pendingRename := f.managedNamespace.Cleanup[name] f.mu.RUnlock() - if !f.canonical || root == "" || retained || name == "" || name == "/" || !canonicalNamespacePath(name) { + if !f.canonical || root == "" || retained || pendingRename || name == "" || name == "/" || !canonicalNamespacePath(name) { return "", false } return nativePathFromRoot(root, name), true @@ -2448,6 +2519,8 @@ func errnoFor(err error) syscall.Errno { return errno } switch { + case errors.Is(err, storage.ErrBudgetExceeded): + return syscall.ENOSPC case errors.Is(err, vfs.ErrWriterBusy): return syscall.EBUSY case errors.Is(err, vfs.ErrSessionDeletionBusy): diff --git a/internal/mountfs/filesystem_test.go b/internal/mountfs/filesystem_test.go index 6fa295d..41034be 100644 --- a/internal/mountfs/filesystem_test.go +++ b/internal/mountfs/filesystem_test.go @@ -1146,8 +1146,11 @@ func TestCanonicalFilesystemSupportsFSKitOpenUnlinkStaging(t *testing.T) { if err := os.WriteFile(filepath.Join(directory, "second.jsonl"), base, 0o600); err != nil { t.Fatal(err) } - if errno := filesystem.Rename(second, "/sessions/2026/07/12/.not-nfs"); errno != syscall.EPERM { - t.Fatalf("non-FSKit hidden rename errno=%v, want EPERM", errno) + if errno := filesystem.Rename(second, "/sessions/2026/07/12/.not-nfs"); errno != 0 { + t.Fatalf("ordinary hidden file rename errno=%v", errno) + } + if errno := filesystem.Rename("/sessions/2026/07/12/.not-nfs", second); errno != 0 { + t.Fatalf("ordinary hidden file restore errno=%v", errno) } if errno := filesystem.Rename(second, "/archived_sessions/.nfs.20051026.83fd"); errno != syscall.EPERM { t.Fatalf("cross-directory open-unlink rename errno=%v, want EPERM", errno) diff --git a/internal/mountfs/foreground_maintenance_test.go b/internal/mountfs/foreground_maintenance_test.go new file mode 100644 index 0000000..286d621 --- /dev/null +++ b/internal/mountfs/foreground_maintenance_test.go @@ -0,0 +1,59 @@ +package mountfs + +import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" + "testing" + "time" + + "github.com/samekind/codexfold/internal/storage" +) + +func TestForegroundOpenPreemptsReclamationAndAppendsExactBytes(t *testing.T) { + filesystem, source := mountFixture(t) + state := filesystem.sessions["session"].State() + store := filepath.Dir(filepath.Dir(state.ManifestPath)) + guard, err := storage.AcquireManagedSessionDeletionGuard(context.Background(), store) + if err != nil { + t.Fatal(err) + } + defer guard.Close() + background := make(chan error, 1) + go func() { + <-guard.Context().Done() + err := guard.YieldError(guard.Context().Err()) + background <- errors.Join(err, guard.Close()) + }() + start := time.Now() + handle, errno := filesystem.Open("/session.jsonl", os.O_RDWR|os.O_APPEND) + if errno != 0 { + t.Fatalf("foreground open exposed maintenance contention: %v", errno) + } + defer filesystem.Release(handle) + if elapsed := time.Since(start); elapsed > time.Second { + t.Fatalf("foreground waited %s for maintenance", elapsed) + } + select { + case err := <-background: + if !errors.Is(err, storage.ErrManagedSessionBusy) { + t.Fatalf("background yielding classification: %v", err) + } + case <-time.After(time.Second): + t.Fatal("background did not release its locks") + } + tail := []byte("foreground-tail\n") + if n, errno := filesystem.Write(handle, tail, int64(len(source))); errno != 0 || n != len(tail) { + t.Fatalf("foreground append after yielding: n=%d errno=%v", n, errno) + } + if errno := filesystem.Fsync(handle); errno != 0 { + t.Fatal(errno) + } + want := append(append([]byte(nil), source...), tail...) + actual := make([]byte, len(want)) + if n, errno := filesystem.Read(handle, actual, 0); errno != 0 || n != len(want) || !bytes.Equal(actual, want) { + t.Fatalf("readback after foreground preemption: n=%d errno=%v equal=%t", n, errno, bytes.Equal(actual, want)) + } +} diff --git a/internal/mountfs/managed_namespace.go b/internal/mountfs/managed_namespace.go new file mode 100644 index 0000000..12e6e6a --- /dev/null +++ b/internal/mountfs/managed_namespace.go @@ -0,0 +1,674 @@ +package mountfs + +// Managed file names are independent of Codex's thread index. In particular, +// renaming a rollout to a backup must not cause the index poll to resurrect it. +// This small durable namespace also gives replacement files their own identity: +// old descriptors and backups continue to reference the old managed session. +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path" + "path/filepath" + "strings" + "sync" + "syscall" + "time" + + "github.com/samekind/codexfold/internal/fold" + "github.com/samekind/codexfold/internal/storage" + "github.com/samekind/codexfold/internal/vfs" +) + +type managedName struct { + Path string `json:"path"` // empty means unlinked/replaced, never rediscover + Origin string `json:"origin"` + Pending bool `json:"pending,omitempty"` +} + +type nativeRenameCleanup struct { + ObjectID string `json:"object_id"` + Size int64 `json:"size"` + ModTime int64 `json:"mod_time_ns"` +} + +type managedNamespace struct { + Version int `json:"version"` + Names map[string]managedName `json:"names"` + Cleanup map[string]nativeRenameCleanup `json:"cleanup,omitempty"` + Native map[string]bool `json:"native,omitempty"` + Move *nativeRenameIntent `json:"move,omitempty"` +} + +type nativeRenameIntent struct { + Source string `json:"source"` + Target string `json:"target"` + ObjectID string `json:"object_id"` + Displaced string `json:"displaced,omitempty"` +} + +func namespaceFilePath(name string) bool { + return cleanPath(name) == name && name != "" && + (strings.HasPrefix(name, "/sessions/") || strings.HasPrefix(name, "/archived_sessions/")) +} + +func readManagedNamespace(store string) (managedNamespace, error) { + n := managedNamespace{Version: 1, Names: make(map[string]managedName), Cleanup: make(map[string]nativeRenameCleanup), Native: make(map[string]bool)} + data, err := os.ReadFile(filepath.Join(store, "fs", "namespace.json")) + if errors.Is(err, os.ErrNotExist) { + return n, nil + } + if err != nil { + return n, err + } + if err := json.Unmarshal(data, &n); err != nil { + return n, fmt.Errorf("decode managed names: %w", err) + } + if n.Version != 1 || n.Names == nil { + return n, errors.New("invalid managed namespace") + } + paths := make(map[string]string) + for id, name := range n.Names { + if !safeSessionID(id) || !namespaceFilePath(name.Origin) || (name.Path != "" && !namespaceFilePath(name.Path)) { + return n, errors.New("unsafe managed namespace entry") + } + if name.Path != "" { + if previous := paths[name.Path]; previous != "" { + return n, errors.New("duplicate managed namespace path") + } + paths[name.Path] = id + } + } + for name, cleanup := range n.Cleanup { + if !namespaceFilePath(name) || cleanup.ObjectID == "" || cleanup.Size < 0 { + return n, errors.New("unsafe native rename cleanup") + } + } + if n.Cleanup == nil { + n.Cleanup = make(map[string]nativeRenameCleanup) + } + if n.Native == nil { + n.Native = make(map[string]bool) + } + for name := range n.Native { + if !namespaceFilePath(name) { + return n, errors.New("unsafe native replacement path") + } + } + if n.Move != nil && (!namespaceFilePath(n.Move.Source) || !namespaceFilePath(n.Move.Target) || n.Move.ObjectID == "") { + return n, errors.New("unsafe native rename intent") + } + return n, nil +} + +// ManagedNamespaceRoutes includes empty entries so callers can distinguish a +// deliberately hidden file from a session whose route has not been discovered. +func ManagedNamespaceRoutes(store string) (map[string]string, error) { + n, err := readManagedNamespace(store) + if err != nil { + return nil, err + } + routes := make(map[string]string, len(n.Names)) + for id, name := range n.Names { + routes[id] = name.Path + } + return routes, nil +} + +// ManagedNamespacePaths includes native replacements awaiting background fold. +// Their logical Codex ID must not be enrolled again under a different inode. +func ManagedNamespacePaths(store string) (map[string]bool, error) { + n, err := readManagedNamespace(store) + if err != nil { + return nil, err + } + paths := make(map[string]bool) + for _, entry := range n.Names { + if entry.Path != "" { + paths[entry.Path] = true + } + } + for name := range n.Native { + paths[name] = true + } + return paths, nil +} + +func (f *Filesystem) SetManagedStore(store string) error { + if !filepath.IsAbs(store) { + return errors.New("absolute managed store is required") + } + n, err := readManagedNamespace(store) + if err != nil { + return err + } + f.mu.Lock() + f.namespaceStore, f.managedNamespace = filepath.Clean(store), n + if n.Move != nil { + source, sourceErr := os.Lstat(nativePathFromRoot(f.nativeRoot, n.Move.Source)) + target, targetErr := os.Lstat(nativePathFromRoot(f.nativeRoot, n.Move.Target)) + if targetErr == nil && fileObjectIdentity(target) == n.Move.ObjectID { + applyNativeRename(&n) + } else if sourceErr == nil && fileObjectIdentity(source) == n.Move.ObjectID { + n.Move = nil + } else { + f.mu.Unlock() + return errors.New("native rename recovery cannot identify source or target; both preserved") + } + if err := f.publishManagedNamespaceLocked(n); err != nil { + f.mu.Unlock() + return err + } + } + for id, entry := range n.Names { + // No rename from an earlier daemon process can still be preparing. + entry.Pending = false + n.Names[id] = entry + } + f.managedNamespace = n + // A committed rename may have crashed before unlinking its old native + // temporary. Only the exact unchanged source inode may be removed on replay. + for name, cleanup := range n.Cleanup { + if err := f.cleanupRenamedNativeLocked(name, cleanup); err != nil { + f.mu.Unlock() + return err + } + } + if len(n.Cleanup) != 0 { + n.Cleanup = make(map[string]nativeRenameCleanup) + err = f.publishManagedNamespaceLocked(n) + } + f.mu.Unlock() + return err +} + +func (f *Filesystem) namespaceRouteLocked(id, fallback string) (string, bool) { + if name, ok := f.managedNamespace.Names[id]; ok { + return name.Path, name.Path != "" + } + return fallback, true +} + +func (f *Filesystem) namespaceReplacementTargetLocked(name string) bool { + for _, entry := range f.managedNamespace.Names { + if entry.Origin == name { + return true + } + } + return false +} + +func (f *Filesystem) cloneManagedNamespaceLocked() managedNamespace { + n := managedNamespace{Version: 1, Names: make(map[string]managedName), Cleanup: make(map[string]nativeRenameCleanup), Native: make(map[string]bool), Move: f.managedNamespace.Move} + for id, name := range f.managedNamespace.Names { + n.Names[id] = name + } + for name, cleanup := range f.managedNamespace.Cleanup { + n.Cleanup[name] = cleanup + } + for name, value := range f.managedNamespace.Native { + n.Native[name] = value + } + return n +} + +func (f *Filesystem) publishManagedNamespaceLocked(n managedNamespace) error { + if f.namespaceStore == "" { + return errors.New("managed file rename requires a configured store") + } + directory := filepath.Join(f.namespaceStore, "fs") + data, err := json.MarshalIndent(n, "", " ") + if err != nil { + return err + } + temporary, err := os.CreateTemp(directory, ".namespace-*.tmp") + if err != nil { + return err + } + defer os.Remove(temporary.Name()) + if _, err := temporary.Write(append(data, '\n')); err != nil { + temporary.Close() + return err + } + if err := temporary.Sync(); err != nil { + temporary.Close() + return err + } + if err := temporary.Close(); err != nil { + return err + } + if err := os.Rename(temporary.Name(), filepath.Join(directory, "namespace.json")); err != nil { + return err + } + // The rename is already visible, so memory must agree even if directory + // synchronization fails and the caller reports the durability error. + f.managedNamespace = n + dir, err := os.Open(directory) + if err != nil { + return err + } + defer dir.Close() + return dir.Sync() +} + +// renameManagedLocked consumes f.mu. No source bytes are copied or rewritten. +func (f *Filesystem) renameManagedLocked(id, oldPath, newPath string) syscall.Errno { + defer f.mu.Unlock() + if _, directory := f.directories[newPath]; directory { + return syscall.EISDIR + } + if info, err := os.Lstat(nativePathFromRoot(f.nativeRoot, newPath)); f.nativeRoot != "" && err == nil && info.IsDir() { + return syscall.EISDIR + } + var cleanup *nativeRenameCleanup + if f.nativeRoot != "" { + target := nativePathFromRoot(f.nativeRoot, newPath) + if info, err := os.Lstat(target); err == nil && info.Mode().IsRegular() { + // Append transactions reopen by path. Never redirect an existing + // native writer into the restored managed file or lose its tail. + if f.nativePathBusyLocked(newPath) || f.nativeSnapshotPathLocked(target) { + return syscall.EBUSY + } + cleanup = &nativeRenameCleanup{ObjectID: fileObjectIdentity(info), Size: info.Size(), ModTime: info.ModTime().UnixNano()} + } + } + if f.namespaceStore == "" { + // Legacy in-memory fixtures and nonpersistent adapters still support + // archive/unarchive, but never pretend arbitrary backups are durable. + if !canonicalSessionPath(newPath) { + return syscall.ENOTSUP + } + if _, exists := f.paths[newPath]; exists { + return syscall.EEXIST + } + } + if err := moveManagedXattrCarrier(f.nativeRoot, oldPath, newPath); err != nil { + return errnoFor(err) + } + if f.namespaceStore != "" { + n := f.cloneManagedNamespaceLocked() + origin := oldPath + if entry, ok := n.Names[id]; ok { + origin = entry.Origin + } + n.Names[id] = managedName{Path: newPath, Origin: origin} + delete(n.Native, newPath) + if cleanup != nil { + n.Cleanup[newPath] = *cleanup + } + if displaced := f.paths[newPath]; displaced != "" && displaced != id { + entry := n.Names[displaced] + if entry.Origin == "" { + entry.Origin = newPath + } + entry.Path = "" + n.Names[displaced] = entry + } + if err := f.publishManagedNamespaceLocked(n); err != nil { + _ = moveManagedXattrCarrier(f.nativeRoot, newPath, oldPath) + return errnoFor(err) + } + if cleanup != nil { + delete(f.nativeAppends, filepath.Clean(nativePathFromRoot(f.nativeRoot, newPath))) + if err := f.cleanupRenamedNativeLocked(newPath, *cleanup); err == nil { + n = f.cloneManagedNamespaceLocked() + delete(n.Cleanup, newPath) + _ = f.publishManagedNamespaceLocked(n) + } + } + } + delete(f.paths, oldPath) + f.paths[newPath] = id + now := time.Now() + f.bumpDirectoryGenerationLocked(path.Dir(oldPath), now) + f.bumpDirectoryGenerationLocked(path.Dir(newPath), now) + for _, h := range f.handles { + if h.path == oldPath { + h.path = newPath + } + } + f.bumpNamespaceVersion() + return 0 +} + +func (f *Filesystem) nativeSnapshotPathLocked(target string) bool { + for _, session := range f.sessions { + if snapshot := session.State().NativeSnapshot.Path; snapshot != "" && filepath.Clean(snapshot) == filepath.Clean(target) { + return true + } + } + return false +} + +func (f *Filesystem) cleanupRenamedNativeLocked(name string, cleanup nativeRenameCleanup) error { + if f.nativeRoot == "" { + return errors.New("native root is required for rename replay") + } + root, err := os.OpenRoot(f.nativeRoot) + if err != nil { + return err + } + defer root.Close() + relative := filepath.FromSlash(strings.TrimPrefix(name, "/")) + info, err := root.Lstat(relative) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + if !info.Mode().IsRegular() || fileObjectIdentity(info) != cleanup.ObjectID || info.Size() != cleanup.Size || info.ModTime().UnixNano() != cleanup.ModTime { + return fmt.Errorf("renamed native source changed; preserving %s", name) + } + if err := root.Remove(relative); err != nil { + return err + } + dir, err := root.Open(filepath.Dir(relative)) + if err != nil { + return err + } + defer dir.Close() + return dir.Sync() +} + +// Native replacement is a namespace operation, not a compression job. Record +// intent first, use the native rename, then publish native authority. A restart +// completes the intent by matching the source inode, never by guessing bytes. +func (f *Filesystem) importNativeRename(oldPath, newPath string) syscall.Errno { + f.mu.Lock() + defer f.mu.Unlock() + if f.namespaceStore == "" { + return syscall.ENOTSUP + } + source := nativePathFromRoot(f.nativeRoot, oldPath) + target := nativePathFromRoot(f.nativeRoot, newPath) + info, err := os.Lstat(source) + if err != nil { + return errnoFor(err) + } + if !info.Mode().IsRegular() { + return syscall.EINVAL + } + if _, isDir := f.directories[newPath]; isDir { + return syscall.EISDIR + } + if dest, err := os.Lstat(target); err == nil && dest.IsDir() { + return syscall.EISDIR + } + if f.nativePathBusyLocked(newPath) || f.nativeSnapshotPathLocked(target) { + return syscall.EBUSY + } + displaced := f.paths[newPath] + n := f.cloneManagedNamespaceLocked() + n.Move = &nativeRenameIntent{Source: oldPath, Target: newPath, ObjectID: fileObjectIdentity(info), Displaced: displaced} + if err := f.publishManagedNamespaceLocked(n); err != nil { + return errnoFor(err) + } + state := f.nativeAppends[filepath.Clean(source)] + if state != nil { + err = state.Relocate(target) + } else { + err = os.Rename(source, target) + } + if err != nil { + n.Move = nil + _ = f.publishManagedNamespaceLocked(n) + return errnoFor(err) + } + applyNativeRename(&n) + // The durable intent is sufficient for recovery even if the final metadata + // sync fails after the OS rename. Do not report failure for a completed move. + if err := f.publishManagedNamespaceLocked(n); err != nil { + f.managedNamespace = n + } + delete(f.paths, newPath) + delete(f.nativeAppends, filepath.Clean(source)) + delete(f.nativeAppends, filepath.Clean(target)) + if state != nil { + f.nativeAppends[filepath.Clean(target)] = state + } + for _, h := range f.handles { + if h.nativePath == source { + h.nativePath = target + h.path = newPath + } + } + f.bumpDirectoryGenerationLocked(path.Dir(oldPath), time.Now()) + f.bumpDirectoryGenerationLocked(path.Dir(newPath), time.Now()) + f.bumpNamespaceVersion() + return 0 +} + +func applyNativeRename(n *managedNamespace) { + m := n.Move + if m.Displaced != "" { + entry := n.Names[m.Displaced] + if entry.Origin == "" { + entry.Origin = m.Target + } + entry.Path = "" + n.Names[m.Displaced] = entry + } + delete(n.Native, m.Source) + n.Native[m.Target] = true + n.Move = nil +} + +// FoldNativeReplacements is background-only. Native open files are left alone; +// a changed source simply remains native until a later stable attempt. +func (f *Filesystem) FoldNativeReplacements(ctx context.Context, prepare func(context.Context, string, string) (*vfs.Session, io.Closer, error)) error { + f.mu.RLock() + var names []string + for name := range f.managedNamespace.Native { + names = append(names, name) + } + root := f.nativeRoot + f.mu.RUnlock() + for _, name := range names { + if err := ctx.Err(); err != nil { + return err + } + f.mu.RLock() + busy := f.nativePathBusyLocked(name) + f.mu.RUnlock() + if busy { + continue + } + source := nativePathFromRoot(root, name) + before, err := os.Lstat(source) + if errors.Is(err, os.ErrNotExist) { + f.renameMu.Lock() + f.mu.Lock() + if _, statErr := os.Lstat(source); errors.Is(statErr, os.ErrNotExist) && f.managedNamespace.Native[name] { + n := f.cloneManagedNamespaceLocked() + delete(n.Native, name) + err = f.publishManagedNamespaceLocked(n) + } else { + err = nil + } + f.mu.Unlock() + f.renameMu.Unlock() + if err != nil { + return err + } + continue + } + if err != nil { + return err + } + var random [16]byte + if _, err := rand.Read(random[:]); err != nil { + return err + } + id := "file-" + hex.EncodeToString(random[:]) + f.mu.Lock() + n := f.cloneManagedNamespaceLocked() + n.Names[id] = managedName{Origin: name, Pending: true} + err = f.publishManagedNamespaceLocked(n) + f.mu.Unlock() + if err != nil { + return err + } + session, closer, prepareErr := prepare(ctx, source, id) + owner := newSessionOwner(closer) + f.renameMu.Lock() + f.mu.Lock() + after, statErr := os.Lstat(source) + unchanged := statErr == nil && os.SameFile(before, after) && before.Size() == after.Size() && before.ModTime().Equal(after.ModTime()) + n = f.cloneManagedNamespaceLocked() + if prepareErr != nil || session == nil || !unchanged || !n.Native[name] || f.nativePathBusyLocked(name) { + n.Names[id] = managedName{Origin: name} + err = f.publishManagedNamespaceLocked(n) + f.mu.Unlock() + f.renameMu.Unlock() + owner.retire() + if err != nil { + return err + } + if prepareErr != nil && unchanged { + return prepareErr + } + continue + } + n.Names[id] = managedName{Origin: name, Path: name} + delete(n.Native, name) + cleanup := nativeRenameCleanup{ObjectID: fileObjectIdentity(after), Size: after.Size(), ModTime: after.ModTime().UnixNano()} + n.Cleanup[name] = cleanup + if err := f.publishManagedNamespaceLocked(n); err != nil { + f.mu.Unlock() + f.renameMu.Unlock() + owner.retire() + return err + } + f.sessions[id], f.owners[id], f.paths[name] = session, owner, id + delete(f.nativeAppends, filepath.Clean(source)) + f.ensureDirectoryChainLocked(path.Dir(name)) + f.bumpNamespaceVersion() + if err := f.cleanupRenamedNativeLocked(name, cleanup); err == nil { + n = f.cloneManagedNamespaceLocked() + delete(n.Cleanup, name) + _ = f.publishManagedNamespaceLocked(n) + } + f.mu.Unlock() + f.renameMu.Unlock() + } + return nil +} + +type namespaceObjectReader struct { + loose *fold.ObjectStore + packed vfs.ObjectReader + mu sync.Mutex + cache map[string][]byte + cacheBytes int +} + +// NamespaceReader serves freshly replaced files before the next pack build. +// Callers doing pack-only retirement proofs must keep using the pack resolver. +func NamespaceReader(store string, packed vfs.ObjectReader) vfs.ObjectReader { + return &namespaceObjectReader{loose: fold.NewObjectStore(store), packed: packed} +} + +// RetireReplacedFiles publishes ordinary deletion authority for overwritten +// inodes, never for named backups. Existing leases continue to delay physical +// removal through the existing deletion recovery/purge protocol. +func (f *Filesystem) RetireReplacedFiles(ctx context.Context) error { + f.renameMu.Lock() + defer f.renameMu.Unlock() + f.mu.RLock() + store := f.namespaceStore + names := make(map[string]managedName) + for id, entry := range f.managedNamespace.Names { + if _, deleted := f.deletedManagedSessions[id]; !deleted && entry.Path == "" && !entry.Pending { + names[id] = entry + } + } + f.mu.RUnlock() + for id, entry := range names { + if err := ctx.Err(); err != nil { + return err + } + state, err := vfs.InspectSessionState(filepath.Join(store, "fs", "sessions", id, "state.json")) + if errors.Is(err, os.ErrNotExist) { + continue + } + if err != nil { + return err + } + guard, acquired, err := vfs.TryAcquireWriterLeaseGuard(store, id) + if err != nil { + return err + } + if !acquired { + continue + } + _, err = vfs.PublishSessionDeletion(store, state, entry.Origin) + guard.Close() + if errors.Is(err, storage.ErrOperationLockHeld) || errors.Is(err, vfs.ErrSessionDeletionBusy) { + continue + } + if err != nil { + return err + } + f.mu.Lock() + f.deletedManagedSessions[id] = struct{}{} + owner := f.owners[id] + delete(f.sessions, id) + delete(f.owners, id) + f.mu.Unlock() + if err := owner.retire(); err != nil { + return err + } + } + return nil +} + +func (r *namespaceObjectReader) ReadAt(ctx context.Context, ref fold.ObjectRef, dest []byte, offset int64) (int, error) { + if err := ctx.Err(); err != nil { + return 0, err + } + if offset < 0 { + return 0, errors.New("negative object offset") + } + if has, ok := r.packed.(interface{ HasObject(fold.ObjectRef) bool }); ok && has.HasObject(ref) { + return r.packed.ReadAt(ctx, ref, dest, offset) + } + r.mu.Lock() + data := r.cache[ref.SHA256] + r.mu.Unlock() + var err error + if data == nil { + data, err = r.loose.Read(ref) + if err == nil && len(data) <= 1<<20 { + r.mu.Lock() + if r.cache == nil || r.cacheBytes+len(data) > 1<<20 { + r.cache = make(map[string][]byte) + r.cacheBytes = 0 + } + if r.cache[ref.SHA256] == nil { + r.cache[ref.SHA256] = data + r.cacheBytes += len(data) + } + r.mu.Unlock() + } + } + if err != nil { + if r.packed != nil { + return r.packed.ReadAt(ctx, ref, dest, offset) + } + return 0, err + } + if offset >= int64(len(data)) { + return 0, io.EOF + } + n := copy(dest, data[offset:]) + if n < len(dest) { + return n, io.EOF + } + return n, nil +} diff --git a/internal/mountfs/managed_namespace_live_darwin_test.go b/internal/mountfs/managed_namespace_live_darwin_test.go new file mode 100644 index 0000000..d148b3a --- /dev/null +++ b/internal/mountfs/managed_namespace_live_darwin_test.go @@ -0,0 +1,171 @@ +//go:build darwin + +package mountfs + +import ( + "bytes" + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +// This uses the installed FSKit module with a NEW resource/mount and the actual +// installed jstarctl. It never points jstarctl at the production SQLite file. +func TestManagedNamespaceMountedJstarctlRebind(t *testing.T) { + if os.Getenv("CODEXFOLD_NAMESPACE_LIVE") != "1" { + t.Skip("explicit isolated native FSKit run required") + } + f, store, _, route, original := namespaceFixture(t) + user, err := os.UserHomeDir() + if err != nil { + t.Fatal(err) + } + resource, err := os.MkdirTemp(filepath.Join(user, "Library", "Group Containers", "group.vip.jstar.codexfold"), "r-") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(resource) + mount, err := os.MkdirTemp("/private/tmp", "cfd-rebind-") + if err != nil { + t.Fatal(err) + } + defer os.Remove(mount) + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { + done <- ServeNativeFSKit(ctx, f, NativeFSKitServerOptions{ResourcePath: resource, SocketPath: filepath.Join(resource, "daemon.sock"), MountPoint: mount}) + }() + defer func() { + cancel() + select { + case err := <-done: + if err != nil && !errors.Is(err, context.Canceled) { + t.Error(err) + } + case <-time.After(5 * time.Second): + t.Error("isolated server shutdown timeout") + } + }() + deadline := time.Now().Add(5 * time.Second) + for { + if _, err := os.Stat(filepath.Join(resource, "daemon.sock")); err == nil { + break + } + if time.Now().After(deadline) { + t.Fatal("isolated server did not start") + } + time.Sleep(10 * time.Millisecond) + } + mountCtx, mountCancel := context.WithTimeout(context.Background(), 20*time.Second) + defer mountCancel() + if output, err := exec.CommandContext(mountCtx, "/sbin/mount", "-F", "-t", "codexfoldnative", resource, mount).CombinedOutput(); err != nil { + t.Fatalf("isolated mount: %v %s", err, output) + } + defer func() { + unmountCtx, unmountCancel := context.WithTimeout(context.Background(), 20*time.Second) + defer unmountCancel() + if output, err := exec.CommandContext(unmountCtx, "/sbin/umount", mount).CombinedOutput(); err != nil { + t.Errorf("isolated unmount: %v %s", err, output) + } + }() + path := nativePathFromRoot(mount, route) + if data, err := os.ReadFile(path); err != nil || !bytes.Equal(data, original) { + t.Fatalf("folded mounted source: %v", err) + } + old, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + defer old.Close() + home := t.TempDir() + if err := os.Mkdir(filepath.Join(home, ".codex"), 0700); err != nil { + t.Fatal(err) + } + db := filepath.Join(home, ".codex", "state_5.sqlite") + sql := "CREATE TABLE threads (id TEXT PRIMARY KEY,title TEXT,cwd TEXT,rollout_path TEXT,model_provider TEXT,model TEXT,updated_at INTEGER,archived INTEGER,git_branch TEXT); INSERT INTO threads VALUES ('user-thread','compatibility fixture','" + home + "','" + path + "','openai','',1,0,'');" + if output, err := exec.Command("/usr/bin/sqlite3", db, sql).CombinedOutput(); err != nil { + t.Fatalf("isolated sqlite: %v %s", err, output) + } + rebindCtx, rebindCancel := context.WithTimeout(context.Background(), 90*time.Second) + defer rebindCancel() + cmd := exec.CommandContext(rebindCtx, filepath.Join(user, ".local", "bin", "jstarctl"), "codex", "rebind", "main", "user-thread", "--force", "--json") + for _, env := range os.Environ() { + if !strings.HasPrefix(env, "HOME=") && !strings.HasPrefix(env, "CODEX_HOME=") { + cmd.Env = append(cmd.Env, env) + } + } + cmd.Env = append(cmd.Env, "HOME="+home, "CODEX_HOME="+filepath.Join(home, ".codex")) + start := time.Now() + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("actual jstarctl rebind: %v\n%s", err, output) + } + t.Logf("actual jstarctl rebind took %s; isolated store=%s; result=%s", time.Since(start), store, output) + got, err := os.ReadFile(path) + if err != nil || !bytes.Contains(got, []byte(`"model_provider":"main"`)) || !bytes.Contains(got, []byte("original history")) { + t.Fatalf("rebound mounted content: %v %s", err, got) + } + backups, err := filepath.Glob(path + ".*.bak") + if err != nil || len(backups) != 1 { + t.Fatalf("mounted backups: %v %v", backups, err) + } + if got, err := os.ReadFile(backups[0]); err != nil || !bytes.Equal(got, original) { + t.Fatalf("mounted backup differs: %v", err) + } + oldBytes := make([]byte, len(original)) + if n, err := old.ReadAt(oldBytes, 0); err != nil || n != len(original) || !bytes.Equal(oldBytes, original) { + t.Fatalf("old mounted descriptor changed: %v", err) + } + provider, err := exec.Command("/usr/bin/sqlite3", db, "SELECT model_provider FROM threads WHERE id='user-thread';").Output() + if err != nil || strings.TrimSpace(string(provider)) != "main" { + t.Fatalf("isolated sqlite provider=%s err=%v", provider, err) + } + // Append after rebind is the operation Codex itself needs next. + writer, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND, 0) + if err != nil { + t.Fatal(err) + } + tail := []byte("{\"after_rebind\":true}\n") + _, writeErr := writer.Write(tail) + err = errors.Join(writeErr, writer.Sync(), writer.Close()) + if err != nil { + t.Fatal(err) + } + if after, err := os.ReadFile(path); err != nil || !bytes.Equal(after, append(got, tail...)) { + t.Fatalf("mounted append/read after rebind: %v", err) + } + if backup, err := os.ReadFile(backups[0]); err != nil || !bytes.Equal(backup, original) { + t.Fatalf("append changed backup: %v", err) + } + // Exercise the native-to-folded cutover through the same mounted inode + // cache the real client uses, not only the in-memory adapter. + if err := f.FoldNativeReplacements(context.Background(), namespacePrepare(t, store)); err != nil { + t.Fatal(err) + } + if after, err := os.ReadFile(path); err != nil || !bytes.Equal(after, append(got, tail...)) { + t.Fatalf("mounted read after background fold: %v", err) + } + if backup, err := os.ReadFile(backups[0]); err != nil || !bytes.Equal(backup, original) { + t.Fatalf("background fold changed mounted backup: %v", err) + } + if n, err := old.ReadAt(oldBytes, 0); err != nil || n != len(original) || !bytes.Equal(oldBytes, original) { + t.Fatalf("background fold changed old mounted descriptor: %v", err) + } + writer, err = os.OpenFile(path, os.O_WRONLY|os.O_APPEND, 0) + if err != nil { + t.Fatal(err) + } + secondTail := []byte("{\"after_background_fold\":true}\n") + _, writeErr = writer.Write(secondTail) + if err := errors.Join(writeErr, writer.Sync(), writer.Close()); err != nil { + t.Fatal(err) + } + if after, err := os.ReadFile(path); err != nil || !bytes.Equal(after, append(append(got, tail...), secondTail...)) { + t.Fatalf("mounted append after background fold: %v", err) + } +} diff --git a/internal/mountfs/managed_namespace_test.go b/internal/mountfs/managed_namespace_test.go new file mode 100644 index 0000000..5c83b61 --- /dev/null +++ b/internal/mountfs/managed_namespace_test.go @@ -0,0 +1,480 @@ +package mountfs + +import ( + "bytes" + "context" + "io" + "os" + "path/filepath" + "strconv" + "syscall" + "testing" + + "github.com/samekind/codexfold/internal/fold" + "github.com/samekind/codexfold/internal/vfs" +) + +func namespaceFixture(t *testing.T) (*Filesystem, string, string, string, []byte) { + t.Helper() + root := t.TempDir() + store, native := filepath.Join(root, "store"), filepath.Join(root, "native") + route := "/sessions/2026/10/15/rollout-user-thread.jsonl" + if err := os.MkdirAll(filepath.Dir(nativePathFromRoot(native, route)), 0700); err != nil { + t.Fatal(err) + } + source := []byte("{\"type\":\"session_meta\",\"payload\":{\"id\":\"user-thread\",\"model_provider\":\"openai\"}}\n{\"type\":\"event_msg\",\"payload\":{\"type\":\"user_message\",\"message\":\"original history\"}}\n") + if mb, _ := strconv.Atoi(os.Getenv("CODEXFOLD_NAMESPACE_LIVE_MB")); mb > 0 && t.Name() == "TestManagedNamespaceMountedJstarctlRebind" { + record := append([]byte("{\"type\":\"event_msg\",\"payload\":{\"message\":\""), bytes.Repeat([]byte("content "), 512)...) + record = append(record, []byte("\"}}\n")...) + source = append(source, bytes.Repeat(record, (mb<<20)/len(record))...) + t.Logf("large folded fixture: %d bytes", len(source)) + } + input := filepath.Join(root, "input.jsonl") + if err := os.WriteFile(input, source, 0600); err != nil { + t.Fatal(err) + } + result, err := fold.Fold(context.Background(), fold.Session{ID: "user-thread", RolloutPath: input}, fold.FoldOptions{StoreDir: store, Apply: true, Opaque: true}) + if err != nil { + t.Fatal(err) + } + manifest, err := fold.LoadManifestPath(result.ManifestPath) + if err != nil { + t.Fatal(err) + } + session, err := vfs.OpenSession(context.Background(), vfs.SessionOptions{Root: store, ManifestPath: result.ManifestPath, Manifest: manifest, Reader: &namespaceObjectReader{loose: fold.NewObjectStore(store)}, NativeSnapshot: vfs.NativeFile{Path: input, Bytes: result.SourceBytes, SHA256: result.SourceSHA256}}) + if err != nil { + t.Fatal(err) + } + f := NewCanonical() + f.SetNativeRoot(native) + if err := f.SetManagedStore(store); err != nil { + t.Fatal(err) + } + if err := f.AddSessionAt("user-thread", route, session); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { f.CloseSessions() }) + return f, store, native, route, source +} + +func namespaceRead(t *testing.T, f *Filesystem, name string) []byte { + t.Helper() + h, errno := f.Open(name, os.O_RDONLY) + if errno != 0 { + t.Fatalf("open %s: %v", name, errno) + } + defer f.Release(h) + attr, errno := f.Getattr(name) + if errno != 0 { + t.Fatal(errno) + } + b := make([]byte, attr.Size+100) + n, errno := f.Read(h, b, 0) + if errno != 0 { + t.Fatal(errno) + } + return b[:n] +} + +func namespaceWriteTemporary(t *testing.T, f *Filesystem, name string, data []byte) { + t.Helper() + h, errno := f.Open(name, os.O_WRONLY|os.O_CREATE|os.O_EXCL) + if errno != 0 { + t.Fatal(errno) + } + if n, errno := f.Write(h, data, 0); errno != 0 || n != len(data) { + t.Fatalf("temporary write: %d %v", n, errno) + } + if errno := f.Fsync(h); errno != 0 { + t.Fatal(errno) + } + if errno := f.Release(h); errno != 0 { + t.Fatal(errno) + } +} + +func namespacePrepare(t *testing.T, store string) func(context.Context, string, string) (*vfs.Session, io.Closer, error) { + t.Helper() + return func(ctx context.Context, source, id string) (*vfs.Session, io.Closer, error) { + result, err := fold.Fold(ctx, fold.Session{ID: id, RolloutPath: source}, fold.FoldOptions{StoreDir: store, Apply: true, Opaque: true}) + if err != nil { + return nil, nil, err + } + snapshot := filepath.Join(store, "fs", "snapshots", id, "native.jsonl") + if err := os.MkdirAll(filepath.Dir(snapshot), 0700); err != nil { + return nil, nil, err + } + input, err := os.Open(source) + if err != nil { + return nil, nil, err + } + defer input.Close() + output, err := os.Create(snapshot) + if err != nil { + return nil, nil, err + } + _, copyErr := io.Copy(output, input) + closeErr := output.Close() + if copyErr != nil { + return nil, nil, copyErr + } + if closeErr != nil { + return nil, nil, closeErr + } + manifest, err := fold.LoadManifestPath(result.ManifestPath) + if err != nil { + return nil, nil, err + } + session, err := vfs.OpenSession(ctx, vfs.SessionOptions{Root: store, ManifestPath: result.ManifestPath, Manifest: manifest, Reader: &namespaceObjectReader{loose: fold.NewObjectStore(store)}, NativeSnapshot: vfs.NativeFile{Path: snapshot, Bytes: result.SourceBytes, SHA256: result.SourceSHA256}}) + return session, nil, err + } +} + +func TestManagedNamespaceRebindBackupReplacementAndReload(t *testing.T) { + f, store, native, route, original := namespaceFixture(t) + backup := route + ".20261015_000000.bak" + temporary := filepath.ToSlash(filepath.Dir(route)) + "/.rollout-user-thread.jsonl.rebind-test" + rewritten := bytes.ReplaceAll(original, []byte("openai"), []byte("main")) + old, errno := f.Open(route, os.O_RDONLY) + if errno != 0 { + t.Fatal(errno) + } + defer f.Release(old) + namespaceWriteTemporary(t, f, temporary, rewritten) + if errno := f.Rename(route, backup); errno != 0 { + t.Fatalf("backup: %v", errno) + } + if _, errno := f.Getattr(route); errno != syscall.ENOENT { + t.Fatalf("source remained: %v", errno) + } + if !bytes.Equal(namespaceRead(t, f, backup), original) { + t.Fatal("backup changed") + } + // A stale SQLite poll must not put the backup back at the original route. + if err := f.MoveSessionAt("user-thread", route); err != nil { + t.Fatal(err) + } + if _, errno := f.Getattr(route); errno != syscall.ENOENT { + t.Fatal("stale index resurrected source") + } + if errno := f.Rename(temporary, route); errno != 0 { + t.Fatalf("replace: %v", errno) + } + if !bytes.Equal(namespaceRead(t, f, route), rewritten) { + t.Fatal("new file differs") + } + if !bytes.Equal(namespaceRead(t, f, backup), original) { + t.Fatal("replacement rewrote backup") + } + b := make([]byte, len(original)+1) + if n, errno := f.Read(old, b, 0); errno != 0 || !bytes.Equal(b[:n], original) { + t.Fatal("old descriptor changed identity") + } + if _, errno := f.Getattr(temporary); errno != syscall.ENOENT { + t.Fatal("temporary remained visible") + } + if _, err := os.Stat(nativePathFromRoot(native, backup)); !os.IsNotExist(err) { + t.Fatal("backup was expanded to a raw file") + } + if err := f.FoldNativeReplacements(context.Background(), namespacePrepare(t, store)); err != nil { + t.Fatal(err) + } + if !bytes.Equal(namespaceRead(t, f, route), rewritten) || !bytes.Equal(namespaceRead(t, f, backup), original) { + t.Fatal("background folding changed either file") + } + if _, err := os.Stat(nativePathFromRoot(native, route)); !os.IsNotExist(err) { + t.Fatal("background cutover left the native current path") + } + // Reconstruct solely from durable names and ordinary managed session state. + reloaded := NewCanonical() + reloaded.SetNativeRoot(native) + if err := reloaded.SetManagedStore(store); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { reloaded.CloseSessions() }) + states, err := vfs.DiscoverSessionStates(store) + if err != nil { + t.Fatal(err) + } + routes, err := ManagedNamespaceRoutes(store) + if err != nil { + t.Fatal(err) + } + for _, state := range states { + name := routes[state.SessionID] + if name == "" { + continue + } + manifest, err := fold.LoadManifestPath(state.ManifestPath) + if err != nil { + t.Fatal(err) + } + session, err := vfs.OpenSession(context.Background(), vfs.SessionOptions{Root: store, ManifestPath: state.ManifestPath, Manifest: manifest, Reader: &namespaceObjectReader{loose: fold.NewObjectStore(store)}, NativeSnapshot: state.NativeSnapshot}) + if err != nil { + t.Fatal(err) + } + if err := reloaded.UpsertSessionAt(state.SessionID, name, session); err != nil { + t.Fatal(err) + } + } + if !bytes.Equal(namespaceRead(t, reloaded, route), rewritten) || !bytes.Equal(namespaceRead(t, reloaded, backup), original) { + t.Fatal("durable rename did not survive reload") + } + // Rollback is also a normal rename, not deletion of either source tree. + if errno := reloaded.Rename(backup, route); errno != 0 { + t.Fatalf("restore: %v", errno) + } + if !bytes.Equal(namespaceRead(t, reloaded, route), original) { + t.Fatal("backup restore failed") + } +} + +func TestManagedNamespaceDirectReplacementPreservesOpenTarget(t *testing.T) { + f, store, _, route, original := namespaceFixture(t) + old, errno := f.Open(route, os.O_RDONLY) + if errno != 0 { + t.Fatal(errno) + } + defer f.Release(old) + temporary := filepath.ToSlash(filepath.Dir(route)) + "/.replace.tmp" + want := []byte("{\"new\":true}\n") + namespaceWriteTemporary(t, f, temporary, want) + if errno := f.Rename(temporary, route); errno != 0 { + t.Fatal(errno) + } + if !bytes.Equal(namespaceRead(t, f, route), want) { + t.Fatal("replacement missing") + } + b := make([]byte, len(original)+1) + if n, errno := f.Read(old, b, 0); errno != 0 || !bytes.Equal(b[:n], original) { + t.Fatal("replaced open descriptor changed") + } + if err := f.RetireReplacedFiles(context.Background()); err != nil { + t.Fatal(err) + } + if _, err := vfs.LoadSessionDeletion(store, "user-thread"); err != nil { + t.Fatalf("overwritten inode was not scheduled for reclamation: %v", err) + } + if n, errno := f.Read(old, b, 0); errno != 0 || !bytes.Equal(b[:n], original) { + t.Fatal("retirement broke old descriptor") + } + if !bytes.Equal(namespaceRead(t, f, route), want) { + t.Fatal("retirement hid replacement") + } +} + +func TestManagedNamespaceBackupCanBeDeletedWithoutDeletingReplacement(t *testing.T) { + f, store, _, route, original := namespaceFixture(t) + f.SetSessionDeletionPublisher(func(state vfs.SessionState, name string) error { + _, err := vfs.PublishSessionDeletion(store, state, name) + return err + }) + backup := route + ".bak" + if errno := f.Rename(route, backup); errno != 0 { + t.Fatal(errno) + } + temporary := filepath.ToSlash(filepath.Dir(route)) + "/.rebind-test" + want := bytes.ReplaceAll(original, []byte("openai"), []byte("main")) + namespaceWriteTemporary(t, f, temporary, want) + if errno := f.Rename(temporary, route); errno != 0 { + t.Fatal(errno) + } + if errno := f.Unlink(backup); errno != 0 { + t.Fatalf("delete backup: %v", errno) + } + if !bytes.Equal(namespaceRead(t, f, route), want) { + t.Fatal("backup deletion removed current file") + } +} + +func TestManagedNamespaceFailedReplacementRestoresBackup(t *testing.T) { + f, _, _, route, original := namespaceFixture(t) + backup := route + ".bak" + if errno := f.Rename(route, backup); errno != 0 { + t.Fatal(errno) + } + if errno := f.Rename(filepath.ToSlash(filepath.Dir(route))+"/.missing-rebind", route); errno != syscall.ENOENT { + t.Fatalf("missing source: %v", errno) + } + if errno := f.Rename(backup, route); errno != 0 { + t.Fatal(errno) + } + if !bytes.Equal(namespaceRead(t, f, route), original) { + t.Fatal("failed replacement lost history") + } +} + +func namespaceNativeReplacement(t *testing.T, f *Filesystem, route string, content []byte) { + t.Helper() + temporary := filepath.ToSlash(filepath.Dir(route)) + "/.replacement" + namespaceWriteTemporary(t, f, temporary, content) + if errno := f.Rename(temporary, route); errno != 0 { + t.Fatal(errno) + } +} + +func TestManagedNamespaceRestoreBeforeBackgroundFold(t *testing.T) { + f, store, native, route, original := namespaceFixture(t) + backup := route + ".bak" + if errno := f.Rename(route, backup); errno != 0 { + t.Fatal(errno) + } + namespaceNativeReplacement(t, f, route, []byte("{\"replacement\":true}\n")) + if errno := f.Rename(backup, route); errno != 0 { + t.Fatal(errno) + } + if err := f.FoldNativeReplacements(context.Background(), func(context.Context, string, string) (*vfs.Session, io.Closer, error) { + t.Fatal("restored file was queued for native folding") + return nil, nil, nil + }); err != nil { + t.Fatal(err) + } + if !bytes.Equal(namespaceRead(t, f, route), original) { + t.Fatal("restored backup changed") + } + if _, err := os.Stat(nativePathFromRoot(native, route)); !os.IsNotExist(err) { + t.Fatal("replaced native bytes leaked") + } + n, err := readManagedNamespace(store) + if err != nil || len(n.Native) != 0 { + t.Fatalf("stale native authority after restore: %+v %v", n, err) + } +} + +func TestManagedNamespaceBackgroundFoldSkipsOpenNativeHandle(t *testing.T) { + f, store, _, route, _ := namespaceFixture(t) + want := []byte("{\"replacement\":true}\n") + namespaceNativeReplacement(t, f, route, want) + handle, errno := f.Open(route, os.O_WRONLY|os.O_APPEND) + if errno != 0 { + t.Fatal(errno) + } + if err := f.FoldNativeReplacements(context.Background(), func(context.Context, string, string) (*vfs.Session, io.Closer, error) { + t.Fatal("attempted compression while native descriptor was open") + return nil, nil, nil + }); err != nil { + t.Fatal(err) + } + if errno := f.Release(handle); errno != 0 { + t.Fatal(errno) + } + if err := f.FoldNativeReplacements(context.Background(), namespacePrepare(t, store)); err != nil { + t.Fatal(err) + } + if !bytes.Equal(namespaceRead(t, f, route), want) { + t.Fatal("closed native handle did not fold correctly") + } +} + +func TestManagedNamespaceConcurrentChangesDoNotCommitStaleFold(t *testing.T) { + for _, action := range []string{"append", "unlink", "rename", "restore"} { + t.Run(action, func(t *testing.T) { + f, store, _, route, original := namespaceFixture(t) + backup := route + ".bak" + if errno := f.Rename(route, backup); errno != 0 { + t.Fatal(errno) + } + content := []byte("{\"replacement\":true}\n") + namespaceNativeReplacement(t, f, route, content) + prepare := namespacePrepare(t, store) + err := f.FoldNativeReplacements(context.Background(), func(ctx context.Context, source, id string) (*vfs.Session, io.Closer, error) { + session, closer, err := prepare(ctx, source, id) + if err != nil { + return session, closer, err + } + switch action { + case "append": + h, errno := f.Open(route, os.O_WRONLY|os.O_APPEND) + if errno != 0 { + t.Fatal(errno) + } + tail := []byte("{\"tail\":true}\n") + if _, errno := f.Write(h, tail, int64(len(content))); errno != 0 { + t.Fatal(errno) + } + if errno := f.Release(h); errno != 0 { + t.Fatal(errno) + } + content = append(content, tail...) + case "unlink": + if errno := f.Unlink(route); errno != 0 { + t.Fatal(errno) + } + case "rename": + if errno := f.Rename(route, route+".moved"); errno != 0 { + t.Fatal(errno) + } + case "restore": + if errno := f.Rename(backup, route); errno != 0 { + t.Fatal(errno) + } + } + return session, closer, nil + }) + if err != nil { + t.Fatal(err) + } + switch action { + case "append": + if !bytes.Equal(namespaceRead(t, f, route), content) { + t.Fatal("lost concurrent append") + } + case "unlink", "rename": + if _, errno := f.Getattr(route); errno != syscall.ENOENT { + t.Fatal("stale fold resurrected source") + } + if action == "rename" && !bytes.Equal(namespaceRead(t, f, route+".moved"), content) { + t.Fatal("lost renamed replacement") + } + case "restore": + if !bytes.Equal(namespaceRead(t, f, route), original) { + t.Fatal("stale fold overwrote restored backup") + } + } + }) + } +} + +func TestManagedNamespaceNativeRenameIntentReplay(t *testing.T) { + for _, committed := range []bool{false, true} { + t.Run(strconv.FormatBool(committed), func(t *testing.T) { + f, store, native, route, original := namespaceFixture(t) + temporary := filepath.ToSlash(filepath.Dir(route)) + "/.rebind-interrupted" + content := []byte("{\"new\":true}\n") + namespaceWriteTemporary(t, f, temporary, content) + info, err := os.Stat(nativePathFromRoot(native, temporary)) + if err != nil { + t.Fatal(err) + } + f.mu.Lock() + n := f.cloneManagedNamespaceLocked() + n.Move = &nativeRenameIntent{Source: temporary, Target: route, ObjectID: fileObjectIdentity(info), Displaced: "user-thread"} + err = f.publishManagedNamespaceLocked(n) + f.mu.Unlock() + if err != nil { + t.Fatal(err) + } + if committed { + if err := os.Rename(nativePathFromRoot(native, temporary), nativePathFromRoot(native, route)); err != nil { + t.Fatal(err) + } + } + reloaded := NewCanonical() + reloaded.SetNativeRoot(native) + if err := reloaded.SetManagedStore(store); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { reloaded.CloseSessions() }) + if err := reloaded.AddSessionAt("user-thread", route, f.sessions["user-thread"]); err != nil { + t.Fatal(err) + } + want := original + if committed { + want = content + } + if !bytes.Equal(namespaceRead(t, reloaded, route), want) { + t.Fatal("intent replay chose wrong inode") + } + }) + } +} diff --git a/internal/mountfs/native_clone_darwin.go b/internal/mountfs/native_clone_darwin.go new file mode 100644 index 0000000..b5a5bf1 --- /dev/null +++ b/internal/mountfs/native_clone_darwin.go @@ -0,0 +1,9 @@ +//go:build darwin + +package mountfs + +import "golang.org/x/sys/unix" + +func cloneNativeFile(source string, destination string) error { + return unix.Clonefile(source, destination, 0) +} diff --git a/internal/mountfs/native_clone_other.go b/internal/mountfs/native_clone_other.go new file mode 100644 index 0000000..f5fd0f1 --- /dev/null +++ b/internal/mountfs/native_clone_other.go @@ -0,0 +1,9 @@ +//go:build !darwin + +package mountfs + +import "errors" + +func cloneNativeFile(string, string) error { + return errors.New("file cloning is not supported on this platform") +} diff --git a/internal/mountfs/native_fskit_diagnostics.go b/internal/mountfs/native_fskit_diagnostics.go new file mode 100644 index 0000000..109a9f7 --- /dev/null +++ b/internal/mountfs/native_fskit_diagnostics.go @@ -0,0 +1,65 @@ +package mountfs + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "sync" + "sync/atomic" + "time" + + "github.com/samekind/codexfold/internal/fskitproto" +) + +// Keep one bounded, local stack snapshot when a real file operation stalls. +// A healthy heartbeat is not evidence that request-handling goroutines move. +// No request payload, session content or credentials are included. +type nativeFSKitStallDiagnostics struct { + path string + after time.Duration + mu sync.Mutex + last time.Time +} + +func (d *nativeFSKitStallDiagnostics) watch(op fskitproto.Op, request uint64) func() { + if d == nil || d.path == "" { + return func() {} + } + after := d.after + if after <= 0 { + after = 10 * time.Second + } + started := time.Now() + var active atomic.Bool + active.Store(true) + timer := time.AfterFunc(after, func() { + if !active.Load() { + return + } + d.mu.Lock() + defer d.mu.Unlock() + if time.Since(d.last) < time.Minute || !active.Load() { + return + } + buffer := make([]byte, 4<<20) + n := runtime.Stack(buffer, true) + header := fmt.Sprintf("CodexFold blocked I/O captured=%s pid=%d operation=%s request=%d elapsed=%s truncated=%t\n", time.Now().UTC().Format(time.RFC3339Nano), os.Getpid(), nativeFSKitOperationName(op), request, time.Since(started), n == len(buffer)) + if err := os.MkdirAll(filepath.Dir(d.path), 0700); err != nil { + return + } + f, err := os.OpenFile(d.path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0600) + if err != nil { + return + } + _, err = f.WriteString(header) + if err == nil { + _, err = f.Write(buffer[:n]) + } + closeErr := f.Close() + if err == nil && closeErr == nil { + d.last = time.Now() + } + }) + return func() { active.Store(false); timer.Stop() } +} diff --git a/internal/mountfs/native_fskit_diagnostics_test.go b/internal/mountfs/native_fskit_diagnostics_test.go new file mode 100644 index 0000000..229c058 --- /dev/null +++ b/internal/mountfs/native_fskit_diagnostics_test.go @@ -0,0 +1,37 @@ +package mountfs + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/samekind/codexfold/internal/fskitproto" +) + +func TestNativeFSKitStallDiagnosticCapturesOnlyBlockedOperation(t *testing.T) { + path := filepath.Join(t.TempDir(), "blocked.txt") + d := &nativeFSKitStallDiagnostics{path: path, after: 15 * time.Millisecond} + stop := d.watch(fskitproto.OpPing, 1) + stop() + time.Sleep(25 * time.Millisecond) + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("fast request produced a dump: %v", err) + } + stop = d.watch(fskitproto.OpRead, 2) + defer stop() + deadline := time.Now().Add(time.Second) + for { + d.mu.Lock() + data, err := os.ReadFile(path) + d.mu.Unlock() + if err == nil && strings.Contains(string(data), "goroutine ") && strings.Contains(string(data), "request=2") { + break + } + if time.Now().After(deadline) { + t.Fatalf("missing stalled I/O dump: %v %q", err, data) + } + time.Sleep(10 * time.Millisecond) + } +} diff --git a/internal/mountfs/native_fskit_request_health.go b/internal/mountfs/native_fskit_request_health.go new file mode 100644 index 0000000..40c79a5 --- /dev/null +++ b/internal/mountfs/native_fskit_request_health.go @@ -0,0 +1,51 @@ +package mountfs + +import ( + "sync" + "time" + + "github.com/samekind/codexfold/internal/fskitproto" +) + +type nativeFSKitPendingRequest struct { + started time.Time + op fskitproto.Op +} + +// Only unfinished user filesystem requests establish an access stall. A slow +// background inventory/reload or an idle authenticated socket does not. +type nativeFSKitRequestHealth struct { + mu sync.Mutex + next uint64 + pending map[uint64]nativeFSKitPendingRequest +} + +func (h *nativeFSKitRequestHealth) begin(op fskitproto.Op) func() { + if h == nil || op == fskitproto.OpHello || op == fskitproto.OpPing { + return func() {} + } + h.mu.Lock() + if h.pending == nil { + h.pending = make(map[uint64]nativeFSKitPendingRequest) + } + h.next++ + id := h.next + h.pending[id] = nativeFSKitPendingRequest{started: time.Now(), op: op} + h.mu.Unlock() + return func() { h.mu.Lock(); delete(h.pending, id); h.mu.Unlock() } +} + +func (h *nativeFSKitRequestHealth) stalled(now time.Time) (nativeFSKitPendingRequest, bool) { + if h == nil { + return nativeFSKitPendingRequest{}, false + } + h.mu.Lock() + defer h.mu.Unlock() + var oldest nativeFSKitPendingRequest + for _, request := range h.pending { + if now.Sub(request.started) >= 10*time.Second && (oldest.started.IsZero() || request.started.Before(oldest.started)) { + oldest = request + } + } + return oldest, !oldest.started.IsZero() +} diff --git a/internal/mountfs/native_fskit_request_health_test.go b/internal/mountfs/native_fskit_request_health_test.go new file mode 100644 index 0000000..d640d1f --- /dev/null +++ b/internal/mountfs/native_fskit_request_health_test.go @@ -0,0 +1,45 @@ +package mountfs + +import ( + "syscall" + "testing" + "time" + + "github.com/samekind/codexfold/internal/fskitproto" + "github.com/samekind/codexfold/internal/mountid" + "github.com/samekind/codexfold/internal/storage" +) + +func TestNativeRequestHealthOnlyAlertsForUnfinishedAccess(t *testing.T) { + h := &nativeFSKitRequestHealth{} + stopPing := h.begin(fskitproto.OpPing) + stopPing() + if _, ok := h.stalled(time.Now().Add(time.Minute)); ok { + t.Fatal("idle health check raised an access incident") + } + stop := h.begin(fskitproto.OpWrite) + if _, ok := h.stalled(time.Now()); ok { + t.Fatal("fast write raised an incident") + } + for id, request := range h.pending { + request.started = time.Now().Add(-11 * time.Second) + h.pending[id] = request + } + options := NativeFSKitServerOptions{requestHealth: h} + tracker := newNativeFSKitDaemonStatusTracker(options) + bad := tracker.snapshot(options, mountid.Identity{}, "healthy", "available", "") + if bad.State != "recovering" || bad.IncidentID == "" || bad.Detail == "" || bad.ElapsedMilliseconds < 10000 { + t.Fatalf("blocked write not exposed: %+v", bad) + } + stop() + ok := tracker.snapshot(options, mountid.Identity{}, "healthy", "available", "") + if ok.State != "healthy" || ok.IncidentID != bad.IncidentID { + t.Fatalf("completed request did not supply matching recovery: %+v", ok) + } +} + +func TestStorageBudgetFailureUsesNoSpaceErrno(t *testing.T) { + if got := errnoFor(storage.ErrBudgetExceeded); got != syscall.ENOSPC { + t.Fatalf("budget rejection becomes %v, want ENOSPC", got) + } +} diff --git a/internal/mountfs/native_fskit_server.go b/internal/mountfs/native_fskit_server.go index fa786cc..ba465cf 100644 --- a/internal/mountfs/native_fskit_server.go +++ b/internal/mountfs/native_fskit_server.go @@ -29,6 +29,7 @@ import ( ) type NativeFSKitServerOptions struct { + requestHealth *nativeFSKitRequestHealth SocketPath string ResourcePath string Token []byte @@ -39,6 +40,7 @@ type NativeFSKitServerOptions struct { MountPoint string StatusInterval time.Duration Recorder func(string) + StallDiagnosticPath string Activity *IOActivityCounter PrewarmSharedMemoryWindows int PrewarmSharedFileWindows int @@ -133,6 +135,12 @@ func (t *nativeFSKitDaemonStatusTracker) snapshot( t.observationSequence++ } now := time.Now().UTC() + blocked, hasBlocked := options.requestHealth.stalled(now) + if state == "healthy" && hasBlocked { + state = "recovering" + summary = "A session file operation has not completed within 10 seconds" + detail = fmt.Sprintf("operation=%s waiting=%s; existing routes remain mounted", nativeFSKitOperationName(blocked.op), now.Sub(blocked.started).Round(time.Millisecond)) + } unhealthy := state != "healthy" if unhealthy && t.incidentID == "" { var token [16]byte @@ -142,6 +150,9 @@ func (t *nativeFSKitDaemonStatusTracker) snapshot( t.incidentID = fmt.Sprintf("daemon-incident-%x", now.UnixNano()) } t.incidentSince = now + if hasBlocked { + t.incidentSince = blocked.started + } } snapshot := fskitstatus.Snapshot{ Component: "daemon", State: state, Summary: summary, Detail: detail, @@ -192,6 +203,8 @@ type nativeFSKitServer struct { generation uint64 maxPayload uint32 recorder func(string) + stallDiagnostics *nativeFSKitStallDiagnostics + requestHealth *nativeFSKitRequestHealth activity *IOActivityCounter health []byte startedAt time.Time @@ -374,6 +387,7 @@ func ServeNativeFSKit(ctx context.Context, filesystem *Filesystem, options Nativ return fmt.Errorf("parse native FSKit mount identity: %w", err) } var statusPublisher *fskitstatus.Publisher + options.requestHealth = &nativeFSKitRequestHealth{} statusTracker := newNativeFSKitDaemonStatusTracker(options) if options.StatusPath != "" { statusPublisher = fskitstatus.NewPublisher(options.StatusPath, func(err error) { @@ -425,14 +439,16 @@ func ServeNativeFSKit(ctx context.Context, filesystem *Filesystem, options Nativ return fmt.Errorf("restrict FSKit socket: %w", err) } server := &nativeFSKitServer{ - filesystem: filesystem, - token: append([]byte(nil), options.Token...), - generation: options.Generation, - maxPayload: options.MaxPayload, - recorder: options.Recorder, - activity: options.Activity, - health: []byte(health), - startedAt: time.Now(), + filesystem: filesystem, + token: append([]byte(nil), options.Token...), + generation: options.Generation, + maxPayload: options.MaxPayload, + recorder: options.Recorder, + stallDiagnostics: &nativeFSKitStallDiagnostics{path: options.StallDiagnosticPath}, + activity: options.Activity, + requestHealth: options.requestHealth, + health: []byte(health), + startedAt: time.Now(), nodes: nativeFSKitNodes{ version: filesystem.NamespaceVersion(), byPath: map[string]nativeFSKitNode{"/": {id: 2}}, @@ -597,6 +613,8 @@ func (c *nativeFSKitConnection) serve() { defer c.conn.Close() defer c.releaseHandles() authenticated := false + endRequest := func() {} + defer func() { endRequest() }() for { request, err := fskitproto.ReadFrame(c.conn, c.server.maxPayload) if err != nil { @@ -605,6 +623,9 @@ func (c *nativeFSKitConnection) serve() { } return } + endDiagnostic := c.server.stallDiagnostics.watch(request.Op, request.RequestID) + endHealth := c.server.requestHealth.begin(request.Op) + endRequest = func() { endDiagnostic(); endHealth() } response := fskitproto.Frame{ Kind: fskitproto.KindResponse, Op: request.Op, @@ -632,6 +653,7 @@ func (c *nativeFSKitConnection) serve() { response.Status = int32(errnoFor(streamErr)) } else if streamed && responseWritten { c.server.record(fmt.Sprintf("operation=%s request=%d status=%d payload=%d", nativeFSKitOperationName(request.Op), request.RequestID, 0, len(request.Payload))) + endRequest() continue } else if streamed { response.Status = streamStatus @@ -649,6 +671,7 @@ func (c *nativeFSKitConnection) serve() { response.Payload = nil } else if handled && responseWritten { c.server.record(fmt.Sprintf("operation=%s request=%d status=%d payload=%d", nativeFSKitOperationName(request.Op), request.RequestID, 0, len(request.Payload))) + endRequest() continue } } @@ -656,6 +679,7 @@ func (c *nativeFSKitConnection) serve() { if err := fskitproto.WriteFrame(c.conn, response, c.server.maxPayload); err != nil { return } + endRequest() if !authenticated || response.Status == int32(syscall.ESTALE) { return } diff --git a/internal/mountfs/native_preflight.go b/internal/mountfs/native_preflight.go index eba864f..f35c34a 100644 --- a/internal/mountfs/native_preflight.go +++ b/internal/mountfs/native_preflight.go @@ -21,6 +21,9 @@ type NativePreflightReport struct { ValidatedBytes int64 `json:"validated_bytes"` CachePath string `json:"cache_path,omitempty"` CacheRebuilt bool `json:"cache_rebuilt,omitempty"` + // Repairs and Issues are only populated by the repairing preflight. + Repairs []NativeRolloutRepair `json:"repairs,omitempty"` + Issues []NativePreflightIssue `json:"issues,omitempty"` } func (f *Filesystem) ValidateNativeWriterRollouts(ctx context.Context) (NativePreflightReport, error) { @@ -33,6 +36,21 @@ func (f *Filesystem) ValidateNativeWriterRollouts(ctx context.Context) (NativePr return validateNativeWriterRollouts(ctx, root) } +// RepairNativeWriterRollouts is the startup preflight. Unlike +// ValidateNativeWriterRollouts it never fails because of a damaged rollout: it +// quarantines the damaged records, rewrites the rollout, and reports whatever +// it could not fix as issues so the mount can still come up. Call it only +// while nothing is writing the native root. +func (f *Filesystem) RepairNativeWriterRollouts(ctx context.Context) (NativePreflightReport, error) { + f.mu.RLock() + root := f.nativeRoot + f.mu.RUnlock() + if root == "" { + return NativePreflightReport{}, nil + } + return preflightNativeWriterRollouts(ctx, root, true) +} + func validateNativeWriterRollouts(ctx context.Context, nativeRoot string) (NativePreflightReport, error) { return validateNativeWriterRolloutsCached(ctx, nativeRoot) } diff --git a/internal/mountfs/native_preflight_cache.go b/internal/mountfs/native_preflight_cache.go index 5af29fa..dc3c4e6 100644 --- a/internal/mountfs/native_preflight_cache.go +++ b/internal/mountfs/native_preflight_cache.go @@ -34,6 +34,14 @@ type nativePreflightEntry struct { } func validateNativeWriterRolloutsCached(ctx context.Context, nativeRoot string) (NativePreflightReport, error) { + return preflightNativeWriterRollouts(ctx, nativeRoot, false) +} + +// preflightNativeWriterRollouts validates every active rollout. In repair mode +// a damaged rollout is repaired in place of failing, and anything that cannot +// be repaired is reported as an issue instead of aborting the walk, so one bad +// file can never keep the mount from coming up. +func preflightNativeWriterRollouts(ctx context.Context, nativeRoot string, repair bool) (NativePreflightReport, error) { root := filepath.Clean(nativeRoot) activeRoot := filepath.Join(root, "sessions") cachePath := filepath.Join(root, ".codexfold-native-preflight-v1.json") @@ -43,83 +51,129 @@ func validateNativeWriterRolloutsCached(ctx context.Context, nativeRoot string) err := filepath.WalkDir(activeRoot, func(filePath string, directoryEntry os.DirEntry, walkErr error) error { if walkErr != nil { + if repair && !errors.Is(walkErr, os.ErrNotExist) && ctx.Err() == nil { + report.Issues = append(report.Issues, NativePreflightIssue{Path: filePath, Message: walkErr.Error()}) + if directoryEntry != nil && directoryEntry.IsDir() { + return filepath.SkipDir + } + return nil + } return walkErr } if err := ctx.Err(); err != nil { return err } - if directoryEntry.Type()&os.ModeSymlink != 0 { - return fmt.Errorf("native writer preflight rejects symlink %s", filePath) - } - if directoryEntry.IsDir() { + if repair && !directoryEntry.IsDir() && isNativeRepairTemporary(directoryEntry.Name()) { + _ = os.Remove(filePath) return nil } - if !directoryEntry.Type().IsRegular() { - return fmt.Errorf("native writer preflight rejects non-regular file %s", filePath) - } - if strings.HasPrefix(directoryEntry.Name(), "._") || !strings.HasSuffix(directoryEntry.Name(), ".jsonl") { + err := preflightNativeFile(ctx, root, filePath, directoryEntry, cache, &next, &report, repair) + if err != nil && repair && ctx.Err() == nil { + // A rollout removed mid-walk is not an issue; anything else is + // reported and the walk continues. + if !errors.Is(err, os.ErrNotExist) { + report.Issues = append(report.Issues, NativePreflightIssue{Path: filePath, Message: err.Error()}) + } return nil } - info, err := directoryEntry.Info() - if err != nil { - return err - } - relative, err := filepath.Rel(root, filePath) - if err != nil || relative == "." || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { - return fmt.Errorf("native writer preflight path escaped native root: %s", filePath) + return err + }) + if errors.Is(err, os.ErrNotExist) { + err = nil + } + if err != nil { + return report, err + } + if err := writeNativePreflightCache(cachePath, next, cacheBytes); err != nil { + // A cache that cannot be saved only costs a slower next start. + if !repair { + return report, err } - key := filepath.ToSlash(relative) - current := nativePreflightEntry{Size: info.Size(), ModTimeNS: info.ModTime().UnixNano()} - report.Files++ - report.Bytes += info.Size() + report.Issues = append(report.Issues, NativePreflightIssue{Path: cachePath, Message: err.Error()}) + } + return report, nil +} - cached, exists := cache.Entries[key] - if exists && cached.Size == current.Size && cached.ModTimeNS == current.ModTimeNS { - next.Entries[key] = cached - report.CachedFiles++ - return nil - } +func preflightNativeFile(ctx context.Context, root string, filePath string, directoryEntry os.DirEntry, cache nativePreflightCache, next *nativePreflightCache, report *NativePreflightReport, repair bool) error { + if directoryEntry.Type()&os.ModeSymlink != 0 { + return fmt.Errorf("native writer preflight rejects symlink %s", filePath) + } + if directoryEntry.IsDir() { + return nil + } + if !directoryEntry.Type().IsRegular() { + return fmt.Errorf("native writer preflight rejects non-regular file %s", filePath) + } + if strings.HasPrefix(directoryEntry.Name(), "._") || !strings.HasSuffix(directoryEntry.Name(), ".jsonl") { + return nil + } + info, err := directoryEntry.Info() + if err != nil { + return err + } + relative, err := filepath.Rel(root, filePath) + if err != nil || relative == "." || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + return fmt.Errorf("native writer preflight path escaped native root: %s", filePath) + } + key := filepath.ToSlash(relative) + current := nativePreflightEntry{Size: info.Size(), ModTimeNS: info.ModTime().UnixNano()} + report.Files++ + report.Bytes += info.Size() - var validatedBytes int64 - incremental := exists && current.Size > cached.Size && nativePrefixMatches(filePath, cached) - if incremental { - validatedBytes, err = validateNativeJSONLFrom(ctx, filePath, cached.Size) - } else { - validatedBytes, err = validateNativeJSONL(ctx, filePath) - } - if err != nil { - return err + cached, exists := cache.Entries[key] + if exists && cached.Size == current.Size && cached.ModTimeNS == current.ModTimeNS { + next.Entries[key] = cached + report.CachedFiles++ + return nil + } + + var validatedBytes int64 + var validatedFrom int64 + incremental := exists && current.Size > cached.Size && nativePrefixMatches(filePath, cached) + if incremental { + validatedFrom = cached.Size + validatedBytes, err = validateNativeJSONLFrom(ctx, filePath, cached.Size) + } else { + validatedBytes, err = validateNativeJSONL(ctx, filePath) + } + if err != nil && repair && ctx.Err() == nil { + repaired, changed, repairErr := repairNativeRollout(ctx, root, filePath, validatedFrom) + if repairErr != nil { + return fmt.Errorf("%w; automatic repair failed: %v", err, repairErr) } - after, err := os.Stat(filePath) - if err != nil { + if !changed { return err } - if after.Size() != current.Size || after.ModTime().UnixNano() != current.ModTimeNS { - return fmt.Errorf("native writer preflight target changed during validation: %s", filePath) - } - current.HeadSHA256, current.TailSHA256, err = nativeFingerprints(filePath, current.Size) - if err != nil { + report.Repairs = append(report.Repairs, repaired) + if info, err = os.Stat(filePath); err != nil { return err } - next.Entries[key] = current - report.ValidatedBytes += validatedBytes - if incremental { - report.IncrementalFiles++ - } else { - report.ValidatedFiles++ - } - return nil - }) - if errors.Is(err, os.ErrNotExist) { - err = nil + current.Size, current.ModTimeNS = info.Size(), info.ModTime().UnixNano() + report.Bytes += current.Size - repaired.OriginalSize + validatedBytes, err = validateNativeJSONLFrom(ctx, filePath, validatedFrom) } if err != nil { - return report, err + return err } - if err := writeNativePreflightCache(cachePath, next, cacheBytes); err != nil { - return report, err + after, err := os.Stat(filePath) + if err != nil { + return err } - return report, nil + if after.Size() != current.Size || after.ModTime().UnixNano() != current.ModTimeNS { + return fmt.Errorf("native writer preflight target changed during validation: %s", filePath) + } + current.HeadSHA256, current.TailSHA256, err = nativeFingerprints(filePath, current.Size) + if err != nil { + return err + } + next.Entries[key] = current + report.ValidatedBytes += validatedBytes + if incremental { + report.IncrementalFiles++ + } else { + report.ValidatedFiles++ + } + return nil } func loadNativePreflightCache(path string) (nativePreflightCache, []byte, bool) { diff --git a/internal/mountfs/native_repair.go b/internal/mountfs/native_repair.go new file mode 100644 index 0000000..0339fed --- /dev/null +++ b/internal/mountfs/native_repair.go @@ -0,0 +1,367 @@ +package mountfs + +import ( + "bufio" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + "unicode/utf8" +) + +const ( + nativeQuarantineDirectory = ".codexfold-native-quarantine" + nativeRepairTempPrefix = ".codexfold-repair-" + nativeRepairTempSuffix = ".tmp" + nativeRepairCopyChunk = 4 << 20 +) + +// nativeByteRange is a half-open [Start, End) span of a rollout file. +type nativeByteRange struct { + Start int64 `json:"start"` + End int64 `json:"end"` +} + +// NativeRolloutRepair describes one automatic repair of a damaged active +// rollout. Every removed byte is kept in QuarantinePath, so the original file +// can be rebuilt by re-inserting each range at its recorded offset. +type NativeRolloutRepair struct { + Path string `json:"path"` + QuarantinePath string `json:"quarantine_path,omitempty"` + RemovedRanges int `json:"removed_ranges"` + RemovedBytes int64 `json:"removed_bytes"` + AddedFinalLF bool `json:"added_final_newline,omitempty"` + OriginalSize int64 `json:"original_size"` + RepairedSize int64 `json:"repaired_size"` + FirstDamagedAt int64 `json:"first_damaged_offset"` + QuarantineError string `json:"quarantine_error,omitempty"` +} + +type nativeQuarantineManifest struct { + Version int `json:"version"` + RelativePath string `json:"relative_path"` + CreatedAt string `json:"created_at"` + OriginalSize int64 `json:"original_size"` + Reason string `json:"reason"` + Ranges []nativeQuarantinedRange `json:"ranges"` +} + +type nativeQuarantinedRange struct { + nativeByteRange + SHA256 string `json:"sha256"` + File string `json:"file"` +} + +type nativeDamage struct { + ranges []nativeByteRange + // finalNewline is set when the file ends with a complete JSON record that + // only lacks its newline, which is repaired by appending one. + finalNewline bool + size int64 +} + +func (d nativeDamage) any() bool { return len(d.ranges) != 0 || d.finalNewline } + +// scanNativeDamage reports the lines at or after from that are not valid +// UTF-8 JSON records. from must be a record boundary. Adjacent damaged lines +// are merged, and a trailing partial line is damaged unless it is a complete +// JSON record without its newline. +func scanNativeDamage(ctx context.Context, filePath string, from int64) (nativeDamage, error) { + file, err := os.Open(filePath) + if err != nil { + return nativeDamage{}, err + } + defer file.Close() + if from > 0 { + boundary := []byte{0} + if _, err := file.ReadAt(boundary, from-1); err != nil || boundary[0] != '\n' { + return nativeDamage{}, fmt.Errorf("native repair offset %d is not a JSONL boundary in %s", from, filePath) + } + } + if _, err := file.Seek(from, io.SeekStart); err != nil { + return nativeDamage{}, err + } + reader := bufio.NewReaderSize(file, 1<<20) + damage := nativeDamage{} + offset := from + add := func(start int64, end int64) { + if last := len(damage.ranges) - 1; last >= 0 && damage.ranges[last].End == start { + damage.ranges[last].End = end + return + } + damage.ranges = append(damage.ranges, nativeByteRange{Start: start, End: end}) + } + for { + if err := ctx.Err(); err != nil { + return nativeDamage{}, err + } + line, readErr := reader.ReadBytes('\n') + start := offset + offset += int64(len(line)) + if errors.Is(readErr, io.EOF) { + if len(line) != 0 { + if utf8.Valid(line) && json.Valid(line) { + damage.finalNewline = true + } else { + add(start, offset) + } + } + break + } + if readErr != nil { + return nativeDamage{}, readErr + } + if !utf8.Valid(line) || !json.Valid(line) { + add(start, offset) + } + } + damage.size = offset + return damage, nil +} + +// repairNativeRollout removes damaged records from one active rollout. The +// removed bytes are written durably to the quarantine before the file is +// touched, and the repaired content replaces the rollout through a same-volume +// rename of a clone, so a crash or a full disk leaves the original in place. +// The caller must make sure nothing is writing the rollout. +func repairNativeRollout(ctx context.Context, nativeRoot string, filePath string, from int64) (NativeRolloutRepair, bool, error) { + damage, err := scanNativeDamage(ctx, filePath, from) + if err != nil { + return NativeRolloutRepair{}, false, err + } + if !damage.any() { + return NativeRolloutRepair{}, false, nil + } + before, err := os.Lstat(filePath) + if err != nil { + return NativeRolloutRepair{}, false, err + } + if !before.Mode().IsRegular() || before.Size() != damage.size { + return NativeRolloutRepair{}, false, fmt.Errorf("native rollout changed before repair: %s", filePath) + } + relative, err := filepath.Rel(filepath.Clean(nativeRoot), filePath) + if err != nil || strings.HasPrefix(relative, "..") { + return NativeRolloutRepair{}, false, fmt.Errorf("native repair path escaped native root: %s", filePath) + } + repair := NativeRolloutRepair{ + Path: filePath, RemovedRanges: len(damage.ranges), AddedFinalLF: damage.finalNewline, + OriginalSize: damage.size, FirstDamagedAt: damage.size, + } + for _, span := range damage.ranges { + repair.RemovedBytes += span.End - span.Start + repair.FirstDamagedAt = min(repair.FirstDamagedAt, span.Start) + } + repair.RepairedSize = damage.size - repair.RemovedBytes + if damage.finalNewline { + repair.RepairedSize++ + } + + quarantine, err := quarantineNativeRanges(nativeRoot, filepath.ToSlash(relative), filePath, damage) + if err != nil { + return repair, false, fmt.Errorf("quarantine damaged native rollout bytes: %w", err) + } + repair.QuarantinePath = quarantine + if err := rewriteNativeRollout(ctx, filePath, before, damage); err != nil { + // Rename may already have committed before the directory sync failed. + // Keep the quarantined bytes even when replacement is unconfirmed. + return repair, false, err + } + return repair, true, nil +} + +func quarantineNativeRanges(nativeRoot string, relative string, filePath string, damage nativeDamage) (string, error) { + if len(damage.ranges) == 0 { + return "", nil + } + sum := sha256.Sum256([]byte(relative)) + directory := filepath.Join(nativeRoot, nativeQuarantineDirectory, + fmt.Sprintf("%s-%d", hex.EncodeToString(sum[:])[:16], time.Now().UnixNano())) + if err := os.MkdirAll(directory, 0o700); err != nil { + return "", err + } + source, err := os.Open(filePath) + if err != nil { + return "", err + } + defer source.Close() + manifest := nativeQuarantineManifest{ + Version: 1, RelativePath: relative, CreatedAt: time.Now().UTC().Format(time.RFC3339Nano), + OriginalSize: damage.size, Reason: "invalid UTF-8 or JSON record in an active native rollout", + } + for index, span := range damage.ranges { + name := fmt.Sprintf("range-%03d.bin", index) + hasher := sha256.New() + if err := copyNativeRangeToFile(source, span, filepath.Join(directory, name), hasher); err != nil { + _ = os.RemoveAll(directory) + return "", err + } + manifest.Ranges = append(manifest.Ranges, nativeQuarantinedRange{ + nativeByteRange: span, SHA256: hex.EncodeToString(hasher.Sum(nil)), File: name, + }) + } + data, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + _ = os.RemoveAll(directory) + return "", err + } + if err := writeNativeFileDurably(filepath.Join(directory, "manifest.json"), data); err != nil { + _ = os.RemoveAll(directory) + return "", err + } + if err := syncDirectory(directory); err != nil { + _ = os.RemoveAll(directory) + return "", err + } + return directory, nil +} + +func copyNativeRangeToFile(source *os.File, span nativeByteRange, destination string, hasher io.Writer) error { + output, err := os.OpenFile(destination, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return err + } + _, copyErr := io.Copy(io.MultiWriter(output, hasher), io.NewSectionReader(source, span.Start, span.End-span.Start)) + syncErr := output.Sync() + closeErr := output.Close() + return errors.Join(copyErr, syncErr, closeErr) +} + +func writeNativeFileDurably(path string, data []byte) error { + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return err + } + _, writeErr := file.Write(data) + syncErr := file.Sync() + closeErr := file.Close() + return errors.Join(writeErr, syncErr, closeErr) +} + +// rewriteNativeRollout builds the repaired file next to the original and +// renames it into place. The clone shares unchanged blocks on APFS, so a +// damaged region near the end of a very large rollout costs only the rewritten +// tail in disk space. +func rewriteNativeRollout(ctx context.Context, filePath string, original os.FileInfo, damage nativeDamage) error { + directory := filepath.Dir(filePath) + temporary := filepath.Join(directory, fmt.Sprintf("%s%d%s", nativeRepairTempPrefix, time.Now().UnixNano(), nativeRepairTempSuffix)) + if err := cloneOrCopyNativeFile(filePath, temporary); err != nil { + _ = os.Remove(temporary) + return fmt.Errorf("prepare native rollout repair: %w", err) + } + cleanup := func(cause error) error { + _ = os.Remove(temporary) + return cause + } + source, err := os.Open(filePath) + if err != nil { + return cleanup(err) + } + defer source.Close() + target, err := os.OpenFile(temporary, os.O_WRONLY, 0) + if err != nil { + return cleanup(err) + } + + // Everything before the first damaged range is already identical in the + // clone, so only the records that follow each damaged range move down. + write := damage.size + var keep []nativeByteRange + if len(damage.ranges) != 0 { + write = damage.ranges[0].Start + for index, span := range damage.ranges { + end := damage.size + if index+1 < len(damage.ranges) { + end = damage.ranges[index+1].Start + } + if span.End < end { + keep = append(keep, nativeByteRange{Start: span.End, End: end}) + } + } + } + buffer := make([]byte, nativeRepairCopyChunk) + for _, span := range keep { + for position := span.Start; position < span.End; { + if err := ctx.Err(); err != nil { + _ = target.Close() + return cleanup(err) + } + count := int(min(int64(len(buffer)), span.End-position)) + if _, err := source.ReadAt(buffer[:count], position); err != nil && !errors.Is(err, io.EOF) { + _ = target.Close() + return cleanup(err) + } + if _, err := target.WriteAt(buffer[:count], write); err != nil { + _ = target.Close() + return cleanup(err) + } + position += int64(count) + write += int64(count) + } + } + if damage.finalNewline { + if _, err := target.WriteAt([]byte{'\n'}, write); err != nil { + _ = target.Close() + return cleanup(err) + } + write++ + } + if err := target.Truncate(write); err != nil { + _ = target.Close() + return cleanup(err) + } + if err := target.Chmod(original.Mode().Perm()); err != nil { + _ = target.Close() + return cleanup(err) + } + if err := target.Sync(); err != nil { + _ = target.Close() + return cleanup(err) + } + if err := target.Close(); err != nil { + return cleanup(err) + } + if err := os.Chtimes(temporary, original.ModTime(), original.ModTime()); err != nil { + return cleanup(err) + } + after, err := os.Lstat(filePath) + if err != nil { + return cleanup(err) + } + if !os.SameFile(original, after) || after.Size() != original.Size() || !after.ModTime().Equal(original.ModTime()) { + return cleanup(fmt.Errorf("native rollout changed during repair: %s", filePath)) + } + if err := os.Rename(temporary, filePath); err != nil { + return cleanup(err) + } + return syncDirectory(directory) +} + +func cloneOrCopyNativeFile(source string, destination string) error { + if err := cloneNativeFile(source, destination); err == nil { + return nil + } + _ = os.Remove(destination) + input, err := os.Open(source) + if err != nil { + return err + } + defer input.Close() + output, err := os.OpenFile(destination, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return err + } + _, copyErr := io.Copy(output, input) + closeErr := output.Close() + return errors.Join(copyErr, closeErr) +} + +func isNativeRepairTemporary(name string) bool { + return strings.HasPrefix(name, nativeRepairTempPrefix) && strings.HasSuffix(name, nativeRepairTempSuffix) +} diff --git a/internal/mountfs/native_repair_test.go b/internal/mountfs/native_repair_test.go new file mode 100644 index 0000000..6ed8806 --- /dev/null +++ b/internal/mountfs/native_repair_test.go @@ -0,0 +1,245 @@ +package mountfs + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" +) + +// heapGarbage mimics the bytes found in a real rollout after a full disk: +// binary memory containing newlines, carriage returns and NULs. +var heapGarbage = []byte("\xf6\x00\x91PV\x0731\x00\x00y\x8c\r\n\x00\x00\x008Ua\xad%\x18\x00\x00\n\x00\x00flow.state\x00\xe7\x8b\n") + +func repairFixtureRecords(first int, count int) []byte { + var out bytes.Buffer + for index := first; index < first+count; index++ { + out.WriteString(`{"ordinal":` + string(rune('0'+index%10)) + `}` + "\n") + } + return out.Bytes() +} + +func readQuarantine(t *testing.T, directory string) nativeQuarantineManifest { + t.Helper() + data, err := os.ReadFile(filepath.Join(directory, "manifest.json")) + if err != nil { + t.Fatal(err) + } + var manifest nativeQuarantineManifest + if err := json.Unmarshal(data, &manifest); err != nil { + t.Fatal(err) + } + return manifest +} + +func assertNoRepairTemporaries(t *testing.T, root string) { + t.Helper() + _ = filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error { + if err == nil && isNativeRepairTemporary(entry.Name()) { + t.Errorf("repair left temporary file %s", path) + } + return nil + }) +} + +func TestNativeRepairFailureRetainsQuarantinedBytes(t *testing.T) { + if runtime.GOOS == "windows" || os.Geteuid() == 0 { + t.Skip("requires Unix directory write permissions") + } + original := append(append([]byte(nil), repairFixtureRecords(0, 2)...), heapGarbage...) + root, path := nativePreflightFixture(t, original) + directory := filepath.Dir(path) + if err := os.Chmod(directory, 0o500); err != nil { + t.Fatal(err) + } + defer os.Chmod(directory, 0o700) + repair, changed, err := repairNativeRollout(context.Background(), root, path, 0) + if err == nil || changed || repair.QuarantinePath == "" { + t.Fatalf("failed replacement lost recovery location: changed=%t err=%v repair=%+v", changed, err, repair) + } + manifest := readQuarantine(t, repair.QuarantinePath) + if len(manifest.Ranges) != 1 { + t.Fatalf("quarantine ranges: %+v", manifest.Ranges) + } + removed, err := os.ReadFile(filepath.Join(repair.QuarantinePath, manifest.Ranges[0].File)) + if err != nil || !bytes.Equal(removed, heapGarbage) { + t.Fatalf("quarantined bytes changed: %v", err) + } + current, err := os.ReadFile(path) + if err != nil || !bytes.Equal(current, original) { + t.Fatalf("original changed before failed replacement: %v", err) + } +} + +func TestNativePreflightRepairsInteriorGarbageLosslessly(t *testing.T) { + good := repairFixtureRecords(0, 5) + tail := repairFixtureRecords(5, 4) + original := append(append(append([]byte(nil), good...), heapGarbage...), tail...) + root, path := nativePreflightFixture(t, original) + modTime := time.Now().Add(-48 * time.Hour).Truncate(time.Second) + if err := os.Chmod(path, 0o640); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(path, modTime, modTime); err != nil { + t.Fatal(err) + } + + report, err := preflightNativeWriterRollouts(context.Background(), root, true) + if err != nil || len(report.Issues) != 0 { + t.Fatalf("repair preflight err=%v report=%#v", err, report) + } + if len(report.Repairs) != 1 || report.Repairs[0].RemovedBytes != int64(len(heapGarbage)) { + t.Fatalf("repairs = %#v", report.Repairs) + } + repaired, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if want := append(append([]byte(nil), good...), tail...); !bytes.Equal(repaired, want) { + t.Fatalf("repaired content = %q", repaired) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o640 || !info.ModTime().Equal(modTime) { + t.Fatalf("repair changed mode/mtime: %v %v", info.Mode().Perm(), info.ModTime()) + } + if _, err := ValidateNativeRollout(context.Background(), path); err != nil { + t.Fatalf("repaired rollout is still invalid: %v", err) + } + + // The quarantine is enough to rebuild the original byte for byte. + manifest := readQuarantine(t, report.Repairs[0].QuarantinePath) + if manifest.OriginalSize != int64(len(original)) || len(manifest.Ranges) != 1 { + t.Fatalf("manifest = %#v", manifest) + } + span := manifest.Ranges[0] + removed, err := os.ReadFile(filepath.Join(report.Repairs[0].QuarantinePath, span.File)) + if err != nil { + t.Fatal(err) + } + rebuilt := append(append(append([]byte(nil), repaired[:span.Start]...), removed...), repaired[span.Start:]...) + if !bytes.Equal(rebuilt, original) { + t.Fatal("quarantine does not reproduce the original rollout") + } + assertNoRepairTemporaries(t, root) + + second, err := preflightNativeWriterRollouts(context.Background(), root, true) + if err != nil || len(second.Repairs) != 0 || second.CachedFiles != 1 { + t.Fatalf("second preflight err=%v report=%#v", err, second) + } +} + +func TestNativePreflightRepairsSeveralDamagedRanges(t *testing.T) { + original := bytes.Join([][]byte{ + repairFixtureRecords(0, 2), []byte("not-json\n"), repairFixtureRecords(2, 3), + {0xff, 0xfe, '\n'}, []byte("{broken\n"), repairFixtureRecords(5, 2), + }, nil) + root, path := nativePreflightFixture(t, original) + report, err := preflightNativeWriterRollouts(context.Background(), root, true) + if err != nil || len(report.Issues) != 0 || len(report.Repairs) != 1 || report.Repairs[0].RemovedRanges != 2 { + t.Fatalf("repair err=%v report=%#v", err, report) + } + want := bytes.Join([][]byte{repairFixtureRecords(0, 2), repairFixtureRecords(2, 3), repairFixtureRecords(5, 2)}, nil) + got, _ := os.ReadFile(path) + if !bytes.Equal(got, want) { + t.Fatalf("repaired content = %q", got) + } +} + +func TestNativePreflightRepairsTornTail(t *testing.T) { + root, path := nativePreflightFixture(t, append(repairFixtureRecords(0, 3), []byte(`{"ordinal":`)...)) + report, err := preflightNativeWriterRollouts(context.Background(), root, true) + if err != nil || len(report.Issues) != 0 || len(report.Repairs) != 1 { + t.Fatalf("repair err=%v report=%#v", err, report) + } + if got, _ := os.ReadFile(path); !bytes.Equal(got, repairFixtureRecords(0, 3)) { + t.Fatalf("torn tail not removed: %q", got) + } +} + +func TestNativePreflightFinishesCompleteRecordMissingNewline(t *testing.T) { + root, path := nativePreflightFixture(t, append(repairFixtureRecords(0, 2), []byte(`{"ordinal":7}`)...)) + report, err := preflightNativeWriterRollouts(context.Background(), root, true) + if err != nil || len(report.Repairs) != 1 || !report.Repairs[0].AddedFinalLF || report.Repairs[0].QuarantinePath != "" { + t.Fatalf("repair err=%v report=%#v", err, report) + } + want := append(repairFixtureRecords(0, 2), []byte("{\"ordinal\":7}\n")...) + if got, _ := os.ReadFile(path); !bytes.Equal(got, want) { + t.Fatalf("content = %q", got) + } +} + +func TestNativePreflightRepairsGarbageAppendedAfterCachedPrefix(t *testing.T) { + prefix := repairFixtureRecords(0, 4) + root, path := nativePreflightFixture(t, prefix) + if _, err := preflightNativeWriterRollouts(context.Background(), root, true); err != nil { + t.Fatal(err) + } + appended := append(append(append([]byte(nil), heapGarbage...), repairFixtureRecords(4, 2)...), []byte("tail-garbage\n")...) + file, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND, 0) + if err != nil { + t.Fatal(err) + } + if _, err := file.Write(appended); err != nil { + t.Fatal(err) + } + _ = file.Close() + report, err := preflightNativeWriterRollouts(context.Background(), root, true) + if err != nil || len(report.Issues) != 0 || len(report.Repairs) != 1 || report.IncrementalFiles != 1 { + t.Fatalf("repair err=%v report=%#v", err, report) + } + if got, _ := os.ReadFile(path); !bytes.Equal(got, append(append([]byte(nil), prefix...), repairFixtureRecords(4, 2)...)) { + t.Fatalf("content = %q", got) + } + if report.Bytes != int64(len(prefix)+len(repairFixtureRecords(4, 2))) { + t.Fatalf("report bytes = %d", report.Bytes) + } +} + +func TestNativePreflightRepairModeNeverBlocksOnUnfixableEntries(t *testing.T) { + root, _ := nativePreflightFixture(t, repairFixtureRecords(0, 2)) + dir := filepath.Join(root, "sessions", "2026", "07", "16") + if err := os.Symlink(filepath.Join(dir, "rollout-fixture.jsonl"), filepath.Join(dir, "rollout-link.jsonl")); err != nil { + t.Fatal(err) + } + if _, err := preflightNativeWriterRollouts(context.Background(), root, false); err == nil { + t.Fatal("strict preflight accepted a symlink") + } + report, err := preflightNativeWriterRollouts(context.Background(), root, true) + if err != nil { + t.Fatalf("repair preflight blocked: %v", err) + } + if len(report.Issues) != 1 || !strings.Contains(report.Issues[0].Message, "symlink") || report.ValidatedFiles != 1 { + t.Fatalf("report = %#v", report) + } +} + +func TestNativePreflightRepairRemovesStaleTemporaries(t *testing.T) { + root, path := nativePreflightFixture(t, repairFixtureRecords(0, 2)) + stale := filepath.Join(filepath.Dir(path), nativeRepairTempPrefix+"123"+nativeRepairTempSuffix) + if err := os.WriteFile(stale, []byte("partial"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := preflightNativeWriterRollouts(context.Background(), root, true); err != nil { + t.Fatal(err) + } + assertNoRepairTemporaries(t, root) +} + +func TestNativeStrictPreflightStillRejectsDamage(t *testing.T) { + root, path := nativePreflightFixture(t, append(repairFixtureRecords(0, 2), heapGarbage...)) + before, _ := os.ReadFile(path) + if _, err := preflightNativeWriterRollouts(context.Background(), root, false); err == nil { + t.Fatal("strict preflight accepted damage") + } + if after, _ := os.ReadFile(path); !bytes.Equal(before, after) { + t.Fatal("strict preflight modified the rollout") + } +} diff --git a/internal/mountfs/read_accounting_diagnostic_test.go b/internal/mountfs/read_accounting_diagnostic_test.go index 46dc6e8..e0887fd 100644 --- a/internal/mountfs/read_accounting_diagnostic_test.go +++ b/internal/mountfs/read_accounting_diagnostic_test.go @@ -140,7 +140,9 @@ func dialAccountedNativeFSKitClient(t *testing.T, resourcePath string, done chan t.Fatalf("FSKit test server exited during startup: %v", serveErr) default: } - client, dialErr := fskitproto.DialResource(resourcePath, 100*time.Millisecond) + // This fixture checks accounting, not a 100ms I/O latency guarantee. + // Call deadlines now apply beyond connect, including APFS fsync. + client, dialErr := fskitproto.DialResource(resourcePath, 2*time.Second) if dialErr == nil { return client } diff --git a/internal/pack/foreground_maintenance_test.go b/internal/pack/foreground_maintenance_test.go new file mode 100644 index 0000000..8e10bbf --- /dev/null +++ b/internal/pack/foreground_maintenance_test.go @@ -0,0 +1,158 @@ +package pack + +import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/samekind/codexfold/internal/fold" + "github.com/samekind/codexfold/internal/storage" + "github.com/samekind/codexfold/internal/vfs" +) + +func TestRetireLooseYieldsToForegroundAndCanResume(t *testing.T) { + root := t.TempDir() + source := bytes.Repeat([]byte("foreground retirement bytes\n"), 1000) + refs := putObjects(t, root, source) + writeManifest(t, root, "session", refs) + if _, err := Build(context.Background(), root, BuildOptions{}); err != nil { + t.Fatal(err) + } + state := persistManagedManifestFixture(t, root, "session", source) + manifest, err := fold.LoadManifest(root, "session") + if err != nil { + t.Fatal(err) + } + resolver, err := Open(root, OpenOptions{}) + if err != nil { + t.Fatal(err) + } + defer resolver.Close() + session, err := vfs.OpenSession(context.Background(), vfs.SessionOptions{Root: root, ManifestPath: state.ManifestPath, Manifest: manifest, Reader: resolver, NativeSnapshot: state.NativeSnapshot}) + if err != nil { + t.Fatal(err) + } + ready := make(chan struct{}) + background := make(chan error, 1) + go func() { + _, err := RetireLoose(context.Background(), root, RetireLooseOptions{Apply: true, BeforeRemove: func(string) error { + close(ready) + // Keep the verified deletion boundary open until the foreground has + // actually requested priority, then allow the watcher to cancel it. + deadline := time.Now().Add(time.Second) + for time.Now().Before(deadline) { + entries, _ := os.ReadDir(root + "/fs/maintenance") + for _, entry := range entries { + if len(entry.Name()) > 6 && entry.Name()[len(entry.Name())-6:] == ".yield" { + time.Sleep(30 * time.Millisecond) + return nil + } + } + time.Sleep(time.Millisecond) + } + return errors.New("foreground did not request maintenance yielding") + }}) + background <- err + }() + select { + case <-ready: + case <-time.After(2 * time.Second): + t.Fatal("retirement did not reach its deletion boundary") + } + writer, err := session.OpenForegroundWriter() + if err != nil { + t.Fatalf("foreground writer did not preempt real retirement: %v", err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + if err := <-background; !errors.Is(err, storage.ErrManagedSessionBusy) { + t.Fatalf("yield did not defer retirement: %v", err) + } + if _, err := os.Stat(fold.NewObjectStore(root).ObjectPath(refs[0].SHA256)); err != nil { + t.Fatalf("cancelled retirement removed its uncommitted candidate: %v", err) + } + result, err := RetireLoose(context.Background(), root, RetireLooseOptions{Apply: true}) + if err != nil || result.RetiredCount != 1 { + t.Fatalf("yielded cleanup could not resume: result=%+v err=%v", result, err) + } +} + +func TestPackGCYieldsToForegroundAndRestoresStagedGeneration(t *testing.T) { + root := t.TempDir() + source := []byte("pack GC foreground bytes\n") + refs := putObjects(t, root, source) + writeManifest(t, root, "session", refs) + first, err := Build(context.Background(), root, BuildOptions{}) + if err != nil { + t.Fatal(err) + } + state := persistManagedManifestFixture(t, root, "session", source) + second, err := Build(context.Background(), root, BuildOptions{}) + if err != nil { + t.Fatal(err) + } + manifest, err := fold.LoadManifest(root, "session") + if err != nil { + t.Fatal(err) + } + resolver, err := Open(root, OpenOptions{}) + if err != nil { + t.Fatal(err) + } + defer resolver.Close() + session, err := vfs.OpenSession(context.Background(), vfs.SessionOptions{Root: root, ManifestPath: state.ManifestPath, Manifest: manifest, Reader: resolver, NativeSnapshot: state.NativeSnapshot}) + if err != nil { + t.Fatal(err) + } + ready := make(chan struct{}) + background := make(chan error, 1) + go func() { + _, err := storage.Collect(context.Background(), storage.GCOptions{ + StoreDir: root, Apply: true, KeepPackGenerations: 1, + AuthorizePackGenerationRemoval: func(ctx context.Context, candidate storage.GCCandidate) (storage.PackGenerationRemovalGuard, error) { + return AuthorizeGenerationRemoval(ctx, root, candidate) + }, + BeforePackStage: func(storage.GCCandidate) error { + close(ready) + deadline := time.Now().Add(time.Second) + for time.Now().Before(deadline) { + entries, _ := os.ReadDir(filepath.Join(root, "fs", "maintenance")) + for _, entry := range entries { + if strings.HasSuffix(entry.Name(), ".yield") { + time.Sleep(30 * time.Millisecond) + return nil + } + } + time.Sleep(time.Millisecond) + } + return errors.New("foreground did not request GC yielding") + }, + }) + background <- err + }() + select { + case <-ready: + case <-time.After(2 * time.Second): + t.Fatal("pack cleanup did not reach its staging boundary") + } + writer, err := session.OpenForegroundWriter() + if err != nil { + t.Fatalf("foreground could not preempt old-pack cleanup: %v", err) + } + defer writer.Close() + if err := <-background; !errors.Is(err, storage.ErrManagedSessionBusy) { + t.Fatalf("pack cleanup yielding classification: %v", err) + } + if _, err := os.Stat(filepath.Join(root, "packs", first.Generation)); err != nil { + t.Fatalf("yielded cleanup did not restore staged generation: %v", err) + } + if current, err := CurrentGeneration(root); err != nil || current != second.Generation { + t.Fatalf("foreground interruption changed CURRENT: %q %v", current, err) + } +} diff --git a/internal/pack/gc_guard.go b/internal/pack/gc_guard.go index 9c6a158..b1b7181 100644 --- a/internal/pack/gc_guard.go +++ b/internal/pack/gc_guard.go @@ -45,7 +45,7 @@ func (r survivingObjectReader) HasObject(ref fold.ObjectRef) bool { // AuthorizeGenerationRemoval proves that live manifests remain fully // reconstructable from loose objects and the current pack while holding every // writer and object mutation lock through the caller's deletion. -func AuthorizeGenerationRemoval(ctx context.Context, storeDir string, candidate storage.GCCandidate) (storage.PackGenerationRemovalGuard, error) { +func AuthorizeGenerationRemoval(ctx context.Context, storeDir string, candidate storage.GCCandidate) (result storage.PackGenerationRemovalGuard, runErr error) { if candidate.Kind != storage.CandidatePackGeneration { return nil, errors.New("pack-generation authorization requires a pack candidate") } @@ -67,6 +67,8 @@ func AuthorizeGenerationRemoval(ctx context.Context, storeDir string, candidate if err != nil { return nil, fmt.Errorf("lock managed sessions before pack cleanup: %w", err) } + ctx = managed.Context() + defer func() { runErr = managed.YieldError(runErr) }() objectLock, err := storage.AcquireOperationLock(storeDir, "objects") if err != nil { _ = managed.Close() @@ -136,7 +138,12 @@ func AuthorizeGenerationRemoval(ctx context.Context, storeDir string, candidate return guard, nil } -func (g *generationRemovalGuard) Revalidate(ctx context.Context) error { +func (g *generationRemovalGuard) Revalidate(ctx context.Context) (runErr error) { + if g == nil || g.managed == nil { + return errors.New("pack-generation removal guard is closed") + } + ctx = g.managed.Context() + defer func() { runErr = g.managed.YieldError(runErr) }() if err := g.revalidateSurvivingStorage(ctx); err != nil { return err } @@ -153,7 +160,12 @@ func (g *generationRemovalGuard) Revalidate(ctx context.Context) error { return nil } -func (g *generationRemovalGuard) RevalidateStaged(ctx context.Context, candidate storage.GCCandidate) error { +func (g *generationRemovalGuard) RevalidateStaged(ctx context.Context, candidate storage.GCCandidate) (runErr error) { + if g == nil || g.managed == nil { + return errors.New("pack-generation removal guard is closed") + } + ctx = g.managed.Context() + defer func() { runErr = g.managed.YieldError(runErr) }() if err := g.revalidateSurvivingStorage(ctx); err != nil { return err } @@ -291,7 +303,7 @@ func validatePackGenerationRemovalOwnership(ctx context.Context, candidate stora if filepath.Base(name) != name { return fmt.Errorf("pack generation owns a nested runtime file: %s", name) } - if err := verifyRecoveryDiskFile(root, name, identity); err != nil { + if err := verifyRecoveryDiskFile(ctx, root, name, identity); err != nil { return fmt.Errorf("verify exact pack generation file %s: %w", name, err) } } diff --git a/internal/pack/recovery.go b/internal/pack/recovery.go index cc5538d..b4d7af7 100644 --- a/internal/pack/recovery.go +++ b/internal/pack/recovery.go @@ -370,7 +370,7 @@ func verifyRecoveryArchive(ctx context.Context, generationDir string, catalog Re if err := ctx.Err(); err != nil { return err } - if err := verifyRecoveryDiskFile(root, identity.Path, identity); err != nil { + if err := verifyRecoveryDiskFile(ctx, root, identity.Path, identity); err != nil { return fmt.Errorf("verify recovery pack %s: %w", identity.Path, err) } } @@ -406,7 +406,7 @@ func verifyRecoveryArchive(ctx context.Context, generationDir string, catalog Re return fmt.Errorf("duplicate pack recovery entry %q", header.Name) } hasher := sha256.New() - written, err := io.Copy(hasher, archive) + written, err := storage.CopyContext(ctx, hasher, archive) if err != nil || written != identity.Bytes || hex.EncodeToString(hasher.Sum(nil)) != identity.SHA256 { return fmt.Errorf("pack recovery entry %s failed verification", header.Name) } @@ -1717,13 +1717,13 @@ func validateRecoveryCatalog(catalog RecoveryCatalog, directoryName string) erro return nil } -func verifyRecoveryDiskFile(root *os.Root, name string, identity RecoveryFile) error { +func verifyRecoveryDiskFile(ctx context.Context, root *os.Root, name string, identity RecoveryFile) error { file, err := openPackGenerationFile(root, name) if err != nil { return err } hasher := sha256.New() - written, copyErr := io.Copy(hasher, file) + written, copyErr := storage.CopyContext(ctx, hasher, file) closeErr := file.Close() if copyErr != nil { return copyErr diff --git a/internal/pack/retire.go b/internal/pack/retire.go index f848dca..975bbe7 100644 --- a/internal/pack/retire.go +++ b/internal/pack/retire.go @@ -44,17 +44,19 @@ type RetireLooseResult struct { } // RetireLoose removes only loose objects that the current verified pack can read. -func RetireLoose(ctx context.Context, storeDir string, options RetireLooseOptions) (RetireLooseResult, error) { +func RetireLoose(ctx context.Context, storeDir string, options RetireLooseOptions) (result RetireLooseResult, runErr error) { workers := options.Workers if workers < 0 || workers > 16 { return RetireLooseResult{}, errors.New("retirement workers must be between 0 and 16") } - result := RetireLooseResult{StoreDir: filepath.Clean(storeDir), DryRun: !options.Apply} + result = RetireLooseResult{StoreDir: filepath.Clean(storeDir), DryRun: !options.Apply} guard, err := storage.AcquireManagedSessionDeletionGuard(ctx, storeDir) if err != nil { return RetireLooseResult{}, fmt.Errorf("refusing loose retirement without complete managed-session proof: %w", err) } defer guard.Close() + ctx = guard.Context() + defer func() { runErr = guard.YieldError(runErr) }() lock, err := storage.AcquireOperationLock(storeDir, "objects") if err != nil { return RetireLooseResult{}, err diff --git a/internal/service/native_fskit_operations_darwin.go b/internal/service/native_fskit_operations_darwin.go index 285eaff..b159454 100644 --- a/internal/service/native_fskit_operations_darwin.go +++ b/internal/service/native_fskit_operations_darwin.go @@ -36,15 +36,17 @@ func nativeFSKitOperationsForType(filesystemType string) (NativeFSKitOperations, } func (*nativeFSKitOperations) DaemonHealthy(ctx context.Context, resourcePath string) error { + ctx, cancel := context.WithTimeout(ctx, 2*time.Second) + defer cancel() if err := ctx.Err(); err != nil { return err } - client, err := fskitproto.DialResource(resourcePath, 2*time.Second) + client, err := fskitproto.DialResourceContext(ctx, resourcePath, 2*time.Second) if err != nil { return err } defer client.Close() - _, err = client.Call(fskitproto.OpPing, nil) + _, err = client.CallContext(ctx, fskitproto.OpPing, nil) return err } diff --git a/internal/storage/copy_context.go b/internal/storage/copy_context.go new file mode 100644 index 0000000..177dfbd --- /dev/null +++ b/internal/storage/copy_context.go @@ -0,0 +1,24 @@ +package storage + +import ( + "context" + "io" +) + +type contextReader struct { + ctx context.Context + io.Reader +} + +func (r contextReader) Read(buffer []byte) (int, error) { + if err := r.ctx.Err(); err != nil { + return 0, err + } + return r.Reader.Read(buffer) +} + +// CopyContext checks cancellation between bounded reads, including while a +// single multi-gigabyte pack file is being hashed during maintenance. +func CopyContext(ctx context.Context, destination io.Writer, source io.Reader) (int64, error) { + return io.CopyBuffer(destination, contextReader{ctx: ctx, Reader: source}, make([]byte, 64<<10)) +} diff --git a/internal/storage/foreground.go b/internal/storage/foreground.go new file mode 100644 index 0000000..fc4b0b8 --- /dev/null +++ b/internal/storage/foreground.go @@ -0,0 +1,144 @@ +package storage + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +var errForegroundAccess = errors.New("maintenance yielded to foreground access") + +// Maintenance makes a background operation interruptible by a foreground file +// open. Cancellation never releases a writer lock on its own: the operation +// unwinds normally and releases its locks before closing this registration. +type Maintenance struct { + ctx context.Context + cancel context.CancelCauseFunc + lease *Lease + request string + done chan struct{} + closeOnce sync.Once + closeErr error +} + +func maintenanceDirectory(store string) string { + return filepath.Join(filepath.Clean(store), "fs", "maintenance") +} + +func BeginMaintenance(ctx context.Context, store string) (*Maintenance, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + if !filepath.IsAbs(store) { + return nil, errors.New("absolute maintenance store path is required") + } + directory := maintenanceDirectory(store) + if err := os.MkdirAll(filepath.Dir(directory), 0o700); err != nil { + return nil, err + } + lease, err := AcquireLease(directory, "maintenance") + if err != nil { + return nil, err + } + ctx, cancel := context.WithCancelCause(ctx) + m := &Maintenance{ctx: ctx, cancel: cancel, lease: lease, request: lease.path + ".yield", done: make(chan struct{})} + go func() { + defer close(m.done) + ticker := time.NewTicker(10 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if info, err := os.Lstat(m.request); err == nil && info.Mode().IsRegular() { + cancel(errForegroundAccess) + return + } + } + } + }() + return m, nil +} + +func (m *Maintenance) Context() context.Context { return m.ctx } + +func (m *Maintenance) YieldError(err error) error { + if m != nil && err != nil && errors.Is(context.Cause(m.ctx), errForegroundAccess) { + return fmt.Errorf("%w: %w", ErrManagedSessionBusy, errForegroundAccess) + } + return err +} + +func (m *Maintenance) Close() error { + if m == nil { + return nil + } + m.closeOnce.Do(func() { + m.cancel(context.Canceled) + <-m.done + m.closeErr = m.lease.Close() + if err := os.Remove(m.request); err != nil && !errors.Is(err, os.ErrNotExist) { + m.closeErr = errors.Join(m.closeErr, err) + } + }) + return m.closeErr +} + +// RequestForegroundAccess only signals live, lock-proven maintenance owners. +// A leftover registration from a crashed process cannot turn a real second +// foreground writer into a maintenance retry or authorize releasing any lock. +func RequestForegroundAccess(store string) (bool, error) { + directory := maintenanceDirectory(store) + entries, err := os.ReadDir(directory) + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + if err != nil { + return false, err + } + requested := false + for _, entry := range entries { + if !strings.HasPrefix(entry.Name(), ".lease-maintenance-") || strings.HasSuffix(entry.Name(), ".yield") { + continue + } + path := filepath.Join(directory, entry.Name()) + info, err := os.Lstat(path) + if errors.Is(err, os.ErrNotExist) { + continue + } + if err != nil || !info.Mode().IsRegular() { + if err != nil { + return requested, err + } + continue + } + active, err := FileHasActiveLock(path) + if err != nil { + return requested, err + } + if !active { + continue + } + request, err := os.OpenFile(path+".yield", os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + if errors.Is(err, os.ErrExist) { + if info, statErr := os.Lstat(path + ".yield"); statErr == nil && info.Mode().IsRegular() { + requested = true + } + continue + } + if err != nil { + return requested, err + } + if err := request.Close(); err != nil { + return requested, err + } + requested = true + } + return requested, nil +} diff --git a/internal/storage/foreground_test.go b/internal/storage/foreground_test.go new file mode 100644 index 0000000..7bdd4e2 --- /dev/null +++ b/internal/storage/foreground_test.go @@ -0,0 +1,102 @@ +package storage + +import ( + "bytes" + "context" + "errors" + "io" + "os" + "path/filepath" + "testing" + "time" +) + +func TestMaintenanceYieldsOnlyToLiveForegroundRequest(t *testing.T) { + store := t.TempDir() + requested, err := RequestForegroundAccess(store) + if err != nil || requested { + t.Fatalf("no maintenance: requested=%t err=%v", requested, err) + } + m, err := BeginMaintenance(context.Background(), store) + if err != nil { + t.Fatal(err) + } + defer m.Close() + if requested, err := RequestForegroundAccess(store); err != nil || !requested { + t.Fatalf("live request: requested=%t err=%v", requested, err) + } + select { + case <-m.Context().Done(): + case <-time.After(time.Second): + t.Fatal("maintenance ignored foreground request") + } + if !errors.Is(m.YieldError(m.Context().Err()), ErrManagedSessionBusy) { + t.Fatal("foreground yielding was not classified as ordinary contention") + } + if err := m.Close(); err != nil { + t.Fatal(err) + } + if requested, err := RequestForegroundAccess(store); err != nil || requested { + t.Fatalf("closed maintenance remained active: %t %v", requested, err) + } + stale := filepath.Join(maintenanceDirectory(store), ".lease-maintenance-123-stale") + if err := os.WriteFile(stale, []byte("123\n"), 0600); err != nil { + t.Fatal(err) + } + if requested, err := RequestForegroundAccess(store); err != nil || requested { + t.Fatalf("stale maintenance authorized a retry: %t %v", requested, err) + } +} + +func TestMaintenanceCancellationDoesNotReleaseDeletionLocks(t *testing.T) { + store := t.TempDir() + manifest := filepath.Join(store, "manifests", "session.json") + writeJSONFile(t, manifest, manifestFixture("session", filepath.Join(store, "native.jsonl"), 4)) + directory := filepath.Join(store, "fs", "sessions", "session") + delta := writeSizedFile(t, filepath.Join(directory, "delta.jsonl"), 0) + writeJSONFile(t, filepath.Join(directory, "state.json"), stateFixture(t, "session", manifest, 4, delta, "", "")) + guard, err := AcquireManagedSessionDeletionGuard(context.Background(), store) + if err != nil { + t.Fatal(err) + } + defer guard.Close() + if requested, err := RequestForegroundAccess(store); err != nil || !requested { + t.Fatalf("foreground request: %t %v", requested, err) + } + select { + case <-guard.Context().Done(): + case <-time.After(time.Second): + t.Fatal("deletion context did not yield") + } + lease := filepath.Join(directory, "writer.lease") + if active, err := FileHasActiveLock(lease); err != nil || !active { + t.Fatalf("request released a deletion lock before its owner unwound: %t %v", active, err) + } + if err := guard.Close(); err != nil { + t.Fatal(err) + } + if active, err := FileHasActiveLock(lease); err != nil || active { + t.Fatalf("lock survived orderly cleanup: %t %v", active, err) + } +} + +type cancelAfterRead struct { + io.Reader + cancel context.CancelFunc +} + +func (r cancelAfterRead) Read(buffer []byte) (int, error) { + n, err := r.Reader.Read(buffer) + r.cancel() + return n, err +} + +func TestCopyContextInterruptsOneLargeFile(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + source := bytes.Repeat([]byte("x"), 1<<20) + written, err := CopyContext(ctx, io.Discard, cancelAfterRead{Reader: bytes.NewReader(source), cancel: cancel}) + if !errors.Is(err, context.Canceled) || written <= 0 || written > 64<<10 { + t.Fatalf("large-file proof ignored cancellation: bytes=%d err=%v", written, err) + } +} diff --git a/internal/storage/gc.go b/internal/storage/gc.go index 6498bf9..2294d37 100644 --- a/internal/storage/gc.go +++ b/internal/storage/gc.go @@ -1200,7 +1200,7 @@ func exactCandidateTreeIdentity(ctx context.Context, root string) (int, int64, s return errors.New("exact deletion candidate changed while opening a file") } contentHash := sha256.New() - bytesRead, copyErr := io.Copy(contentHash, file) + bytesRead, copyErr := CopyContext(ctx, contentHash, file) after, statErr := file.Stat() closeErr := file.Close() if err := errors.Join(copyErr, statErr, closeErr); err != nil { diff --git a/internal/storage/guard.go b/internal/storage/guard.go index dde8a0a..8577480 100644 --- a/internal/storage/guard.go +++ b/internal/storage/guard.go @@ -6,6 +6,7 @@ import ( "fmt" "path/filepath" "sync" + "time" ) type Projection struct { @@ -71,6 +72,128 @@ func (g *OnceInventoryGuard) Check(ctx context.Context, projection Projection) ( return g.Guard.checkWithInventory(ctx, store, inventory, projection) } +// DefaultSharedInventoryMaxAge bounds how stale the shared inventory may be +// before a background refresh starts. Free space is still probed on every check. +const DefaultSharedInventoryMaxAge = 2 * time.Minute + +// scanInventory is replaced in tests to count or delay scans. +var scanInventory = Scan + +// SharedInventoryGuard prices foreground work (copy-on-write, materialization) +// against a process-wide inventory that is refreshed in the background. A full +// Scan walks the whole store and takes seconds, so foreground paths that hold +// handle or session locks must never run one themselves. Only the very first +// check in a process waits for a scan; Prime starts it ahead of time. +type SharedInventoryGuard struct { + Guard Guard + MaxAge time.Duration +} + +type inventoryCache struct { + mu sync.Mutex + inv Inventory + loadedAt time.Time + loaded bool + flight chan struct{} + lastErr error +} + +var ( + sharedInventoriesMu sync.Mutex + sharedInventories = map[string]*inventoryCache{} +) + +func sharedInventoryFor(store string) *inventoryCache { + sharedInventoriesMu.Lock() + defer sharedInventoriesMu.Unlock() + cache := sharedInventories[store] + if cache == nil { + cache = &inventoryCache{} + sharedInventories[store] = cache + } + return cache +} + +func (c *inventoryCache) startRefreshLocked(store string) { + flight := make(chan struct{}) + c.flight = flight + scan := scanInventory + go func() { + inventory, err := scan(context.Background(), Options{StoreDir: store, AllowMetadataIssues: true}) + c.mu.Lock() + if err == nil { + c.inv, c.loaded, c.loadedAt, c.lastErr = inventory, true, time.Now(), nil + } else { + c.lastErr = err + } + c.flight = nil + c.mu.Unlock() + close(flight) + }() +} + +func (c *inventoryCache) get(ctx context.Context, store string, maxAge time.Duration) (Inventory, error) { + c.mu.Lock() + if c.loaded { + inventory := c.inv + if c.flight == nil && time.Since(c.loadedAt) > maxAge { + c.startRefreshLocked(store) + } + c.mu.Unlock() + return inventory, nil + } + if c.flight == nil { + c.startRefreshLocked(store) + } + flight := c.flight + c.mu.Unlock() + select { + case <-flight: + case <-ctx.Done(): + return Inventory{}, ctx.Err() + } + c.mu.Lock() + defer c.mu.Unlock() + if c.loaded { + return c.inv, nil + } + return Inventory{}, c.lastErr +} + +// Prime starts the first inventory scan in the background without waiting. +func (g SharedInventoryGuard) Prime() { + store, err := g.Guard.absoluteStore() + if err != nil { + return + } + PrepareReadReserve(store) + cache := sharedInventoryFor(store) + cache.mu.Lock() + if !cache.loaded && cache.flight == nil { + cache.startRefreshLocked(store) + } + cache.mu.Unlock() +} + +func (g SharedInventoryGuard) Check(ctx context.Context, projection Projection) (Assessment, error) { + if err := ctx.Err(); err != nil { + return Assessment{}, err + } + store, err := g.Guard.absoluteStore() + if err != nil { + return Assessment{}, err + } + maxAge := g.MaxAge + if maxAge <= 0 { + maxAge = DefaultSharedInventoryMaxAge + } + inventory, err := sharedInventoryFor(store).get(ctx, store, maxAge) + if err != nil { + return Assessment{}, err + } + return g.Guard.checkWithInventory(ctx, store, inventory, projection) +} + func (g Guard) Check(ctx context.Context, projection Projection) (Assessment, error) { if err := ctx.Err(); err != nil { return Assessment{}, err diff --git a/internal/storage/guard_shared_test.go b/internal/storage/guard_shared_test.go new file mode 100644 index 0000000..d4adf0c --- /dev/null +++ b/internal/storage/guard_shared_test.go @@ -0,0 +1,123 @@ +package storage + +import ( + "context" + "errors" + "sync" + "sync/atomic" + "testing" + "time" +) + +func sharedTestGuard(t *testing.T) (SharedInventoryGuard, string) { + t.Helper() + store := t.TempDir() + guard := Guard{StoreDir: store, Limits: DefaultLimits, Probe: func(string) (int64, error) { return 1 << 40, nil }} + return SharedInventoryGuard{Guard: guard, MaxAge: time.Hour}, store +} + +func stubScan(t *testing.T, fn func(context.Context, Options) (Inventory, error)) { + t.Helper() + previous := scanInventory + scanInventory = fn + t.Cleanup(func() { scanInventory = previous }) +} + +func TestSharedInventoryGuardScansOnceWithinMaxAge(t *testing.T) { + guard, store := sharedTestGuard(t) + var scans atomic.Int32 + stubScan(t, func(_ context.Context, options Options) (Inventory, error) { + scans.Add(1) + return Inventory{StoreDir: options.StoreDir}, nil + }) + var wg sync.WaitGroup + for i := 0; i < 16; i++ { + wg.Add(1) + go func() { + defer wg.Done() + if _, err := guard.Check(context.Background(), Projection{Operation: "copy-on-write"}); err != nil { + t.Error(err) + } + }() + } + wg.Wait() + if got := scans.Load(); got != 1 { + t.Fatalf("scans = %d, want 1 for %s", got, store) + } +} + +func TestSharedInventoryGuardStaleInventoryDoesNotBlockForeground(t *testing.T) { + guard, _ := sharedTestGuard(t) + guard.MaxAge = time.Nanosecond + release := make(chan struct{}) + var scans atomic.Int32 + stubScan(t, func(_ context.Context, options Options) (Inventory, error) { + if scans.Add(1) > 1 { + <-release + } + return Inventory{StoreDir: options.StoreDir}, nil + }) + if _, err := guard.Check(context.Background(), Projection{Operation: "copy-on-write"}); err != nil { + t.Fatal(err) + } + time.Sleep(time.Millisecond) + done := make(chan error, 1) + go func() { + _, err := guard.Check(context.Background(), Projection{Operation: "copy-on-write"}) + done <- err + }() + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(2 * time.Second): + t.Fatal("check blocked behind a background refresh of a stale inventory") + } + close(release) +} + +func TestSharedInventoryGuardFirstScanErrorPropagatesAndRetries(t *testing.T) { + guard, _ := sharedTestGuard(t) + boom := errors.New("scan failed") + var scans atomic.Int32 + stubScan(t, func(_ context.Context, options Options) (Inventory, error) { + if scans.Add(1) == 1 { + return Inventory{}, boom + } + return Inventory{StoreDir: options.StoreDir}, nil + }) + if _, err := guard.Check(context.Background(), Projection{Operation: "copy-on-write"}); !errors.Is(err, boom) { + t.Fatalf("first check error = %v, want %v", err, boom) + } + if _, err := guard.Check(context.Background(), Projection{Operation: "copy-on-write"}); err != nil { + t.Fatalf("second check error = %v", err) + } +} + +func TestSharedInventoryGuardStillEnforcesFreeSpace(t *testing.T) { + guard, _ := sharedTestGuard(t) + guard.Guard.Probe = func(string) (int64, error) { return 1, nil } + stubScan(t, func(_ context.Context, options Options) (Inventory, error) { + return Inventory{StoreDir: options.StoreDir}, nil + }) + _, err := guard.Check(context.Background(), Projection{Operation: "copy-on-write", AdditionalPersistentBytes: 1 << 20, TemporaryBytes: 1 << 20, TemporaryPersistentOverlapBytes: 1 << 20}) + if !errors.Is(err, ErrBudgetExceeded) { + t.Fatalf("error = %v, want ErrBudgetExceeded", err) + } +} + +func TestSharedInventoryGuardCancelledWaiterReturns(t *testing.T) { + guard, _ := sharedTestGuard(t) + release := make(chan struct{}) + stubScan(t, func(_ context.Context, options Options) (Inventory, error) { + <-release + return Inventory{StoreDir: options.StoreDir}, nil + }) + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + defer cancel() + if _, err := guard.Check(ctx, Projection{Operation: "copy-on-write"}); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("error = %v, want deadline exceeded", err) + } + close(release) +} diff --git a/internal/storage/lease.go b/internal/storage/lease.go index c344686..e884ade 100644 --- a/internal/storage/lease.go +++ b/internal/storage/lease.go @@ -9,6 +9,7 @@ import ( "path/filepath" "strings" "sync" + "syscall" ) type Lease struct { @@ -19,6 +20,17 @@ type Lease struct { } func AcquireLease(directory string, label string) (*Lease, error) { + lease, err := acquireLease(directory, label) + if errors.Is(err, syscall.ENOSPC) && (label == "reader" || label == "resolver") { + // Another reader may have released the shared reserve after our failed + // attempt. Retry once even when that reader won the release race. + _ = releaseReadReserveFor(directory) + return acquireLease(directory, label) + } + return lease, err +} + +func acquireLease(directory string, label string) (*Lease, error) { if directory == "" || label == "" || filepath.Base(label) != label || strings.ContainsAny(label, "/\\\x00") { return nil, errors.New("lease directory and safe label are required") } diff --git a/internal/storage/proof.go b/internal/storage/proof.go index 230e284..8810c14 100644 --- a/internal/storage/proof.go +++ b/internal/storage/proof.go @@ -30,11 +30,13 @@ type ManagedSessionReference struct { // ManagedSessionDeletionGuard keeps every managed session's writer lock held // while shared storage is being reclaimed. type ManagedSessionDeletionGuard struct { - References []ManagedSessionReference - files []*os.File - sessions managedSessionsStamp - proofFiles map[string]os.FileInfo - closed bool + References []ManagedSessionReference + files []*os.File + maintenance *Maintenance + ctx context.Context + sessions managedSessionsStamp + proofFiles map[string]os.FileInfo + closed bool } // ErrManagedSessionBusy is ordinary live I/O contention, not corrupt storage. @@ -267,16 +269,24 @@ var reloadManagedSessionReferences = ManagedSessionReferences // AcquireManagedSessionDeletionGuard reloads every managed state and current // manifest, then holds writer locks so that proof stays valid until Close. -func AcquireManagedSessionDeletionGuard(ctx context.Context, storeDir string) (*ManagedSessionDeletionGuard, error) { +func AcquireManagedSessionDeletionGuard(ctx context.Context, storeDir string) (result *ManagedSessionDeletionGuard, runErr error) { s, exists, err := prepareScanner(ctx, Options{StoreDir: storeDir}) if err != nil { return nil, err } if !exists { - return &ManagedSessionDeletionGuard{}, nil + return &ManagedSessionDeletionGuard{ctx: ctx}, nil } references := managedSessionReferences(s) guard := &ManagedSessionDeletionGuard{References: references} + maintenance, err := BeginMaintenance(ctx, storeDir) + if err != nil { + return nil, err + } + guard.maintenance = maintenance + ctx = maintenance.Context() + guard.ctx = ctx + defer func() { runErr = maintenance.YieldError(runErr) }() for _, reference := range references { sessionID := reference.SessionID if err := ctx.Err(); err != nil { @@ -316,6 +326,17 @@ func AcquireManagedSessionDeletionGuard(ctx context.Context, storeDir string) (* return guard, nil } +func (g *ManagedSessionDeletionGuard) Context() context.Context { + if g.ctx != nil { + return g.ctx + } + return context.Background() +} + +func (g *ManagedSessionDeletionGuard) YieldError(err error) error { + return g.maintenance.YieldError(err) +} + // Refresh re-proves that the managed session set has not moved underneath a // long reclamation. // @@ -497,7 +518,7 @@ func (g *ManagedSessionDeletionGuard) Close() error { g.sessions = managedSessionsStamp{} g.proofFiles = nil g.closed = true - return result + return errors.Join(result, g.maintenance.Close()) } func acquireExclusiveFileLock(path string) (*os.File, error) { diff --git a/internal/storage/read_reserve.go b/internal/storage/read_reserve.go new file mode 100644 index 0000000..85d372a --- /dev/null +++ b/internal/storage/read_reserve.go @@ -0,0 +1,88 @@ +package storage + +import ( + "bytes" + "errors" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +const readReserveBytes = 256 << 10 +const readReserveName = ".codexfold-read-reserve-v1" + +var readReserveMagic = []byte("CodexFold read-lease emergency reserve v1\n") +var readReserves = struct { + sync.Mutex + roots map[string]time.Time +}{roots: make(map[string]time.Time)} + +// PrepareReadReserve keeps only 256 KiB per store, not a session backup. It is +// released solely to establish reader/resolver leases when the disk is full; +// bypassing those leases would make concurrent reclamation unsafe. +func PrepareReadReserve(store string) { + store, err := filepath.Abs(store) + if err != nil { + return + } + readReserves.Lock() + defer readReserves.Unlock() + if last, ok := readReserves.roots[store]; ok && time.Since(last) < 10*time.Second { + return + } + readReserves.roots[store] = time.Now() + path := filepath.Join(store, readReserveName) + if _, err := os.Lstat(path); err == nil { + return + } + available, err := AvailableBytes(store) + if err != nil || available < 16<<20 { + return + } + f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) + if err != nil { + return + } + data := make([]byte, readReserveBytes) + copy(data, readReserveMagic) + _, writeErr := f.Write(data) + syncErr := f.Sync() + created, statErr := f.Stat() + closeErr := f.Close() + if errors.Join(writeErr, syncErr, closeErr) != nil && statErr == nil { + if current, err := os.Lstat(path); err == nil && os.SameFile(created, current) { + _ = os.Remove(path) + } + } +} + +func releaseReadReserveFor(directory string) bool { + readReserves.Lock() + defer readReserves.Unlock() + for store := range readReserves.roots { + if !strings.HasPrefix(filepath.Clean(directory), store+string(filepath.Separator)) { + continue + } + path := filepath.Join(store, readReserveName) + info, err := os.Lstat(path) + if err != nil || !info.Mode().IsRegular() || info.Mode().Perm() != 0600 || info.Size() != readReserveBytes { + continue + } + f, err := os.Open(path) + if err != nil { + continue + } + opened, statErr := f.Stat() + data := make([]byte, readReserveBytes) + _, readErr := f.ReadAt(data, 0) + _ = f.Close() + current, currentErr := os.Lstat(path) + if errors.Join(statErr, readErr, currentErr) != nil || !os.SameFile(info, opened) || !os.SameFile(opened, current) || !bytes.Equal(data[:len(readReserveMagic)], readReserveMagic) || !bytes.Equal(data[len(readReserveMagic):], make([]byte, readReserveBytes-len(readReserveMagic))) { + continue + } + return os.Remove(path) == nil + } + return false +} diff --git a/internal/storage/read_reserve_test.go b/internal/storage/read_reserve_test.go new file mode 100644 index 0000000..c259e89 --- /dev/null +++ b/internal/storage/read_reserve_test.go @@ -0,0 +1,36 @@ +package storage + +import ( + "os" + "path/filepath" + "testing" +) + +func TestReadReserveIsBoundedAndDoesNotRemoveForeignMaterial(t *testing.T) { + root := t.TempDir() + PrepareReadReserve(root) + path := filepath.Join(root, readReserveName) + info, err := os.Stat(path) + if err != nil || info.Size() != readReserveBytes { + t.Fatalf("reserve: %v %v", info, err) + } + if releaseReadReserveFor(t.TempDir()) { + t.Fatal("released reserve for unrelated store") + } + if !releaseReadReserveFor(filepath.Join(root, "fs", "sessions", "test", "leases")) { + t.Fatal("could not release owned reserve for reader lease") + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("reserve remains: %v", err) + } + foreign := make([]byte, readReserveBytes) + if err := os.WriteFile(path, foreign, 0600); err != nil { + t.Fatal(err) + } + if releaseReadReserveFor(filepath.Join(root, "packs", "test", "leases")) { + t.Fatal("removed unproven material") + } + if _, err := os.Stat(path); err != nil { + t.Fatal(err) + } +} diff --git a/internal/vfs/append_no_space_test.go b/internal/vfs/append_no_space_test.go new file mode 100644 index 0000000..05bd6f6 --- /dev/null +++ b/internal/vfs/append_no_space_test.go @@ -0,0 +1,145 @@ +package vfs + +import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" + "strings" + "syscall" + "testing" + + "github.com/samekind/codexfold/internal/storage" +) + +func TestAppendNoSpaceRollsBackPartialRecordAndCanRetry(t *testing.T) { + root := t.TempDir() + manifest, reader, source := sessionFixture(t, root) + session := openFixtureSession(t, root, manifest, reader, nil) + writer, err := session.OpenWriter() + if err != nil { + t.Fatal(err) + } + defer writer.Close() + before := []byte("{\"accepted\":true}\n") + if _, err := writer.Append(context.Background(), before); err != nil { + t.Fatal(err) + } + failed := []byte("{\"retry\":true}\n") + writer.appendWrite = func(f *os.File, p []byte) (int, error) { + n, err := f.Write(p[:len(p)/2]) + if err != nil { + return n, err + } + return n, syscall.ENOSPC + } + if n, err := writer.Append(context.Background(), failed); n != 0 || !errors.Is(err, syscall.ENOSPC) { + t.Fatalf("partial ENOSPC: n=%d err=%v", n, err) + } + got, err := os.ReadFile(session.State().DeltaPath) + if err != nil || !bytes.Equal(got, before) { + t.Fatalf("accepted bytes changed or partial record survived: %q %v", got, err) + } + writer.appendWrite = nil + if n, err := writer.Append(context.Background(), failed); n != len(failed) || err != nil { + t.Fatalf("retry: n=%d err=%v", n, err) + } + if err := writer.Sync(); err != nil { + t.Fatal(err) + } + r, err := session.OpenReader() + if err != nil { + t.Fatal(err) + } + defer r.Close() + want := append(append(append([]byte{}, source...), before...), failed...) + if got := readHandle(t, r); !bytes.Equal(got, want) { + t.Fatal("read after space recovery differs") + } +} + +func TestAppendActualFullVolumePreservesReadsAndRecovers(t *testing.T) { + volume := os.Getenv("CODEXFOLD_TEST_FULL_VOLUME") + if !strings.HasPrefix(volume, "/private/tmp/codexfold-pressure-") { + t.Skip("requires an explicitly created small test volume") + } + space, err := storage.AvailableBytes(volume) + if err != nil || space > 300<<20 { + t.Fatalf("not a small disposable volume: space=%d err=%v", space, err) + } + root, err := os.MkdirTemp(volume, "vfs-") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(root) + manifest, reader, source := sessionFixture(t, root) + session := openFixtureSession(t, root, manifest, reader, nil) + writer, err := session.OpenWriter() + if err != nil { + t.Fatal(err) + } + defer writer.Close() + r, err := session.OpenReader() + if err != nil { + t.Fatal(err) + } + defer r.Close() + filler := filepath.Join(root, "test-only-filler") + f, err := os.Create(filler) + if err != nil { + t.Fatal(err) + } + block := make([]byte, 1<<20) + var fillErr error + for i := 0; i < 300; i++ { + if _, fillErr = f.Write(block); fillErr != nil { + break + } + } + _ = f.Close() + if !errors.Is(fillErr, syscall.ENOSPC) { + t.Fatalf("did not reach actual ENOSPC: %v", fillErr) + } + if got := readHandle(t, r); !bytes.Equal(got, source) { + t.Fatal("existing read changed at ENOSPC") + } + if n, err := writer.Append(context.Background(), block); n != 0 || !errors.Is(err, syscall.ENOSPC) { + t.Fatalf("full-volume append n=%d err=%v", n, err) + } + if got, err := os.ReadFile(session.State().DeltaPath); err != nil || len(got) != 0 { + t.Fatalf("partial append remained: bytes=%d err=%v", len(got), err) + } + _ = r.Close() + r, err = session.OpenReader() + if err != nil { + t.Fatalf("fresh reader at ENOSPC: %v", err) + } + if got := readHandle(t, r); !bytes.Equal(got, source) { + t.Fatal("fresh reader differs on full volume") + } + _ = r.Close() + if err := os.Remove(filler); err != nil { + t.Fatal(err) + } + appendix := []byte("{\"space_recovered\":true}\n") + if n, err := writer.Append(context.Background(), appendix); n != len(appendix) || err != nil { + t.Fatalf("automatic retry n=%d err=%v", n, err) + } + if err := writer.Sync(); err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + reopened := openFixtureSession(t, root, manifest, reader, nil) + check, err := reopened.OpenReader() + if err != nil { + t.Fatal(err) + } + defer check.Close() + if got := readHandle(t, check); !bytes.Equal(got, append(append([]byte{}, source...), appendix...)) { + t.Fatal("reopen after freeing space lost bytes") + } + t.Log("actual ENOSPC: existing/fresh reads, failed append rollback, automatic retry and reopen passed") +} diff --git a/internal/vfs/compact.go b/internal/vfs/compact.go index f5cf0df..4ee71bb 100644 --- a/internal/vfs/compact.go +++ b/internal/vfs/compact.go @@ -13,6 +13,7 @@ import ( "time" "github.com/samekind/codexfold/internal/fold" + "github.com/samekind/codexfold/internal/storage" ) type PreparedGeneration struct { @@ -33,7 +34,7 @@ type CompactResult struct { SHA256 string `json:"sha256"` } -func (s *Session) Compact(ctx context.Context, options CompactOptions) (CompactResult, error) { +func (s *Session) Compact(ctx context.Context, options CompactOptions) (result CompactResult, runErr error) { s.mu.Lock() if s.recoveryDeferred { s.mu.Unlock() @@ -43,6 +44,14 @@ func (s *Session) Compact(ctx context.Context, options CompactOptions) (CompactR if options.Prepare == nil { return CompactResult{}, errors.New("compact preparation function is required") } + store := filepath.Dir(filepath.Dir(filepath.Dir(s.directory))) + maintenance, err := storage.BeginMaintenance(ctx, store) + if err != nil { + return CompactResult{}, err + } + defer maintenance.Close() + defer func() { runErr = maintenance.YieldError(runErr) }() + ctx = maintenance.Context() s.mu.Lock() if s.writerOpen { s.mu.Unlock() diff --git a/internal/vfs/foreground_maintenance_test.go b/internal/vfs/foreground_maintenance_test.go new file mode 100644 index 0000000..f9b0768 --- /dev/null +++ b/internal/vfs/foreground_maintenance_test.go @@ -0,0 +1,51 @@ +package vfs + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/samekind/codexfold/internal/storage" +) + +func TestForegroundWriterPreemptsInFlightCompaction(t *testing.T) { + root := t.TempDir() + manifest, reader, _ := sessionFixture(t, root) + session := openFixtureSession(t, root, manifest, reader, nil) + preparing := make(chan struct{}) + background := make(chan error, 1) + go func() { + _, err := session.Compact(context.Background(), CompactOptions{Prepare: func(ctx context.Context, _ NativeFile, _ uint64) (PreparedGeneration, error) { + close(preparing) + <-ctx.Done() + return PreparedGeneration{}, ctx.Err() + }}) + background <- err + }() + select { + case <-preparing: + case <-time.After(time.Second): + t.Fatal("compaction did not enter its slow phase") + } + start := time.Now() + writer, err := session.OpenForegroundWriter() + if err != nil { + t.Fatalf("foreground saw background compaction as a busy session: %v", err) + } + defer writer.Close() + if elapsed := time.Since(start); elapsed > time.Second { + t.Fatalf("foreground preemption took %s", elapsed) + } + select { + case err := <-background: + if !errors.Is(err, storage.ErrManagedSessionBusy) { + t.Fatalf("compaction yielding was not deferred: %v", err) + } + case <-time.After(time.Second): + t.Fatal("compaction remained blocked") + } + if session.State().Generation != 1 { + t.Fatal("cancelled compaction published a partial generation") + } +} diff --git a/internal/vfs/handles.go b/internal/vfs/handles.go index b6fcd52..12b88d6 100644 --- a/internal/vfs/handles.go +++ b/internal/vfs/handles.go @@ -116,6 +116,7 @@ type WriteHandle struct { appendHashPath string appendHashBytes int64 appendHashValid bool + appendWrite func(*os.File, []byte) (int, error) // nil outside fault-injection tests } func (h *WriteHandle) Append(ctx context.Context, data []byte) (int, error) { @@ -139,7 +140,36 @@ func (h *WriteHandle) Append(ctx context.Context, data []byte) (int, error) { if err != nil { return 0, fmt.Errorf("open append target: %w", err) } - n, writeErr := file.Write(data) + original, statErr := file.Stat() + if statErr != nil { + _ = file.Close() + return 0, statErr + } + write := h.appendWrite + if write == nil { + write = func(f *os.File, p []byte) (int, error) { return f.Write(p) } + } + n, writeErr := write(file, data) + if n != len(data) && writeErr == nil { + writeErr = io.ErrShortWrite + } + if writeErr != nil && n > 0 { + // A failed append must not leave half a JSONL record. The session's + // exclusive writer lease is held here. Preserve all earlier successful + // appends and let the caller retry this request from its original EOF. + rollbackErr := file.Truncate(original.Size()) + if rollbackErr == nil { + rollbackErr = file.Sync() + } + if rollbackErr == nil { + _ = file.Close() + return 0, writeErr + } + h.appendHashValid = false + h.dirty = true + _ = file.Close() + return n, errors.Join(writeErr, fmt.Errorf("rollback incomplete append: %w", rollbackErr)) + } closeErr := file.Close() if n > 0 { _, _ = h.appendHasher.Write(data[:n]) diff --git a/internal/vfs/session.go b/internal/vfs/session.go index 81c5489..28037d5 100644 --- a/internal/vfs/session.go +++ b/internal/vfs/session.go @@ -100,7 +100,9 @@ func openSession(ctx context.Context, options SessionOptions, reserveWriter bool if err != nil { return nil, nil, err } - budget = guard + shared := storage.SharedInventoryGuard{Guard: guard} + shared.Prime() + budget = shared } view, err := NewView(options.Manifest, options.Reader) if err != nil { @@ -345,6 +347,30 @@ func (s *Session) OpenWriter() (*WriteHandle, error) { return &WriteHandle{session: s, leasePath: leasePath, lease: lease}, nil } +// OpenForegroundWriter lets a user's open preempt background reclamation. +// Real foreground writer exclusion remains unchanged; only a live maintenance +// registration authorizes the bounded, internal retry. +func (s *Session) OpenForegroundWriter() (*WriteHandle, error) { + writer, err := s.OpenWriter() + if !errors.Is(err, ErrWriterBusy) { + return writer, err + } + store := filepath.Dir(filepath.Dir(filepath.Dir(s.directory))) + requested, requestErr := storage.RequestForegroundAccess(store) + if requestErr != nil || !requested { + return nil, err + } + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + time.Sleep(5 * time.Millisecond) + writer, err = s.OpenWriter() + if !errors.Is(err, ErrWriterBusy) { + return writer, err + } + } + return nil, err +} + func acquireWriterLease(leasePath string) (*os.File, error) { lease, err := os.OpenFile(leasePath, os.O_CREATE|os.O_RDWR, 0o600) if err != nil { diff --git a/internal/vfs/session_deletion.go b/internal/vfs/session_deletion.go index 398d6f9..24badaa 100644 --- a/internal/vfs/session_deletion.go +++ b/internal/vfs/session_deletion.go @@ -840,7 +840,7 @@ func cleanSessionDeletionRoute(route string) (string, error) { return "", errors.New("canonical session deletion route is required") } cleaned := path.Clean("/" + strings.TrimPrefix(route, "/")) - if cleaned != route || !strings.HasSuffix(cleaned, ".jsonl") || (!strings.HasPrefix(cleaned, "/sessions/") && !strings.HasPrefix(cleaned, "/archived_sessions/")) { + if cleaned != route || (!strings.HasPrefix(cleaned, "/sessions/") && !strings.HasPrefix(cleaned, "/archived_sessions/")) { return "", errors.New("session deletion route is not a clean canonical session path") } return cleaned, nil diff --git a/internal/vfs/session_deletion_object_darwin.go b/internal/vfs/session_deletion_object_darwin.go index 5e833bb..0d9e82b 100644 --- a/internal/vfs/session_deletion_object_darwin.go +++ b/internal/vfs/session_deletion_object_darwin.go @@ -48,7 +48,7 @@ func captureSessionDeletionXattrs(file *os.File, info os.FileInfo, path string) if err != nil { return SessionDeletionPurgeXattrs{}, err } - if first != second { + if !sameDeletionPurgeXattrs(first, second) { return SessionDeletionPurgeXattrs{}, errors.New("extended attributes changed while exact purge metadata was captured") } return first, nil diff --git a/internal/vfs/session_deletion_provenance_darwin_test.go b/internal/vfs/session_deletion_provenance_darwin_test.go new file mode 100644 index 0000000..1af65ca --- /dev/null +++ b/internal/vfs/session_deletion_provenance_darwin_test.go @@ -0,0 +1,99 @@ +//go:build darwin + +package vfs + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "golang.org/x/sys/unix" +) + +func TestDeletionPurgeProvenanceComparisonIsNarrow(t *testing.T) { + old := SessionDeletionPurgeXattrs{Count: 1, Bytes: 11, SHA256: strings.Repeat("a", 64)} + current := old + current.SHA256 = strings.Repeat("b", 64) + if !sameDeletionPurgeXattrs(old, current) || !sameDeletionPurgeXattrs(old, SessionDeletionPurgeXattrs{SHA256: digestStateBytes(nil)}) { + t.Fatal("OS provenance transition rejected") + } + for _, changed := range []SessionDeletionPurgeXattrs{ + {Count: 2, Bytes: 11, SHA256: current.SHA256}, + {Count: 1, Bytes: 12, SHA256: current.SHA256}, + {Count: 0, Bytes: 0, SHA256: current.SHA256}, + {Count: 1, Bytes: 11, SHA256: "invalid"}, + } { + if sameDeletionPurgeXattrs(old, changed) { + t.Fatalf("unknown metadata accepted: %+v", changed) + } + } +} + +func TestSessionDeletionPurgeResumesLegacyProvenanceReceipts(t *testing.T) { + for _, phase := range []string{sessionDeletionPurgePrepared, sessionDeletionPurgeRenamed} { + t.Run(phase, func(t *testing.T) { + root, _, tombstone := sessionDeletionFixture(t) + changed := false + sessionDeletionPurgeHook = func(got string) error { + if got != phase || changed { + return nil + } + changed = true + r, err := LoadSessionDeletionPurge(root, tombstone.SessionID) + if err != nil { + return err + } + old := SessionDeletionPurgeXattrs{Count: 1, Bytes: 11, SHA256: strings.Repeat("a", 64)} + r.QuarantineRootXattrs = old + for i := range r.Entries { + r.Entries[i].Xattrs = old + } + h := sha256.New() + fmt.Fprintf(h, "R\x00%o\x00%s\x00%s\n", r.QuarantineRootMode, deletionPurgeObjectHashFields(r.QuarantineRootObject), deletionPurgeXattrHashFields(old)) + for _, e := range r.Entries { + if e.Kind == "directory" { + fmt.Fprintf(h, "D\x00%s\x00%o\x00%s\x00%s\n", e.Path, e.Mode, deletionPurgeObjectHashFields(e.Object), deletionPurgeXattrHashFields(e.Xattrs)) + } else { + fmt.Fprintf(h, "F\x00%s\x00%o\x00%s\x00%s\x00%d\x00%s\n", e.Path, e.Mode, deletionPurgeObjectHashFields(e.Object), deletionPurgeXattrHashFields(e.Xattrs), e.Bytes, e.SHA256) + } + } + r.QuarantineTreeSHA256 = hex.EncodeToString(h.Sum(nil)) + if err := replaceSessionDeletionPurge(root, r); err != nil { + return err + } + return fmt.Errorf("stop after provenance fixture") + } + t.Cleanup(func() { sessionDeletionPurgeHook = nil }) + if completed, err := AdvanceSessionDeletion(root, tombstone); completed || err == nil { + t.Fatalf("fixture stop completed=%t err=%v", completed, err) + } + sessionDeletionPurgeHook = nil + if !changed { + t.Fatal("receipt fixture was not changed") + } + if completed, err := AdvanceSessionDeletion(root, tombstone); !completed || err != nil { + t.Fatalf("resume provenance completed=%t err=%v", completed, err) + } + assertSessionDeletionPurged(t, root, tombstone) + }) + } +} + +func TestSessionDeletionPurgeStillPreservesForeignXattr(t *testing.T) { + root, _, tombstone := sessionDeletionFixture(t) + stageSessionDeletionQuarantine(t, tombstone) + file := filepath.Join(tombstone.RetiredSessionPath, "delta.jsonl") + if err := unix.Setxattr(file, "com.codexfold.foreign", []byte("preserve"), 0); err != nil { + t.Fatal(err) + } + if completed, err := AdvanceSessionDeletion(root, tombstone); completed || err == nil { + t.Fatalf("foreign xattr purged completed=%t err=%v", completed, err) + } + if _, err := os.Stat(file); err != nil { + t.Fatalf("foreign metadata object removed: %v", err) + } +} diff --git a/internal/vfs/session_deletion_purge.go b/internal/vfs/session_deletion_purge.go index c794530..fb19ad3 100644 --- a/internal/vfs/session_deletion_purge.go +++ b/internal/vfs/session_deletion_purge.go @@ -11,6 +11,7 @@ import ( "io/fs" "os" "path/filepath" + "runtime" "slices" "sort" "strconv" @@ -984,15 +985,31 @@ func captureDeletionPurgeTreeIdentity(storeRoot string, root string) (deletionPu } func sameDeletionPurgeTreeIdentity(left deletionPurgeTreeIdentity, right deletionPurgeTreeIdentity) bool { - if left.SHA256 != right.SHA256 || left.Bytes != right.Bytes || left.Entries != right.Entries || left.RootMode != right.RootMode || left.RootObject != right.RootObject || left.RootXattrs != right.RootXattrs || len(left.Tree) != len(right.Tree) { + if left.Bytes != right.Bytes || left.Entries != right.Entries || left.RootMode != right.RootMode || left.RootObject != right.RootObject || !sameDeletionPurgeXattrs(left.RootXattrs, right.RootXattrs) { return false } - for index := range left.Tree { - if left.Tree[index] != right.Tree[index] { - return false - } + return sameDeletionPurgeEntries(left.Tree, right.Tree) +} + +// Darwin capture rejects every xattr except com.apple.provenance, as well as +// ACLs and BSD flags. Provenance is an OS-maintained origin tag, not session +// content or object identity; moving an inode must not make its deletion proof +// permanently unusable when that tag changes. Receipts remain hash-validated +// exactly as written; only comparison with live, freshly inspected metadata +// allows the known 11-byte tag (or its absence) to differ. No other metadata, +// inode identity, path, file size or content digest is relaxed. +func sameDeletionPurgeXattrs(left, right SessionDeletionPurgeXattrs) bool { + if left == right { + return true } - return true + if runtime.GOOS != "darwin" { + return false + } + provenanceOnly := func(value SessionDeletionPurgeXattrs) bool { + return value.Count == 1 && value.Bytes == 11 && validStateSHA256(value.SHA256) || + value.Count == 0 && value.Bytes == 0 && value.SHA256 == digestStateBytes(nil) + } + return provenanceOnly(left) && provenanceOnly(right) } func captureDeletionPurgeTreeIdentityOnce(storeRoot string, root string) (deletionPurgeTreeIdentity, error) { @@ -1267,7 +1284,9 @@ func verifyDeletionPurgeTreeIdentity(storeRoot string, root string, receipt Sess if err != nil { return err } - if identity.SHA256 != receipt.QuarantineTreeSHA256 || identity.Bytes != receipt.QuarantineBytes || identity.Entries != receipt.QuarantineEntries || identity.RootMode != receipt.QuarantineRootMode || identity.RootObject != receipt.QuarantineRootObject || identity.RootXattrs != receipt.QuarantineRootXattrs || !sameDeletionPurgeEntries(identity.Tree, receipt.Entries) { + // LoadSessionDeletionPurge validates the recorded aggregate hash. Comparing + // every live field also permits only the Darwin provenance exception above. + if identity.Bytes != receipt.QuarantineBytes || identity.Entries != receipt.QuarantineEntries || identity.RootMode != receipt.QuarantineRootMode || identity.RootObject != receipt.QuarantineRootObject || !sameDeletionPurgeXattrs(identity.RootXattrs, receipt.QuarantineRootXattrs) || !sameDeletionPurgeEntries(identity.Tree, receipt.Entries) { return errors.New("deleted session quarantine differs from its durable purge proof") } return nil @@ -1283,9 +1302,15 @@ func verifyDeletionPurgeEntry(storeRoot string, path string, entry SessionDeleti if err != nil { return err } - if info.Mode()&os.ModeSymlink != 0 || uint32(info.Mode().Perm()) != entry.Mode || object != entry.Object || xattrs != entry.Xattrs { + if info.Mode()&os.ModeSymlink != 0 || uint32(info.Mode().Perm()) != entry.Mode { return fmt.Errorf("deletion purge entry changed type or mode: %s", entry.Path) } + if object != entry.Object { + return fmt.Errorf("deletion purge entry changed object identity: %s", entry.Path) + } + if !sameDeletionPurgeXattrs(xattrs, entry.Xattrs) { + return fmt.Errorf("deletion purge entry changed extended attributes: %s", entry.Path) + } switch entry.Kind { case "directory": if !info.IsDir() { @@ -1303,7 +1328,7 @@ func verifyDeletionPurgeEntry(storeRoot string, path string, entry SessionDeleti return fmt.Errorf("deletion purge file differs from its durable receipt: %s", entry.Path) } infoAfter, objectAfter, xattrsAfter, err := captureDeletionPurgeObject(store, path) - if err != nil || !os.SameFile(info, infoAfter) || objectAfter != object || xattrsAfter != xattrs { + if err != nil || !os.SameFile(info, infoAfter) || objectAfter != object || !sameDeletionPurgeXattrs(xattrsAfter, xattrs) { if err == nil { err = fmt.Errorf("deletion purge file metadata changed during verification: %s", entry.Path) } @@ -1593,7 +1618,7 @@ func removeDeletionPurgeStaging(storeRoot string, stagingRoot string, receipt Se if err != nil { return receipt, err } - if !rootInfo.IsDir() || uint32(rootInfo.Mode().Perm()) != receipt.QuarantineRootMode || rootObject != receipt.QuarantineRootObject || rootXattrs != receipt.QuarantineRootXattrs { + if !rootInfo.IsDir() || uint32(rootInfo.Mode().Perm()) != receipt.QuarantineRootMode || rootObject != receipt.QuarantineRootObject || !sameDeletionPurgeXattrs(rootXattrs, receipt.QuarantineRootXattrs) { return receipt, errors.New("deletion purge staging root differs from its durable receipt") } empty, err := deletionPurgeDirectoryEmpty(store, stagingRoot) @@ -1700,7 +1725,7 @@ func inspectDeletionPurgeStaging(storeRoot string, stagingRoot string, receipt S replacement = true return fs.SkipAll } - if uint32(info.Mode().Perm()) != receipt.QuarantineRootMode || object != receipt.QuarantineRootObject || xattrs != receipt.QuarantineRootXattrs { + if uint32(info.Mode().Perm()) != receipt.QuarantineRootMode || object != receipt.QuarantineRootObject || !sameDeletionPurgeXattrs(xattrs, receipt.QuarantineRootXattrs) { replacement = true return fs.SkipAll } @@ -1818,7 +1843,12 @@ func sameDeletionPurgeEntries(left []SessionDeletionPurgeEntry, right []SessionD return false } for index := range left { - if left[index] != right[index] { + current, expected := left[index], right[index] + if !sameDeletionPurgeXattrs(current.Xattrs, expected.Xattrs) { + return false + } + current.Xattrs = expected.Xattrs + if current != expected { return false } } diff --git a/platform/darwin/fskit/Host/Host.swift b/platform/darwin/fskit/Host/Host.swift index da45b0b..9a73612 100644 --- a/platform/darwin/fskit/Host/Host.swift +++ b/platform/darwin/fskit/Host/Host.swift @@ -2,6 +2,8 @@ import AppKit import Darwin import Dispatch import Foundation +import ServiceManagement +import UserNotifications @main struct CodexFoldFSKitHost { @@ -71,6 +73,54 @@ struct CodexFoldFSKitHost { } print(output) return 0 + case "--refresh-incident-monitor": + let service = SMAppService.agent(plistName: "CodexFoldIncidentMonitor.plist") + try service.unregister() + try service.register() + print("incident-monitor registration refreshed") + return 0 + case "--notification-test": + let center = UNUserNotificationCenter.current() + let finished = DispatchSemaphore(value: 0) + center.requestAuthorization(options: [.alert, .provisional]) { granted, error in + guard granted else { + print("notifications unavailable: \(error?.localizedDescription ?? "permission denied")") + finished.signal() + return + } + let content = UNMutableNotificationContent() + content.title = "CodexFold" + content.body = "通知通道测试:故障将通过系统通知提示,不会自动弹出窗口。" + content.sound = nil + center.add(UNNotificationRequest(identifier: "codexfold-notification-channel-test", content: content, trigger: nil)) { error in + print(error.map { "notification submission failed: \($0.localizedDescription)" } ?? "notification submitted") + finished.signal() + } + } + guard finished.wait(timeout: .now() + 10) == .success else { throw POSIXError(.ETIMEDOUT) } + return 0 + case "--notification-status": + let center = UNUserNotificationCenter.current() + let finished = DispatchSemaphore(value: 0) + center.getNotificationSettings { settings in + center.getDeliveredNotifications { notifications in + let report: [String: Any] = [ + "authorization_status": settings.authorizationStatus.rawValue, + "notification_center_enabled": settings.notificationCenterSetting == .enabled, + "delivered_requests": notifications.map { $0.request.identifier }.filter { $0.hasPrefix("codexfold-") }.sorted(), + ] + if let data = try? JSONSerialization.data(withJSONObject: report, options: [.sortedKeys]), + let output = String(data: data, encoding: .utf8) { print(output) } + finished.signal() + } + } + guard finished.wait(timeout: .now() + 10) == .success else { throw POSIXError(.ETIMEDOUT) } + return 0 + case "--clear-notification-test": + let center = UNUserNotificationCenter.current() + center.removePendingNotificationRequests(withIdentifiers: ["codexfold-notification-channel-test"]) + center.removeDeliveredNotifications(withIdentifiers: ["codexfold-notification-channel-test"]) + return 0 case "--run-helper": guard arguments.count >= 2 else { throw POSIXError(.EINVAL) diff --git a/platform/darwin/fskit/Host/IncidentMonitorMain.swift b/platform/darwin/fskit/Host/IncidentMonitorMain.swift index 5f83164..967c48d 100644 --- a/platform/darwin/fskit/Host/IncidentMonitorMain.swift +++ b/platform/darwin/fskit/Host/IncidentMonitorMain.swift @@ -39,7 +39,7 @@ private final class CodexFoldIncidentMonitorDelegate: NSObject, NSApplicationDel monitorDaemonStatus: true, recordsHistory: false ) - let coordinator = IncidentPresentationCoordinator(appGroupURL: appGroupURL, statusStore: store) + let coordinator = IncidentPresentationCoordinator(appGroupURL: appGroupURL, statusStore: store, isNotificationHost: false) statusStore = store self.coordinator = coordinator coordinator.start() diff --git a/platform/darwin/fskit/Host/IncidentPresentation.swift b/platform/darwin/fskit/Host/IncidentPresentation.swift index 99fbc88..5d79d92 100644 --- a/platform/darwin/fskit/Host/IncidentPresentation.swift +++ b/platform/darwin/fskit/Host/IncidentPresentation.swift @@ -3,6 +3,79 @@ import Darwin import Foundation import OSLog import SwiftUI +import UserNotifications + +@MainActor +protocol IncidentNotificationSending: AnyObject { + var onOpen: ((String) -> Void)? { get set } + func post(_ incident: FrontendIncident) + func remove(_ incidentID: String) +} + +@MainActor +final class SystemIncidentNotificationSender: NSObject, IncidentNotificationSending, UNUserNotificationCenterDelegate { + var onOpen: ((String) -> Void)? + private let center = UNUserNotificationCenter.current() + private var active = Set() + + override init() { + super.init() + center.delegate = self + } + + func post(_ incident: FrontendIncident) { + active.insert(incident.id) + center.getNotificationSettings { [weak self] settings in + Task { @MainActor in + guard let self, self.active.contains(incident.id) else { return } + switch settings.authorizationStatus { + case .notDetermined: + // Provisional authorization is quiet: do not replace an + // unwanted fault window with a permission dialog. + self.center.requestAuthorization(options: [.alert, .provisional]) { [weak self] granted, _ in + Task { @MainActor in + if granted { self?.submit(incident) } + } + } + case .authorized, .provisional: + self.submit(incident) + default: + break // Denied notifications never fall back to a modal window. + } + } + } + } + + private func submit(_ incident: FrontendIncident) { + guard active.contains(incident.id) else { return } + let content = UNMutableNotificationContent() + content.title = L10n.text(.productName) + content.subtitle = incident.reason + content.body = String(incident.impact.prefix(280)) + content.sound = nil + content.userInfo = ["codexfoldIncidentID": incident.id] + center.add(UNNotificationRequest(identifier: "codexfold-incident-" + incident.id, content: content, trigger: nil)) + } + + func remove(_ incidentID: String) { + active.remove(incidentID) + let id = "codexfold-incident-" + incidentID + center.removePendingNotificationRequests(withIdentifiers: [id]) + center.removeDeliveredNotifications(withIdentifiers: [id]) + } + + nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { + completionHandler([.banner, .list]) + } + + nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { + if response.actionIdentifier == UNNotificationDefaultActionIdentifier, + let id = response.notification.request.content.userInfo["codexfoldIncidentID"] as? String { + Task { @MainActor [weak self] in self?.onOpen?(id) } + } + completionHandler() + } +} @_silgen_name("flock") private func codexfold_flock(_ descriptor: Int32, _ operation: Int32) -> Int32 @@ -59,6 +132,14 @@ struct IncidentAcknowledgementStore { return acknowledgement.matches(incident) } + var incidentID: String? { + guard let data = try? DurableAppGroupFile.read(url), + let acknowledgement = try? JSONDecoder().decode(IncidentAcknowledgement.self, from: data) else { + return nil + } + return acknowledgement.incidentID + } + func acknowledge(_ incident: FrontendIncident) throws { let data = try JSONEncoder().encode(IncidentAcknowledgement(incident: incident)) try DurableAppGroupFile.write(data, to: url) @@ -122,22 +203,44 @@ final class IncidentWindowPresenter: NSObject, NSWindowDelegate { private let localAuthorizationCapability = LocalAuthorizationCapability.current() private var incidentController: NSWindowController? private var presentedIncident: FrontendIncident? + private let notifications: IncidentNotificationSending? + private var lastNotified: IncidentAcknowledgement? + private var latestIncident: FrontendIncident? init( appGroupURL: URL, diagnosticPayload: @escaping (FrontendIncident) -> Data?, - presentWindow: ((NSWindowController) -> Void)? = nil + presentWindow: ((NSWindowController) -> Void)? = nil, + notifications: IncidentNotificationSending? = nil ) { acknowledgementStore = IncidentAcknowledgementStore(appGroupURL: appGroupURL) self.diagnosticPayload = diagnosticPayload self.presentWindow = presentWindow + self.notifications = notifications ?? (presentWindow == nil ? SystemIncidentNotificationSender() : nil) + super.init() + self.notifications?.onOpen = { [weak self] id in + guard let self, let incident = self.latestIncident, incident.id == id else { return } + self.present(self.incidentForPresentation(incident), isNew: true, allowAcknowledged: true) + } } func handle(_ update: IncidentUpdate) { switch update { case .active(let incident, let isNew): - present(incidentForPresentation(incident), isNew: isNew) + latestIncident = incident + if let notifications { + guard isNew, lastNotified?.matches(incident) != true, !acknowledgementStore.contains(incident) else { return } + lastNotified = IncidentAcknowledgement(incident: incident) + try? acknowledgementStore.acknowledge(incident) + notifications.post(incident) + } else { + present(incidentForPresentation(incident), isNew: isNew) + } case .recovered(let incident): + notifications?.remove(incident.id) + if let previous = lastNotified, previous.incidentID != incident.id { notifications?.remove(previous.incidentID) } + lastNotified = nil + latestIncident = nil let presented = incidentForPresentation(incident) acknowledgementStore.clear(matching: presented) presentedIncident = presented @@ -150,6 +253,7 @@ final class IncidentWindowPresenter: NSObject, NSWindowDelegate { } func clearAcknowledgementAfterHealthyBaseline() { + if let id = acknowledgementStore.incidentID { notifications?.remove(id) } acknowledgementStore.clearAll() } @@ -170,7 +274,7 @@ final class IncidentWindowPresenter: NSObject, NSWindowDelegate { presentedIncident = nil } - private func present(_ incident: FrontendIncident, isNew: Bool) { + private func present(_ incident: FrontendIncident, isNew: Bool, allowAcknowledged: Bool = false) { if let incidentController { presentedIncident = incident updateWindow(incidentController, incident: incident) @@ -179,7 +283,7 @@ final class IncidentWindowPresenter: NSObject, NSWindowDelegate { } return } - guard isNew, !acknowledgementStore.contains(incident) else { return } + guard isNew, allowAcknowledged || !acknowledgementStore.contains(incident) else { return } let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 500, height: 390), styleMask: [.titled, .closable], @@ -206,7 +310,6 @@ final class IncidentWindowPresenter: NSObject, NSWindowDelegate { controller.showWindow(nil) controller.window?.makeKeyAndOrderFront(nil) NSApp.activate(ignoringOtherApps: true) - NSApp.requestUserAttention(.criticalRequest) } private func updateWindow(_ controller: NSWindowController, incident: FrontendIncident) { @@ -274,24 +377,35 @@ final class IncidentWindowPresenter: NSObject, NSWindowDelegate { final class IncidentPresentationCoordinator { private let statusStore: StatusStore private let lease: IncidentPresentationLease - private let presenter: IncidentWindowPresenter + private let presenter: IncidentWindowPresenter? + private let isNotificationHost: Bool private var timer: Timer? - init(appGroupURL: URL, statusStore: StatusStore) { + init(appGroupURL: URL, statusStore: StatusStore, isNotificationHost: Bool = true) { self.statusStore = statusStore lease = IncidentPresentationLease(appGroupURL: appGroupURL) - presenter = IncidentWindowPresenter( + self.isNotificationHost = isNotificationHost + presenter = isNotificationHost ? IncidentWindowPresenter( appGroupURL: appGroupURL, diagnosticPayload: { [weak statusStore] incident in statusStore?.diagnosticPayload(incident: incident) } - ) + ) : nil } func start() { statusStore.onIncident = { [weak self] update in - guard let self, self.lease.isHeld else { return } - self.presenter.handle(update) + guard let self else { return } + if !self.isNotificationHost { + guard case .active(_, let isNew) = update, isNew, + let app = NSWorkspace.shared.urlForApplication(withBundleIdentifier: "vip.jstar.codexfold.fskitprofileprobe") else { return } + let configuration = NSWorkspace.OpenConfiguration() + configuration.activates = false + NSWorkspace.shared.openApplication(at: app, configuration: configuration) { _, _ in } + return + } + guard self.lease.isHeld else { return } + self.presenter?.handle(update) } attemptLease() let timer = Timer(timeInterval: 1, repeats: true) { [weak self] _ in @@ -309,13 +423,14 @@ final class IncidentPresentationCoordinator { } private func attemptLease() { + guard isNotificationHost else { return } if statusStore.hasTrustedHealthyBaseline { - presenter.clearAcknowledgementAfterHealthyBaseline() + presenter?.clearAcknowledgementAfterHealthyBaseline() } let alreadyHeld = lease.isHeld guard lease.acquire(), !alreadyHeld else { return } if let incident = statusStore.currentIncident { - presenter.presentCurrent(incident) + presenter?.handle(.active(incident, isNew: true)) } } } diff --git a/platform/darwin/fskit/Host/Localization.swift b/platform/darwin/fskit/Host/Localization.swift index 684c105..d4a4096 100644 --- a/platform/darwin/fskit/Host/Localization.swift +++ b/platform/darwin/fskit/Host/Localization.swift @@ -1,6 +1,10 @@ import Foundation enum L10nKey: String { + case diskSpaceExhausted + case diskSpaceImpact + case diskSpaceFreeRecommendation + case diskSpaceKeepOpenRecommendation case productName case statusHealthy case statusRecovering @@ -177,6 +181,10 @@ enum L10n { } private static let simplifiedChinese: [L10nKey: String] = [ + .diskSpaceExhausted: "磁盘空间不足,新的写入可能无法保存", + .diskSpaceImpact: "已有会话会尽量保持可读。新的消息或修改可能写入失败;释放空间前,不能把新内容视为已经保存。", + .diskSpaceFreeRecommendation: "释放系统盘或 CodexFold 存储盘的空间;不要手动删除 CodexFold 的会话、对象或恢复记录。", + .diskSpaceKeepOpenRecommendation: "保持 Codex 和 CodexFold 运行。空间恢复后会自动重试,无需重启或重新挂载。", .productName: "CodexFold", .statusHealthy: "运行正常", .statusRecovering: "正在恢复", @@ -319,6 +327,10 @@ enum L10n { ] private static let traditionalChinese: [L10nKey: String] = [ + .diskSpaceExhausted: "磁碟空間不足,新的寫入可能無法儲存", + .diskSpaceImpact: "既有對話會盡量保持可讀。新訊息或修改可能寫入失敗;釋放空間前,不能將新內容視為已儲存。", + .diskSpaceFreeRecommendation: "釋放系統碟或 CodexFold 儲存碟的空間;不要手動刪除 CodexFold 的對話、物件或復原紀錄。", + .diskSpaceKeepOpenRecommendation: "保持 Codex 和 CodexFold 執行。空間恢復後會自動重試,不需重新啟動或掛載。", .productName: "CodexFold", .statusHealthy: "運作正常", .statusRecovering: "正在復原", @@ -461,6 +473,10 @@ enum L10n { ] private static let english: [L10nKey: String] = [ + .diskSpaceExhausted: "The disk is nearly full; new writes may not be saved", + .diskSpaceImpact: "Existing sessions remain readable where possible. New messages or changes may fail to save; do not assume new content is saved until space is available.", + .diskSpaceFreeRecommendation: "Free space on the system or CodexFold storage volume. Do not manually delete CodexFold sessions, objects, or recovery records.", + .diskSpaceKeepOpenRecommendation: "Keep Codex and CodexFold open. Recovery retries automatically when space is available; no restart or remount is needed.", .productName: "CodexFold", .statusHealthy: "Running normally", .statusRecovering: "Recovering", diff --git a/platform/darwin/fskit/Host/StatusModel.swift b/platform/darwin/fskit/Host/StatusModel.swift index fcf71b7..21e825f 100644 --- a/platform/darwin/fskit/Host/StatusModel.swift +++ b/platform/darwin/fskit/Host/StatusModel.swift @@ -502,6 +502,57 @@ private extension ComponentStatus { } } +// This probe deliberately needs no writable status/history/acknowledgement +// file. A full status volume must not silence the very alert explaining it. +struct RuntimeDiskSpaceMonitor { + // APFS may refuse new metadata while still reporting several MiB free. + // Warn before that boundary and require headroom before clearing it. + static let criticalBytes: Int64 = 16 << 20 + static let recoveryBytes: Int64 = 64 << 20 + private var activeID: String? + private var since: Date? + + static func availableBytes(at url: URL) -> Int64? { + var path = url.path + var info = statfs() + while true { + if path.withCString({ statfs($0, &info) }) == 0 { + let (bytes, overflow) = Int64(clamping: info.f_bavail).multipliedReportingOverflow(by: Int64(info.f_bsize)) + return overflow ? Int64.max : max(0, bytes) + } + if path == "/" { return nil } + path = (path as NSString).deletingLastPathComponent + } + } + + mutating func evaluate(availableBytes: Int64?, at now: Date) -> ComponentStatus? { + guard let availableBytes else { return nil } // An unreadable probe is not proof of recovery. + if activeID == nil { + guard availableBytes < Self.criticalBytes else { return nil } + activeID = "disk-space-\(UUID().uuidString.lowercased())" + since = now + } + let recovered = availableBytes >= Self.recoveryBytes + let status = ComponentStatus( + id: "storage-pressure", schemaVersion: nil, + health: recovered ? .healthy : .recovering, + summary: L10n.text(.diskSpaceExhausted), + detail: "Available bytes on a required volume: \(availableBytes). Live filesystem-space probe; no status-file write is required.", + updatedAt: now, incidentID: activeID, incidentSince: since, + reason: L10n.text(.diskSpaceExhausted), + impact: L10n.text(.diskSpaceImpact), + recommendations: [L10n.text(.diskSpaceFreeRecommendation), L10n.text(.diskSpaceKeepOpenRecommendation)], + elapsedMilliseconds: Int64(max(0, now.timeIntervalSince(since ?? now)) * 1_000), + managedSessions: nil, observationSequence: nil, publisherInstanceID: nil, + backendID: nil, mountPoint: nil, resourcePath: nil, recoveryEpochID: nil, + recoveryEpochEstablishedAt: nil, logicalBytes: nil, physicalBytes: nil, + readBytesTotal: nil, writtenBytesTotal: nil + ) + if recovered { activeID = nil; since = nil } + return status + } +} + struct FrontendIncident: Equatable { let id: String let since: Date @@ -521,6 +572,7 @@ enum IncidentUpdate: Equatable { /// malformed, unknown, or stale observations never count as recovery evidence. struct IncidentTracker { private enum Source: Hashable { + case storagePressure case frontend case daemonStatusChannel case managed @@ -582,6 +634,7 @@ struct IncidentTracker { mutating func evaluate( frontend: ComponentStatus?, + storagePressure: ComponentStatus? = nil, managed: ComponentStatus? = nil, daemonStatusChannel: ComponentStatus? = nil, managedStatusChannel: ComponentStatus? = nil, @@ -592,6 +645,7 @@ struct IncidentTracker { let previousSource = selectedSource let previousState = previousSource.flatMap { incidents[$0] } + updateCriticalIncident(source: .storagePressure, status: storagePressure, at: now) updateCriticalIncident(source: .frontend, status: frontend, at: now) updateCriticalIncident(source: .daemonStatusChannel, status: daemonStatusChannel, at: now) updateCriticalIncident(source: .managed, status: managed, at: now) @@ -599,6 +653,7 @@ struct IncidentTracker { updateCriticalIncident(source: .supervisorStatusChannel, status: supervisorStatusChannel, at: now) updateSupervisorIncident(supervisor, frontend: frontend, at: now) + resolveCriticalIncident(source: .storagePressure, status: storagePressure, supervisor: supervisor, at: now) resolveCriticalIncident(source: .frontend, status: frontend, supervisor: supervisor, at: now) resolveCriticalIncident( source: .daemonStatusChannel, @@ -633,6 +688,13 @@ struct IncidentTracker { : .active(merged.incident, isNew: false) } + if let pressure = incidents[.storagePressure] { + selectedSource = .storagePressure + if previousSource != .storagePressure || pressure.incident != previousState?.incident { + return .active(pressure.incident, isNew: previousSource == nil) + } + return nil + } if let previousSource, let current = incidents[previousSource] { selectedSource = previousSource let identityChanged = current.upstreamIdentity != previousState?.upstreamIdentity @@ -661,7 +723,7 @@ struct IncidentTracker { status: ComponentStatus?, at now: Date ) { - guard source == .frontend + guard source == .storagePressure || source == .frontend || source == .daemonStatusChannel || source == .managed || source == .managedStatusChannel @@ -1033,7 +1095,7 @@ struct IncidentTracker { L10n.text(.managedStatusChannelWaitRecommendation), L10n.text(.openDashboardRecommendation), ] - case .daemonStatusChannel, .managedStatusChannel, .supervisorStatusChannel, .supervisor: + case .storagePressure, .daemonStatusChannel, .managedStatusChannel, .supervisorStatusChannel, .supervisor: reason = status.reason ?? status.summary.nonEmpty ?? L10n.text(.unknownReason) impact = status.impact ?? L10n.text(.unknownImpact) recommendations = status.recommendations.isEmpty @@ -1092,7 +1154,7 @@ struct IncidentTracker { } private func nextIncident() -> ActiveState? { - for source in [Source.frontend, .daemonStatusChannel, .managed, .managedStatusChannel, .supervisorStatusChannel, .supervisor] { + for source in [Source.storagePressure, .frontend, .daemonStatusChannel, .managed, .managedStatusChannel, .supervisorStatusChannel, .supervisor] { if let incident = incidents[source] { return incident } } return nil @@ -1137,6 +1199,7 @@ struct IncidentTracker { private static func sourceName(_ source: Source) -> String { switch source { + case .storagePressure: return "storage-pressure" case .frontend: return "frontend" case .daemonStatusChannel: return "daemon-status-channel" case .managed: return "managed" @@ -2220,6 +2283,8 @@ final class StatusStore: ObservableObject { private var managedStatusChannel: ManagedStatusChannelMonitor private var supervisorStatusChannel: SupervisorStatusChannelMonitor private var hasObservedRuntime = false + private var diskSpaceMonitor = RuntimeDiskSpaceMonitor() + private let availableDiskBytes: (URL) -> Int64? init( appGroupURL: URL?, @@ -2230,6 +2295,7 @@ final class StatusStore: ObservableObject { alertsBeforeRuntimeObserved: Bool = true, recordsHistory: Bool = true, enrollmentStoreURL: URL? = nil, + availableDiskBytes: @escaping (URL) -> Int64? = RuntimeDiskSpaceMonitor.availableBytes, now: @escaping () -> Date = Date.init ) { self.appGroupURL = appGroupURL @@ -2239,6 +2305,7 @@ final class StatusStore: ObservableObject { self.recordsHistory = recordsHistory self.runtimeFreshness = managedStatusFreshness self.enrollmentStoreOverride = enrollmentStoreURL + self.availableDiskBytes = availableDiskBytes let historyArchive = StatusHistoryPersistence.load(appGroupURL: appGroupURL) self.historyArchive = historyArchive storageHistory = historyArchive.storageSamples @@ -2262,7 +2329,7 @@ final class StatusStore: ObservableObject { let id = component.id.lowercased() return [ "frontend", "native-fskit", "fskit-frontend", "managed", - "supervisor", "daemon", "service", "backend", + "supervisor", "daemon", "service", "backend", "storage-pressure", ].contains(id) } return critical.map(\.health).max() ?? .unknown @@ -2333,6 +2400,10 @@ final class StatusStore: ObservableObject { currentActivity = updateIOActivity() enrollmentProgress = loaded.enrollmentProgress enrollmentSettings = loaded.enrollmentSettings + let capacityPaths = [appGroupURL, enrollmentStoreURL()].compactMap { $0 } + let freeBytes = capacityPaths.compactMap { availableDiskBytes($0) }.min() + let pressure = monitorsRuntime ? diskSpaceMonitor.evaluate(availableBytes: freeBytes, at: observedAt) : nil + if let pressure { components.append(pressure) } let frontend = componentStatus(aliases: ["frontend", "native-fskit", "fskit-frontend"]) let daemonChannel = monitorDaemonStatus && monitorsRuntime ? daemonStatusChannel.evaluate( @@ -2359,6 +2430,7 @@ final class StatusStore: ObservableObject { : nil let incidentUpdate = incidentTracker.evaluate( frontend: frontend, + storagePressure: pressure, managed: managed, daemonStatusChannel: daemonChannel, managedStatusChannel: managedChannel, diff --git a/platform/darwin/fskit/Host/Tests/IncidentPresentationTests.swift b/platform/darwin/fskit/Host/Tests/IncidentPresentationTests.swift index b035cd6..ec5011e 100644 --- a/platform/darwin/fskit/Host/Tests/IncidentPresentationTests.swift +++ b/platform/darwin/fskit/Host/Tests/IncidentPresentationTests.swift @@ -2,8 +2,79 @@ import Foundation import AppKit import XCTest +@MainActor +final class RecordingIncidentNotifications: IncidentNotificationSending { + var onOpen: ((String) -> Void)? + var posted: [FrontendIncident] = [] + var removed: [String] = [] + func post(_ incident: FrontendIncident) { posted.append(incident) } + func remove(_ id: String) { removed.append(id) } +} + @MainActor final class IncidentWindowLifecycleTests: XCTestCase { + func testAutomaticIncidentUsesOneNotificationAndOnlyClickOpensDetails() throws { + _ = NSApplication.shared + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let notifications = RecordingIncidentNotifications() + var shown = 0 + let presenter = IncidentWindowPresenter(appGroupURL: root, diagnosticPayload: { _ in nil }, presentWindow: { _ in shown += 1 }, notifications: notifications) + let incident = makeIncident("notification-only") + presenter.handle(.active(incident, isNew: true)) + presenter.handle(.active(incident, isNew: false)) + presenter.handle(.active(incident, isNew: true)) + XCTAssertEqual(notifications.posted.count, 1) + XCTAssertEqual(shown, 0, "background incidents must not activate a window") + notifications.onOpen?(incident.id) + XCTAssertEqual(shown, 1, "only the user's notification click opens details") + var recovered = incident + recovered.recoveredAt = Date() + presenter.handle(.recovered(recovered)) + XCTAssertEqual(notifications.removed, [incident.id]) + } + + func testNotificationDeliveryDoesNotNeedWritableAcknowledgement() { + let notifications = RecordingIncidentNotifications() + var windows = 0 + let presenter = IncidentWindowPresenter(appGroupURL: URL(fileURLWithPath: "/nonexistent-codexfold-test-root"), diagnosticPayload: { _ in nil }, presentWindow: { _ in windows += 1 }, notifications: notifications) + let incident = makeIncident("full-disk") + presenter.handle(.active(incident, isNew: true)) + presenter.handle(.active(incident, isNew: true)) + XCTAssertEqual(notifications.posted.count, 1) + XCTAssertEqual(windows, 0) + } + + func testOldNotificationClickCannotOpenAnotherIncident() throws { + _ = NSApplication.shared + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let notifications = RecordingIncidentNotifications() + var shown = 0 + let presenter = IncidentWindowPresenter(appGroupURL: root, diagnosticPayload: { _ in nil }, presentWindow: { _ in shown += 1 }, notifications: notifications) + presenter.handle(.active(makeIncident("current"), isNew: true)) + notifications.onOpen?("older") + XCTAssertEqual(shown, 0) + notifications.onOpen?("current") + XCTAssertEqual(shown, 1) + } + + func testHealthyBaselineAfterRestartRemovesPersistedNotification() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let incident = makeIncident("before-restart") + let acknowledgement = IncidentAcknowledgementStore(appGroupURL: root) + try acknowledgement.acknowledge(incident) + let notifications = RecordingIncidentNotifications() + let presenter = IncidentWindowPresenter(appGroupURL: root, diagnosticPayload: { _ in nil }, notifications: notifications) + presenter.clearAcknowledgementAfterHealthyBaseline() + XCTAssertEqual(notifications.removed, [incident.id]) + XCTAssertFalse(acknowledgement.contains(incident)) + } + func testOneWindowUpdatesWithoutRePresentingAndRecoveryClosesIt() throws { _ = NSApplication.shared let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) diff --git a/platform/darwin/fskit/Host/Tests/StatusModelTests.swift b/platform/darwin/fskit/Host/Tests/StatusModelTests.swift index 6fbbca8..a3b6a04 100644 --- a/platform/darwin/fskit/Host/Tests/StatusModelTests.swift +++ b/platform/darwin/fskit/Host/Tests/StatusModelTests.swift @@ -2,6 +2,86 @@ import Darwin import Foundation import XCTest +final class RuntimeDiskSpaceMonitorTests: XCTestCase { + @MainActor + func testActualFullVolumeCanStillPresentAndRecoverWithoutWritingStatus() throws { + guard let volume = ProcessInfo.processInfo.environment["CODEXFOLD_TEST_FULL_VOLUME"], + volume.hasPrefix("/private/tmp/codexfold-pressure-") else { throw XCTSkip("requires the small disposable full-volume fixture") } + let attributes = try FileManager.default.attributesOfFileSystem(forPath: volume) + guard let volumeBytes = (attributes[.systemSize] as? NSNumber)?.int64Value, volumeBytes < 300 << 20 else { throw XCTSkip("not the small disposable mounted volume") } + let root = URL(fileURLWithPath: volume).appendingPathComponent("swift-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: false) + defer { try? FileManager.default.removeItem(at: root) } + let filler = root.appendingPathComponent("test-only-filler") + FileManager.default.createFile(atPath: filler.path, contents: nil) + let handle = try FileHandle(forWritingTo: filler) + let block = Data(repeating: 0x78, count: 1 << 20) + var reachedFull = false + for _ in 0..<300 { + do { try handle.write(contentsOf: block) } catch { reachedFull = true; break } + } + let smallBlock = Data(repeating: 0x79, count: 4096) + for _ in 0..<512 { + do { try handle.write(contentsOf: smallBlock) } catch { break } + } + try? handle.close() + XCTAssertTrue(reachedFull) + XCTAssertLessThan(RuntimeDiskSpaceMonitor.availableBytes(at: root) ?? Int64.max, RuntimeDiskSpaceMonitor.criticalBytes) + XCTAssertThrowsError(try DurableAppGroupFile.write(Data("not enough space".utf8), to: root.appendingPathComponent("status.json"))) + let start = Date() + var monitor = RuntimeDiskSpaceMonitor() + var tracker = IncidentTracker(timeout: 10) + let first = monitor.evaluate(availableBytes: RuntimeDiskSpaceMonitor.availableBytes(at: root), at: start) + _ = tracker.evaluate(frontend: nil, storagePressure: first, supervisor: nil, at: start) + let pressure = monitor.evaluate(availableBytes: RuntimeDiskSpaceMonitor.availableBytes(at: root), at: start.addingTimeInterval(10)) + guard let alert = tracker.evaluate(frontend: nil, storagePressure: pressure, supervisor: nil, at: start.addingTimeInterval(10)) else { return XCTFail("no live alert when status publication is impossible") } + var shown = 0 + let notifications = RecordingIncidentNotifications() + let presenter = IncidentWindowPresenter(appGroupURL: root, diagnosticPayload: { _ in nil }, presentWindow: { _ in shown += 1 }, notifications: notifications) + presenter.handle(alert) + XCTAssertEqual(notifications.posted.count, 1) + XCTAssertEqual(shown, 0) + try FileManager.default.removeItem(at: filler) + let recovered = monitor.evaluate(availableBytes: RuntimeDiskSpaceMonitor.availableBytes(at: root), at: start.addingTimeInterval(11)) + guard case .recovered? = tracker.evaluate(frontend: nil, storagePressure: recovered, supervisor: nil, at: start.addingTimeInterval(11)) else { return XCTFail("space recovery did not clear live alert") } + } + + func testFullDiskAlertNeedsNoStatusPublicationAndHasStableIdentity() { + let start = Date(timeIntervalSince1970: 10_000) + var monitor = RuntimeDiskSpaceMonitor() + var tracker = IncidentTracker(timeout: 10) + let first = monitor.evaluate(availableBytes: 0, at: start) + XCTAssertNil(tracker.evaluate(frontend: nil, storagePressure: first, supervisor: nil, at: start)) + let later = monitor.evaluate(availableBytes: 0, at: start.addingTimeInterval(10)) + guard case .active(let incident, let isNew)? = tracker.evaluate(frontend: nil, storagePressure: later, supervisor: nil, at: start.addingTimeInterval(10)) else { return XCTFail("disk-full alert was silenced with no writable status channel") } + XCTAssertTrue(isNew) + XCTAssertEqual(first?.incidentID, later?.incidentID) + XCTAssertEqual(incident.reason, L10n.text(.diskSpaceExhausted)) + let stillLow = monitor.evaluate(availableBytes: 2 << 20, at: start.addingTimeInterval(11)) + XCTAssertEqual(stillLow?.health, .recovering) + if case .active(_, let isNew)? = tracker.evaluate(frontend: nil, storagePressure: stillLow, supervisor: nil, at: start.addingTimeInterval(11)) { + XCTAssertFalse(isNew, "space fluctuation must update, not reopen, the existing alert") + } + let recovered = monitor.evaluate(availableBytes: 128 << 20, at: start.addingTimeInterval(12)) + guard case .recovered? = tracker.evaluate(frontend: nil, storagePressure: recovered, supervisor: nil, at: start.addingTimeInterval(12)) else { return XCTFail("free space did not automatically recover") } + XCTAssertNil(monitor.evaluate(availableBytes: 128 << 20, at: start.addingTimeInterval(13))) + } + + func testFailedSpaceProbeDoesNotClaimRecovery() { + let start = Date() + var monitor = RuntimeDiskSpaceMonitor() + let first = monitor.evaluate(availableBytes: 0, at: start) + XCTAssertNil(monitor.evaluate(availableBytes: nil, at: start.addingTimeInterval(1))) + XCTAssertEqual(monitor.evaluate(availableBytes: 0, at: start.addingTimeInterval(2))?.incidentID, first?.incidentID) + } + + func testNativeProbeDoesNotNeedToCreateAFile() throws { + let root = FileManager.default.temporaryDirectory + XCTAssertNotNil(RuntimeDiskSpaceMonitor.availableBytes(at: root)) + XCTAssertNotNil(RuntimeDiskSpaceMonitor.availableBytes(at: root.appendingPathComponent("nonexistent-\(UUID().uuidString)/child"))) + } +} + final class CodexFoldRuntimeLocationTests: XCTestCase { func testDefaultRuntimeUsesAppGroupRoot() { let root = URL(fileURLWithPath: "/tmp/group.vip.jstar.codexfold", isDirectory: true) From 85fbcde87329c6b60fd15cbd4939555d55ecad41 Mon Sep 17 00:00:00 2001 From: King Star Date: Sun, 18 Oct 2026 00:06:00 +0800 Subject: [PATCH 4/4] fix(windows): replace repaired rollouts through directory handles --- docs/windows-integration-2026-10-18.md | 2 +- internal/mountfs/native_repair.go | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/windows-integration-2026-10-18.md b/docs/windows-integration-2026-10-18.md index 616d078..58dacae 100644 --- a/docs/windows-integration-2026-10-18.md +++ b/docs/windows-integration-2026-10-18.md @@ -10,7 +10,7 @@ 1. 共享文件自动合并后保留两边的行为:namespace 路径记录与 Windows 路径别名、前台维护让行、Windows 文件共享、删除证明、目录同步、状态心跳和恢复唤醒。 2. Windows 独立折叠服务使用自己的策略,不能再只用内置 loop 的开关判断替换文件后台折叠。整合后复用 external worker 的有效策略;暂停或内置暂停确认过期时不折叠。macOS/Linux 不因此改用 Windows worker 策略。 -3. Windows 修复原生文件时通过已有共享打开入口保留 delete sharing,避免修复器自己的 reader 阻止替换。新增 Windows 实际运行测试验证旧 reader 保留旧内容、新路径读到修复内容。 +3. Windows 修复原生文件时通过已有共享打开入口保留 delete sharing,并使用 `os.Root.Rename` 的目录句柄相对替换。新增 Windows 实际运行测试验证旧 reader 保留旧内容、新路径读到修复内容。首次整合 CI 确实发现普通 `os.Rename` 在已打开目标上返回 Access denied;保留该测试并修正替换实现,没有跳过或放宽断言。 4. Windows 可写文件由 Go 报告为 `0666`,不伪装成 Unix 的 `0600`。应急预留只在相应平台模式、固定大小、完整 magic/零填充和文件身份全部匹配时释放,未知材料仍保留。 5. native repair 的替换完成状态不确定时保留已隔离字节,不能在 rename 可能已成功但目录同步失败后删除恢复材料。 6. 修正两处 Go 格式;Windows CI 保持保护规则要求的 `windows-compile` 名称,同时实际执行专项测试与新接缝回归,没有删除或降低原测试断言。 diff --git a/internal/mountfs/native_repair.go b/internal/mountfs/native_repair.go index 31a9db9..55eb632 100644 --- a/internal/mountfs/native_repair.go +++ b/internal/mountfs/native_repair.go @@ -337,7 +337,14 @@ func rewriteNativeRollout(ctx context.Context, filePath string, original os.File if !os.SameFile(original, after) || after.Size() != original.Size() || !after.ModTime().Equal(original.ModTime()) { return cleanup(fmt.Errorf("native rollout changed during repair: %s", filePath)) } - if err := os.Rename(temporary, filePath); err != nil { + parent, err := os.OpenRoot(directory) + if err != nil { + return cleanup(err) + } + defer parent.Close() + // Root.Rename uses handle-relative POSIX replacement on Windows as well, + // so readers that allowed delete sharing retain the old file contents. + if err := parent.Rename(filepath.Base(temporary), filepath.Base(filePath)); err != nil { return cleanup(err) } return syncDirectory(directory)