1
0
Fork 0
mirror of https://git.sr.ht/~rjarry/aerc synced 2025-10-15 23:05:23 +02:00
aerc/app/terminal.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

224 lines
4.3 KiB
Go

package app
import (
"bytes"
"os/exec"
"sync/atomic"
"git.sr.ht/~rjarry/aerc/config"
"git.sr.ht/~rjarry/aerc/lib/log"
"git.sr.ht/~rjarry/aerc/lib/state"
"git.sr.ht/~rjarry/aerc/lib/ui"
"git.sr.ht/~rockorager/vaxis"
"git.sr.ht/~rockorager/vaxis/widgets/term"
)
type HasTerminal interface {
Terminal() *Terminal
}
type Terminal struct {
// Bell was rung in the terminal since it last had focus.
bell bool
closed int32
visible int32 // visible if >0
cmd *exec.Cmd
ctx *ui.Context
focus bool
vterm *term.Model
running bool
tab *ui.Tab
// The window title set by the application running in the terminal.
title string
OnClose func(err error)
OnEvent func(event vaxis.Event) bool
OnStart func()
OnTitle func(title string)
}
func NewTerminal(cmd *exec.Cmd) (*Terminal, error) {
term := &Terminal{
cmd: cmd,
vterm: term.New(),
visible: 1,
}
term.vterm.OSC8 = config.General().EnableOSC8
term.vterm.TERM = config.General().Term
return term, nil
}
func (term *Terminal) Close() {
term.closeErr(nil)
}
// TODO: replace with atomic.Bool when min go version will have it (1.19+)
const closed int32 = 1
func (term *Terminal) isClosed() bool {
return atomic.LoadInt32(&term.closed) == closed
}
func (term *Terminal) closeErr(err error) {
if atomic.SwapInt32(&term.closed, closed) == closed {
return
}
if term.vterm != nil {
// Stop receiving events
term.vterm.Detach()
term.vterm.Close()
if term.ctx != nil {
term.ctx.HideCursor()
}
}
if term.OnClose != nil {
term.OnClose(err)
}
ui.Invalidate()
}
func (term *Terminal) Destroy() {
// If we destroy, we don't want to call the OnClose callback
term.OnClose = nil
term.closeErr(nil)
}
func (term *Terminal) Invalidate() {
ui.Invalidate()
}
func (term *Terminal) Draw(ctx *ui.Context) {
if ctx.Width() == 0 || ctx.Height() == 0 {
return
}
term.ctx = ctx
if !term.running && term.cmd != nil {
term.vterm.Attach(term.HandleEvent)
w, h := ctx.Window().Size()
if err := term.vterm.StartWithSize(term.cmd, w, h); err != nil {
log.Errorf("error running terminal: %v", err)
term.closeErr(err)
return
}
term.running = true
if term.OnStart != nil {
term.OnStart()
}
}
term.vterm.Draw(ctx.Window())
}
func (term *Terminal) Show(visible bool) {
if visible {
atomic.StoreInt32(&term.visible, 1)
} else {
atomic.StoreInt32(&term.visible, 0)
}
}
func (term *Terminal) Terminal() *Terminal {
return term
}
func (term *Terminal) MouseEvent(localX int, localY int, event vaxis.Event) {
ev, ok := event.(vaxis.Mouse)
if !ok {
return
}
if term.OnEvent != nil {
term.OnEvent(ev)
}
if term.isClosed() {
return
}
ev.Row = localY
ev.Col = localX
term.vterm.Update(ev)
}
func (term *Terminal) Focus(focus bool) {
if term.isClosed() {
return
}
term.focus = focus
term.bell = false
if term.focus {
term.vterm.Focus()
} else {
term.vterm.Blur()
}
term.setTitle()
ui.Invalidate()
}
// HandleEvent is used to watch the underlying terminal events
func (t *Terminal) HandleEvent(ev vaxis.Event) {
if t.isClosed() {
return
}
switch ev := ev.(type) {
case vaxis.Redraw:
if atomic.LoadInt32(&t.visible) > 0 {
ui.Invalidate()
}
case term.EventTitle:
t.title = string(ev)
t.setTitle()
ui.Invalidate()
if t.OnTitle != nil {
t.OnTitle(string(ev))
}
case term.EventClosed:
t.Close()
ui.Invalidate()
case term.EventBell:
if !t.focus {
t.bell = true
t.setTitle()
ui.Invalidate()
}
aerc.Beep()
}
}
func (term *Terminal) Event(event vaxis.Event) bool {
if term.OnEvent != nil {
if term.OnEvent(event) {
return true
}
}
if term.isClosed() {
return false
}
term.vterm.Update(event)
return true
}
// Renders the terminal tab title based on the template, falls back to the
// unformatted title in case of errors.
func (t *Terminal) RenderTitle() string {
title := t.title
if title == "" {
title = t.cmd.Path
}
data := state.NewDataSetter()
data.SetTitle(title)
data.SetBell(t.bell)
var buf bytes.Buffer
if err := config.Ui().TabTitleTerminal.Execute(&buf, data); err != nil {
PushError("failed to render terminal tab title: " + err.Error())
return title
}
return buf.String()
}
func (t *Terminal) SetTab(tab *ui.Tab) {
t.tab = tab
}
func (t *Terminal) setTitle() {
if t.tab != nil {
t.tab.SetTitle(t.RenderTitle())
}
}