refactor(pkg/utils): add unified atomic file write utility

This commit is contained in:
mosir
2026-02-24 13:22:52 +08:00
parent 7cbfa89a96
commit c56fcedcb1
10 changed files with 166 additions and 71 deletions
+10 -3
View File
@@ -12,6 +12,8 @@ import (
"path/filepath"
"strings"
"time"
"github.com/sipeed/picoclaw/pkg/utils"
)
// MemoryStore manages persistent memory for the agent.
@@ -58,7 +60,9 @@ func (ms *MemoryStore) ReadLongTerm() string {
// WriteLongTerm writes content to the long-term memory file (MEMORY.md).
func (ms *MemoryStore) WriteLongTerm(content string) error {
return os.WriteFile(ms.memoryFile, []byte(content), 0o644)
// Use unified atomic write utility with explicit sync for flash storage reliability.
// Using 0o600 (owner read/write only) for secure default permissions.
return utils.WriteFileAtomic(ms.memoryFile, []byte(content), 0o600)
}
// ReadToday reads today's daily note.
@@ -78,7 +82,9 @@ func (ms *MemoryStore) AppendToday(content string) error {
// Ensure month directory exists
monthDir := filepath.Dir(todayFile)
os.MkdirAll(monthDir, 0o755)
if err := os.MkdirAll(monthDir, 0o755); err != nil {
return err
}
var existingContent string
if data, err := os.ReadFile(todayFile); err == nil {
@@ -95,7 +101,8 @@ func (ms *MemoryStore) AppendToday(content string) error {
newContent = existingContent + "\n" + content
}
return os.WriteFile(todayFile, []byte(newContent), 0o644)
// Use unified atomic write utility with explicit sync for flash storage reliability.
return utils.WriteFileAtomic(todayFile, []byte(newContent), 0o600)
}
// GetRecentDailyNotes returns daily notes from the last N days.
+5 -6
View File
@@ -5,6 +5,8 @@ import (
"os"
"path/filepath"
"time"
"github.com/sipeed/picoclaw/pkg/utils"
)
type AuthCredential struct {
@@ -63,16 +65,13 @@ func LoadStore() (*AuthStore, error) {
func SaveStore(store *AuthStore) error {
path := authFilePath()
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
data, err := json.MarshalIndent(store, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0o600)
// Use unified atomic write utility with explicit sync for flash storage reliability.
return utils.WriteFileAtomic(path, data, 0o600)
}
func GetCredential(provider string) (*AuthCredential, error) {
+3 -7
View File
@@ -4,10 +4,10 @@ import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync/atomic"
"github.com/caarlos0/env/v11"
"github.com/sipeed/picoclaw/pkg/utils"
)
// rrCounter is a global counter for round-robin load balancing across models.
@@ -526,12 +526,8 @@ func SaveConfig(path string, cfg *Config) error {
return err
}
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
return os.WriteFile(path, data, 0o600)
// Use unified atomic write utility with explicit sync for flash storage reliability.
return utils.WriteFileAtomic(path, data, 0o600)
}
func (c *Config) WorkspacePath() string {
+3 -7
View File
@@ -7,11 +7,11 @@ import (
"fmt"
"log"
"os"
"path/filepath"
"sync"
"time"
"github.com/adhocore/gronx"
"github.com/sipeed/picoclaw/pkg/utils"
)
type CronSchedule struct {
@@ -330,17 +330,13 @@ func (cs *CronService) loadStore() error {
}
func (cs *CronService) saveStoreUnsafe() error {
dir := filepath.Dir(cs.storePath)
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
data, err := json.MarshalIndent(cs.store, "", " ")
if err != nil {
return err
}
return os.WriteFile(cs.storePath, data, 0o600)
// Use unified atomic write utility with explicit sync for flash storage reliability.
return utils.WriteFileAtomic(cs.storePath, data, 0o600)
}
func (cs *CronService) AddJob(
+2 -1
View File
@@ -19,6 +19,7 @@ import (
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/state"
"github.com/sipeed/picoclaw/pkg/tools"
"github.com/sipeed/picoclaw/pkg/utils"
)
const (
@@ -275,7 +276,7 @@ This file contains tasks for the heartbeat service to check periodically.
Add your heartbeat tasks below this line:
`
if err := os.WriteFile(heartbeatPath, []byte(defaultContent), 0o644); err != nil {
if err := utils.WriteFileAtomic(heartbeatPath, []byte(defaultContent), 0o644); err != nil {
hs.logError("Failed to create default HEARTBEAT.md: %v", err)
} else {
hs.logInfo("Created default HEARTBEAT.md template")
+5 -1
View File
@@ -9,6 +9,8 @@ import (
"os"
"path/filepath"
"time"
"github.com/sipeed/picoclaw/pkg/utils"
)
type SkillInstaller struct {
@@ -64,7 +66,9 @@ func (si *SkillInstaller) InstallFromGitHub(ctx context.Context, repo string) er
}
skillPath := filepath.Join(skillDir, "SKILL.md")
if err := os.WriteFile(skillPath, body, 0o644); err != nil {
// Use unified atomic write utility with explicit sync for flash storage reliability.
if err := utils.WriteFileAtomic(skillPath, body, 0o600); err != nil {
return fmt.Errorf("failed to write skill file: %w", err)
}
+8 -19
View File
@@ -8,6 +8,8 @@ import (
"path/filepath"
"sync"
"time"
"github.com/sipeed/picoclaw/pkg/utils"
)
// State represents the persistent state for a workspace.
@@ -124,33 +126,20 @@ func (sm *Manager) GetTimestamp() time.Time {
// saveAtomic performs an atomic save using temp file + rename.
// This ensures that the state file is never corrupted:
// 1. Write to a temp file
// 2. Rename temp file to target (atomic on POSIX systems)
// 3. If rename fails, cleanup the temp file
// 2. Sync to disk (critical for SD cards/flash storage)
// 3. Rename temp file to target (atomic on POSIX systems)
// 4. If rename fails, cleanup the temp file
//
// Must be called with the lock held.
func (sm *Manager) saveAtomic() error {
// Create temp file in the same directory as the target
tempFile := sm.stateFile + ".tmp"
// Marshal state to JSON
// Use unified atomic write utility with explicit sync for flash storage reliability.
// Using 0o600 (owner read/write only) for secure default permissions.
data, err := json.MarshalIndent(sm.state, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal state: %w", err)
}
// Write to temp file
if err := os.WriteFile(tempFile, data, 0o644); err != nil {
return fmt.Errorf("failed to write temp file: %w", err)
}
// Atomic rename from temp to target
if err := os.Rename(tempFile, sm.stateFile); err != nil {
// Cleanup temp file if rename fails
os.Remove(tempFile)
return fmt.Errorf("failed to rename temp file: %w", err)
}
return nil
return utils.WriteFileAtomic(sm.stateFile, data, 0o600)
}
// load loads the state from disk.
+31 -26
View File
@@ -8,6 +8,8 @@ import (
"path/filepath"
"strings"
"time"
"github.com/sipeed/picoclaw/pkg/utils"
)
// validatePath ensures the given path is within the workspace if restrict is true.
@@ -276,25 +278,9 @@ func (h *hostFs) ReadDir(path string) ([]os.DirEntry, error) {
}
func (h *hostFs) WriteFile(path string, data []byte) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("failed to create parent directories: %w", err)
}
// We use a "write-then-rename" pattern here to ensure an atomic write.
// This prevents the target file from being left in a truncated or partial state
// if the operation is interrupted, as the rename operation is atomic on Linux.
tmpPath := fmt.Sprintf("%s.%d.tmp", path, time.Now().UnixNano())
if err := os.WriteFile(tmpPath, data, 0o644); err != nil {
os.Remove(tmpPath) // Ensure cleanup of partial/empty temp file
return fmt.Errorf("failed to write temp file: %w", err)
}
if err := os.Rename(tmpPath, path); err != nil {
os.Remove(tmpPath)
return fmt.Errorf("failed to replace original file: %w", err)
}
return nil
// Use unified atomic write utility with explicit sync for flash storage reliability.
// Using 0o600 (owner read/write only) for secure default permissions.
return utils.WriteFileAtomic(path, data, 0o600)
}
// sandboxFs is a sandboxed fileSystem that operates within a strictly defined workspace using os.Root.
@@ -351,14 +337,33 @@ func (r *sandboxFs) WriteFile(path string, data []byte) error {
}
}
// We use a "write-then-rename" pattern here to ensure an atomic write.
// This prevents the target file from being left in a truncated or partial state
// if the operation is interrupted, as the rename operation is atomic on Linux.
tmpRelPath := fmt.Sprintf("%s.%d.tmp", relPath, time.Now().UnixNano())
// Use atomic write pattern with explicit sync for flash storage reliability.
// Using 0o600 (owner read/write only) for secure default permissions.
tmpRelPath := fmt.Sprintf(".tmp-%d.tmp", time.Now().UnixNano())
if err := root.WriteFile(tmpRelPath, data, 0o644); err != nil {
root.Remove(tmpRelPath) // Ensure cleanup of partial/empty temp file
return fmt.Errorf("failed to write to temp file: %w", err)
tmpFile, err := root.OpenFile(tmpRelPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
if err != nil {
root.Remove(tmpRelPath)
return fmt.Errorf("failed to open temp file: %w", err)
}
if _, err := tmpFile.Write(data); err != nil {
tmpFile.Close()
root.Remove(tmpRelPath)
return fmt.Errorf("failed to write temp file: %w", err)
}
// CRITICAL: Force sync to storage medium before rename.
// This ensures data is physically written to disk, not just cached.
if err := tmpFile.Sync(); err != nil {
tmpFile.Close()
root.Remove(tmpRelPath)
return fmt.Errorf("failed to sync temp file: %w", err)
}
if err := tmpFile.Close(); err != nil {
root.Remove(tmpRelPath)
return fmt.Errorf("failed to close temp file: %w", err)
}
if err := root.Rename(tmpRelPath, relPath); err != nil {
+2 -1
View File
@@ -197,5 +197,6 @@ func writeOriginMeta(targetDir, registryName, slug, version string) error {
return err
}
return os.WriteFile(filepath.Join(targetDir, ".skill-origin.json"), data, 0o644)
// Use unified atomic write utility with explicit sync for flash storage reliability.
return utils.WriteFileAtomic(filepath.Join(targetDir, ".skill-origin.json"), data, 0o600)
}
+97
View File
@@ -0,0 +1,97 @@
// PicoClaw - Ultra-lightweight personal AI agent
// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
package utils
import (
"fmt"
"os"
"path/filepath"
)
// WriteFileAtomic atomically writes data to a file using a temp file + rename pattern.
//
// This guarantees that the target file is either:
// - Completely written with the new data
// - Unchanged (if write fails or power loss during write)
//
// The function:
// 1. Creates a temp file in the same directory
// 2. Writes data to temp file
// 3. Syncs to disk (critical for SD cards/flash storage)
// 4. Sets file permissions
// 5. Atomically renames temp file to target path
//
// Parameters:
// - path: Target file path
// - data: Data to write
// - perm: File permission mode (e.g., 0o600 for secure, 0o644 for readable)
//
// Returns:
// - Error if any step fails, nil on success
//
// Example:
//
// // Secure config file (owner read/write only)
// err := utils.WriteFileAtomic("config.json", data, 0o600)
//
// // Public readable file
// err := utils.WriteFileAtomic("public.txt", data, 0o644)
func WriteFileAtomic(path string, data []byte, perm os.FileMode) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("failed to create directory: %w", err)
}
// Create temp file in the same directory (ensures atomic rename works)
tmpFile, err := os.CreateTemp(dir, ".tmp-*.tmp")
if err != nil {
return fmt.Errorf("failed to create temp file: %w", err)
}
tmpPath := tmpFile.Name()
// Cleanup on error: ensure temp file is removed if anything fails
cleanup := true
defer func() {
if cleanup {
_ = os.Remove(tmpPath)
}
}()
// Write data to temp file
if _, err := tmpFile.Write(data); err != nil {
tmpFile.Close()
return fmt.Errorf("failed to write temp file: %w", err)
}
// CRITICAL: Force sync to storage medium before rename.
// This ensures data is physically written to disk, not just cached.
// Essential for SD cards, eMMC, and other flash storage on edge devices.
if err := tmpFile.Sync(); err != nil {
tmpFile.Close()
return fmt.Errorf("failed to sync temp file: %w", err)
}
// Set file permissions
if err := tmpFile.Chmod(perm); err != nil {
tmpFile.Close()
return fmt.Errorf("failed to set permissions: %w", err)
}
// Close file before rename
if err := tmpFile.Close(); err != nil {
return fmt.Errorf("failed to close temp file: %w", err)
}
// Atomic rename: temp file becomes the target
if err := os.Rename(tmpPath, path); err != nil {
return fmt.Errorf("failed to rename temp file: %w", err)
}
// Success: skip cleanup
cleanup = false
return nil
}