detached db3c5c7

fix(download): surface CEF interrupt codes more clearly

commit
db3c5c7cd3dda4a9ff60aaf9fd38da121dd7e09b
author
brice · · 2026-04-19 19:55 UTC
parent
4c6e36a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +118 -14
M internal/infrastructure/cef/download_handler.go +66 -1
@@ -31,10 +31,72 @@ type cefDownloadState struct {
3131 destination string
3232 }
3333
34+type cefDownloadInterruptError struct {
35+ filename string
36+ reason purecef.DownloadInterruptReason
37+}
38+
39+func (e cefDownloadInterruptError) Error() string {
40+ return fmt.Sprintf(
41+ "download interrupted: %s (code=%d, reason=%s)",
42+ e.filename,
43+ e.reason,
44+ downloadInterruptReasonLabel(e.reason),
45+ )
46+}
47+
48+func (e cefDownloadInterruptError) DownloadErrorCode() int {
49+ return int(e.reason)
50+}
51+
52+func (e cefDownloadInterruptError) DownloadErrorReason() string {
53+ return downloadInterruptReasonLabel(e.reason)
54+}
55+
3456 type cefDownloadResponseAdapter struct {
3557 item purecef.DownloadItem
3658 }
3759
60+var downloadInterruptReasonLabels = map[purecef.DownloadInterruptReason]string{
61+ purecef.DownloadInterruptReasonNone: "none",
62+ purecef.DownloadInterruptReasonFileFailed: "file_failed",
63+ purecef.DownloadInterruptReasonFileAccessDenied: "file_access_denied",
64+ purecef.DownloadInterruptReasonFileNoSpace: "file_no_space",
65+ purecef.DownloadInterruptReasonFileNameTooLong: "file_name_too_long",
66+ purecef.DownloadInterruptReasonFileTooLarge: "file_too_large",
67+ purecef.DownloadInterruptReasonFileVirusInfected: "file_virus_infected",
68+ purecef.DownloadInterruptReasonFileTransientError: "file_transient_error",
69+ purecef.DownloadInterruptReasonFileBlocked: "file_blocked",
70+ purecef.DownloadInterruptReasonFileSecurityCheckFailed: "file_security_check_failed",
71+ purecef.DownloadInterruptReasonFileTooShort: "file_too_short",
72+ purecef.DownloadInterruptReasonFileHashMismatch: "file_hash_mismatch",
73+ purecef.DownloadInterruptReasonFileSameAsSource: "file_same_as_source",
74+ purecef.DownloadInterruptReasonNetworkFailed: "network_failed",
75+ purecef.DownloadInterruptReasonNetworkTimeout: "network_timeout",
76+ purecef.DownloadInterruptReasonNetworkDisconnected: "network_disconnected",
77+ purecef.DownloadInterruptReasonNetworkServerDown: "network_server_down",
78+ purecef.DownloadInterruptReasonNetworkInvalidRequest: "network_invalid_request",
79+ purecef.DownloadInterruptReasonServerFailed: "server_failed",
80+ purecef.DownloadInterruptReasonServerNoRange: "server_no_range",
81+ purecef.DownloadInterruptReasonServerBadContent: "server_bad_content",
82+ purecef.DownloadInterruptReasonServerUnauthorized: "server_unauthorized",
83+ purecef.DownloadInterruptReasonServerCertProblem: "server_cert_problem",
84+ purecef.DownloadInterruptReasonServerForbidden: "server_forbidden",
85+ purecef.DownloadInterruptReasonServerUnreachable: "server_unreachable",
86+ purecef.DownloadInterruptReasonServerContentLengthMismatch: "server_content_length_mismatch",
87+ purecef.DownloadInterruptReasonServerCrossOriginRedirect: "server_cross_origin_redirect",
88+ purecef.DownloadInterruptReasonUserCanceled: "user_canceled",
89+ purecef.DownloadInterruptReasonUserShutdown: "user_shutdown",
90+ purecef.DownloadInterruptReasonCrash: "crash",
91+}
92+
93+func downloadInterruptReasonLabel(reason purecef.DownloadInterruptReason) string {
94+ if label, ok := downloadInterruptReasonLabels[reason]; ok {
95+ return label
96+ }
97+ return "unknown"
98+}
99+
38100 func newDownloadHandler(
39101 downloadPath string,
40102 eventHandler port.DownloadEventHandler,
@@ -117,7 +179,10 @@ func (h *downloadHandler) onDownloadUpdated(
117179 }
118180 var err error
119181 if downloadItem.IsInterrupted() {
120- err = fmt.Errorf("download interrupted: %s (%v)", state.filename, downloadItem.GetInterruptReason())
182+ err = cefDownloadInterruptError{
183+ filename: state.filename,
184+ reason: downloadItem.GetInterruptReason(),
185+ }
121186 } else {
122187 err = fmt.Errorf("download canceled: %s", state.filename)
123188 }
@@ -31,10 +31,72 @@ type cefDownloadState struct {
31 destination string31 destination string
32 }32 }
33 33
34+type cefDownloadInterruptError struct {
35+ filename string
36+ reason purecef.DownloadInterruptReason
37+}
38+
39+func (e cefDownloadInterruptError) Error() string {
40+ return fmt.Sprintf(
41+ "download interrupted: %s (code=%d, reason=%s)",
42+ e.filename,
43+ e.reason,
44+ downloadInterruptReasonLabel(e.reason),
45+ )
46+}
47+
48+func (e cefDownloadInterruptError) DownloadErrorCode() int {
49+ return int(e.reason)
50+}
51+
52+func (e cefDownloadInterruptError) DownloadErrorReason() string {
53+ return downloadInterruptReasonLabel(e.reason)
54+}
55+
34 type cefDownloadResponseAdapter struct {56 type cefDownloadResponseAdapter struct {
35 item purecef.DownloadItem57 item purecef.DownloadItem
36 }58 }
37 59
60+var downloadInterruptReasonLabels = map[purecef.DownloadInterruptReason]string{
61+ purecef.DownloadInterruptReasonNone: "none",
62+ purecef.DownloadInterruptReasonFileFailed: "file_failed",
63+ purecef.DownloadInterruptReasonFileAccessDenied: "file_access_denied",
64+ purecef.DownloadInterruptReasonFileNoSpace: "file_no_space",
65+ purecef.DownloadInterruptReasonFileNameTooLong: "file_name_too_long",
66+ purecef.DownloadInterruptReasonFileTooLarge: "file_too_large",
67+ purecef.DownloadInterruptReasonFileVirusInfected: "file_virus_infected",
68+ purecef.DownloadInterruptReasonFileTransientError: "file_transient_error",
69+ purecef.DownloadInterruptReasonFileBlocked: "file_blocked",
70+ purecef.DownloadInterruptReasonFileSecurityCheckFailed: "file_security_check_failed",
71+ purecef.DownloadInterruptReasonFileTooShort: "file_too_short",
72+ purecef.DownloadInterruptReasonFileHashMismatch: "file_hash_mismatch",
73+ purecef.DownloadInterruptReasonFileSameAsSource: "file_same_as_source",
74+ purecef.DownloadInterruptReasonNetworkFailed: "network_failed",
75+ purecef.DownloadInterruptReasonNetworkTimeout: "network_timeout",
76+ purecef.DownloadInterruptReasonNetworkDisconnected: "network_disconnected",
77+ purecef.DownloadInterruptReasonNetworkServerDown: "network_server_down",
78+ purecef.DownloadInterruptReasonNetworkInvalidRequest: "network_invalid_request",
79+ purecef.DownloadInterruptReasonServerFailed: "server_failed",
80+ purecef.DownloadInterruptReasonServerNoRange: "server_no_range",
81+ purecef.DownloadInterruptReasonServerBadContent: "server_bad_content",
82+ purecef.DownloadInterruptReasonServerUnauthorized: "server_unauthorized",
83+ purecef.DownloadInterruptReasonServerCertProblem: "server_cert_problem",
84+ purecef.DownloadInterruptReasonServerForbidden: "server_forbidden",
85+ purecef.DownloadInterruptReasonServerUnreachable: "server_unreachable",
86+ purecef.DownloadInterruptReasonServerContentLengthMismatch: "server_content_length_mismatch",
87+ purecef.DownloadInterruptReasonServerCrossOriginRedirect: "server_cross_origin_redirect",
88+ purecef.DownloadInterruptReasonUserCanceled: "user_canceled",
89+ purecef.DownloadInterruptReasonUserShutdown: "user_shutdown",
90+ purecef.DownloadInterruptReasonCrash: "crash",
91+}
92+
93+func downloadInterruptReasonLabel(reason purecef.DownloadInterruptReason) string {
94+ if label, ok := downloadInterruptReasonLabels[reason]; ok {
95+ return label
96+ }
97+ return "unknown"
98+}
99+
38 func newDownloadHandler(100 func newDownloadHandler(
39 downloadPath string,101 downloadPath string,
40 eventHandler port.DownloadEventHandler,102 eventHandler port.DownloadEventHandler,
@@ -117,7 +179,10 @@ func (h *downloadHandler) onDownloadUpdated(
117 }179 }
118 var err error180 var err error
119 if downloadItem.IsInterrupted() {181 if downloadItem.IsInterrupted() {
120- err = fmt.Errorf("download interrupted: %s (%v)", state.filename, downloadItem.GetInterruptReason())182+ err = cefDownloadInterruptError{
183+ filename: state.filename,
184+ reason: downloadItem.GetInterruptReason(),
185+ }
121 } else {186 } else {
122 err = fmt.Errorf("download canceled: %s", state.filename)187 err = fmt.Errorf("download canceled: %s", state.filename)
123 }188 }
M internal/infrastructure/cef/download_handler_test.go +39 -10
@@ -12,22 +12,24 @@ import (
1212 )
1313
1414 type stubDownloadItem struct {
15- id uint32
16- url string
17- suggested string
18- mimeType string
19- fullPath string
20- complete bool
21- canceled bool
15+ id uint32
16+ url string
17+ suggested string
18+ mimeType string
19+ fullPath string
20+ complete bool
21+ canceled bool
22+ interrupted bool
23+ reason purecef.DownloadInterruptReason
2224 }
2325
2426 func (s stubDownloadItem) IsValid() bool { return true }
25-func (s stubDownloadItem) IsInProgress() bool { return !s.complete && !s.canceled }
27+func (s stubDownloadItem) IsInProgress() bool { return !s.complete && !s.canceled && !s.interrupted }
2628 func (s stubDownloadItem) IsComplete() bool { return s.complete }
2729 func (s stubDownloadItem) IsCanceled() bool { return s.canceled }
28-func (s stubDownloadItem) IsInterrupted() bool { return false }
30+func (s stubDownloadItem) IsInterrupted() bool { return s.interrupted }
2931 func (s stubDownloadItem) GetInterruptReason() purecef.DownloadInterruptReason {
30- return 0
32+ return s.reason
3133 }
3234 func (s stubDownloadItem) GetCurrentSpeed() int64 { return 0 }
3335 func (s stubDownloadItem) GetPercentComplete() int32 { return 0 }
@@ -130,3 +132,30 @@ func TestMarkFinished_SuppressesDuplicatesAfterCleanup(t *testing.T) {
130132 handler.mu.Unlock()
131133 require.False(t, inActive)
132134 }
135+
136+func TestCEFDownloadInterruptedErrorIncludesCodeAndReason(t *testing.T) {
137+ ctx := context.Background()
138+ preparer := usecase.NewPrepareDownloadUseCase(nil)
139+ events := &captureDownloadEvents{}
140+ handler := newDownloadHandler("/tmp/downloads", events, preparer)
141+ callback := &stubBeforeDownloadCallback{}
142+
143+ item := stubDownloadItem{
144+ id: 9,
145+ url: "https://example.com/ubuntu.iso",
146+ suggested: "ubuntu.iso",
147+ mimeType: "application/x-iso9660-image",
148+ }
149+
150+ require.True(t, handler.onBeforeDownload(ctx, nil, item, item.suggested, callback))
151+
152+ item.fullPath = callback.path
153+ item.interrupted = true
154+ item.reason = purecef.DownloadInterruptReasonServerBadContent
155+ handler.onDownloadUpdated(ctx, item, nil)
156+
157+ require.Len(t, events.events, 2)
158+ require.Error(t, events.events[1].Error)
159+ require.ErrorContains(t, events.events[1].Error, "code=33")
160+ require.ErrorContains(t, events.events[1].Error, "reason=server_bad_content")
161+}
@@ -12,22 +12,24 @@ import (
12 )12 )
13 13
14 type stubDownloadItem struct {14 type stubDownloadItem struct {
15- id uint3215+ id uint32
16- url string16+ url string
17- suggested string17+ suggested string
18- mimeType string18+ mimeType string
19- fullPath string19+ fullPath string
20- complete bool20+ complete bool
21- canceled bool21+ canceled bool
22+ interrupted bool
23+ reason purecef.DownloadInterruptReason
22 }24 }
23 25
24 func (s stubDownloadItem) IsValid() bool { return true }26 func (s stubDownloadItem) IsValid() bool { return true }
25-func (s stubDownloadItem) IsInProgress() bool { return !s.complete && !s.canceled }27+func (s stubDownloadItem) IsInProgress() bool { return !s.complete && !s.canceled && !s.interrupted }
26 func (s stubDownloadItem) IsComplete() bool { return s.complete }28 func (s stubDownloadItem) IsComplete() bool { return s.complete }
27 func (s stubDownloadItem) IsCanceled() bool { return s.canceled }29 func (s stubDownloadItem) IsCanceled() bool { return s.canceled }
28-func (s stubDownloadItem) IsInterrupted() bool { return false }30+func (s stubDownloadItem) IsInterrupted() bool { return s.interrupted }
29 func (s stubDownloadItem) GetInterruptReason() purecef.DownloadInterruptReason {31 func (s stubDownloadItem) GetInterruptReason() purecef.DownloadInterruptReason {
30- return 032+ return s.reason
31 }33 }
32 func (s stubDownloadItem) GetCurrentSpeed() int64 { return 0 }34 func (s stubDownloadItem) GetCurrentSpeed() int64 { return 0 }
33 func (s stubDownloadItem) GetPercentComplete() int32 { return 0 }35 func (s stubDownloadItem) GetPercentComplete() int32 { return 0 }
@@ -130,3 +132,30 @@ func TestMarkFinished_SuppressesDuplicatesAfterCleanup(t *testing.T) {
130 handler.mu.Unlock()132 handler.mu.Unlock()
131 require.False(t, inActive)133 require.False(t, inActive)
132 }134 }
135+
136+func TestCEFDownloadInterruptedErrorIncludesCodeAndReason(t *testing.T) {
137+ ctx := context.Background()
138+ preparer := usecase.NewPrepareDownloadUseCase(nil)
139+ events := &captureDownloadEvents{}
140+ handler := newDownloadHandler("/tmp/downloads", events, preparer)
141+ callback := &stubBeforeDownloadCallback{}
142+
143+ item := stubDownloadItem{
144+ id: 9,
145+ url: "https://example.com/ubuntu.iso",
146+ suggested: "ubuntu.iso",
147+ mimeType: "application/x-iso9660-image",
148+ }
149+
150+ require.True(t, handler.onBeforeDownload(ctx, nil, item, item.suggested, callback))
151+
152+ item.fullPath = callback.path
153+ item.interrupted = true
154+ item.reason = purecef.DownloadInterruptReasonServerBadContent
155+ handler.onDownloadUpdated(ctx, item, nil)
156+
157+ require.Len(t, events.events, 2)
158+ require.Error(t, events.events[1].Error)
159+ require.ErrorContains(t, events.events[1].Error, "code=33")
160+ require.ErrorContains(t, events.events[1].Error, "reason=server_bad_content")
161+}
M internal/infrastructure/downloadruntime/runtime.go +13 -3
@@ -12,6 +12,11 @@ import (
1212
1313 const dirPerm = 0o755
1414
15+type codedDownloadError interface {
16+ DownloadErrorCode() int
17+ DownloadErrorReason() string
18+}
19+
1520 // Runtime shares common download preparation and event emission logic across engines.
1621 type Runtime struct {
1722 mu sync.RWMutex
@@ -118,9 +123,14 @@ func (r *Runtime) EmitFailed(ctx context.Context, filename, destination string,
118123 })
119124 }
120125
121- logging.FromContext(ctx).Warn().
126+ log := logging.FromContext(ctx).Warn().
122127 Err(err).
123128 Str("filename", filename).
124- Str("destination", destination).
125- Msg("download failed")
129+ Str("destination", destination)
130+ if codedErr, ok := err.(codedDownloadError); ok {
131+ log = log.
132+ Int("error_code", codedErr.DownloadErrorCode()).
133+ Str("error_reason", codedErr.DownloadErrorReason())
134+ }
135+ log.Msg("download failed")
126136 }
@@ -12,6 +12,11 @@ import (
12 12
13 const dirPerm = 0o75513 const dirPerm = 0o755
14 14
15+type codedDownloadError interface {
16+ DownloadErrorCode() int
17+ DownloadErrorReason() string
18+}
19+
15 // Runtime shares common download preparation and event emission logic across engines.20 // Runtime shares common download preparation and event emission logic across engines.
16 type Runtime struct {21 type Runtime struct {
17 mu sync.RWMutex22 mu sync.RWMutex
@@ -118,9 +123,14 @@ func (r *Runtime) EmitFailed(ctx context.Context, filename, destination string,
118 })123 })
119 }124 }
120 125
121- logging.FromContext(ctx).Warn().126+ log := logging.FromContext(ctx).Warn().
122 Err(err).127 Err(err).
123 Str("filename", filename).128 Str("filename", filename).
124- Str("destination", destination).129+ Str("destination", destination)
125- Msg("download failed")130+ if codedErr, ok := err.(codedDownloadError); ok {
131+ log = log.
132+ Int("error_code", codedErr.DownloadErrorCode()).
133+ Str("error_reason", codedErr.DownloadErrorReason())
134+ }
135+ log.Msg("download failed")
126 }136 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close