From bfd3b7b47e57b423c11cdfba58f0a2a5938eaabb Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 8 Jul 2026 14:47:06 +0200 Subject: [PATCH] Allow Then and BatchUIUpdates to work with an async refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Then, and BatchUIUpdates, previously only worked for a SYNC refresh: the calling goroutine blocked in wg.Wait until every scope had finished, and only then flushed the batch and ran Then. An ASYNC refresh had no such join point — it dispatched each scope onto its own worker and returned right away — so Then was forbidden (it would have run before the scopes finished) and a batch would never be drained. Give the async path a join of its own. Both paths now register their scopes in the WaitGroup, and the finishing work — wg.Wait, the batch flush, and Then — moves into a closure. A SYNC refresh runs it inline as before; an ASYNC refresh dispatches it to a worker, so the caller still returns immediately but the batch and Then run once every scope is done. Besides lifting the restriction, this makes SYNC and ASYNC differ only in whether the finishing work blocks the caller, which is what lets a later commit drop the mode entirely and key the choice off the calling thread instead. --- pkg/gui/controllers/helpers/refresh_helper.go | 61 +++++++++++-------- 1 file changed, 35 insertions(+), 26 deletions(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 002391aa1..dd6d948f7 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -142,10 +142,6 @@ func (self *refreshBounceBatch) close() []func() { } func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFromWorker bool) { - if options.Mode == types.ASYNC && options.Then != nil { - panic("RefreshOptions.Then doesn't work with mode ASYNC") - } - t := time.Now() defer func() { self.c.Log.Infof("Refresh took %s", time.Since(t)) @@ -234,16 +230,18 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr wg := sync.WaitGroup{} refresh := func(name string, f func()) { + wg.Add(1) + // if we're in a demo we don't want any async refreshes because // everything happens fast and it's better to have everything update // in the one frame if !self.c.InDemo() && options.Mode == types.ASYNC { self.onWorker(env.background, func(t gocui.Task) error { + defer wg.Done() f() return nil }) } else { - wg.Add(1) go utils.Safe(func() { t := time.Now() defer wg.Done() @@ -410,30 +408,41 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr self.refreshStatus(env) - wg.Wait() + waitAndFinalize := func() { + wg.Wait() - if env.batch != nil { - // Apply all the scopes' collected bounces in a single UI-thread task, - // so they land in one frame: gocui drains every queued event before it - // redraws, so one task means one repaint. Bounces enqueued from within - // these (see refreshBounceBatch) run as ordinary follow-ups. - bounces := env.batch.close() - self.onUIThread(env.background, func() error { - for _, bounce := range bounces { - bounce() - } - return nil - }) + if env.batch != nil { + // Apply all the scopes' collected bounces in a single UI-thread task, + // so they land in one frame: gocui drains every queued event before it + // redraws, so one task means one repaint. Bounces enqueued from within + // these (see refreshBounceBatch) run as ordinary follow-ups. + bounces := env.batch.close() + self.onUIThread(env.background, func() error { + for _, bounce := range bounces { + bounce() + } + return nil + }) + } + + if options.Then != nil { + // Queue Then via OnUIThread so it runs *after* the refresh-scope + // functions' model-update bounces (which are already queued by + // now), not synchronously here — at this point the workers have + // returned but their bounces haven't been processed yet, so + // invoking Then synchronously would run it on a model that's + // still pre-refresh. + self.onUIThread(env.background, options.Then) + } } - if options.Then != nil { - // Queue Then via OnUIThread so it runs *after* the refresh-scope - // functions' model-update bounces (which are already queued by - // now), not synchronously here — at this point the workers have - // returned but their bounces haven't been processed yet, so - // invoking Then synchronously would run it on a model that's - // still pre-refresh. - self.onUIThread(env.background, options.Then) + if options.Mode == types.SYNC { + waitAndFinalize() + } else { + self.onWorker(env.background, func(t gocui.Task) error { + waitAndFinalize() + return nil + }) } }