Skip to content

Commit 6478b16

Browse files
committed
fix(context switching concurrency + dedicated health column)
1 parent 8aadf10 commit 6478b16

10 files changed

Lines changed: 182 additions & 85 deletions

File tree

internal/dao/common/utils.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@ func ParseStatus(s string) (status, age, health string) {
3333
status = "Paused"
3434
} else if detail != "" {
3535
health = detail
36-
status = fmt.Sprintf("Up (%s)", detail)
3736
}
3837
}
3938

internal/dao/docker.go

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ type DockerClient struct {
7878
Cli *client.Client
7979
Ctx context.Context
8080
ContextName string
81+
cancel context.CancelFunc
8182

8283
// Managers
8384
Container *container.Manager
@@ -135,12 +136,13 @@ func NewDockerClient(contextName string, apiTimeout time.Duration, defaultContex
135136
if err != nil {
136137
return nil, err
137138
}
138-
ctx := context.Background()
139+
ctx, cancel := context.WithCancel(context.Background())
139140

140141
return &DockerClient{
141142
Cli: cli,
142143
Ctx: ctx,
143144
ContextName: ctxName,
145+
cancel: cancel,
144146
Container: container.NewManager(cli, ctx),
145147
Image: image.NewManager(cli, ctx),
146148
Volume: volume.NewManager(cli, ctx),
@@ -157,6 +159,20 @@ func NewDockerClient(contextName string, apiTimeout time.Duration, defaultContex
157159
}, nil
158160
}
159161

162+
// Close cancels in-flight API calls and releases the client's idle connections.
163+
func (d *DockerClient) Close() error {
164+
if d == nil {
165+
return nil
166+
}
167+
if d.cancel != nil {
168+
d.cancel()
169+
}
170+
if d.Cli != nil {
171+
return d.Cli.Close()
172+
}
173+
return nil
174+
}
175+
160176
func initLogger() (*log.Logger, func()) {
161177
f, err := os.OpenFile("/tmp/d4s_debug_dao.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
162178
if err != nil {

internal/dao/docker/container/container.go

Lines changed: 14 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -75,43 +75,37 @@ type Container struct {
7575
}
7676

7777
func (c Container) GetID() string { return c.ID }
78+
79+
func (c Container) healthStatus() string {
80+
health := strings.TrimSpace(c.Health)
81+
health = strings.TrimSpace(strings.TrimPrefix(strings.ToLower(health), "health:"))
82+
if health == "" {
83+
return ""
84+
}
85+
return strings.ToUpper(health[:1]) + health[1:]
86+
}
87+
7888
func (c Container) GetCells() []string {
7989
id := c.ID
8090
if len(id) > 12 {
8191
id = id[:12]
8292
}
83-
return []string{id, c.Names, c.Image, c.Status, c.CPU, c.Mem, c.Age, c.IP, c.Ports, c.Compose, c.Cmd, c.Created}
93+
return []string{id, c.Names, c.Image, c.Status, c.healthStatus(), c.CPU, c.Mem, c.Age, c.IP, c.Ports, c.Compose, c.Cmd, c.Created}
8494
}
8595

8696
func (c Container) GetStatusColor() (tcell.Color, tcell.Color) {
87-
lower := strings.ToLower(c.State)
8897
health := strings.ToLower(strings.TrimSpace(c.Health))
8998
health = strings.TrimPrefix(health, "health: ")
9099

91100
switch health {
92101
case "healthy":
93-
return styles.ColorStatusGreen, styles.ColorBlack
102+
return styles.ColorIdle, styles.ColorBlack
94103
case "unhealthy":
95104
return styles.ColorStatusRed, styles.ColorBlack
96105
case "starting":
97106
return styles.ColorStatusBlue, styles.ColorBlack
98107
}
99108

100-
switch lower {
101-
case "paused":
102-
return styles.ColorStatusYellow, styles.ColorBlack
103-
case "restarting":
104-
return styles.ColorStatusOrange, styles.ColorBlack
105-
case "stopping":
106-
return styles.ColorStatusRed, styles.ColorBlack
107-
case "starting":
108-
return styles.ColorStatusBlue, styles.ColorBlack
109-
case "exited", "dead":
110-
return styles.ColorStatusGray, styles.ColorBlack
111-
case "created":
112-
return styles.ColorStatusBlue, styles.ColorBlack
113-
}
114-
115109
return styles.ColorIdle, styles.ColorBlack
116110
}
117111

@@ -127,6 +121,8 @@ func (c Container) GetColumnValue(column string) string {
127121
return c.IP
128122
case "status":
129123
return c.Status
124+
case "health":
125+
return c.healthStatus()
130126
case "age":
131127
return c.Age
132128
case "ports":

internal/ui/app.go

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"sort"
77
"strings"
88
"sync"
9+
"sync/atomic"
910
"time"
1011

1112
"runtime/debug"
@@ -44,6 +45,7 @@ type App struct {
4445
TviewApp *tview.Application
4546
Screen tcell.Screen
4647
Docker *dao.DockerClient
48+
dockerMx sync.RWMutex
4749
Cfg *config.Config
4850
PortForwards *portforward.Manager
4951

@@ -68,9 +70,11 @@ type App struct {
6870
LatestVersion string
6971

7072
// Concurrency
71-
pauseMx sync.RWMutex
72-
paused bool
73-
stopTicker chan struct{}
73+
pauseMx sync.RWMutex
74+
paused bool
75+
stopTicker chan struct{}
76+
contextSwitchGen atomic.Uint64
77+
contextSaveMx sync.Mutex
7478

7579
flashMx sync.Mutex
7680
flashExpiry time.Time
@@ -666,9 +670,19 @@ func (a *App) GetScreen() tcell.Screen {
666670
}
667671

668672
func (a *App) GetDocker() *dao.DockerClient {
673+
a.dockerMx.RLock()
674+
defer a.dockerMx.RUnlock()
669675
return a.Docker
670676
}
671677

678+
func (a *App) swapDocker(newDocker *dao.DockerClient) *dao.DockerClient {
679+
a.dockerMx.Lock()
680+
defer a.dockerMx.Unlock()
681+
oldDocker := a.Docker
682+
a.Docker = newDocker
683+
return oldDocker
684+
}
685+
672686
func (a *App) GetPortForwardManager() *portforward.Manager {
673687
return a.PortForwards
674688
}

internal/ui/app_context.go

Lines changed: 77 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,18 @@ import (
88
"github.com/jr-k/d4s/internal/config"
99
"github.com/jr-k/d4s/internal/dao"
1010
"github.com/jr-k/d4s/internal/ui/dialogs"
11+
"github.com/jr-k/d4s/internal/ui/styles"
1112
)
1213

1314
func (a *App) ShowContextPicker() {
14-
contexts, err := a.Docker.ListContexts()
15+
docker := a.GetDocker()
16+
contexts, err := docker.ListContexts()
1517
if err != nil {
1618
a.AppendFlashError(fmt.Sprintf("failed to load docker contexts: %v", err))
1719
return
1820
}
1921

20-
active := strings.TrimSpace(a.Docker.ContextName)
22+
active := strings.TrimSpace(docker.ContextName)
2123
saved := strings.TrimSpace(a.Cfg.D4S.DefaultContext)
2224

2325
items := make([]dialogs.PickerItem, 0, len(contexts))
@@ -66,14 +68,11 @@ func (a *App) SetDefaultContext(contextName string) {
6668
return
6769
}
6870

69-
if a.Docker != nil && a.Docker.ContextName == contextName {
70-
a.Cfg.D4S.DefaultContext = contextName
71-
if err := config.Save(a.Cfg); err != nil {
72-
a.AppendFlashError(fmt.Sprintf("failed to save default context: %v", err))
73-
return
74-
}
75-
76-
a.AppendFlashSuccess(contextSavedMessage(contextName))
71+
if docker := a.GetDocker(); docker != nil && docker.ContextName == contextName {
72+
// Re-selecting the active context cancels any older switch that may
73+
// still be preparing another client in the background.
74+
switchGen := a.contextSwitchGen.Add(1)
75+
a.saveDefaultContext(contextName, switchGen)
7776
a.updateHeader()
7877
return
7978
}
@@ -90,60 +89,94 @@ func (a *App) ReloadContext(contextName string) {
9089
return
9190
}
9291

92+
switchGen := a.contextSwitchGen.Add(1)
9393
a.SetFlashPending(fmt.Sprintf("switching context to %s...", contextName))
94-
a.SetPaused(true)
95-
a.StopAutoRefresh()
96-
97-
if a.ActiveInspector != nil {
98-
a.ActiveInspector.OnUnmount()
99-
a.ActiveInspector = nil
100-
}
101-
if a.Pages.HasPage("inspect") {
102-
a.Pages.RemovePage("inspect")
103-
}
104-
105-
a.SafeSetScope(nil)
106-
a.ActiveFilter = ""
107-
for _, v := range a.Views {
108-
v.SetLoading(true)
109-
// Free the fetch guard held by any fetch still running against the
110-
// old endpoint (possibly hung), and mark its results as stale.
111-
v.InvalidateFetch()
112-
}
113-
a.RestoreFocus()
114-
a.UpdateShortcuts()
11594

11695
a.RunInBackground(func() {
11796
newDocker, err := dao.NewDockerClient(contextName, a.Cfg.D4S.GetAPIServerTimeout(), contextName)
11897
if err != nil {
11998
a.TviewApp.QueueUpdateDraw(func() {
120-
a.SetPaused(false)
121-
a.StartAutoRefresh()
99+
if a.contextSwitchGen.Load() != switchGen {
100+
return
101+
}
122102
a.AppendFlashError(fmt.Sprintf("failed to switch context: %v", err))
123-
a.RefreshCurrentView()
124103
a.updateHeader()
125104
})
126105
return
127106
}
128107

129-
a.Cfg.D4S.DefaultContext = contextName
130-
saveErr := config.Save(a.Cfg)
131-
132108
a.TviewApp.QueueUpdateDraw(func() {
133-
a.Docker = newDocker
134-
a.SetPaused(false)
135-
a.StartAutoRefresh()
109+
if a.contextSwitchGen.Load() != switchGen {
110+
a.RunInBackground(func() {
111+
_ = newDocker.Close()
112+
})
113+
return
114+
}
115+
116+
if a.ActiveInspector != nil {
117+
a.ActiveInspector.OnUnmount()
118+
a.ActiveInspector = nil
119+
}
120+
if a.Pages.HasPage("inspect") {
121+
a.Pages.RemovePage("inspect")
122+
}
123+
124+
a.SafeSetScope(nil)
125+
a.ActiveFilter = ""
126+
currentPage, _ := a.Pages.GetFrontPage()
127+
for title, v := range a.Views {
128+
// Context metadata remains usable while resources from the
129+
// newly selected Docker endpoint are loading.
130+
if title == currentPage && title != styles.TitleContexts {
131+
v.SetLoading(true)
132+
} else if title != styles.TitleContexts {
133+
v.InvalidateData()
134+
}
135+
// Cancel requests against the previous endpoint and reject
136+
// any results that arrive after the client swap.
137+
v.InvalidateFetch()
138+
}
139+
140+
oldDocker := a.swapDocker(newDocker)
141+
if oldDocker != nil {
142+
a.RunInBackground(func() {
143+
_ = oldDocker.Close()
144+
})
145+
}
146+
147+
a.saveDefaultContext(contextName, switchGen)
148+
136149
a.RestoreFocus()
137150
a.UpdateShortcuts()
138151
a.updateHeader()
139152
a.RefreshCurrentView()
140-
a.preloadViews()
153+
})
154+
})
155+
}
156+
157+
func (a *App) saveDefaultContext(contextName string, switchGen uint64) {
158+
a.Cfg.D4S.DefaultContext = contextName
159+
cfg := *a.Cfg
160+
161+
a.RunInBackground(func() {
162+
// Serialize writes so an older context switch can never overwrite a
163+
// newer selection after a slow filesystem operation.
164+
a.contextSaveMx.Lock()
165+
defer a.contextSaveMx.Unlock()
166+
167+
if a.contextSwitchGen.Load() != switchGen {
168+
return
169+
}
170+
err := config.Save(&cfg)
141171

142-
if saveErr != nil {
143-
a.AppendFlashError(fmt.Sprintf("switched to %s, but failed to save default: %v", contextName, saveErr))
172+
a.TviewApp.QueueUpdateDraw(func() {
173+
if a.contextSwitchGen.Load() != switchGen {
174+
return
175+
}
176+
if err != nil {
177+
a.AppendFlashError(fmt.Sprintf("failed to save default context: %v", err))
144178
return
145179
}
146-
147180
a.AppendFlashSuccess(contextSavedMessage(contextName))
148181
})
149182
})

internal/ui/app_shortcuts.go

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import (
88
func (a *App) getCurrentShortcuts() []string {
99
page, _ := a.Pages.GetFrontPage()
1010
var shortcuts []string
11-
11+
1212
// Handle special pages (modals, logs) manually for now, or could attach view logic too.
1313
if page == "inspect" {
1414
if a.ActiveInspector != nil {
@@ -20,14 +20,14 @@ func (a *App) getCurrentShortcuts() []string {
2020
if view, ok := a.Views[page]; ok && view.ShortcutsFunc != nil {
2121
shortcuts = view.ShortcutsFunc()
2222
}
23-
23+
2424
shortcuts = append(shortcuts, common.FormatSCHeaderGlobal("tab", "Context"))
2525
shortcuts = append(shortcuts, common.FormatSCHeaderGlobal("shift ←/→", "Sort"))
2626
shortcuts = append(shortcuts, common.FormatSCHeaderGlobal("shift-c", "Copy Table"))
2727
shortcuts = append(shortcuts, common.FormatSCHeaderGlobal("c", "Copy Cell"))
2828
shortcuts = append(shortcuts, common.FormatSCHeaderGlobal("u", "Unselect All"))
2929
shortcuts = append(shortcuts, common.FormatSCHeaderGlobal("?", "Help"))
30-
30+
3131
return shortcuts
3232
}
3333

@@ -49,15 +49,18 @@ func (a *App) UpdateShortcuts() {
4949
}
5050

5151
func (a *App) updateHeader() {
52-
docker := a.Docker
52+
docker := a.GetDocker()
53+
if docker == nil {
54+
return
55+
}
5356
go func() {
5457
stats, err := docker.GetHostStats()
5558
if err != nil {
5659
return
5760
}
5861

5962
a.TviewApp.QueueUpdateDraw(func() {
60-
if a.Docker != docker {
63+
if a.GetDocker() != docker {
6164
return
6265
}
6366
shortcuts := a.getCurrentShortcuts()
@@ -68,7 +71,7 @@ func (a *App) updateHeader() {
6871
statsWithUsage, err := docker.GetHostStatsWithUsage()
6972
if err == nil {
7073
a.TviewApp.QueueUpdateDraw(func() {
71-
if a.Docker != docker {
74+
if a.GetDocker() != docker {
7275
return
7376
}
7477
shortcuts := a.getCurrentShortcuts()

0 commit comments

Comments
 (0)