diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dfae646..c196f38 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -70,8 +70,12 @@ jobs: args: check windows-compile: + # Preserve the required-check name while expanding its coverage. name: windows-compile 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 @@ -80,7 +84,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 ./internal/sessionns ./internal/vfs ./internal/storage -run 'TestMonitor|TestPolicy|TestDashboard|TestHistory|TestIncidents|TestUnreadable|TestPeriodicEnrollment|TestEnrollmentWorker|TestPersistentEnrollment|TestWindows|TestFuse|TestPublishedLogicalBytes|TestNamespaceFolding|TestReadReserve' -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..6671917 100644 --- a/.gitignore +++ b/.gitignore @@ -6,10 +6,12 @@ /*.sqlite-shm /*.sqlite-wal *.prof +*.profraw *.test .DS_Store .worktrees/ /.tmp/ +/.tmp-test-windows-runtime/ # Xcode build products and derived data platform/darwin/fskit/build/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 67839d2..c23f0b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,66 @@ pre-1.0, so a minor version may contain compatibility changes. ## [Unreleased] +### Added + +- Windows WinFsp drive hosting, persistent SCM folding service, native tray with + a WebView2 dashboard, policy controls, batch progress, measured space accounting, + aggregate history and diagnostics. Activation and ordinary-storage recovery + scripts retain the original namespace and verified recovery materials. +- Windows resident-frontend/storage-engine separation with paused-policy and + open-handle checks, verified candidate images and failed-candidate rollback. + Legacy installations need an offline upgrade before live engine replacement; + frontend updates still require an offline operation. +- Windows CI now executes the focused adapter, service, worker, tray and shared + integration tests in addition to compiling every package. The required check + keeps its existing `windows-compile` name. This is not a full Desktop GUI or + power-loss qualification. + +### 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. +- Replacement-file background folding follows the effective external-worker + policy on Windows, including the fresh paused-builtin acknowledgement. Native + repair readers allow delete sharing there, and the read reserve recognizes + Windows writable file modes while retaining identity and exact-content checks. +- 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..ae20e33 100644 --- a/README.md +++ b/README.md @@ -18,26 +18,41 @@ Client version and build identity are diagnostic metadata only. Unknown or newly The requirements and release gates for normal JSONL paths backed transparently by shared storage are defined in [the transparent filesystem product contract](docs/superpowers/specs/2026-07-11-transparent-session-filesystem-design.md). No release may claim `随点随开`, transparent session access, or production-ready virtual sessions before the platform-specific gates in that contract pass. -| Platform | Adapter | Evidence in this release | Readiness | +| Platform | Adapter | Evidence in current source | Readiness | |---|---|---|---| | 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: - macOS now targets an Apple-native Swift FSKit extension connected over a versioned Unix-domain-socket protocol to the Go CodexFold daemon. The signed 2026-07-23 build 102 App/extension and its then-current helper historically passed the isolated mounted-operation, exact-byte, cache-coherency, performance, crash/host-restart, rollback, and real Codex CLI/Desktop matrix. That evidence does not automatically transfer to the current worktree candidate. -- 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. +- FSKit source metadata is `0.4.0 (116)`. Dated local reports below identify the exact installed helper/UI subsets and their checks; historical build 102 client evidence does not automatically qualify the complete combined source. - 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. +- The Windows contributor reports 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 full Desktop GUI, reboot/power loss and SYSTEM engine-split qualification remain unverified. See the [Windows preview guide](docs/windows-preview.md) for that tested revision and its limits; the combined source still needs its own target-machine checks. +- Production routing remains opt-in. The dated macOS reports below record authorized local helper/UI updates, real mounted checks and continuity observations. The complete combined macOS/Windows source is not installed by this integration, and those subset observations do not establish a new combined-binary Desktop or fault-recovery result. - 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. +- This source integration does not install, stop, restart or signal the running CodexFold service, FSKit App/extension or Codex clients. Production installation and lifecycle actions remain separate, explicitly authorized operations. 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. +The [Windows integration record](docs/windows-integration-2026-10-18.md) describes +how the local fixes and Windows preview are combined without dropping either. + ## Install Install the versioned preview with Go: 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 0000000..a27be91 Binary files /dev/null and b/cmd/codexfold-tray/assets/codexfold-attention.ico differ diff --git a/cmd/codexfold-tray/assets/codexfold.ico b/cmd/codexfold-tray/assets/codexfold.ico new file mode 100644 index 0000000..993278b Binary files /dev/null and b/cmd/codexfold-tray/assets/codexfold.ico differ diff --git a/cmd/codexfold-tray/assets/generate.go b/cmd/codexfold-tray/assets/generate.go new file mode 100644 index 0000000..130dc04 --- /dev/null +++ b/cmd/codexfold-tray/assets/generate.go @@ -0,0 +1,100 @@ +//go:build ignore + +// Draw the Windows storage glyph at multiple resolutions without external assets. +// Run from cmd/codexfold-tray: go run assets/generate.go +package main + +import ( + "bytes" + "encoding/binary" + "image" + "image/color" + "image/png" + "math" + "os" +) + +func rounded(x, y, left, top, right, bottom, radius float64) bool { + dx := math.Max(math.Max(left+radius-x, 0), x-(right-radius)) + dy := math.Max(math.Max(top+radius-y, 0), y-(bottom-radius)) + return x >= 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 0000000..c2ec89f Binary files /dev/null and b/cmd/codexfold-tray/rsrc_windows_amd64.syso differ diff --git a/cmd/codexfold-tray/rsrc_windows_arm64.syso b/cmd/codexfold-tray/rsrc_windows_arm64.syso new file mode 100644 index 0000000..09272d4 Binary files /dev/null and b/cmd/codexfold-tray/rsrc_windows_arm64.syso differ 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/docs/windows-integration-2026-10-18.md b/docs/windows-integration-2026-10-18.md new file mode 100644 index 0000000..58dacae --- /dev/null +++ b/docs/windows-integration-2026-10-18.md @@ -0,0 +1,34 @@ +# 本机修复与 Windows 适配整合(2026-10-18) + +## 来源与上传顺序 + +- 本机完整源码首先整理为签名提交 `783e231` 并上传 `origin/main`:76 个文件,保留会话路径替换、前台访问优先、删除清理隔离、超时诊断、native repair、满盘保护和 macOS 通知等改动。 +- Windows 来源为 [PR #2](https://github.com/samekind/codexfold/pull/2),保留 `5749a3b`、`0361d19` 原提交及作者记录。整合使用普通 merge,不覆盖或改写任何一方历史。 +- 原始本机文档与未提交材料另保存在忽略的 `.tmp/merge-windows-20261017/`;公开文档使用 home-relative / repo-relative 路径。性能文件 `*.profraw`、本地证据、会话数据、签名材料和安装产物不上传。 + +## 整合时额外处理 + +1. 共享文件自动合并后保留两边的行为:namespace 路径记录与 Windows 路径别名、前台维护让行、Windows 文件共享、删除证明、目录同步、状态心跳和恢复唤醒。 +2. Windows 独立折叠服务使用自己的策略,不能再只用内置 loop 的开关判断替换文件后台折叠。整合后复用 external worker 的有效策略;暂停或内置暂停确认过期时不折叠。macOS/Linux 不因此改用 Windows worker 策略。 +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` 名称,同时实际执行专项测试与新接缝回归,没有删除或降低原测试断言。 + +## 验证与边界 + +本机材料保存在 `.tmp/merge-windows-20261017/`: + +- `local-go-test.log`、`local-repair-test.log`:本机整理版全量 Go 与修复保留回归。 +- `merged-go-test.log`、`merged-final-go-test.log`:整合源码全量 Go 测试。 +- `integration-regression.log`:有效外部策略、预留空间和 native repair 的针对性回归。 +- `merged-race.log`:共享 CLI、文件系统、存储、VFS、pack、协议、状态与 tray 的 race 测试。 +- `merged-final-windows-compile.log`、`merged-final-winfsp-compile.log`:Mac 上通过 `-exec /usr/bin/true` 仅编译全部 Windows 测试,不将其计作 Windows 执行结果。 +- `merged-swift-tests.log`:macOS UI 测试;本次没有重新创建满盘磁盘,该项条件测试跳过。此前实际 APFS 满盘结果见 2026-10-17 报告,不能把旧结果算成整合源码的新一次满盘实测。 +- Windows amd64/arm64 WinFsp CLI 和 tray、Linux amd64 CLI 构建,Go vet、native cache、公开材料检查与脱敏 secret scan。 + +最终 Windows 专项运行、Linux/macOS 回归、race 与构建结果,以整合提交的 GitHub Actions 为准。作者此前 Windows 实测与本轮整合后的 CI 是不同证据;没有在本轮声称重新运行完整 Windows Desktop、重启/断电或真实模型故障矩阵。 + +本机整理提交上传时 GitHub 使用仓库既有管理员 bypass 接受了 main 更新。随后 Windows 整合走现有 PR;用户明确批准仅在整合版 CI 全部通过后使用管理员权限完成合并。没有修改 branch protection、required checks 或审批规则。 + +本轮只整理、上传、合并源码,不重装当前后台、UI 或 FSKit 扩展,不重启客户端,不切换生产挂载,不改变自动折叠设置,也不创建新的发布 tag。 diff --git a/docs/windows-preview.md b/docs/windows-preview.md new file mode 100644 index 0000000..266ea07 --- /dev/null +++ b/docs/windows-preview.md @@ -0,0 +1,280 @@ +# Windows preview + +Build and test from PowerShell in the repository root. The filesystem needs +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: + +```powershell +node internal/tray/testdata/windows-smoke.cjs dist/codexfold-tray.exe .tmp/tray-screenshots +``` + +Fixture data and the temporary WebView2 profile stay under the checkout's `.tmp` +directory and are removed after the check. `CODEXFOLD_TEST_TEMP` may select another +temporary directory. Screenshots requested by the command remain for inspection. + +The script creates two synthetic sessions and an independent SQLite database +under `.tmp/windows-use/run-*`. It checks fold/restore hashes, packed storage, +real mounted reads, flushed appends, service termination and remount, rejected +writes while unmounted, and a byte-identical rollback. It keeps one mounted +session and opens the tray for that test store. It does not access the default +Codex home. Each run keeps its original files, restored files and logs. + +To try copies of this computer's existing sessions instead: + +```powershell +./scripts/test-windows-use.ps1 -Mode Mount -RealSessionCopies -KeepRunning +``` + +This reads the default Codex home (or the existing `CODEX_HOME`) without changing +its database, routes or session files. It selects two regular session files +inside that home, each no larger than 64 MiB and unchanged for at least an hour, +then copies them into a new `run-local-*` directory with an independent test +database. Source hashes are checked during copying and after the test. Login +credentials, configuration and the original database are not copied. These test +files still contain real session text; keep the local test directory private. + +Storage figures include retained sources, backups, loose objects and packs. +This test deliberately retains recovery copies, so a smaller encoded data set +does not mean the test directory has reclaimed disk space. + +The active test paths and PID are in `.tmp/windows-use/current.json`. Open the +printed mount directory to inspect the remaining `.jsonl` file. This isolated +namespace exposes managed sessions; it is not a general-purpose writable drive. +The tray shows aggregate health, storage and I/O without reading session text. + +Stop the active test filesystem with: + +```powershell +./scripts/stop-windows-test.ps1 +``` + +Closing or quitting the tray leaves the filesystem running. The stop script +checks the saved process identity before stopping it and retains the data. + +The complete-copy canary also uses the installed, unmodified Codex 0.159.2 +app-server. It has passed resume, history reads, recorder writes, fork, +archive/unarchive (including the compressed managed thread), and deletion of a +separate managed fork with a completed physical purge receipt. SCM installation, +graceful stop/start, unchanged SHA-256 across restart, and client access after +restart have been exercised. The tray receives live health and storage metrics. + +`scripts/windows-model-smoke.cjs` also creates a short synthetic conversation +using the installed client and a real `gpt-6.1-sol` model at medium effort. Its +first reply, continuation after fold/pack/canonical migration, and continuation +in an already-open client after an unexpected filesystem-child exit all passed. +SCM restarted the service automatically; both committed rollout hashes matched +before and after that failure, and a fresh client recovered all three completed +turns. Packed storage verified 386 objects and both manifests with zero issues. +This crash test ran between turns, not during an in-flight write. The test uses +existing ChatGPT authentication through the experimental external-token protocol +in memory; it does not copy login files or transmit copied production history. + +Canonical Windows use requires an SCM-managed local WinFsp drive with an NTFS store. +The SCM host runs a separate filesystem child: WinFsp's FUSE loop has its own +service runner, so hosting it directly inside `svc.Run` prematurely unmounts. +The parent closes its control pipe to request a normal unmount. The global volume +restricts access to the Codex home owner and LocalSystem. Directory junctions and +SQLite normalization preserve the original home paths, including Windows +verbatim drive aliases. Plain directory mounts and foreground network drives +are insufficient for the canonical client namespace. + +To prepare full local use, build these binaries and preview the paths: + +```powershell +go build -tags winfsp -o dist/codexfold.exe ./cmd/codexfold +go build -ldflags='-H=windowsgui' -o dist/codexfold-tray.exe ./cmd/codexfold-tray +go build -o dist/codexfold-home-backup.exe ./scripts/windows-full-fixture.go +./scripts/enable-windows-local.ps1 +``` + +After exiting **all Codex Desktop and CLI clients**, run the following in an +administrator PowerShell window. The script creates a verified session-tree and +consistent SQLite recovery copy without rebasing its original routes, installs +the binaries in Program Files, activates the namespace, preflights native files, +and starts automatic enrollment (one stable session every five minutes, with a +one-hour unchanged window). It retains existing configuration and login files. + +```powershell +./scripts/enable-windows-local.ps1 -Apply +``` + +For the local checkout, double-click `启用正式使用.cmd` after exiting the clients. +It opens the administrator confirmation and runs the same activation script. +If a managed Codex daemon remains, stop it yourself with +`codex app-server daemon stop`, then retry the launcher. It never closes Codex +processes. Installation logs are kept in `%LOCALAPPDATA%\CodexFold\logs`; the +console reports the verified recovery-copy path and stays open for review. + +The default mount is `V:`; use `-MountDrive` to select a different unused drive. +For an alternate home, pass `-CodexHome` consistently to both scripts. The tray +starts with Windows login; the filesystem starts as a Windows service. Reopen +Codex only after the script reports success. + +To restore ordinary directories, exit the clients again and run: + +```powershell +./scripts/restore-windows-local.ps1 -Apply +``` + +The equivalent local entry point is `恢复普通存储.cmd`, also after exiting clients. + +This restores the latest visible bytes of each managed session, stops the +service, removes the canonical links, and disables service/tray autostart. +Recovery copies and the store remain on disk. The recovery copy is intentionally +retained, so full local use does not immediately reclaim its space. + +For an already mounted Windows home, the compression worker can be updated +without stopping the filesystem or Codex: + +```powershell +go build -tags winfsp -ldflags='-H=windowsgui' -o dist/codexfold-enroll.exe ./cmd/codexfold +./scripts/enable-windows-enrollment-service.ps1 -Apply +``` + +This installs `com.codexfold.enroll` as a delayed automatic Windows service; +the old worker script remains a compatible entry point. Installation requests +administrator elevation, replaces only the enrollment executable, and verifies +that the running filesystem service PID has not changed. The executable lives +under `%ProgramFiles%\CodexFold\Enrollment`, and its administrator-owned binding +and logs live under `%ProgramData%\CodexFold\Enrollment`. The binding explicitly +records the user's SID, home, store, native root and mount: SYSTEM's profile is +never a default data location. The owned login shortcut is removed after a +successful handover; the tray remains a login companion. + +The existing `worker-policy.json` is preserved, including a user's disabled +setting. The service has an exclusive process lock and an independent progress +file. Missing or malformed policy cannot fall back to stale process defaults. +The persisted enabled setting remains separate from effective readiness: +missing mount, inactive namespace, stale paused acknowledgement or a re-enabled +built-in loop publish `waiting-filesystem` and prevent work. There is no five-minute +startup deadline. User pause keeps the service available for later re-enable; +SCM stop/shutdown cancels work without changing the policy. Observation records +and existing pack/migration/retirement recovery proofs are reused on subsequent +cycles rather than fabricated or discarded. + +Both services use 5/15/60-second SCM failure restart actions. Enrollment and its +children run below normal priority. A Windows job with kill-on-close contains +the host and its children so a failed host cannot leave pack/migrate processes +running alongside its replacement. The restore script stops and disables the +owned enrollment service before changing storage. The tray distinguishes waiting +for the filesystem, a stopped worker, invalid settings and a user-disabled policy. +Missing Windows rollout paths are skipped individually while mount, +namespace, active-writer, and stability checks remain required. Freshly observed +sessions still need the configured one-hour observation window. +For a selected idle session using a Windows verbatim drive path, the worker +checks file identity, the observed fingerprint, and content hashes before +normalizing only that row's equivalent path through a guarded SQLite update. +The file stays in place, allowing the existing mounted host to recognize it. + +The tray separates published-pack compression reduction from retained/pending +occupancy and current net saving. Its background read-only inventory measures +physical usage, while the verified published recovery archive supplies the +compression baseline; unpublished manifests do not inflate compression saving. +The current generation is scanned separately so shared hardlinks cannot make +its compressed footprint disappear through whole-store traversal order. Negative +net saving is displayed as extra occupancy rather than clamped to zero. These +measurements do not authorize deletion or promise that every retained byte is +immediately reclaimable. + +The Windows tray now edits the persisted automatic-folding policy: enable/pause, +check interval, idle window, archived-session scope and automatic/fixed batch size. +It displays the real batch completion, last observed queue and next check time. +Missing or invalid policy can be repaired explicitly into a paused configuration; +concurrent changes are refused rather than overwritten. Aggregate chart samples and +confirmed incidents persist in `enrollment/ui-history-v1.json` for 30 days with +age-based downsampling. Gaps while the tray is quit are not filled in. Incident +details include impact, recovery time, recommendations and the relevant logs. +The diagnostics dialog copies or exports JSON with settings, aggregate status, +history and build identity; it excludes session contents and authentication data. +The application-owned window caption, buttons, switches, progress tracks, range +selector, selectable options, menus and diagnostic sheets share the same Liquid +Glass material system. Light/dark themes and reduced-motion, reduced-transparency +and increased-contrast settings are supported. The selector remains a standard +HTML select with styled in-view picker surfaces. Tray context actions open this +same glass menu in the status window. Windows-owned save dialogs, notifications +and startup-failure dialogs retain their native appearance. + +The material and control proportions were reworked against Apple's published +[iOS 27 design resources](https://developer.apple.com/design/resources/), including +the [iOS 27 UI Kit](https://www.sketch.com/s/04c24d8b-38fb-4afb-8836-36617e022f02) +and the [iOS and iPadOS 27 Figma kit](https://www.figma.com/community/file/1651309003795292092/ios-and-ipados-27). +The Windows renderer uses neutral glass, 64-by-28 switches, capsule selections, +and rounded menu surfaces. Size-specific SVG displacement maps refract the +backdrop at rounded edges while leaving text sharp. This is an independent +WebView2 implementation, not UIKit's native material renderer; Apple fonts, +symbols, wallpapers and kit files are not distributed with the application. + +New Windows services keep WinFsp in a resident frontend and run the existing storage +engine in a separate child through a local named pipe. RPC uses a private launch +token, and pipe access excludes network logons; a SYSTEM frontend accepts update +control only from SYSTEM and administrators. Engine images use verified SHA-256 +names beneath the installed binary's protected `Core` directory. Startup reloads +the committed image binding. A live update drains current calls, refuses open file +handles, requires folding to be paused, stops the old engine cleanly, and verifies +the replacement protocol/PID/build before committing. A failed candidate restores +the prior engine; ambiguous writes are returned as errors and are never replayed. +The mounted health identity retains its nonce and reports the current engine build. + +An existing combined Windows mount needs one offline upgrade to activate this +separation. After exiting all Codex clients, build the candidate and run the +following from an administrator PowerShell. Omit `-Apply` to preview the exact +installation paths and hashes. The script verifies both service bindings, stops +only the owned folding worker without changing its policy, uses the existing +verified binary-update/rollback transaction, and resumes the worker afterward. + +```powershell +go build -tags winfsp -o dist/codexfold.exe ./cmd/codexfold +./scripts/upgrade-windows-core.ps1 -Apply +``` + +Subsequent engine updates preserve the mount and resident frontend. Pause folding +in the tray and wait for its paused acknowledgement, then run as administrator: + +```powershell +./dist/codexfold.exe fs service update-daemon-live ./dist/codexfold.exe --definition "$env:ProgramData\CodexFold\service.json" --apply --json +``` + +An update waits for existing file handles to close; it does not close clients or +their files. The resident WinFsp frontend itself still needs an offline update. +Isolated real WinFsp checks verified managed reads, flushed appends, the open-handle +guard, unchanged mount nonce/frontend PID during engine replacement, failed +candidate rollback and subsequent appends. The production mount has not yet been +switched to this architecture. Native clipboard/save-dialog interaction and the +SYSTEM version of the new engine split remain outside those isolated checks. + +The production mount has accepted a manually selected archived session without +restarting the filesystem, with its bytes verified against the pre-activation +backup. Windows policy/status readers allow atomic replacement; native snapshot +retirement uses the existing Windows directory-sync handling. This does not add +a sudden-power-loss durability guarantee. + +The local activation completed after the clients were exited, with a verified +343-file recovery copy. The worker update and archived-session migration then +completed while Codex stayed open, without restarting the mounted filesystem. +The persistent worker's SCM stop/shutdown paths, startup waiting, hot pause/resume, +policy retention and isolated child-job teardown have regression coverage. On +the local installation, a safely paused enrollment host was terminated and SCM +automatically started a new instance; the original filesystem PID and an archived +rollout hash remained unchanged. The tray's new states were checked in real +WebView2 at desktop, narrow and short window sizes. Windows reboot, sudden machine power +loss, failure during an in-flight turn, and the full Desktop GUI remain +unvalidated. The whole +repository suite still includes failures in macOS-specific paths, Unix file +modes, and privilege-dependent symlink fixtures; Windows-focused checks and the +session-deletion suite pass. These results establish the tested preview flows, +not complete cross-platform release qualification. diff --git a/go.mod b/go.mod index 9ecbd8b..90b117e 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,11 @@ module github.com/samekind/codexfold go 1.26 require ( + github.com/Microsoft/go-winio v0.6.2 + github.com/jchv/go-webview2 v0.0.0-20260205173254-56598839c808 github.com/klauspost/compress v1.19.0 github.com/spf13/cobra v1.10.2 + github.com/spf13/pflag v1.0.9 github.com/winfsp/cgofuse v1.6.0 golang.org/x/sys v0.36.0 modernc.org/sqlite v1.40.1 @@ -14,10 +17,10 @@ require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - github.com/spf13/pflag v1.0.9 // indirect golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect modernc.org/libc v1.66.10 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/go.sum b/go.sum index f642994..ad88398 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= @@ -7,6 +9,10 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jchv/go-webview2 v0.0.0-20260205173254-56598839c808 h1:ftnsTqIUH57XQEF+PnXX9++nlHCzdkuB5zbWyMMruZo= +github.com/jchv/go-webview2 v0.0.0-20260205173254-56598839c808/go.mod h1:rWifBlzkgrvd7zUqlfq91sWt3473OikgnglnIILx/Jo= +github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ= +github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs= github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ= github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -29,6 +35,8 @@ golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210218145245-beda7e5e158e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= diff --git a/internal/cli/fs.go b/internal/cli/fs.go index 26f6353..ac276c2 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 } @@ -584,8 +602,11 @@ func newFSServeCommand() *cobra.Command { } nativeRoot = filepath.Clean(nativeRoot) } - if frontend != "fuse" && frontend != "native-fskit" { - return errors.New("filesystem frontend must be fuse or native-fskit") + if frontend != "fuse" && frontend != "native-fskit" && frontend != "windows-proxy" && frontend != "windows-engine" { + return errors.New("filesystem frontend must be fuse, native-fskit, windows-proxy or windows-engine") + } + if strings.HasPrefix(frontend, "windows-") && runtime.GOOS != "windows" { + return errors.New("Windows engine frontends are available only on Windows") } if frontend == "native-fskit" { if runtime.GOOS != "darwin" { @@ -616,6 +637,16 @@ func newFSServeCommand() *cobra.Command { return err } } + if frontend == "windows-proxy" && apply { + return serveWindowsResidentHost(command, home, store, mount, foreground) + } + if frontend == "windows-engine" && apply { + stopEngine, err := prepareWindowsStorageEngine(command) + if err != nil { + return err + } + defer stopEngine() + } states, _, err := vfs.DiscoverSessionStatesDetailedReadOnly(store) if err != nil && !(errors.Is(err, os.ErrNotExist) && !apply) { return err @@ -690,6 +721,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 +739,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 @@ -776,11 +820,17 @@ func newFSServeCommand() *cobra.Command { known := make(map[string]uint64) knownRoutes := make(map[string]string) knownPacks := make(map[string]string) + pendingRecovery := make(map[string]bool) + recoveryRequested := make(chan struct{}, 1) 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) @@ -788,11 +838,14 @@ func newFSServeCommand() *cobra.Command { managedStatusPath := "" if frontend == "native-fskit" { managedStatusPath = service.FSKitStatusPath(nativeFSKitResource, "managed") + } else if runtime.GOOS == "windows" { + managedStatusPath = filepath.Join(store, "fs", "status", "managed.json") } managedStatus := newManagedStatusReporterForStore(managedStatusPath, store, mount, nativeFSKitResource, func(err error) { _, _ = 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 +854,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 } @@ -811,6 +864,7 @@ func newFSServeCommand() *cobra.Command { delete(known, sessionID) delete(knownRoutes, sessionID) delete(knownPacks, sessionID) + delete(pendingRecovery, sessionID) observation := managedReloadObservation{ Sequence: nextManagedObservationSequence(), Fatal: fmt.Errorf("open managed session %s: %w", sessionID, err), @@ -842,6 +896,12 @@ func newFSServeCommand() *cobra.Command { if err == nil { known[state.SessionID] = state.Generation knownPacks[state.SessionID] = resolver.Generation() + // An exact read-only open still needs background recovery. + pendingRecovery[state.SessionID] = true + select { + case recoveryRequested <- struct{}{}: + default: + } } else { recordManagedLoaderFailure(sessionID, started, err) } @@ -941,11 +1001,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 +1180,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 +1201,9 @@ func newFSServeCommand() *cobra.Command { } } route, exists := routes[state.SessionID] + if exists && route == "" { + continue + } if !exists { missingRoutes = append(missingRoutes, state.SessionID) } @@ -1156,7 +1224,7 @@ func newFSServeCommand() *cobra.Command { continue } generation, generationKnown := known[state.SessionID] - if generationKnown && generation == state.Generation && knownPacks[state.SessionID] == currentPack { + if !pendingRecovery[state.SessionID] && generationKnown && generation == state.Generation && knownPacks[state.SessionID] == currentPack { if knownRoutes[state.SessionID] == route { continue } @@ -1177,9 +1245,10 @@ func newFSServeCommand() *cobra.Command { return observation } upsertTime += time.Since(stepStarted) + delete(pendingRecovery, state.SessionID) continue } - if known[state.SessionID] == state.Generation && knownPacks[state.SessionID] == currentPack { + if !pendingRecovery[state.SessionID] && known[state.SessionID] == state.Generation && knownPacks[state.SessionID] == currentPack { continue } managed, resolver, err := openState(state) @@ -1193,6 +1262,7 @@ func newFSServeCommand() *cobra.Command { } known[state.SessionID] = managed.State().Generation knownPacks[state.SessionID] = resolver.Generation() + delete(pendingRecovery, state.SessionID) } if profileStartup { _, _ = fmt.Fprintf(command.ErrOrStderr(), "startup-profile loop retirement_sync=%s upsert=%s open=%s attach=%s ack=%s states=%d\n", retirementSyncTime, upsertTime, upsertProfile.Open, upsertProfile.Attach, upsertProfile.Ack, len(states)) @@ -1234,6 +1304,9 @@ func newFSServeCommand() *cobra.Command { break } } + if ctx.Err() != nil { + return ctx.Err() + } managedStatus.Observe(observation) return observation.Err() } @@ -1250,12 +1323,16 @@ func newFSServeCommand() *cobra.Command { }) var storageStatusDone <-chan struct{} var activityCounter *mountfs.IOActivityCounter - if frontend == "native-fskit" { + if frontend == "native-fskit" || runtime.GOOS == "windows" { + storageStatusPath := service.FSKitStatusPath(nativeFSKitResource, "storage") + if runtime.GOOS == "windows" { + storageStatusPath = filepath.Join(store, "fs", "status", "storage.json") + } storageStatusDone = startStorageStatusReporter( ctx, command.ErrOrStderr(), store, - service.FSKitStatusPath(nativeFSKitResource, "storage"), + storageStatusPath, 10*time.Minute, storageMaintenanceDone, storageStatusRefresh, @@ -1265,26 +1342,34 @@ func newFSServeCommand() *cobra.Command { activityCounter = &mountfs.IOActivityCounter{} } runtimeMemoryMaintenanceDone := startRuntimeMemoryMaintenance(ctx, filesystem) + namespaceMaintenanceDone := startNamespaceRetirement(ctx, filesystem, store, func() bool { + return namespaceFoldingEnabled(flags, enrollmentInterval, runtime.GOOS == "windows") + }, command.ErrOrStderr()) watcherDone := make(chan struct{}) go func() { defer close(watcherDone) - runManagedReloadLoopAfterInitial(ctx, time.Second, 10*time.Second, initialManagedReloadErr, load, func(err error) { + runManagedReloadLoopAfterInitial(ctx, time.Second, 10*time.Second, initialManagedReloadErr, recoveryRequested, load, func(err error) { reportManagedReloadSafe(err) }) }() var mountErr error - if frontend == "native-fskit" { + if frontend == "windows-engine" { + mountErr = serveWindowsStorageEngine(ctx, command, filesystem, startEnrollment) + } else if frontend == "native-fskit" { mountErr = mountfs.ServeNativeFSKit(ctx, filesystem, mountfs.NativeFSKitServerOptions{ 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, }) } else { + stopStatus := startPlatformDaemonStatus(ctx, store, mount, activityCounter, command.ErrOrStderr()) startEnrollment() - mountErr = mountfs.Mount(ctx, mountfs.HostOptions{MountPoint: mount, Filesystem: filesystem, Foreground: foreground, OperationRecorder: operationRecorder}) + mountErr = mountfs.Mount(ctx, mountfs.HostOptions{MountPoint: mount, StorageRoot: store, NamespaceRoot: home, Filesystem: filesystem, Foreground: foreground, OperationRecorder: operationRecorder, Activity: activityCounter}) + stopStatus() } cancel() <-managedHeartbeatDone @@ -1294,6 +1379,7 @@ func newFSServeCommand() *cobra.Command { <-storageStatusDone } <-runtimeMemoryMaintenanceDone + <-namespaceMaintenanceDone <-enrollmentDone var nativeWatcherErr error if nativeWatcherDone != nil { @@ -1303,6 +1389,9 @@ func newFSServeCommand() *cobra.Command { } } sessionCloseErr := filesystem.CloseSessions() + if frontend == "windows-engine" && errors.Is(mountErr, context.Canceled) { + mountErr = nil + } return errors.Join(mountErr, nativeWatcherErr, sessionCloseErr) }, } @@ -1313,7 +1402,7 @@ func newFSServeCommand() *cobra.Command { command.Flags().BoolVar(&foreground, "foreground", true, "Keep the FUSE host in the foreground") command.Flags().BoolVar(&canonicalNamespace, "canonical-namespace", false, "Expose sessions and archived_sessions as a shared virtual namespace") command.Flags().StringVar(&nativeRoot, "native-root", "", "Backing root for unmanaged canonical session files") - command.Flags().StringVar(&frontend, "frontend", "fuse", "Filesystem frontend: fuse or native-fskit") + command.Flags().StringVar(&frontend, "frontend", "fuse", "Filesystem frontend: fuse, native-fskit, windows-proxy or windows-engine") command.Flags().StringVar(&nativeFSKitSocket, "fskit-socket", "", "Native FSKit daemon Unix socket; defaults to a short per-home path in /private/tmp") command.Flags().StringVar(&nativeFSKitResource, "fskit-resource", "", "Native FSKit resource; defaults to the security-scoped /fs/native-fskit directory") command.Flags().StringVar(&operationTracePath, "operation-trace", "", "Absolute path for sanitized FUSE operation names") @@ -1434,7 +1523,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 +1535,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) { @@ -1604,19 +1710,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,8 +1732,13 @@ func runManagedReloadLoopWithDelay(ctx context.Context, delayState managedReload } return case <-timer.C: + case <-wake: + timer.Stop() } err := load() + if ctx.Err() != nil { + return + } report(err) delay = delayState.next(err, time.Now()) } @@ -1878,7 +1989,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 +2012,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 +2728,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 +3018,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 +3034,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 +3269,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..28ff665 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 } @@ -267,6 +274,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) { @@ -322,6 +336,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 +479,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 +524,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 +533,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 +556,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 +578,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 +639,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 +674,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 +720,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 +755,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 +794,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 +817,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 +870,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 { @@ -900,6 +977,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 +1044,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_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_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_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_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.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_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_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_namespace_retirement.go b/internal/cli/fs_namespace_retirement.go new file mode 100644 index 0000000..22fe421 --- /dev/null +++ b/internal/cli/fs_namespace_retirement.go @@ -0,0 +1,99 @@ +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 namespaceFoldingEnabled(flags enrollmentFlags, interval time.Duration, allowExternal bool) bool { + if resolveEnrollmentControl(flags, interval).Enabled { + return true + } + if !allowExternal { + return false + } + // Windows can move the policy to its independent worker. Reuse that + // worker's effective policy, including the fresh paused-builtin check. + flags.externalWorker = true + return resolveEnrollmentControl(flags, interval).Enabled +} + +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..3c027a2 --- /dev/null +++ b/internal/cli/fs_namespace_retirement_test.go @@ -0,0 +1,189 @@ +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 TestNamespaceFoldingFollowsEffectiveExternalPolicy(t *testing.T) { + store := t.TempDir() + flags := enrollmentFlags{storeDir: store} + 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) + } + control.Enabled = true + if err := enroll.SaveControl(enroll.WorkerControlPath(store), control); err != nil { + t.Fatal(err) + } + if namespaceFoldingEnabled(flags, 0, false) { + t.Fatal("external policy changed a platform without external-worker support") + } + if !namespaceFoldingEnabled(flags, 0, true) { + t.Fatal("enabled external policy stranded native replacements") + } + if err := enroll.SaveProgress(enroll.ProgressPath(store), enroll.Progress{Phase: enroll.PhaseDisabled, UpdatedAt: time.Now().Add(-time.Minute)}); err != nil { + t.Fatal(err) + } + if namespaceFoldingEnabled(flags, 0, true) { + t.Fatal("stale builtin acknowledgement enabled namespace folding") + } + if err := enroll.SaveProgress(enroll.ProgressPath(store), enroll.Progress{Phase: enroll.PhaseDisabled, UpdatedAt: time.Now()}); err != nil { + t.Fatal(err) + } + control.Enabled = false + if err := enroll.SaveControl(enroll.WorkerControlPath(store), control); err != nil { + t.Fatal(err) + } + if namespaceFoldingEnabled(flags, 0, true) { + t.Fatal("paused external policy enabled namespace folding") + } +} + +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..1b49f67 --- /dev/null +++ b/internal/cli/fs_reload_cancellation_test.go @@ -0,0 +1,21 @@ +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..a4c719a 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 } @@ -1844,6 +1861,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_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_darwin.go b/internal/cli/fs_service_live_update_darwin.go index 0f59c88..f0d48f3 100644 --- a/internal/cli/fs_service_live_update_darwin.go +++ b/internal/cli/fs_service_live_update_darwin.go @@ -47,11 +47,15 @@ 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 +164,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 +199,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_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_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_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..721e42d 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", @@ -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 { @@ -1916,7 +1918,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/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..b8aa535 100644 --- a/internal/fskitstatus/status.go +++ b/internal/fskitstatus/status.go @@ -11,7 +11,10 @@ import ( "os" "path/filepath" "sync" + "syscall" "time" + + "github.com/samekind/codexfold/internal/dirsync" ) const ( @@ -205,8 +208,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) } } @@ -523,7 +536,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<