Files
aerc-fork-mirror/main.go
Robin Jarry 51fd25c0f1 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-23 14:02:37 +02:00

325 lines
8.8 KiB
Go

package main
import (
"context"
"errors"
"fmt"
"os"
"runtime"
"sort"
"strings"
"sync"
"time"
"git.sr.ht/~rjarry/go-opt/v2"
"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/crypto"
"git.sr.ht/~rjarry/aerc/lib/hooks"
"git.sr.ht/~rjarry/aerc/lib/ipc"
"git.sr.ht/~rjarry/aerc/lib/log"
"git.sr.ht/~rjarry/aerc/lib/pinentry"
"git.sr.ht/~rjarry/aerc/lib/templates"
"git.sr.ht/~rjarry/aerc/lib/ui"
"git.sr.ht/~rjarry/aerc/models"
"git.sr.ht/~rjarry/aerc/worker/types"
_ "git.sr.ht/~rjarry/aerc/commands/account"
_ "git.sr.ht/~rjarry/aerc/commands/compose"
_ "git.sr.ht/~rjarry/aerc/commands/msg"
_ "git.sr.ht/~rjarry/aerc/commands/msgview"
_ "git.sr.ht/~rjarry/aerc/commands/patch"
)
func execCommand(
cmdline string,
acct *config.AccountConfig, msg *models.MessageInfo,
) error {
cmdline, cmd, err := commands.ResolveCommand(cmdline, acct, msg)
if err != nil {
return err
}
err = commands.ExecuteCommand(cmd, cmdline)
if errors.As(err, new(commands.ErrorExit)) {
ui.Exit()
return nil
}
return err
}
func getCompletions(ctx context.Context, cmdline string) ([]opt.Completion, string) {
// complete template terms
if options, prefix, ok := commands.GetTemplateCompletion(cmdline); ok {
sort.Strings(options)
completions := make([]opt.Completion, 0, len(options))
for _, o := range options {
completions = append(completions, opt.Completion{
Value: o,
Description: "Template",
})
}
return completions, prefix
}
args := opt.LexArgs(cmdline)
if args.Count() < 2 && args.TrailingSpace() == "" {
// complete command names
var completions []opt.Completion
for _, cmd := range commands.ActiveCommands() {
for _, alias := range cmd.Aliases() {
if strings.HasPrefix(alias, cmdline) {
completions = append(completions, opt.Completion{
Value: alias + " ",
Description: cmd.Description(),
})
}
}
}
sort.Slice(completions, func(i, j int) bool {
return completions[i].Value < completions[j].Value
})
return completions, ""
}
// complete command arguments
_, cmd, err := commands.ExpandAbbreviations(args.Arg(0))
if err != nil {
return nil, cmdline
}
return commands.GetCompletions(cmd, args)
}
// set at build time
var (
Version string
Date string
)
func buildInfo() string {
info := Version
if soVersion, hasNotmuch := lib.NotmuchVersion(); hasNotmuch {
info += fmt.Sprintf(" +notmuch-%s", soVersion)
}
info += fmt.Sprintf(" (%s %s %s %s)",
runtime.Version(), runtime.GOARCH, runtime.GOOS, Date)
return info
}
type Opts struct {
Help bool `opt:"-h,--help" action:"ShowHelp"`
Version bool `opt:"-v,--version" action:"ShowVersion"`
Accounts []string `opt:"-a,--account" action:"ParseAccounts" metavar:"<name>"`
ConfAerc string `opt:"-C,--aerc-conf" metavar:"<file>"`
ConfAccounts string `opt:"-A,--accounts-conf" metavar:"<file>"`
ConfBinds string `opt:"-B,--binds-conf" metavar:"<file>"`
NoIPC bool `opt:"-I,--no-ipc"`
Command []string `opt:"..." required:"false" metavar:"mailto:<address> | mbox:<file> | :<command...>"`
}
func (o *Opts) ShowHelp(arg string) error {
fmt.Println("Usage: " + opt.NewCmdSpec(os.Args[0], o).Usage())
fmt.Print(`
Aerc is an email client for your terminal.
Options:
-h, --help Show this help message and exit.
-v, --version Print version information.
-a <name>, --account <name>
Load only the named account, as opposed to all configured
accounts. It can also be a comma separated list of names.
This option may be specified multiple times. The account
order will be preserved.
-C <file>, --aerc-conf <file>
Path to configuration file to be used instead of the default.
-A <file>, --accounts-conf <file>
Path to configuration file to be used instead of the default.
-B <file>, --binds-conf <file>
Path to configuration file to be used instead of the default.
-I, --no-ipc Run any commands in this aerc instance, and don't create a
socket for other aerc instances to communicate with this one.
mailto:<address> Open the composer with the address(es) in the To field.
If aerc is already running, the composer is started in
this instance, otherwise aerc will be started.
mbox:<file> Open the specified mbox file as a virtual temporary account.
:<command...> Run an aerc command as you would in Ex-Mode.
`)
os.Exit(0)
return nil
}
func (o *Opts) ShowVersion(arg string) error {
fmt.Println("aerc " + log.BuildInfo)
os.Exit(0)
return nil
}
func (o *Opts) ParseAccounts(arg string) error {
o.Accounts = append(o.Accounts, strings.Split(arg, ",")...)
return nil
}
func die(format string, args ...any) {
fmt.Fprintf(os.Stderr, "error: "+format+"\n", args...)
os.Exit(1)
}
func main() {
defer log.PanicHandler()
log.BuildInfo = buildInfo()
var opts Opts
args := opt.QuoteArgs(os.Args...)
err := opt.ArgsToStruct(args, &opts)
if err != nil {
die("%s", err)
}
switch {
case len(opts.Command) == 0:
break
case strings.HasPrefix(opts.Command[0], ":"):
case strings.HasPrefix(opts.Command[0], "mailto:"):
case strings.HasPrefix(opts.Command[0], "mbox:"):
break
default:
die("unknown argument: %s", opts.Command[0])
}
err = config.LoadConfigFromFile(
nil, opts.Accounts, opts.ConfAerc, opts.ConfBinds, opts.ConfAccounts,
)
if err != nil {
die("%s", err)
}
noIPC := opts.NoIPC || config.General().DisableIPC
if len(opts.Command) > 0 && !noIPC &&
!(config.General().DisableIPCMailto && strings.HasPrefix(opts.Command[0], "mailto:")) &&
!(config.General().DisableIPCMbox && strings.HasPrefix(opts.Command[0], "mbox:")) {
response, err := ipc.ConnectAndExec(opts.Command)
if err == nil {
if response.Error != "" {
fmt.Printf("response: %s\n", response.Error)
}
return // other aerc instance takes over
}
// continue with setting up a new aerc instance and retry after init
}
log.Infof("Starting up version %s", log.BuildInfo)
deferLoop := make(chan struct{})
c := crypto.New()
err = c.Init()
if err != nil {
log.Warnf("failed to initialise crypto interface: %v", err)
}
defer c.Close()
app.Init(c, execCommand, getCompletions, &commands.CmdHistory, deferLoop)
err = ui.Initialize(app.Drawable())
if err != nil {
panic(err)
}
defer ui.Close()
log.UICleanup = func() {
ui.Close()
}
close(deferLoop)
config.EnablePinentry = pinentry.Enable
config.DisablePinentry = pinentry.Disable
config.SetPinentryEnv = pinentry.SetCmdEnv
startup, startupDone := context.WithCancel(context.Background())
if !noIPC {
as, err := ipc.StartServer(app.IPCHandler(), startup)
if err != nil {
log.Warnf("Failed to start Unix server: %v", err)
} else {
defer as.Close()
}
}
// set the aerc version so that we can use it in the template funcs
templates.SetVersion(Version)
templates.SetExecPath(config.SearchDirs)
endStartup := func() {
startupDone()
if len(opts.Command) == 0 {
return
}
// Retry execution. Since IPC has already failed, we know no
// other aerc instance is running (or IPC was explicitly
// disabled); run the command directly.
err := app.Command(opts.Command)
if err != nil {
// no other aerc instance is running, so let
// this one stay running but show the error
errMsg := fmt.Sprintf("Startup command (%s) failed: %s\n",
strings.Join(opts.Command, " "), err)
log.Errorf(errMsg)
app.PushError(errMsg)
}
}
go func() {
defer log.PanicHandler()
err := hooks.RunHook(&hooks.AercStartup{Version: Version})
if err != nil {
msg := fmt.Sprintf("aerc-startup hook: %s", err)
app.PushError(msg)
}
}()
defer func(start time.Time) {
err := hooks.RunHook(
&hooks.AercShutdown{Lifetime: time.Since(start)},
)
if err != nil {
log.Errorf("aerc-shutdown hook: %s", err)
}
}(time.Now())
var once sync.Once
loop:
for {
select {
case event := <-ui.Events:
ui.HandleEvent(event)
case msg := <-types.WorkerMessages:
app.HandleMessage(msg)
// XXX: The app may not be 100% ready at this point.
// The issue is that there is no real way to tell when
// it will be ready. And in some cases, it may never be.
// At least, we can be confident that accepting IPC
// commands will not crash the whole process.
once.Do(endStartup)
case callback := <-ui.Callbacks:
callback()
case <-ui.Redraw:
ui.Render()
case <-ui.SuspendQueue:
err = ui.Suspend()
if err != nil {
app.PushError(fmt.Sprintf("suspend: %s", err))
}
case <-ui.Quit:
err = app.CloseBackends()
if err != nil {
log.Warnf("failed to close backends: %v", err)
}
break loop
}
}
}