Skip to content

Commit fd9b21e

Browse files
appleboyclaude
andauthored
refactor(cli): fix timer leak, remove dead code, and consolidate error parsing (#27)
- Replace time.After with time.NewTimer in callback server to prevent goroutine leak - Remove unnecessary time.Sleep calls in browser and device flow error paths - Remove unused functions: GetFloat64, FlowUpdateType.String, FormatDurationCompact, FormatInterval - Extract parseOAuthError helper to consolidate triplicated error unmarshaling - Delegate error formatting in doTokenExchange to formatHTTPError Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent cac3dab commit fd9b21e

7 files changed

Lines changed: 23 additions & 119 deletions

File tree

browser_flow.go

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -189,14 +189,11 @@ func performBrowserFlowWithUpdates(
189189
}
190190
// Hard error (CSRF mismatch, token exchange failure, OAuth rejection,
191191
// etc.) — surface it to the user.
192-
// Send error update and give UI time to process it
193192
updates <- tui.FlowUpdate{
194193
Type: tui.StepError,
195194
Step: 3,
196195
Message: err.Error(),
197196
}
198-
// Small delay to ensure error is displayed before returning
199-
time.Sleep(100 * time.Millisecond)
200197
return nil, false, fmt.Errorf("authentication failed: %w", err)
201198
}
202199

callback.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,9 @@ func startCallbackServer(ctx context.Context, port int, expectedState string,
158158
_ = srv.Shutdown(shutdownCtx)
159159
}()
160160

161+
timer := time.NewTimer(callbackTimeout)
162+
defer timer.Stop()
163+
161164
select {
162165
case result := <-resultCh:
163166
if result.Error != "" {
@@ -168,7 +171,7 @@ func startCallbackServer(ctx context.Context, port int, expectedState string,
168171
}
169172
return result.Storage, nil
170173

171-
case <-time.After(callbackTimeout):
174+
case <-timer.C:
172175
return nil, fmt.Errorf("%w after %s", ErrCallbackTimeout, callbackTimeout)
173176

174177
case <-ctx.Done():

device_flow.go

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -104,8 +104,8 @@ func handleDevicePollError(
104104
return pollErrorResult{pollFail, fmt.Errorf("token exchange failed: %w", err)}
105105
}
106106

107-
var errResp ErrorResponse
108-
if json.Unmarshal(oauthErr.Body, &errResp) != nil || errResp.Error == "" {
107+
errResp, ok := parseOAuthError(oauthErr.Body)
108+
if !ok {
109109
return pollErrorResult{
110110
pollFail,
111111
fmt.Errorf("token exchange failed (body: %s): %w", oauthErr.Body, err),
@@ -253,8 +253,6 @@ func performDeviceFlowWithUpdates(
253253
Step: 2,
254254
Message: fmt.Sprintf("Authorization failed: %v", err),
255255
}
256-
// Small delay to ensure error is displayed before returning
257-
time.Sleep(100 * time.Millisecond)
258256
return nil, fmt.Errorf("token poll failed: %w", err)
259257
}
260258

tokens.go

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,17 @@ type ErrorResponse struct {
3030
ErrorDescription string `json:"error_description"`
3131
}
3232

33+
// parseOAuthError attempts to unmarshal an OAuth error response from raw JSON.
34+
// Returns the parsed response and true if successful, or a zero value and false
35+
// if the body is not a valid OAuth error (missing "error" field).
36+
func parseOAuthError(body []byte) (ErrorResponse, bool) {
37+
var errResp ErrorResponse
38+
if err := json.Unmarshal(body, &errResp); err != nil || errResp.Error == "" {
39+
return ErrorResponse{}, false
40+
}
41+
return errResp, true
42+
}
43+
3344
// readResponseBody reads the response body with a size limit to guard against oversized responses.
3445
func readResponseBody(resp *http.Response) ([]byte, error) {
3546
body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBodySize))
@@ -42,10 +53,10 @@ func readResponseBody(resp *http.Response) ([]byte, error) {
4253
// formatHTTPError attempts to parse an OAuth error response from body,
4354
// falling back to a generic status+body error message.
4455
func formatHTTPError(body []byte, statusCode int) error {
45-
var errResp ErrorResponse
46-
if json.Unmarshal(body, &errResp) == nil && errResp.Error != "" {
47-
if errResp.ErrorDescription != "" {
48-
return fmt.Errorf("%s: %s", errResp.Error, errResp.ErrorDescription)
56+
if errResp, ok := parseOAuthError(body); ok {
57+
desc := strings.TrimSpace(errResp.ErrorDescription)
58+
if desc != "" {
59+
return fmt.Errorf("%s: %s", errResp.Error, desc)
4960
}
5061
return fmt.Errorf("%s", errResp.Error)
5162
}
@@ -83,18 +94,13 @@ func doTokenExchange(
8394
}
8495

8596
if resp.StatusCode != http.StatusOK {
86-
var errResp ErrorResponse
87-
if jsonErr := json.Unmarshal(body, &errResp); jsonErr == nil && errResp.Error != "" {
97+
if errResp, ok := parseOAuthError(body); ok {
8898
if errHook != nil {
8999
if hookErr := errHook(errResp, body); hookErr != nil {
90100
return nil, hookErr
91101
}
92102
}
93-
desc := strings.TrimSpace(errResp.ErrorDescription)
94-
if desc == "" {
95-
return nil, fmt.Errorf("%s", errResp.Error)
96-
}
97-
return nil, fmt.Errorf("%s: %s", errResp.Error, desc)
103+
return nil, formatHTTPError(body, resp.StatusCode)
98104
}
99105
return nil, fmt.Errorf(
100106
"token exchange failed with status %d: %s",

tui/manager_test.go

Lines changed: 0 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,6 @@ func TestFlowUpdateHelpers(t *testing.T) {
112112
Data: map[string]any{
113113
"string_val": "hello",
114114
"int_val": 42,
115-
"float_val": 3.14,
116115
"duration_val": 5000000000, // 5 seconds in nanoseconds
117116
},
118117
}
@@ -127,11 +126,6 @@ func TestFlowUpdateHelpers(t *testing.T) {
127126
t.Errorf("GetInt: expected 42, got %d", got)
128127
}
129128

130-
// Test GetFloat64
131-
if got := update.GetFloat64("float_val"); got != 3.14 {
132-
t.Errorf("GetFloat64: expected 3.14, got %f", got)
133-
}
134-
135129
// Test missing keys return zero values
136130
if got := update.GetString("missing"); got != "" {
137131
t.Errorf("GetString (missing): expected empty string, got '%s'", got)
@@ -163,29 +157,3 @@ func TestFlowUpdate_FallbackField(t *testing.T) {
163157
}
164158
})
165159
}
166-
167-
func TestFlowUpdateTypeString(t *testing.T) {
168-
tests := []struct {
169-
updateType FlowUpdateType
170-
expected string
171-
}{
172-
{StepStart, "StepStart"},
173-
{StepProgress, "StepProgress"},
174-
{StepComplete, "StepComplete"},
175-
{StepError, "StepError"},
176-
{TimerTick, "TimerTick"},
177-
{BrowserOpened, "BrowserOpened"},
178-
{CallbackReceived, "CallbackReceived"},
179-
{DeviceCodeReceived, "DeviceCodeReceived"},
180-
{PollingUpdate, "PollingUpdate"},
181-
{BackoffChanged, "BackoffChanged"},
182-
}
183-
184-
for _, tt := range tests {
185-
t.Run(tt.expected, func(t *testing.T) {
186-
if got := tt.updateType.String(); got != tt.expected {
187-
t.Errorf("Expected %s, got %s", tt.expected, got)
188-
}
189-
})
190-
}
191-
}

tui/messages.go

Lines changed: 0 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -42,34 +42,6 @@ type FlowUpdate struct {
4242
Data map[string]any // Additional data for specific update types
4343
}
4444

45-
// String returns a human-readable representation of the FlowUpdateType.
46-
func (t FlowUpdateType) String() string {
47-
switch t {
48-
case StepStart:
49-
return "StepStart"
50-
case StepProgress:
51-
return "StepProgress"
52-
case StepComplete:
53-
return "StepComplete"
54-
case StepError:
55-
return "StepError"
56-
case TimerTick:
57-
return "TimerTick"
58-
case BrowserOpened:
59-
return "BrowserOpened"
60-
case CallbackReceived:
61-
return "CallbackReceived"
62-
case DeviceCodeReceived:
63-
return "DeviceCodeReceived"
64-
case PollingUpdate:
65-
return "PollingUpdate"
66-
case BackoffChanged:
67-
return "BackoffChanged"
68-
default:
69-
return "Unknown"
70-
}
71-
}
72-
7345
// Helper functions to extract data from FlowUpdate.Data
7446

7547
// GetString safely extracts a string value from Data.
@@ -104,14 +76,3 @@ func (u *FlowUpdate) GetDuration(key string) time.Duration {
10476
}
10577
return 0
10678
}
107-
108-
// GetFloat64 safely extracts a float64 value from Data.
109-
func (u *FlowUpdate) GetFloat64(key string) float64 {
110-
if u.Data == nil {
111-
return 0
112-
}
113-
if val, ok := u.Data[key].(float64); ok {
114-
return val
115-
}
116-
return 0
117-
}

tui/styles.go

Lines changed: 0 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -22,21 +22,6 @@ var (
2222
colorBright = lipgloss.Color("#FFFFFF")
2323
)
2424

25-
// FormatDurationCompact formats a duration in MM:SS format (e.g., "2:05").
26-
// Negative durations are formatted with a leading "-" (e.g., "-1:05").
27-
func FormatDurationCompact(d time.Duration) string {
28-
sign := ""
29-
if d < 0 {
30-
sign = "-"
31-
d = -d
32-
}
33-
d = d.Round(time.Second)
34-
totalSeconds := int(d.Seconds())
35-
minutes := totalSeconds / 60
36-
seconds := totalSeconds % 60
37-
return fmt.Sprintf("%s%d:%02d", sign, minutes, seconds)
38-
}
39-
4025
// FormatDurationHuman formats a duration in human-readable format (e.g., "1h 30m", "5m", "30s").
4126
func FormatDurationHuman(d time.Duration) string {
4227
if d < 0 {
@@ -64,20 +49,6 @@ func FormatDurationHuman(d time.Duration) string {
6449
return fmt.Sprintf("%ds", seconds)
6550
}
6651

67-
// FormatInterval formats a duration as a compact interval string (e.g., "5s", "2m30s").
68-
func FormatInterval(d time.Duration) string {
69-
seconds := int(d.Seconds())
70-
if seconds < 60 {
71-
return fmt.Sprintf("%ds", seconds)
72-
}
73-
minutes := seconds / 60
74-
seconds %= 60
75-
if seconds == 0 {
76-
return fmt.Sprintf("%dm", minutes)
77-
}
78-
return fmt.Sprintf("%dm%ds", minutes, seconds)
79-
}
80-
8152
// maskTokenPreview masks token for preview display (shows first 8 and last 4 chars)
8253
func maskTokenPreview(token string) string {
8354
if len(token) <= 16 {

0 commit comments

Comments
 (0)