fix: guard terminal deferred quit state

This commit is contained in:
proxyctl coordinator
2026-07-17 10:40:52 +08:00
parent eb8932a319
commit e92a4788ba
2 changed files with 94 additions and 5 deletions
+8 -1
View File
@@ -77,7 +77,7 @@ func (m Model) Init() tea.Cmd { return nil }
// operation start checks it, so a completion can only belong to the one current
// operation rather than racing another operation for shared model state.
func (m Model) busy() bool {
return m.operationInFlight() || m.pendingActivation.Kind != 0
return m.quitRequested || m.operationInFlight() || m.pendingActivation.Kind != 0
}
func (m Model) operationInFlight() bool {
@@ -85,6 +85,13 @@ func (m Model) operationInFlight() bool {
}
func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
// A deferred tea.Quit is queued only after the in-flight completion has
// been consumed. Until Bubble Tea processes that QuitMsg, keep the model
// terminal so intervening input cannot cancel a confirmation or start a
// second mutation.
if m.quitRequested && !m.operationInFlight() {
return m, nil
}
if key, ok := message.(tea.KeyMsg); ok && m.operationInFlight() && isQuitKey(key) {
m.quitRequested = true
return m, nil
+86 -4
View File
@@ -2,6 +2,7 @@ package tui
import (
"errors"
"reflect"
"strings"
"testing"
@@ -288,8 +289,8 @@ func TestSingleOperationGuardRejectsMismatchedCompletionAndPreventsOverlap(t *te
refreshCompletion := refreshCommand()
updated, command = model.Update(refreshCompletion)
model = updated.(Model)
if command == nil || model.busy() || model.snapshot.ActiveProfile != "fresh" {
t.Fatal("current refresh completion did not apply fresh state and trigger deferred quit")
if command == nil || !model.busy() || model.snapshot.ActiveProfile != "fresh" {
t.Fatal("current refresh completion did not apply fresh state and enter terminal deferred-quit state")
}
if _, ok := command().(tea.QuitMsg); !ok {
t.Fatal("current completion did not return tea.Quit")
@@ -321,8 +322,8 @@ func TestBusyQuitWaitsForLongRunningTargetMutationCompletion(t *testing.T) {
}
updated, quit = model.Update(completion)
model = updated.(Model)
if quit == nil || model.busy() || model.snapshot.Targets[0].State != target.Enabled {
t.Fatalf("mutation completion was not consumed before deferred quit: busy=%v snapshot=%#v", model.busy(), model.snapshot)
if quit == nil || !model.busy() || model.snapshot.Targets[0].State != target.Enabled {
t.Fatalf("mutation completion was not consumed before terminal deferred quit: busy=%v snapshot=%#v", model.busy(), model.snapshot)
}
if _, ok := quit().(tea.QuitMsg); !ok {
t.Fatal("completion did not return tea.Quit")
@@ -331,6 +332,87 @@ func TestBusyQuitWaitsForLongRunningTargetMutationCompletion(t *testing.T) {
}
}
func TestDeferredQuitIsTerminalAfterTargetConfirmationCompletion(t *testing.T) {
initial := app.Snapshot{
ActiveProfile: "office",
Profiles: []profile.Profile{{Name: "office"}},
Targets: []app.TargetSnapshot{{ID: "docker-daemon", Description: target.Description{Name: "Docker daemon"}, Available: true, State: target.Disabled}},
}
module := &targetModule{snapshot: initial, result: app.Result{Snapshot: initial, Confirmation: "Docker will restart immediately."}}
model := NewModel(module)
updated, operation := model.Update(tea.KeyMsg{Type: tea.KeySpace})
model = updated.(Model)
updated, command := model.Update(keyMessage("q"))
model = updated.(Model)
if command != nil || !model.quitRequested {
t.Fatal("quit was not deferred during Target operation")
}
updated, queuedQuit := model.Update(operation())
model = updated.(Model)
if queuedQuit == nil || model.targetConfirm == nil || len(module.intents) != 1 {
t.Fatalf("confirmation completion did not queue quit: command=%v confirmation=%v intents=%#v", queuedQuit != nil, model.targetConfirm != nil, module.intents)
}
baselineSnapshot := model.snapshot
baselineStatus := model.status
baselinePage := model.page
baselineSelected := model.selected
baselineConfirmation := *model.targetConfirm
for _, message := range []tea.Msg{
keyMessage("y"), keyMessage("n"), tea.KeyMsg{Type: tea.KeyEsc}, keyMessage(" "),
keyMessage("r"), keyMessage("p"), keyMessage("?"), keyMessage("j"), queuedQuit(),
} {
updated, command = model.Update(message)
model = updated.(Model)
if command != nil {
t.Fatalf("terminal deferred-quit model returned a command for %T", message)
}
if !model.quitRequested || !reflect.DeepEqual(model.snapshot, baselineSnapshot) || model.status != baselineStatus || model.page != baselinePage || model.selected != baselineSelected || model.targetConfirm == nil || !reflect.DeepEqual(*model.targetConfirm, baselineConfirmation) || len(module.intents) != 1 {
t.Fatalf("terminal deferred-quit model changed for %T: model=%#v intents=%#v", message, model, module.intents)
}
}
}
func TestDeferredQuitIsTerminalAfterProfileConfirmationCompletion(t *testing.T) {
initial := app.Snapshot{Profiles: []profile.Profile{{Name: "office"}}}
updates := make(chan app.Result, 1)
updates <- app.Result{Snapshot: initial, Confirmation: "confirm transition"}
module := &progressiveTargetModule{targetModule: targetModule{snapshot: initial}, updates: updates}
model := updateKey(NewModel(module), "p")
updated, operation := model.Update(tea.KeyMsg{Type: tea.KeyEnter})
model = updated.(Model)
updated, command := model.Update(keyMessage("q"))
model = updated.(Model)
if command != nil || !model.quitRequested {
t.Fatal("quit was not deferred during Profile activation")
}
updated, queuedQuit := model.Update(operation())
model = updated.(Model)
if queuedQuit == nil || model.profileConfirm == nil || len(module.intents) != 1 {
t.Fatalf("Profile confirmation completion did not queue quit: command=%v confirmation=%v intents=%#v", queuedQuit != nil, model.profileConfirm != nil, module.intents)
}
baselineSnapshot := model.snapshot
baselineStatus := model.status
baselinePage := model.page
baselineSelected := model.selected
baselinePending := model.pendingActivation
baselineConfirmation := *model.profileConfirm
for _, message := range []tea.Msg{
keyMessage("y"), keyMessage("n"), tea.KeyMsg{Type: tea.KeyEsc}, tea.KeyMsg{Type: tea.KeyEnter},
keyMessage("d"), keyMessage("e"), keyMessage("a"), keyMessage("r"), queuedQuit(),
} {
updated, command = model.Update(message)
model = updated.(Model)
if command != nil {
t.Fatalf("terminal deferred-quit Profile model returned a command for %T", message)
}
if !model.quitRequested || !reflect.DeepEqual(model.snapshot, baselineSnapshot) || model.status != baselineStatus || model.page != baselinePage || model.selected != baselineSelected || !reflect.DeepEqual(model.pendingActivation, baselinePending) || model.profileConfirm == nil || !reflect.DeepEqual(*model.profileConfirm, baselineConfirmation) || len(module.intents) != 1 {
t.Fatalf("terminal deferred-quit Profile model changed for %T: model=%#v intents=%#v", message, model, module.intents)
}
}
}
func TestRefreshClampsSelectionAfterMultipleTargetsDisappear(t *testing.T) {
initial := app.Snapshot{Targets: []app.TargetSnapshot{
{ID: "one", Description: target.Description{Name: "One"}, Available: true},