Files
zyedidia.micro/internal/buffer/save.go
Dmytro Maluka 2c010afbe4 Fix races between removing backups and creating periodic backups
Micro's logic for periodic backup creation is racy and may cause
spurious backups of unmodified buffers, at least for the following
reasons:

1. When a buffer is closed, its backup is removed by the main goroutine,
   without any synchronization with the backup/save goroutine which
   creates periodic backups in the background.

   A part of the problem here is that the main goroutine removes the
   backup before setting b.fini to true, not after it, so the
   backup/save goroutine may start creating a new backup even after it
   has been removed by the main goroutine. But even if we move the
   b.RemoveBackup() call after setting b.fini, it will not solve the
   problem, since the backup/save goroutine may have already started
   creating a new periodic backup just before b.fini was set to true.

2. When a buffer is successfully saved and thus its backup is removed,
   if there was a periodic backup for this buffer requested by the main
   goroutine but not saved by the backup/save goroutine yet (i.e. this
   request is still pending in backupRequestChan), micro doesn't cancel
   this pending request, so a backup is unexpectedly saved a couple of
   seconds after the file itself was saved.

   Although usually this erroneous backup is removed later, when the
   buffer is closed. But if micro terminates abnormally and the buffer
   is not properly closed, this backup is not removed. Also if this
   issue occurs in combination with the race issue #1 described above,
   this backup may not be successfully removed either.

So, to fix these issues:

1. Do the backup removal in the backup/save goroutine (at requests from
   the main goroutine), not directly in the main goroutine.

2. Make the communication between these goroutines fully synchronous:

2a. Instead of using the buffered channel backupRequestChan as a storage
    for pending requests for periodic backups, let the backup/save
    goroutine itself store this information, in the requestesBackups
    map. Then, backupRequestChan can be made non-buffered.

2b. Make saveRequestChan a non-buffered channel as well. (There was no
    point in making it buffered in the first place, actually.) Once both
    channels are non-buffered, the backup/save goroutine receives both
    backup and save requests from the main goroutine in exactly the same
    order as the main goroutine sends them, so we can guarantee that
    saving the buffer will cancel the previous pending backup request
    for this buffer.
2025-08-03 16:17:03 +02:00

407 lines
9.1 KiB
Go

package buffer
import (
"bufio"
"bytes"
"errors"
"io"
"io/fs"
"os"
"os/exec"
"os/signal"
"path/filepath"
"runtime"
"time"
"unicode"
"github.com/zyedidia/micro/v2/internal/config"
"github.com/zyedidia/micro/v2/internal/screen"
"github.com/zyedidia/micro/v2/internal/util"
"golang.org/x/text/transform"
)
// LargeFileThreshold is the number of bytes when fastdirty is forced
// because hashing is too slow
const LargeFileThreshold = 50000
type wrappedFile struct {
writeCloser io.WriteCloser
withSudo bool
screenb bool
cmd *exec.Cmd
sigChan chan os.Signal
}
type saveResponse struct {
size int
err error
}
type saveRequest struct {
buf *Buffer
path string
withSudo bool
newFile bool
saveResponseChan chan saveResponse
}
var saveRequestChan chan saveRequest
var backupRequestChan chan backupRequest
func init() {
// Both saveRequestChan and backupRequestChan need to be non-buffered
// so the save/backup goroutine receives both save and backup requests
// in the same order the main goroutine sends them.
saveRequestChan = make(chan saveRequest)
backupRequestChan = make(chan backupRequest)
go func() {
duration := backupSeconds * float64(time.Second)
backupTicker := time.NewTicker(time.Duration(duration))
for {
select {
case sr := <-saveRequestChan:
size, err := sr.buf.safeWrite(sr.path, sr.withSudo, sr.newFile)
sr.saveResponseChan <- saveResponse{size, err}
case br := <-backupRequestChan:
handleBackupRequest(br)
case <-backupTicker.C:
periodicBackup()
}
}
}()
}
func openFile(name string, withSudo bool) (wrappedFile, error) {
var err error
var writeCloser io.WriteCloser
var screenb bool
var cmd *exec.Cmd
var sigChan chan os.Signal
if withSudo {
cmd = exec.Command(config.GlobalSettings["sucmd"].(string), "dd", "bs=4k", "of="+name)
writeCloser, err = cmd.StdinPipe()
if err != nil {
return wrappedFile{}, err
}
sigChan = make(chan os.Signal, 1)
signal.Reset(os.Interrupt)
signal.Notify(sigChan, os.Interrupt)
screenb = screen.TempFini()
// need to start the process now, otherwise when we flush the file
// contents to its stdin it might hang because the kernel's pipe size
// is too small to handle the full file contents all at once
err = cmd.Start()
if err != nil {
screen.TempStart(screenb)
signal.Notify(util.Sigterm, os.Interrupt)
signal.Stop(sigChan)
return wrappedFile{}, err
}
} else {
writeCloser, err = os.OpenFile(name, os.O_WRONLY|os.O_CREATE, util.FileMode)
if err != nil {
return wrappedFile{}, err
}
}
return wrappedFile{writeCloser, withSudo, screenb, cmd, sigChan}, nil
}
func (wf wrappedFile) Write(b *SharedBuffer) (int, error) {
file := bufio.NewWriter(transform.NewWriter(wf.writeCloser, b.encoding.NewEncoder()))
b.Lock()
defer b.Unlock()
if len(b.lines) == 0 {
return 0, nil
}
// end of line
var eol []byte
if b.Endings == FFDos {
eol = []byte{'\r', '\n'}
} else {
eol = []byte{'\n'}
}
if !wf.withSudo {
f := wf.writeCloser.(*os.File)
err := f.Truncate(0)
if err != nil {
return 0, err
}
}
// write lines
size, err := file.Write(b.lines[0].data)
if err != nil {
return 0, err
}
for _, l := range b.lines[1:] {
if _, err = file.Write(eol); err != nil {
return 0, err
}
if _, err = file.Write(l.data); err != nil {
return 0, err
}
size += len(eol) + len(l.data)
}
err = file.Flush()
if err == nil && !wf.withSudo {
// Call Sync() on the file to make sure the content is safely on disk.
f := wf.writeCloser.(*os.File)
err = f.Sync()
}
return size, err
}
func (wf wrappedFile) Close() error {
err := wf.writeCloser.Close()
if wf.withSudo {
// wait for dd to finish and restart the screen if we used sudo
err := wf.cmd.Wait()
screen.TempStart(wf.screenb)
signal.Notify(util.Sigterm, os.Interrupt)
signal.Stop(wf.sigChan)
if err != nil {
return err
}
}
return err
}
func (b *SharedBuffer) overwriteFile(name string) (int, error) {
file, err := openFile(name, false)
if err != nil {
return 0, err
}
size, err := file.Write(b)
err2 := file.Close()
if err2 != nil && err == nil {
err = err2
}
return size, err
}
// Save saves the buffer to its default path
func (b *Buffer) Save() error {
return b.SaveAs(b.Path)
}
// AutoSave saves the buffer to its default path
func (b *Buffer) AutoSave() error {
if !b.Modified() {
return nil
}
return b.saveToFile(b.Path, false, true)
}
// SaveAs saves the buffer to a specified path (filename), creating the file if it does not exist
func (b *Buffer) SaveAs(filename string) error {
return b.saveToFile(filename, false, false)
}
func (b *Buffer) SaveWithSudo() error {
return b.SaveAsWithSudo(b.Path)
}
func (b *Buffer) SaveAsWithSudo(filename string) error {
return b.saveToFile(filename, true, false)
}
func (b *Buffer) saveToFile(filename string, withSudo bool, autoSave bool) error {
var err error
if b.Type.Readonly {
return errors.New("Cannot save readonly buffer")
}
if b.Type.Scratch {
return errors.New("Cannot save scratch buffer")
}
if withSudo && runtime.GOOS == "windows" {
return errors.New("Save with sudo not supported on Windows")
}
if !autoSave && b.Settings["rmtrailingws"].(bool) {
for i, l := range b.lines {
leftover := util.CharacterCount(bytes.TrimRightFunc(l.data, unicode.IsSpace))
linelen := util.CharacterCount(l.data)
b.Remove(Loc{leftover, i}, Loc{linelen, i})
}
b.RelocateCursors()
}
if b.Settings["eofnewline"].(bool) {
end := b.End()
if b.RuneAt(Loc{end.X - 1, end.Y}) != '\n' {
b.insert(end, []byte{'\n'})
}
}
filename, err = util.ReplaceHome(filename)
if err != nil {
return err
}
newFile := false
fileInfo, err := os.Stat(filename)
if err != nil {
if !errors.Is(err, fs.ErrNotExist) {
return err
}
newFile = true
}
if err == nil && fileInfo.IsDir() {
return errors.New("Error: " + filename + " is a directory and cannot be saved")
}
if err == nil && !fileInfo.Mode().IsRegular() {
return errors.New("Error: " + filename + " is not a regular file and cannot be saved")
}
absFilename, err := filepath.Abs(filename)
if err != nil {
return err
}
// Get the leading path to the file | "." is returned if there's no leading path provided
if dirname := filepath.Dir(absFilename); dirname != "." {
// Check if the parent dirs don't exist
if _, statErr := os.Stat(dirname); errors.Is(statErr, fs.ErrNotExist) {
// Prompt to make sure they want to create the dirs that are missing
if b.Settings["mkparents"].(bool) {
// Create all leading dir(s) since they don't exist
if mkdirallErr := os.MkdirAll(dirname, os.ModePerm); mkdirallErr != nil {
// If there was an error creating the dirs
return mkdirallErr
}
} else {
return errors.New("Parent dirs don't exist, enable 'mkparents' for auto creation")
}
}
}
saveResponseChan := make(chan saveResponse)
saveRequestChan <- saveRequest{b, absFilename, withSudo, newFile, saveResponseChan}
result := <-saveResponseChan
err = result.err
if err != nil {
if errors.Is(err, util.ErrOverwrite) {
screen.TermMessage(err)
err = errors.Unwrap(err)
b.UpdateModTime()
}
return err
}
if !b.Settings["fastdirty"].(bool) {
if result.size > LargeFileThreshold {
// For large files 'fastdirty' needs to be on
b.Settings["fastdirty"] = true
} else {
b.calcHash(&b.origHash)
}
}
newPath := b.Path != filename
b.Path = filename
b.AbsPath = absFilename
b.isModified = false
b.UpdateModTime()
if newPath {
// need to update glob-based and filetype-based settings
b.ReloadSettings(true)
}
err = b.Serialize()
return err
}
func (b *SharedBuffer) writeBackup(path string) (string, error) {
backupDir := b.backupDir()
if _, err := os.Stat(backupDir); err != nil {
if !errors.Is(err, fs.ErrNotExist) {
return "", err
}
if err = os.Mkdir(backupDir, os.ModePerm); err != nil {
return "", err
}
}
backupName := util.DetermineEscapePath(backupDir, path)
_, err := b.overwriteFile(backupName)
if err != nil {
os.Remove(backupName)
return "", err
}
return backupName, nil
}
// safeWrite writes the buffer to a file in a "safe" way, preventing loss of the
// contents of the file if it fails to write the new contents.
// This means that the file is not overwritten directly but by writing to the
// backup file first.
func (b *SharedBuffer) safeWrite(path string, withSudo bool, newFile bool) (int, error) {
file, err := openFile(path, withSudo)
if err != nil {
return 0, err
}
defer func() {
if newFile && err != nil {
os.Remove(path)
}
}()
// Try to backup first before writing
backupName, err := b.writeBackup(path)
if err != nil {
file.Close()
return 0, err
}
// Backup saved, so cancel pending periodic backup, if any
delete(requestedBackups, b)
b.forceKeepBackup = true
size := 0
{
// If we failed to write or close, keep the backup we made
size, err = file.Write(b)
if err != nil {
file.Close()
return 0, util.OverwriteError{err, backupName}
}
err = file.Close()
if err != nil {
return 0, util.OverwriteError{err, backupName}
}
}
b.forceKeepBackup = false
if !b.keepBackup() {
os.Remove(backupName)
}
return size, err
}