Files
aerc-fork-mirror/commands/msg/move.go
Robin Jarry 0211c9bf23 reload: fix crash when reloading via IPC
When reloading the configuration with :reload, global variables in the
config package are reset to their startup values and then, the config is
parsed from disk. While the parsing is done, these variables are
temporarily in an inconsistent and possibly invalid state.

When commands are executed interactively from aerc, they are handled by
the main goroutine which also deals with UI rendering. No UI render will
be done while :reload is in progress.

However, the IPC socket handler runs in an independent goroutine. This
has the unfortunate side effect to let the UI goroutine to run while
config parsing is in progress and causes crashes:

[signal SIGSEGV: segmentation violation code=0x1 addr=0x0 pc=0x6bb142]

goroutine 1 [running]:
git.sr.ht/~rjarry/aerc/lib/log.PanicHandler()
	lib/log/panic-logger.go:51 +0x6cf
panic({0xc1d960?, 0x134a6e0?})
	/usr/lib/go/src/runtime/panic.go:783 +0x132
git.sr.ht/~rjarry/aerc/config.(*StyleConf).getStyle(0xc00038b908?, 0x4206b7?)
	config/style.go:386 +0x42
git.sr.ht/~rjarry/aerc/config.StyleSet.Get({0x0, 0x0, 0x0, {0x0, 0x0, 0x0}}, 0x421a65?, 0x0)
	config/style.go:408 +0x8b
git.sr.ht/~rjarry/aerc/config.(*UIConfig).GetStyle(...)
	config/ui.go:379
git.sr.ht/~rjarry/aerc/lib/ui.(*TabStrip).Draw(0xc000314700, 0xc000192230)
	lib/ui/tab.go:378 +0x15b
git.sr.ht/~rjarry/aerc/lib/ui.(*Grid).Draw(0xc000186fc0, 0xc0002c25f0)
	lib/ui/grid.go:126 +0x28e
git.sr.ht/~rjarry/aerc/app.(*Aerc).Draw(0x14b9f00, 0xc0002c25f0)
	app/aerc.go:192 +0x1fe
git.sr.ht/~rjarry/aerc/lib/ui.Render()
	lib/ui/ui.go:155 +0x16b
main.main()
	main.go:310 +0x997

Make the reload operation safe by changing how config objects are
exposed and updated. Change all objects to be atomic pointers. Expose
public functions to access their value atomically. Only update their
value after a complete and successful config parse. This way the UI
thread will always have access to a valid configuration.

NB: The account configuration is not included in this change since it
cannot be reloaded.

Fixes: https://todo.sr.ht/~rjarry/aerc/319
Reported-by: Anachron <gith@cron.world>
Signed-off-by: Robin Jarry <robin@jarry.cc>
2025-09-08 12:19:51 +02:00

268 lines
6.2 KiB
Go

package msg
import (
"bytes"
"fmt"
"time"
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/commands"
"git.sr.ht/~rjarry/aerc/config"
"git.sr.ht/~rjarry/aerc/lib"
"git.sr.ht/~rjarry/aerc/lib/log"
"git.sr.ht/~rjarry/aerc/lib/marker"
"git.sr.ht/~rjarry/aerc/lib/ui"
"git.sr.ht/~rjarry/aerc/models"
"git.sr.ht/~rjarry/aerc/worker/types"
)
type Move struct {
CreateFolders bool `opt:"-p" desc:"Create missing folders if required."`
Account string `opt:"-a" complete:"CompleteAccount" desc:"Move to specified account."`
MultiFileStrategy *types.MultiFileStrategy `opt:"-m" action:"ParseMFS" complete:"CompleteMFS" desc:"Multi-file strategy."`
Folder string `opt:"folder" complete:"CompleteFolder" desc:"Target folder."`
}
func init() {
commands.Register(Move{})
}
func (Move) Description() string {
return "Move the selected message(s) to the specified folder."
}
func (Move) Context() commands.CommandContext {
return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER
}
func (Move) Aliases() []string {
return []string{"mv", "move"}
}
func (m *Move) ParseMFS(arg string) error {
if arg != "" {
mfs, ok := types.StrToStrategy[arg]
if !ok {
return fmt.Errorf("invalid multi-file strategy %s", arg)
}
m.MultiFileStrategy = &mfs
}
return nil
}
func (*Move) CompleteAccount(arg string) []string {
return commands.FilterList(app.AccountNames(), arg, commands.QuoteSpace)
}
func (m *Move) CompleteFolder(arg string) []string {
var acct *app.AccountView
if len(m.Account) > 0 {
acct, _ = app.Account(m.Account)
} else {
acct = app.SelectedAccount()
}
if acct == nil {
return nil
}
return commands.FilterList(acct.Directories().List(), arg, nil)
}
func (Move) CompleteMFS(arg string) []string {
return commands.FilterList(types.StrategyStrs(), arg, nil)
}
func (m Move) Execute(args []string) error {
h := newHelper()
acct, err := h.account()
if err != nil {
return err
}
store, err := h.store()
if err != nil {
return err
}
uids, err := h.markedOrSelectedUids()
if err != nil {
return err
}
next := findNextNonDeleted(uids, store)
marker := store.Marker()
marker.ClearVisualMark()
if len(m.Account) == 0 {
store.Move(uids, m.Folder, m.CreateFolders, m.MultiFileStrategy,
func(msg types.WorkerMessage) {
m.CallBack(msg, acct, uids, next, marker, false)
})
return nil
}
destAcct, err := app.Account(m.Account)
if err != nil {
return err
}
destStore := destAcct.Store()
if destStore == nil {
app.PushError(fmt.Sprintf("No message store in %s", m.Account))
return nil
}
var messages []*types.FullMessage
fetchDone := make(chan bool, 1)
store.FetchFull(uids, func(fm *types.FullMessage) {
messages = append(messages, fm)
if len(messages) == len(uids) {
fetchDone <- true
}
})
// Since this operation can take some time with some backends
// (e.g. IMAP), provide some feedback to inform the user that
// something is happening
app.PushStatus("Moving messages...", 10*time.Second)
var appended []models.UID
var timeout bool
go func() {
defer log.PanicHandler()
select {
case <-fetchDone:
break
case <-time.After(30 * time.Second):
// TODO: find a better way to determine if store.FetchFull()
// has finished with some errors.
app.PushError("Failed to fetch all messages")
if len(messages) == 0 {
return
}
}
AppendLoop:
for _, fm := range messages {
done := make(chan bool, 1)
uid := fm.Content.Uid
buf := new(bytes.Buffer)
_, err = buf.ReadFrom(fm.Content.Reader)
if err != nil {
log.Errorf("could not get reader for uid %d", uid)
break
}
destStore.Append(
m.Folder,
models.SeenFlag,
time.Now(),
buf,
buf.Len(),
func(msg types.WorkerMessage) {
switch msg := msg.(type) {
case *types.Done:
appended = append(appended, uid)
done <- true
case *types.Error:
log.Errorf("AppendMessage failed: %v", msg.Error)
done <- false
}
},
)
select {
case ok := <-done:
if !ok {
break AppendLoop
}
case <-time.After(30 * time.Second):
log.Warnf("timed-out: appended %d of %d", len(appended), len(messages))
timeout = true
break AppendLoop
}
}
if len(appended) > 0 {
mfs := types.Refuse
store.Delete(appended, &mfs, func(msg types.WorkerMessage) {
m.CallBack(msg, acct, appended, next, marker, timeout)
})
}
}()
return nil
}
func (m Move) CallBack(
msg types.WorkerMessage,
acct *app.AccountView,
uids []models.UID,
next *models.MessageInfo,
marker marker.Marker,
timeout bool,
) {
switch msg := msg.(type) {
case *types.Done:
var s string
if len(uids) > 1 {
s = "%d messages moved to %s"
} else {
s = "%d message moved to %s"
}
dest := m.Folder
if len(m.Account) > 0 {
dest = fmt.Sprintf("%s in %s", m.Folder, m.Account)
}
if timeout {
s = "timed-out: only " + s
app.PushError(fmt.Sprintf(s, len(uids), dest))
} else {
app.PushStatus(fmt.Sprintf(s, len(uids), dest), 10*time.Second)
}
if store := acct.Store(); store != nil {
handleDone(acct, next, store)
}
case *types.Error:
app.PushError(msg.Error.Error())
marker.Remark()
case *types.Unsupported:
marker.Remark()
app.PushError("error, unsupported for this worker")
}
}
func handleDone(
acct *app.AccountView,
next *models.MessageInfo,
store *lib.MessageStore,
) {
h := newHelper()
mv, isMsgView := h.msgProvider.(*app.MessageViewer)
switch {
case isMsgView && !config.Ui().NextMessageOnDelete:
app.RemoveTab(h.msgProvider, true)
case isMsgView:
if next == nil {
app.RemoveTab(h.msgProvider, true)
acct.Messages().Select(-1)
ui.Invalidate()
return
}
lib.NewMessageStoreView(next, mv.MessageView().SeenFlagSet(),
store, app.CryptoProvider(), app.DecryptKeys,
func(view lib.MessageView, err error) {
if err != nil {
app.PushError(err.Error())
return
}
nextMv, err := app.NewMessageViewer(acct, view)
if err != nil {
app.PushError(err.Error())
return
}
app.ReplaceTab(mv, nextMv, next.Envelope.Subject, true)
})
default:
if next == nil {
// We moved the last message, select the new last message
// instead of the first message
acct.Messages().Select(-1)
}
}
}