Files
aerc-fork-mirror/lib/ui/ui.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

209 lines
4.5 KiB
Go

package ui
import (
"os"
"os/signal"
"sync/atomic"
"syscall"
"git.sr.ht/~rjarry/aerc/config"
"git.sr.ht/~rjarry/aerc/lib/log"
"git.sr.ht/~rockorager/vaxis"
)
// Use unbuffered channels (always blocking unless somebody can read
// immediately) We are merely using this as a proxy to the internal vaxis event
// channel.
var Events = make(chan vaxis.Event)
var Quit = make(chan struct{})
var Callbacks = make(chan func(), 50)
// QueueFunc queues a function to be called in the main goroutine. This can be
// used to prevent race conditions from delayed functions
func QueueFunc(fn func()) {
Callbacks <- fn
}
// Use a buffered channel of size 1 to avoid blocking callers of Invalidate()
var Redraw = make(chan bool, 1)
// Invalidate marks the entire UI as invalid and request a redraw as soon as
// possible. Invalidate can be called from any goroutine and will never block.
func Invalidate() {
if atomic.SwapUint32(&state.dirty, 1) != 1 {
Redraw <- true
}
}
var state struct {
content DrawableInteractive
ctx *Context
vx *vaxis.Vaxis
popover *Popover
dirty uint32 // == 1 if render has been queued in Redraw channel
// == 1 if suspend is pending
suspending uint32
refresh uint32 // == 1 if a refresh has been queued
}
func Initialize(content DrawableInteractive) error {
opts := vaxis.Options{
DisableMouse: !config.Ui().MouseEnabled,
CSIuBitMask: vaxis.CSIuDisambiguate,
WithTTY: "/dev/tty",
}
vx, err := vaxis.New(opts)
if err != nil {
return err
}
vx.Window().Clear()
vx.HideCursor()
state.content = content
state.vx = vx
state.ctx = NewContext(state.vx, onPopover)
vx.SetTitle("aerc")
Invalidate()
if beeper, ok := content.(DrawableInteractiveBeeper); ok {
beeper.OnBeep(vx.Bell)
}
content.Focus(true)
go func() {
defer log.PanicHandler()
for event := range vx.Events() {
Events <- event
}
}()
return nil
}
func onPopover(p *Popover) {
state.popover = p
}
func Exit() {
close(Quit)
}
var SuspendQueue = make(chan bool, 1)
func QueueSuspend() {
if atomic.SwapUint32(&state.suspending, 1) != 1 {
SuspendQueue <- true
}
}
// SuspendScreen should be called from the main thread.
func SuspendScreen() {
_ = state.vx.Suspend()
}
func ResumeScreen() {
err := state.vx.Resume()
if err != nil {
log.Errorf("ui: cannot resume after suspend: %v", err)
}
Invalidate()
}
func Suspend() error {
var err error
if atomic.SwapUint32(&state.suspending, 0) != 0 {
err = state.vx.Suspend()
if err == nil {
sigcont := make(chan os.Signal, 1)
signal.Notify(sigcont, syscall.SIGCONT)
err = syscall.Kill(0, syscall.SIGTSTP)
if err == nil {
<-sigcont
}
signal.Reset(syscall.SIGCONT)
err = state.vx.Resume()
state.content.Draw(state.ctx)
state.vx.Render()
}
}
return err
}
func Close() {
state.vx.Close()
}
func QueueRefresh() {
if atomic.SwapUint32(&state.refresh, 1) != 1 {
Invalidate()
}
}
func PushClipboard(text string) {
state.vx.ClipboardPush(text)
}
func Render() {
if atomic.SwapUint32(&state.dirty, 0) != 0 {
state.vx.Window().Clear()
// reset popover for the next Draw
state.popover = nil
state.vx.HideCursor()
state.content.Draw(state.ctx)
if state.popover != nil {
// if the Draw resulted in a popover, draw it
state.popover.Draw(state.ctx)
}
switch atomic.SwapUint32(&state.refresh, 0) {
case 0:
state.vx.Render()
case 1:
state.vx.Refresh()
}
}
}
func HandleEvent(event vaxis.Event) {
switch event := event.(type) {
case vaxis.Resize:
state.ctx = NewContext(state.vx, onPopover)
Invalidate()
case vaxis.Redraw:
Invalidate()
default:
// We never care about num or caps lock. Remove them so it
// doesn't interfere with key matching
if key, ok := event.(vaxis.Key); ok {
log.Tracef("registered key event: %#v", key)
key.Modifiers &^= vaxis.ModCapsLock
key.Modifiers &^= vaxis.ModNumLock
event = remapKeys(key)
}
// if we have a popover, and it can handle the event, it does so
if state.popover == nil || !state.popover.Event(event) {
// otherwise, we send the event to the main content
state.content.Event(event)
}
}
}
func remapKeys(key vaxis.Key) vaxis.Key {
// accept number block <CR> as regular <CR>
switch key.Keycode {
case vaxis.KeyKeyPadEnter:
key.Keycode = vaxis.KeyEnter
case vaxis.KeyKeyPadUp:
key.Keycode = vaxis.KeyUp
case vaxis.KeyKeyPadDown:
key.Keycode = vaxis.KeyDown
case vaxis.KeyKeyPadLeft:
key.Keycode = vaxis.KeyLeft
case vaxis.KeyKeyPadRight:
key.Keycode = vaxis.KeyRight
}
return key
}