Skip to content

Commit 684e179

Browse files
committed
Improve port-in-use error with structured actions and recovery hints
1 parent 02fadd2 commit 684e179

File tree

8 files changed

+62
-12
lines changed

8 files changed

+62
-12
lines changed

internal/container/start.go

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -211,17 +211,29 @@ func selectContainersToStart(ctx context.Context, rt runtime.Runtime, sink outpu
211211
continue
212212
}
213213
if err := ports.CheckAvailable(c.Port); err != nil {
214-
configPath, pathErr := config.ConfigFilePath()
215-
if pathErr != nil {
216-
return nil, err
217-
}
218-
return nil, fmt.Errorf("%w\nTo use a different port, edit %s", err, configPath)
214+
emitPortInUseError(sink, c.Port)
215+
return nil, output.NewSilentError(err)
219216
}
220217
filtered = append(filtered, c)
221218
}
222219
return filtered, nil
223220
}
224221

222+
func emitPortInUseError(sink output.Sink, port string) {
223+
actions := []output.ErrorAction{
224+
{Label: "Stop existing emulator:", Value: "lstk stop"},
225+
}
226+
configPath, pathErr := config.ConfigFilePath()
227+
if pathErr == nil {
228+
actions = append(actions, output.ErrorAction{Label: "Use another port in the configuration:", Value: configPath})
229+
}
230+
output.EmitError(sink, output.ErrorEvent{
231+
Title: fmt.Sprintf("Port %s already in use", port),
232+
Summary: "LocalStack may already be running.",
233+
Actions: actions,
234+
})
235+
}
236+
225237
func validateLicense(ctx context.Context, rt runtime.Runtime, sink output.Sink, platformClient api.PlatformAPI, containerConfig runtime.ContainerConfig, token string) error {
226238
version := containerConfig.Tag
227239
if version == "" || version == "latest" {

internal/output/events.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ type SpinnerEvent struct {
3737
MinDuration time.Duration // Minimum time spinner should display (0 = use default)
3838
}
3939

40+
const ErrorActionPrefix = "==> "
41+
4042
type ErrorAction struct {
4143
Label string
4244
Value string

internal/output/plain_format.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ func formatErrorEvent(e ErrorEvent) string {
132132
sb.WriteString(e.Detail)
133133
}
134134
for _, action := range e.Actions {
135-
sb.WriteString("\n → ")
135+
sb.WriteString("\n " + ErrorActionPrefix)
136136
sb.WriteString(action.Label)
137137
sb.WriteString(" ")
138138
sb.WriteString(action.Value)

internal/output/plain_format_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ func TestFormatEventLine(t *testing.T) {
104104
{Label: "Start Docker:", Value: "open -a Docker"},
105105
},
106106
},
107-
want: "Error: Docker not running\n Cannot connect to Docker daemon\n Start Docker: open -a Docker",
107+
want: "Error: Docker not running\n Cannot connect to Docker daemon\n ==> Start Docker: open -a Docker",
108108
wantOK: true,
109109
},
110110
{

internal/output/plain_sink_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ func TestPlainSink_EmitsErrorEvent(t *testing.T) {
144144
Actions: []ErrorAction{{Label: "Start Docker:", Value: "open -a Docker"}},
145145
})
146146

147-
expected := "Error: Connection failed\n Cannot connect to Docker\n Start Docker: open -a Docker\n"
147+
expected := "Error: Connection failed\n Cannot connect to Docker\n ==> Start Docker: open -a Docker\n"
148148
assert.Equal(t, expected, out.String())
149149
}
150150

internal/ui/components/error_display.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,9 +62,9 @@ func (e ErrorDisplay) View(maxWidth int) string {
6262
sb.WriteString("\n")
6363
for i, action := range e.event.Actions {
6464
if i > 0 {
65-
sb.WriteString(styles.SecondaryMessage.Render("⇒ " + action.Label + " " + action.Value))
65+
sb.WriteString(styles.SecondaryMessage.Render(output.ErrorActionPrefix + action.Label + " " + action.Value))
6666
} else {
67-
sb.WriteString(styles.ErrorAction.Render("⇒ " + action.Label + " "))
67+
sb.WriteString(styles.ErrorAction.Render(output.ErrorActionPrefix + action.Label + " "))
6868
sb.WriteString(styles.Message.Bold(true).Render(action.Value))
6969
}
7070
sb.WriteString("\n")

internal/ui/components/error_display_test.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,40 @@ func TestErrorDisplay_ShowView(t *testing.T) {
5050
}
5151
}
5252

53+
func TestErrorDisplay_MultiActionRenders(t *testing.T) {
54+
t.Parallel()
55+
56+
e := NewErrorDisplay()
57+
e = e.Show(output.ErrorEvent{
58+
Title: "Port 4566 already in use",
59+
Summary: "LocalStack may already be running.",
60+
Actions: []output.ErrorAction{
61+
{Label: "Stop existing emulator:", Value: "lstk stop"},
62+
{Label: "Use another port in the configuration:", Value: "/home/user/.config/lstk/config.toml"},
63+
},
64+
})
65+
66+
view := e.View(80)
67+
if !strings.Contains(view, "Port 4566 already in use") {
68+
t.Fatalf("expected view to contain title, got: %q", view)
69+
}
70+
if !strings.Contains(view, "LocalStack may already be running.") {
71+
t.Fatalf("expected view to contain summary, got: %q", view)
72+
}
73+
if !strings.Contains(view, "Stop existing emulator:") {
74+
t.Fatalf("expected view to contain first action label, got: %q", view)
75+
}
76+
if !strings.Contains(view, "lstk stop") {
77+
t.Fatalf("expected view to contain first action value, got: %q", view)
78+
}
79+
if !strings.Contains(view, "Use another port in the configuration:") {
80+
t.Fatalf("expected view to contain second action label, got: %q", view)
81+
}
82+
if !strings.Contains(view, "/home/user/.config/lstk/config.toml") {
83+
t.Fatalf("expected view to contain second action value, got: %q", view)
84+
}
85+
}
86+
5387
func TestErrorDisplay_MinimalEvent(t *testing.T) {
5488
t.Parallel()
5589

test/integration/start_test.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,9 +88,11 @@ func TestStartCommandFailsWhenPortInUse(t *testing.T) {
8888
require.NoError(t, err, "failed to bind port 4566 for test")
8989
defer func() { _ = ln.Close() }()
9090

91-
_, stderr, err := runLstk(t, testContext(t), "", env.With(env.AuthToken, "fake-token"), "start")
91+
stdout, _, err := runLstk(t, testContext(t), "", env.With(env.AuthToken, "fake-token"), "start")
9292
require.Error(t, err, "expected lstk start to fail when port is in use")
93-
assert.Contains(t, stderr, "port 4566 already in use")
93+
assert.Contains(t, stdout, "Port 4566 already in use")
94+
assert.Contains(t, stdout, "LocalStack may already be running.")
95+
assert.Contains(t, stdout, "lstk stop")
9496
}
9597

9698
func cleanup() {

0 commit comments

Comments
 (0)