diff --git a/core/internal/clipboard/store.go b/core/internal/clipboard/store.go index 17ad7a923..bf7ad6cd4 100644 --- a/core/internal/clipboard/store.go +++ b/core/internal/clipboard/store.go @@ -47,7 +47,15 @@ func Store(data []byte, mimeType string) error { return StoreWithConfig(data, mimeType, DefaultStoreConfig()) } -func StoreWithConfig(data []byte, mimeType string, cfg StoreConfig) error { +func StoreWithConfig(data []byte, mimeType string, cfg StoreConfig) (err error) { + defer func() { + r := recover() + if r == nil { + return + } + err = fmt.Errorf("clipboard db panic: %v", r) + }() + if len(data) == 0 { return nil } diff --git a/core/internal/server/clipboard/manager.go b/core/internal/server/clipboard/manager.go index 44e1ef55f..afb2e8b06 100644 --- a/core/internal/server/clipboard/manager.go +++ b/core/internal/server/clipboard/manager.go @@ -113,7 +113,61 @@ func NewManager(wlCtx wlcontext.WaylandContext, config Config) (*Manager, error) } func openDB(path string) (*bolt.DB, error) { - db, err := bolt.Open(path, 0o644, &bolt.Options{ + db, err := tryOpenDB(path) + if err == nil { + return db, nil + } + + log.Errorf("Clipboard db corrupted, salvaging readable entries: %v", err) + + salvagePath := path + ".salvage" + os.Remove(salvagePath) + // chunked commits keep entries copied before the first unreadable page + if salvageErr := compactInto(path, salvagePath, 64<<10); salvageErr != nil { + log.Warnf("Clipboard db salvage stopped early: %v", salvageErr) + } + + corruptPath := path + ".corrupt" + os.Remove(corruptPath) + if renameErr := os.Rename(path, corruptPath); renameErr != nil { + os.Remove(salvagePath) + return nil, err + } + + os.Rename(salvagePath, path) + + db, err = tryOpenDB(path) + if err == nil { + return db, nil + } + + os.Remove(path) + return tryOpenDB(path) +} + +// bbolt reports corrupted pages by panicking (internal/common/verify.go), not +// by returning errors, so every db touchpoint recovers and converts to error. +func recoverDBPanic(err *error) { + r := recover() + if r == nil { + return + } + *err = fmt.Errorf("clipboard db panic: %v", r) +} + +func tryOpenDB(path string) (db *bolt.DB, err error) { + defer func() { + r := recover() + if r == nil { + return + } + if db != nil { + db.Close() + } + db, err = nil, fmt.Errorf("clipboard db panic: %v", r) + }() + + db, err = bolt.Open(path, 0o644, &bolt.Options{ Timeout: 1 * time.Second, }) if err != nil { @@ -129,9 +183,31 @@ func openDB(path string) (*bolt.DB, error) { return nil, err } + err = db.View(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte("clipboard")) + if b == nil { + return nil + } + return b.ForEach(func(k, v []byte) error { return nil }) + }) + if err != nil { + db.Close() + return nil, err + } + return db, nil } +func (m *Manager) dbUpdate(fn func(tx *bolt.Tx) error) (err error) { + defer recoverDBPanic(&err) + return m.db.Update(fn) +} + +func (m *Manager) dbView(fn func(tx *bolt.Tx) error) (err error) { + defer recoverDBPanic(&err) + return m.db.View(fn) +} + func (m *Manager) post(fn func()) { m.wlCtx.Post(fn) } @@ -444,8 +520,11 @@ func (m *Manager) storeEntry(entry Entry) error { entry.Hash = computeHash(entry.Data) - return m.db.Update(func(tx *bolt.Tx) error { + return m.dbUpdate(func(tx *bolt.Tx) error { b := tx.Bucket([]byte("clipboard")) + if b == nil { + return fmt.Errorf("clipboard bucket missing") + } if err := m.deduplicateInTx(b, entry.Hash); err != nil { return err @@ -908,8 +987,11 @@ func (m *Manager) GetHistory() []Entry { var history []Entry var stale []uint64 - if err := m.db.View(func(tx *bolt.Tx) error { + if err := m.dbView(func(tx *bolt.Tx) error { b := tx.Bucket([]byte("clipboard")) + if b == nil { + return nil + } c := b.Cursor() for k, v := c.Last(); k != nil; k, v = c.Prev() { @@ -940,8 +1022,11 @@ func (m *Manager) deleteStaleEntries(ids []uint64) { return } - if err := m.db.Update(func(tx *bolt.Tx) error { + if err := m.dbUpdate(func(tx *bolt.Tx) error { b := tx.Bucket([]byte("clipboard")) + if b == nil { + return nil + } for _, id := range ids { if err := b.Delete(itob(id)); err != nil { log.Errorf("Failed to delete stale entry %d: %v", id, err) @@ -961,8 +1046,11 @@ func (m *Manager) GetEntry(id uint64) (*Entry, error) { var entry Entry var found bool - err := m.db.View(func(tx *bolt.Tx) error { + err := m.dbView(func(tx *bolt.Tx) error { b := tx.Bucket([]byte("clipboard")) + if b == nil { + return nil + } v := b.Get(itob(id)) if v == nil { return nil @@ -992,8 +1080,11 @@ func (m *Manager) DeleteEntry(id uint64) error { return fmt.Errorf("database not available") } - err := m.db.Update(func(tx *bolt.Tx) error { + err := m.dbUpdate(func(tx *bolt.Tx) error { b := tx.Bucket([]byte("clipboard")) + if b == nil { + return nil + } return b.Delete(itob(id)) }) @@ -1061,7 +1152,7 @@ func (m *Manager) ClearHistory() { } // Delete only non-pinned entries - if err := m.db.Update(func(tx *bolt.Tx) error { + if err := m.dbUpdate(func(tx *bolt.Tx) error { b := tx.Bucket([]byte("clipboard")) if b == nil { return nil @@ -1088,7 +1179,7 @@ func (m *Manager) ClearHistory() { } pinnedCount := 0 - if err := m.db.View(func(tx *bolt.Tx) error { + if err := m.dbView(func(tx *bolt.Tx) error { b := tx.Bucket([]byte("clipboard")) if b != nil { c := b.Cursor() @@ -1120,42 +1211,55 @@ func (m *Manager) compactDB() error { tmpPath := m.dbPath + ".compact" defer os.Remove(tmpPath) - srcDB, err := bolt.Open(m.dbPath, 0o644, &bolt.Options{ReadOnly: true, Timeout: time.Second}) - if err != nil { - m.db, _ = bolt.Open(m.dbPath, 0o644, &bolt.Options{Timeout: time.Second}) - return fmt.Errorf("open source: %w", err) + if err := compactInto(m.dbPath, tmpPath, 0); err != nil { + m.reopenDB() + return err } - dstDB, err := bolt.Open(tmpPath, 0o644, &bolt.Options{Timeout: time.Second}) - if err != nil { - srcDB.Close() - m.db, _ = bolt.Open(m.dbPath, 0o644, &bolt.Options{Timeout: time.Second}) - return fmt.Errorf("open destination: %w", err) - } - - if err := bolt.Compact(dstDB, srcDB, 0); err != nil { - srcDB.Close() - dstDB.Close() - m.db, _ = bolt.Open(m.dbPath, 0o644, &bolt.Options{Timeout: time.Second}) - return fmt.Errorf("compact: %w", err) - } - - srcDB.Close() - dstDB.Close() - if err := os.Rename(tmpPath, m.dbPath); err != nil { - m.db, _ = bolt.Open(m.dbPath, 0o644, &bolt.Options{Timeout: time.Second}) + m.reopenDB() return fmt.Errorf("rename: %w", err) } - m.db, err = bolt.Open(m.dbPath, 0o644, &bolt.Options{Timeout: time.Second}) - if err != nil { - return fmt.Errorf("reopen: %w", err) + m.reopenDB() + if m.db == nil { + return fmt.Errorf("reopen failed") } return nil } +func compactInto(srcPath, dstPath string, txMaxSize int64) (err error) { + defer recoverDBPanic(&err) + + srcDB, err := bolt.Open(srcPath, 0o644, &bolt.Options{ReadOnly: true, Timeout: time.Second}) + if err != nil { + return fmt.Errorf("open source: %w", err) + } + defer srcDB.Close() + + dstDB, err := bolt.Open(dstPath, 0o644, &bolt.Options{Timeout: time.Second}) + if err != nil { + return fmt.Errorf("open destination: %w", err) + } + defer dstDB.Close() + + if err := bolt.Compact(dstDB, srcDB, txMaxSize); err != nil { + return fmt.Errorf("compact: %w", err) + } + return nil +} + +func (m *Manager) reopenDB() { + db, err := openDB(m.dbPath) + if err != nil { + log.Errorf("Failed to reopen clipboard db: %v", err) + m.db = nil + return + } + m.db = db +} + func (m *Manager) SetClipboard(data []byte, mimeType string) error { if int64(len(data)) > m.config.MaxEntrySize { return fmt.Errorf("data too large") @@ -1339,7 +1443,7 @@ func (m *Manager) Close() { } func (m *Manager) clearHistoryInternal() error { - return m.db.Update(func(tx *bolt.Tx) error { + return m.dbUpdate(func(tx *bolt.Tx) error { if err := tx.DeleteBucket([]byte("clipboard")); err != nil { return err } @@ -1351,7 +1455,7 @@ func (m *Manager) clearHistoryInternal() error { func (m *Manager) clearOldEntries(days int) error { cutoff := time.Now().AddDate(0, 0, -days) - return m.db.Update(func(tx *bolt.Tx) error { + return m.dbUpdate(func(tx *bolt.Tx) error { b := tx.Bucket([]byte("clipboard")) if b == nil { return nil @@ -1387,7 +1491,7 @@ func (m *Manager) migrateHashes() error { } var needsMigration bool - if err := m.db.View(func(tx *bolt.Tx) error { + if err := m.dbView(func(tx *bolt.Tx) error { b := tx.Bucket([]byte("clipboard")) if b == nil { return nil @@ -1410,7 +1514,7 @@ func (m *Manager) migrateHashes() error { log.Info("Migrating clipboard entries to add hashes...") - return m.db.Update(func(tx *bolt.Tx) error { + return m.dbUpdate(func(tx *bolt.Tx) error { b := tx.Bucket([]byte("clipboard")) if b == nil { return nil @@ -1470,7 +1574,7 @@ func (m *Manager) Search(params SearchParams) SearchResult { mimeFilter := strings.ToLower(params.MimeType) var all []Entry - if err := m.db.View(func(tx *bolt.Tx) error { + if err := m.dbView(func(tx *bolt.Tx) error { b := tx.Bucket([]byte("clipboard")) if b == nil { return nil @@ -1664,7 +1768,7 @@ func (m *Manager) PinEntry(id uint64) error { } var hashExists bool - if err := m.db.View(func(tx *bolt.Tx) error { + if err := m.dbView(func(tx *bolt.Tx) error { b := tx.Bucket([]byte("clipboard")) if b == nil { return nil @@ -1691,7 +1795,7 @@ func (m *Manager) PinEntry(id uint64) error { cfg := m.getConfig() pinnedCount := 0 - if err := m.db.View(func(tx *bolt.Tx) error { + if err := m.dbView(func(tx *bolt.Tx) error { b := tx.Bucket([]byte("clipboard")) if b == nil { return nil @@ -1712,8 +1816,11 @@ func (m *Manager) PinEntry(id uint64) error { return fmt.Errorf("maximum pinned entries reached (%d)", cfg.MaxPinned) } - err = m.db.Update(func(tx *bolt.Tx) error { + err = m.dbUpdate(func(tx *bolt.Tx) error { b := tx.Bucket([]byte("clipboard")) + if b == nil { + return fmt.Errorf("entry not found") + } v := b.Get(itob(id)) if v == nil { return fmt.Errorf("entry not found") @@ -1746,8 +1853,11 @@ func (m *Manager) UnpinEntry(id uint64) error { return fmt.Errorf("database not available") } - err := m.db.Update(func(tx *bolt.Tx) error { + err := m.dbUpdate(func(tx *bolt.Tx) error { b := tx.Bucket([]byte("clipboard")) + if b == nil { + return fmt.Errorf("entry not found") + } v := b.Get(itob(id)) if v == nil { return fmt.Errorf("entry not found") @@ -1812,7 +1922,7 @@ func (m *Manager) GetPinnedEntries() []Entry { } var pinned []Entry - if err := m.db.View(func(tx *bolt.Tx) error { + if err := m.dbView(func(tx *bolt.Tx) error { b := tx.Bucket([]byte("clipboard")) if b == nil { return nil @@ -1842,7 +1952,7 @@ func (m *Manager) GetPinnedCount() int { } count := 0 - if err := m.db.View(func(tx *bolt.Tx) error { + if err := m.dbView(func(tx *bolt.Tx) error { b := tx.Bucket([]byte("clipboard")) if b == nil { return nil diff --git a/core/internal/server/clipboard/manager_test.go b/core/internal/server/clipboard/manager_test.go index 61036f007..f1e476cf8 100644 --- a/core/internal/server/clipboard/manager_test.go +++ b/core/internal/server/clipboard/manager_test.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "net" + "os" "path/filepath" "sync" "sync/atomic" @@ -792,3 +793,63 @@ func TestManager_ConcurrentPostWithMock(t *testing.T) { wg.Wait() assert.Equal(t, int32(100), postCount.Load()) } + +func corruptInlineBucketRoot(t *testing.T, path string) { + t.Helper() + + f, err := os.OpenFile(path, os.O_RDWR, 0o644) + require.NoError(t, err) + defer f.Close() + + // flips the inline bucket root pgid to an out-of-range page, per issue #3232 + _, err = f.WriteAt([]byte{16}, int64(4*os.Getpagesize()+32+9)) + require.NoError(t, err) +} + +func TestOpenDB_HealsCorruptedBucketPage(t *testing.T) { + path := filepath.Join(t.TempDir(), "db") + + db, err := openDB(path) + require.NoError(t, err) + require.NoError(t, db.Close()) + + corruptInlineBucketRoot(t, path) + + db, err = openDB(path) + require.NoError(t, err) + defer db.Close() + + m := &Manager{config: DefaultConfig(), db: db, dbPath: path} + require.NoError(t, m.storeEntry(Entry{Data: []byte("x"), MimeType: "text/plain"})) + assert.Len(t, m.GetHistory(), 1) +} + +func TestOpenDB_HealsGarbageFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "db") + require.NoError(t, os.WriteFile(path, bytes.Repeat([]byte{0xAB}, 64<<10), 0o644)) + + db, err := openDB(path) + require.NoError(t, err) + defer db.Close() + + _, err = os.Stat(path + ".corrupt") + require.NoError(t, err) +} + +func TestStoreEntry_CorruptDBReturnsErrorNotPanic(t *testing.T) { + path := filepath.Join(t.TempDir(), "db") + + db, err := openDB(path) + require.NoError(t, err) + require.NoError(t, db.Close()) + + corruptInlineBucketRoot(t, path) + + db, err = bolt.Open(path, 0o644, &bolt.Options{Timeout: time.Second}) + require.NoError(t, err) + defer db.Close() + + m := &Manager{config: DefaultConfig(), db: db, dbPath: path} + _ = m.storeEntry(Entry{Data: []byte("x"), MimeType: "text/plain"}) + _ = m.GetHistory() +}