fix: address all 29 CodeRabbit findings across transcoder, CEF, config, UI
Transcoder (10 findings): - Fix inconsistent FFmpeg version comments (7.x→8.x), add validated version block - Add SwrContext explicit channel layout config (in_chlayout/out_chlayout) - Add safety comments for unavoidable unsafe pointer casts in audio frame data - Use local config copy to prevent shared struct mutation race - Make audio processing errors non-fatal (match non-fatal init behavior) - Add 10 edge case tests for ParseSyntheticTranscodeURL - Add retry loop for io.Reader n=0/err=nil edge case in read callback - Refactor pool.closeAll() to release lock before closing sessions - Remove unused error return from audio flush() CEF (12 findings): - Fix double-patching of Element.prototype.attachShadow (marker + chaining) - Self-clearing setInterval in Reddit video patch (30s timeout) - Gate videoDiagnosticJS behind DUMBER_VIDEO_DIAG env var - Propagate parent context in handleTranscodeAPI and transcoding handler - Add nil receiver guard to glLoader.Close() - Replace math.Abs with assert.InDelta in zoom test - Replace busy-wait polling with channel signal in closeActiveWebViews - Extract console message filter markers to package constants - Add CORS comment documenting why wildcard is needed - Reduce lock scope in uploadPopupTexture (copy-release-GL-reacquire) Config/UI/WebUI (7 findings): - Validate HandlerDeps.SaveConfig in deps.Validate() - Add try/catch error handling to vite-plugin-pages fetch bridge - Extract setTranscodingDefaults from setEngineDefaults - Make writer_test assertion specific to [transcoding] section - Add explicit null check in ConfigPage.isPerformanceSupported - Propagate context to textureFromBytesOnGTK (5 call sites) - Document local replace directives in go.mod
- commit
- 37f0ef7143388e63138a277b049369a4bef3b86c
- author
- brice · · 2026-03-25 05:48 UTC
- committer
- brice · · 2026-03-28 04:39 UTC
- parent
- aeff368
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
20 files · +358 -122
M internal/infrastructure/cef/content_injector.go +36 -13
| @@ -3,6 +3,7 @@ package cef | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "fmt" | |
| 6 | + | "os" | |
| 6 | 7 | "strings" | |
| 7 | 8 | "sync" | |
| 8 | 9 | ||
| @@ -118,7 +119,12 @@ const videoDiagnosticJS = `(function(){ | |||
| 118 | 119 | } | |
| 119 | 120 | ||
| 120 | 121 | // Patch attachShadow to automatically observe new shadow roots. | |
| 121 | - | var origAttach = Element.prototype.attachShadow; | |
| 122 | + | // Use a marker to avoid double-patching if redditDirectVideoJS also patches. | |
| 123 | + | var origAttach = Element.prototype.__dumberOrigAttachShadow || Element.prototype.attachShadow; | |
| 124 | + | if (!Element.prototype.__dumberPatched) { | |
| 125 | + | Element.prototype.__dumberOrigAttachShadow = origAttach; | |
| 126 | + | Element.prototype.__dumberPatched = true; | |
| 127 | + | } | |
| 122 | 128 | Element.prototype.attachShadow = function(opts) { | |
| 123 | 129 | var sr = origAttach.call(this, opts); | |
| 124 | 130 | console.warn(tag, 'shadowRoot attached on', this.tagName.toLowerCase()); | |
| @@ -355,15 +361,22 @@ const redditDirectVideoJS = `(function(){ | |||
| 355 | 361 | attributeFilter: ['src'] | |
| 356 | 362 | }); | |
| 357 | 363 | ||
| 358 | - | var origAttach = Element.prototype.attachShadow; | |
| 364 | + | // Chain to existing attachShadow patch (e.g. videoDiagnosticJS) instead of overwriting. | |
| 365 | + | var prevAttach = Element.prototype.attachShadow; | |
| 359 | 366 | Element.prototype.attachShadow = function(opts) { | |
| 360 | - | var sr = origAttach.call(this, opts); | |
| 367 | + | var sr = prevAttach.call(this, opts); | |
| 361 | 368 | patchTree(sr); | |
| 362 | 369 | return sr; | |
| 363 | 370 | }; | |
| 364 | 371 | ||
| 365 | 372 | patchTree(document); | |
| 366 | - | setInterval(function() { patchTree(document); }, 1000); | |
| 373 | + | var pollCount = 0; | |
| 374 | + | var maxPolls = 30; | |
| 375 | + | var pollID = setInterval(function() { | |
| 376 | + | patchTree(document); | |
| 377 | + | pollCount++; | |
| 378 | + | if (pollCount >= maxPolls) clearInterval(pollID); | |
| 379 | + | }, 1000); | |
| 367 | 380 | })();` | |
| 368 | 381 | ||
| 369 | 382 | // contentInjector implements port.ContentInjector for the CEF engine. | |
| @@ -371,11 +384,12 @@ const redditDirectVideoJS = `(function(){ | |||
| 371 | 384 | // Thread-safe: InjectThemeCSS may be called from the UI thread while OnLoadEnd | |
| 372 | 385 | // fires on the CEF IO thread. | |
| 373 | 386 | type contentInjector struct { | |
| 374 | - | mu sync.RWMutex | |
| 375 | - | themeCSS string | |
| 376 | - | findHighlightCSS string | |
| 377 | - | engine *Engine | |
| 378 | - | colorResolver port.ColorSchemeResolver | |
| 387 | + | mu sync.RWMutex | |
| 388 | + | themeCSS string | |
| 389 | + | findHighlightCSS string | |
| 390 | + | engine *Engine | |
| 391 | + | colorResolver port.ColorSchemeResolver | |
| 392 | + | videoDiagnosticsEnabled bool | |
| 379 | 393 | } | |
| 380 | 394 | ||
| 381 | 395 | // setColorResolver updates the color scheme resolver used for dark mode detection. | |
| @@ -386,10 +400,17 @@ func (ci *contentInjector) setColorResolver(resolver port.ColorSchemeResolver) { | |||
| 386 | 400 | } | |
| 387 | 401 | ||
| 388 | 402 | // newContentInjector creates a content injector wired to the given engine. | |
| 403 | + | // Video diagnostics are enabled when DUMBER_VIDEO_DIAG=1 is set. | |
| 389 | 404 | func newContentInjector(engine *Engine, resolver port.ColorSchemeResolver) *contentInjector { | |
| 405 | + | diagEnabled := false | |
| 406 | + | switch strings.ToLower(strings.TrimSpace(os.Getenv("DUMBER_VIDEO_DIAG"))) { | |
| 407 | + | case "1", "true", "yes", "on": | |
| 408 | + | diagEnabled = true | |
| 409 | + | } | |
| 390 | 410 | return &contentInjector{ | |
| 391 | - | engine: engine, | |
| 392 | - | colorResolver: resolver, | |
| 411 | + | engine: engine, | |
| 412 | + | colorResolver: resolver, | |
| 413 | + | videoDiagnosticsEnabled: diagEnabled, | |
| 393 | 414 | } | |
| 394 | 415 | } | |
| 395 | 416 | ||
| @@ -483,8 +504,10 @@ func (ci *contentInjector) onLoadEnd(wv *WebView) { | |||
| 483 | 504 | wv.RunJavaScript(context.Background(), scrollbarAutoHideJS) | |
| 484 | 505 | ||
| 485 | 506 | // Video playback diagnostic — logs video element state changes. | |
| 486 | - | // TODO: remove once video playback is stable. | |
| 487 | - | wv.RunJavaScript(context.Background(), videoDiagnosticJS) | |
| 507 | + | // Gated behind DUMBER_VIDEO_DIAG=1 environment variable. | |
| 508 | + | if ci.videoDiagnosticsEnabled { | |
| 509 | + | wv.RunJavaScript(context.Background(), videoDiagnosticJS) | |
| 510 | + | } | |
| 488 | 511 | ||
| 489 | 512 | if strings.Contains(uri, "reddit.com") { | |
| 490 | 513 | wv.RunJavaScript(context.Background(), redditDirectVideoJS) | |
| @@ -3,6 +3,7 @@ package cef | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | + | "os" | |||
| 6 | "strings" | 7 | "strings" | ||
| 7 | "sync" | 8 | "sync" | ||
| 8 | 9 | ||||
| @@ -118,7 +119,12 @@ const videoDiagnosticJS = `(function(){ | |||||
| 118 | } | 119 | } | ||
| 119 | 120 | ||||
| 120 | // Patch attachShadow to automatically observe new shadow roots. | 121 | // Patch attachShadow to automatically observe new shadow roots. | ||
| 121 | - | var origAttach = Element.prototype.attachShadow; | 122 | + | // Use a marker to avoid double-patching if redditDirectVideoJS also patches. |
| 123 | + | var origAttach = Element.prototype.__dumberOrigAttachShadow || Element.prototype.attachShadow; | |||
| 124 | + | if (!Element.prototype.__dumberPatched) { | |||
| 125 | + | Element.prototype.__dumberOrigAttachShadow = origAttach; | |||
| 126 | + | Element.prototype.__dumberPatched = true; | |||
| 127 | + | } | |||
| 122 | Element.prototype.attachShadow = function(opts) { | 128 | Element.prototype.attachShadow = function(opts) { | ||
| 123 | var sr = origAttach.call(this, opts); | 129 | var sr = origAttach.call(this, opts); | ||
| 124 | console.warn(tag, 'shadowRoot attached on', this.tagName.toLowerCase()); | 130 | console.warn(tag, 'shadowRoot attached on', this.tagName.toLowerCase()); | ||
| @@ -355,15 +361,22 @@ const redditDirectVideoJS = `(function(){ | |||||
| 355 | attributeFilter: ['src'] | 361 | attributeFilter: ['src'] | ||
| 356 | }); | 362 | }); | ||
| 357 | 363 | ||||
| 358 | - | var origAttach = Element.prototype.attachShadow; | 364 | + | // Chain to existing attachShadow patch (e.g. videoDiagnosticJS) instead of overwriting. |
| 365 | + | var prevAttach = Element.prototype.attachShadow; | |||
| 359 | Element.prototype.attachShadow = function(opts) { | 366 | Element.prototype.attachShadow = function(opts) { | ||
| 360 | - | var sr = origAttach.call(this, opts); | 367 | + | var sr = prevAttach.call(this, opts); |
| 361 | patchTree(sr); | 368 | patchTree(sr); | ||
| 362 | return sr; | 369 | return sr; | ||
| 363 | }; | 370 | }; | ||
| 364 | 371 | ||||
| 365 | patchTree(document); | 372 | patchTree(document); | ||
| 366 | - | setInterval(function() { patchTree(document); }, 1000); | 373 | + | var pollCount = 0; |
| 374 | + | var maxPolls = 30; | |||
| 375 | + | var pollID = setInterval(function() { | |||
| 376 | + | patchTree(document); | |||
| 377 | + | pollCount++; | |||
| 378 | + | if (pollCount >= maxPolls) clearInterval(pollID); | |||
| 379 | + | }, 1000); | |||
| 367 | })();` | 380 | })();` | ||
| 368 | 381 | ||||
| 369 | // contentInjector implements port.ContentInjector for the CEF engine. | 382 | // contentInjector implements port.ContentInjector for the CEF engine. | ||
| @@ -371,11 +384,12 @@ const redditDirectVideoJS = `(function(){ | |||||
| 371 | // Thread-safe: InjectThemeCSS may be called from the UI thread while OnLoadEnd | 384 | // Thread-safe: InjectThemeCSS may be called from the UI thread while OnLoadEnd | ||
| 372 | // fires on the CEF IO thread. | 385 | // fires on the CEF IO thread. | ||
| 373 | type contentInjector struct { | 386 | type contentInjector struct { | ||
| 374 | - | mu sync.RWMutex | 387 | + | mu sync.RWMutex |
| 375 | - | themeCSS string | 388 | + | themeCSS string |
| 376 | - | findHighlightCSS string | 389 | + | findHighlightCSS string |
| 377 | - | engine *Engine | 390 | + | engine *Engine |
| 378 | - | colorResolver port.ColorSchemeResolver | 391 | + | colorResolver port.ColorSchemeResolver |
| 392 | + | videoDiagnosticsEnabled bool | |||
| 379 | } | 393 | } | ||
| 380 | 394 | ||||
| 381 | // setColorResolver updates the color scheme resolver used for dark mode detection. | 395 | // setColorResolver updates the color scheme resolver used for dark mode detection. | ||
| @@ -386,10 +400,17 @@ func (ci *contentInjector) setColorResolver(resolver port.ColorSchemeResolver) { | |||||
| 386 | } | 400 | } | ||
| 387 | 401 | ||||
| 388 | // newContentInjector creates a content injector wired to the given engine. | 402 | // newContentInjector creates a content injector wired to the given engine. | ||
| 403 | + | // Video diagnostics are enabled when DUMBER_VIDEO_DIAG=1 is set. | |||
| 389 | func newContentInjector(engine *Engine, resolver port.ColorSchemeResolver) *contentInjector { | 404 | func newContentInjector(engine *Engine, resolver port.ColorSchemeResolver) *contentInjector { | ||
| 405 | + | diagEnabled := false | |||
| 406 | + | switch strings.ToLower(strings.TrimSpace(os.Getenv("DUMBER_VIDEO_DIAG"))) { | |||
| 407 | + | case "1", "true", "yes", "on": | |||
| 408 | + | diagEnabled = true | |||
| 409 | + | } | |||
| 390 | return &contentInjector{ | 410 | return &contentInjector{ | ||
| 391 | - | engine: engine, | 411 | + | engine: engine, |
| 392 | - | colorResolver: resolver, | 412 | + | colorResolver: resolver, |
| 413 | + | videoDiagnosticsEnabled: diagEnabled, | |||
| 393 | } | 414 | } | ||
| 394 | } | 415 | } | ||
| 395 | 416 | ||||
| @@ -483,8 +504,10 @@ func (ci *contentInjector) onLoadEnd(wv *WebView) { | |||||
| 483 | wv.RunJavaScript(context.Background(), scrollbarAutoHideJS) | 504 | wv.RunJavaScript(context.Background(), scrollbarAutoHideJS) | ||
| 484 | 505 | ||||
| 485 | // Video playback diagnostic — logs video element state changes. | 506 | // Video playback diagnostic — logs video element state changes. | ||
| 486 | - | // TODO: remove once video playback is stable. | 507 | + | // Gated behind DUMBER_VIDEO_DIAG=1 environment variable. |
| 487 | - | wv.RunJavaScript(context.Background(), videoDiagnosticJS) | 508 | + | if ci.videoDiagnosticsEnabled { |
| 509 | + | wv.RunJavaScript(context.Background(), videoDiagnosticJS) | |||
| 510 | + | } | |||
| 488 | 511 | ||||
| 489 | if strings.Contains(uri, "reddit.com") { | 512 | if strings.Contains(uri, "reddit.com") { | ||
| 490 | wv.RunJavaScript(context.Background(), redditDirectVideoJS) | 513 | wv.RunJavaScript(context.Background(), redditDirectVideoJS) | ||
M internal/infrastructure/cef/engine.go +30 -19
| @@ -34,6 +34,10 @@ type Engine struct { | |||
| 34 | 34 | // activeWebViews tracks all live webviews for CSS broadcast. | |
| 35 | 35 | activeWebViews sync.Map // map[port.WebViewID]*WebView | |
| 36 | 36 | ||
| 37 | + | // shutdownNotify is signalled when the active webview count reaches 0 | |
| 38 | + | // during shutdown, replacing the busy-wait poll in closeActiveWebViews. | |
| 39 | + | shutdownNotify chan struct{} | |
| 40 | + | ||
| 37 | 41 | multiThreadedMessageLoop bool | |
| 38 | 42 | manualPumpInterval int64 | |
| 39 | 43 | ||
| @@ -123,8 +127,15 @@ func (e *Engine) registerWebView(wv *WebView) { | |||
| 123 | 127 | } | |
| 124 | 128 | ||
| 125 | 129 | // unregisterWebView removes a webview from the active tracking map. | |
| 130 | + | // If the count reaches 0 and a shutdown is in progress, it signals shutdownNotify. | |
| 126 | 131 | func (e *Engine) unregisterWebView(wv *WebView) { | |
| 127 | 132 | e.activeWebViews.Delete(wv.id) | |
| 133 | + | if e.shutdownNotify != nil && e.activeWebViewCount() == 0 { | |
| 134 | + | select { | |
| 135 | + | case e.shutdownNotify <- struct{}{}: | |
| 136 | + | default: | |
| 137 | + | } | |
| 138 | + | } | |
| 128 | 139 | } | |
| 129 | 140 | ||
| 130 | 141 | // internalSchemePath is the URI scheme used for internal app resources. | |
| @@ -158,10 +169,7 @@ func (e *Engine) Close() error { | |||
| 158 | 169 | return nil | |
| 159 | 170 | } | |
| 160 | 171 | ||
| 161 | - | const ( | |
| 162 | - | cefShutdownWaitStep = 10 * time.Millisecond | |
| 163 | - | cefShutdownWaitTimeout = 2 * time.Second | |
| 164 | - | ) | |
| 172 | + | const cefShutdownWaitTimeout = 2 * time.Second | |
| 165 | 173 | ||
| 166 | 174 | func (e *Engine) activeWebViewCount() int { | |
| 167 | 175 | count := 0 | |
| @@ -195,25 +203,28 @@ func (e *Engine) closeActiveWebViews() { | |||
| 195 | 203 | Int("count", len(webViews)). | |
| 196 | 204 | Msg("cef: closing active webviews before shutdown") | |
| 197 | 205 | ||
| 206 | + | // Set up the shutdown notification channel before destroying webviews. | |
| 207 | + | e.shutdownNotify = make(chan struct{}, 1) | |
| 208 | + | ||
| 198 | 209 | for _, wv := range webViews { | |
| 199 | 210 | wv.Destroy() | |
| 200 | 211 | } | |
| 201 | 212 | ||
| 202 | - | deadline := time.Now().Add(cefShutdownWaitTimeout) | |
| 203 | - | for { | |
| 204 | - | remaining := e.activeWebViewCount() | |
| 205 | - | if remaining == 0 { | |
| 206 | - | log.Debug().Msg("cef: all active webviews closed before shutdown") | |
| 207 | - | return | |
| 208 | - | } | |
| 209 | - | if time.Now().After(deadline) { | |
| 210 | - | log.Warn(). | |
| 211 | - | Int("remaining", remaining). | |
| 212 | - | Str("timeout", cefShutdownWaitTimeout.String()). | |
| 213 | - | Msg("cef: timed out waiting for OnBeforeClose before shutdown") | |
| 214 | - | return | |
| 215 | - | } | |
| 216 | - | time.Sleep(cefShutdownWaitStep) | |
| 213 | + | // Check immediately in case all webviews closed synchronously. | |
| 214 | + | if e.activeWebViewCount() == 0 { | |
| 215 | + | log.Debug().Msg("cef: all active webviews closed before shutdown") | |
| 216 | + | return | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | // Wait for unregisterWebView to signal that all webviews are gone, or timeout. | |
| 220 | + | select { | |
| 221 | + | case <-e.shutdownNotify: | |
| 222 | + | log.Debug().Msg("cef: all active webviews closed before shutdown") | |
| 223 | + | case <-time.After(cefShutdownWaitTimeout): | |
| 224 | + | log.Warn(). | |
| 225 | + | Int("remaining", e.activeWebViewCount()). | |
| 226 | + | Str("timeout", cefShutdownWaitTimeout.String()). | |
| 227 | + | Msg("cef: timed out waiting for OnBeforeClose before shutdown") | |
| 217 | 228 | } | |
| 218 | 229 | } | |
| 219 | 230 | ||
| @@ -34,6 +34,10 @@ type Engine struct { | |||||
| 34 | // activeWebViews tracks all live webviews for CSS broadcast. | 34 | // activeWebViews tracks all live webviews for CSS broadcast. | ||
| 35 | activeWebViews sync.Map // map[port.WebViewID]*WebView | 35 | activeWebViews sync.Map // map[port.WebViewID]*WebView | ||
| 36 | 36 | ||||
| 37 | + | // shutdownNotify is signalled when the active webview count reaches 0 | |||
| 38 | + | // during shutdown, replacing the busy-wait poll in closeActiveWebViews. | |||
| 39 | + | shutdownNotify chan struct{} | |||
| 40 | + | ||||
| 37 | multiThreadedMessageLoop bool | 41 | multiThreadedMessageLoop bool | ||
| 38 | manualPumpInterval int64 | 42 | manualPumpInterval int64 | ||
| 39 | 43 | ||||
| @@ -123,8 +127,15 @@ func (e *Engine) registerWebView(wv *WebView) { | |||||
| 123 | } | 127 | } | ||
| 124 | 128 | ||||
| 125 | // unregisterWebView removes a webview from the active tracking map. | 129 | // unregisterWebView removes a webview from the active tracking map. | ||
| 130 | + | // If the count reaches 0 and a shutdown is in progress, it signals shutdownNotify. | |||
| 126 | func (e *Engine) unregisterWebView(wv *WebView) { | 131 | func (e *Engine) unregisterWebView(wv *WebView) { | ||
| 127 | e.activeWebViews.Delete(wv.id) | 132 | e.activeWebViews.Delete(wv.id) | ||
| 133 | + | if e.shutdownNotify != nil && e.activeWebViewCount() == 0 { | |||
| 134 | + | select { | |||
| 135 | + | case e.shutdownNotify <- struct{}{}: | |||
| 136 | + | default: | |||
| 137 | + | } | |||
| 138 | + | } | |||
| 128 | } | 139 | } | ||
| 129 | 140 | ||||
| 130 | // internalSchemePath is the URI scheme used for internal app resources. | 141 | // internalSchemePath is the URI scheme used for internal app resources. | ||
| @@ -158,10 +169,7 @@ func (e *Engine) Close() error { | |||||
| 158 | return nil | 169 | return nil | ||
| 159 | } | 170 | } | ||
| 160 | 171 | ||||
| 161 | - | const ( | 172 | + | const cefShutdownWaitTimeout = 2 * time.Second |
| 162 | - | cefShutdownWaitStep = 10 * time.Millisecond | |||
| 163 | - | cefShutdownWaitTimeout = 2 * time.Second | |||
| 164 | - | ) | |||
| 165 | 173 | ||||
| 166 | func (e *Engine) activeWebViewCount() int { | 174 | func (e *Engine) activeWebViewCount() int { | ||
| 167 | count := 0 | 175 | count := 0 | ||
| @@ -195,25 +203,28 @@ func (e *Engine) closeActiveWebViews() { | |||||
| 195 | Int("count", len(webViews)). | 203 | Int("count", len(webViews)). | ||
| 196 | Msg("cef: closing active webviews before shutdown") | 204 | Msg("cef: closing active webviews before shutdown") | ||
| 197 | 205 | ||||
| 206 | + | // Set up the shutdown notification channel before destroying webviews. | |||
| 207 | + | e.shutdownNotify = make(chan struct{}, 1) | |||
| 208 | + | ||||
| 198 | for _, wv := range webViews { | 209 | for _, wv := range webViews { | ||
| 199 | wv.Destroy() | 210 | wv.Destroy() | ||
| 200 | } | 211 | } | ||
| 201 | 212 | ||||
| 202 | - | deadline := time.Now().Add(cefShutdownWaitTimeout) | 213 | + | // Check immediately in case all webviews closed synchronously. |
| 203 | - | for { | 214 | + | if e.activeWebViewCount() == 0 { |
| 204 | - | remaining := e.activeWebViewCount() | 215 | + | log.Debug().Msg("cef: all active webviews closed before shutdown") |
| 205 | - | if remaining == 0 { | 216 | + | return |
| 206 | - | log.Debug().Msg("cef: all active webviews closed before shutdown") | 217 | + | } |
| 207 | - | return | 218 | + | |
| 208 | - | } | 219 | + | // Wait for unregisterWebView to signal that all webviews are gone, or timeout. |
| 209 | - | if time.Now().After(deadline) { | 220 | + | select { |
| 210 | - | log.Warn(). | 221 | + | case <-e.shutdownNotify: |
| 211 | - | Int("remaining", remaining). | 222 | + | log.Debug().Msg("cef: all active webviews closed before shutdown") |
| 212 | - | Str("timeout", cefShutdownWaitTimeout.String()). | 223 | + | case <-time.After(cefShutdownWaitTimeout): |
| 213 | - | Msg("cef: timed out waiting for OnBeforeClose before shutdown") | 224 | + | log.Warn(). |
| 214 | - | return | 225 | + | Int("remaining", e.activeWebViewCount()). |
| 215 | - | } | 226 | + | Str("timeout", cefShutdownWaitTimeout.String()). |
| 216 | - | time.Sleep(cefShutdownWaitStep) | 227 | + | Msg("cef: timed out waiting for OnBeforeClose before shutdown") |
| 217 | } | 228 | } | ||
| 218 | } | 229 | } | ||
| 219 | 230 | ||||
M internal/infrastructure/cef/gl_loader.go +3 -0
| @@ -98,6 +98,9 @@ type glLoader struct { | |||
| 98 | 98 | ||
| 99 | 99 | // Close releases the OpenGL library handle. | |
| 100 | 100 | func (gl *glLoader) Close() error { | |
| 101 | + | if gl == nil { | |
| 102 | + | return nil | |
| 103 | + | } | |
| 101 | 104 | if gl.handle != 0 { | |
| 102 | 105 | err := purego.Dlclose(gl.handle) | |
| 103 | 106 | gl.handle = 0 | |
| @@ -98,6 +98,9 @@ type glLoader struct { | |||||
| 98 | 98 | ||||
| 99 | // Close releases the OpenGL library handle. | 99 | // Close releases the OpenGL library handle. | ||
| 100 | func (gl *glLoader) Close() error { | 100 | func (gl *glLoader) Close() error { | ||
| 101 | + | if gl == nil { | |||
| 102 | + | return nil | |||
| 103 | + | } | |||
| 101 | if gl.handle != 0 { | 104 | if gl.handle != 0 { | ||
| 102 | err := purego.Dlclose(gl.handle) | 105 | err := purego.Dlclose(gl.handle) | ||
| 103 | gl.handle = 0 | 106 | gl.handle = 0 | ||
M internal/infrastructure/cef/render_pipeline.go +37 -11
| @@ -474,47 +474,73 @@ func (rp *renderPipeline) uploadToPBO(dirtyRects []rect, fullUpload bool, render | |||
| 474 | 474 | } | |
| 475 | 475 | ||
| 476 | 476 | func (rp *renderPipeline) uploadPopupTexture(renderSeq, paintSeq uint64) bool { | |
| 477 | + | // Copy staging data under lock, then release to avoid holding the mutex | |
| 478 | + | // during GL upload calls which may block on GPU synchronization. | |
| 477 | 479 | rp.mu.Lock() | |
| 478 | - | defer rp.mu.Unlock() | |
| 479 | 480 | ||
| 480 | 481 | if !rp.popupVisible || rp.popupWidth <= 0 || rp.popupHeight <= 0 || len(rp.popupStaging) == 0 { | |
| 482 | + | rp.mu.Unlock() | |
| 481 | 483 | return false | |
| 482 | 484 | } | |
| 483 | 485 | ||
| 484 | 486 | if !rp.popupNeedsUpload && !rp.popupSizeChanged && rp.popupTexture != 0 { | |
| 487 | + | rp.mu.Unlock() | |
| 485 | 488 | return true | |
| 486 | 489 | } | |
| 487 | 490 | ||
| 491 | + | // Snapshot the staging data and dimensions while holding the lock. | |
| 492 | + | pixelsCopy := make([]byte, len(rp.popupStaging)) | |
| 493 | + | copy(pixelsCopy, rp.popupStaging) | |
| 494 | + | popupW := rp.popupWidth | |
| 495 | + | popupH := rp.popupHeight | |
| 496 | + | sizeChanged := rp.popupSizeChanged | |
| 497 | + | needsCreate := rp.popupTexture == 0 | |
| 498 | + | ||
| 499 | + | rp.mu.Unlock() | |
| 500 | + | ||
| 501 | + | // Perform GL operations without the lock. | |
| 488 | 502 | gl := rp.gl | |
| 489 | 503 | ||
| 490 | - | if rp.popupTexture == 0 { | |
| 491 | - | gl.genTextures(1, &rp.popupTexture) | |
| 492 | - | gl.bindTexture(glTexture2D, rp.popupTexture) | |
| 504 | + | if needsCreate { | |
| 505 | + | var tex uint32 | |
| 506 | + | gl.genTextures(1, &tex) | |
| 507 | + | gl.bindTexture(glTexture2D, tex) | |
| 493 | 508 | gl.texParameteri(glTexture2D, glTextureMinFilter, int32(glLinear)) | |
| 494 | 509 | gl.texParameteri(glTexture2D, glTextureMagFilter, int32(glLinear)) | |
| 495 | 510 | gl.texParameteri(glTexture2D, glTextureWrapS, int32(glClampToEdge)) | |
| 496 | 511 | gl.texParameteri(glTexture2D, glTextureWrapT, int32(glClampToEdge)) | |
| 512 | + | ||
| 513 | + | // Store the texture ID under lock. | |
| 514 | + | rp.mu.Lock() | |
| 515 | + | rp.popupTexture = tex | |
| 516 | + | rp.mu.Unlock() | |
| 497 | 517 | } else { | |
| 498 | - | gl.bindTexture(glTexture2D, rp.popupTexture) | |
| 518 | + | rp.mu.Lock() | |
| 519 | + | tex := rp.popupTexture | |
| 520 | + | rp.mu.Unlock() | |
| 521 | + | gl.bindTexture(glTexture2D, tex) | |
| 499 | 522 | } | |
| 500 | 523 | ||
| 501 | - | pixels := unsafe.Pointer(&rp.popupStaging[0]) | |
| 502 | - | if rp.popupSizeChanged { | |
| 503 | - | gl.texImage2D(glTexture2D, 0, int32(glRGBA), rp.popupWidth, rp.popupHeight, 0, glBGRA, glUnsignedByte, pixels) | |
| 524 | + | pixels := unsafe.Pointer(&pixelsCopy[0]) | |
| 525 | + | if sizeChanged { | |
| 526 | + | gl.texImage2D(glTexture2D, 0, int32(glRGBA), popupW, popupH, 0, glBGRA, glUnsignedByte, pixels) | |
| 504 | 527 | } else { | |
| 505 | - | gl.texSubImage2D(glTexture2D, 0, 0, 0, rp.popupWidth, rp.popupHeight, glBGRA, glUnsignedByte, pixels) | |
| 528 | + | gl.texSubImage2D(glTexture2D, 0, 0, 0, popupW, popupH, glBGRA, glUnsignedByte, pixels) | |
| 506 | 529 | } | |
| 507 | 530 | gl.bindTexture(glTexture2D, 0) | |
| 508 | 531 | ||
| 532 | + | // Update state under lock. | |
| 533 | + | rp.mu.Lock() | |
| 509 | 534 | rp.popupNeedsUpload = false | |
| 510 | 535 | rp.popupSizeChanged = false | |
| 536 | + | rp.mu.Unlock() | |
| 511 | 537 | ||
| 512 | 538 | if rp.ctx != nil { | |
| 513 | 539 | logging.FromContext(rp.ctx).Debug(). | |
| 514 | 540 | Uint64("render_seq", renderSeq). | |
| 515 | 541 | Uint64("paint_seq", paintSeq). | |
| 516 | - | Int32("popup_width", rp.popupWidth). | |
| 517 | - | Int32("popup_height", rp.popupHeight). | |
| 542 | + | Int32("popup_width", popupW). | |
| 543 | + | Int32("popup_height", popupH). | |
| 518 | 544 | Msg("cef: popup texture uploaded") | |
| 519 | 545 | } | |
| 520 | 546 | ||
| @@ -474,47 +474,73 @@ func (rp *renderPipeline) uploadToPBO(dirtyRects []rect, fullUpload bool, render | |||||
| 474 | } | 474 | } | ||
| 475 | 475 | ||||
| 476 | func (rp *renderPipeline) uploadPopupTexture(renderSeq, paintSeq uint64) bool { | 476 | func (rp *renderPipeline) uploadPopupTexture(renderSeq, paintSeq uint64) bool { | ||
| 477 | + | // Copy staging data under lock, then release to avoid holding the mutex | |||
| 478 | + | // during GL upload calls which may block on GPU synchronization. | |||
| 477 | rp.mu.Lock() | 479 | rp.mu.Lock() | ||
| 478 | - | defer rp.mu.Unlock() | |||
| 479 | 480 | ||||
| 480 | if !rp.popupVisible || rp.popupWidth <= 0 || rp.popupHeight <= 0 || len(rp.popupStaging) == 0 { | 481 | if !rp.popupVisible || rp.popupWidth <= 0 || rp.popupHeight <= 0 || len(rp.popupStaging) == 0 { | ||
| 482 | + | rp.mu.Unlock() | |||
| 481 | return false | 483 | return false | ||
| 482 | } | 484 | } | ||
| 483 | 485 | ||||
| 484 | if !rp.popupNeedsUpload && !rp.popupSizeChanged && rp.popupTexture != 0 { | 486 | if !rp.popupNeedsUpload && !rp.popupSizeChanged && rp.popupTexture != 0 { | ||
| 487 | + | rp.mu.Unlock() | |||
| 485 | return true | 488 | return true | ||
| 486 | } | 489 | } | ||
| 487 | 490 | ||||
| 491 | + | // Snapshot the staging data and dimensions while holding the lock. | |||
| 492 | + | pixelsCopy := make([]byte, len(rp.popupStaging)) | |||
| 493 | + | copy(pixelsCopy, rp.popupStaging) | |||
| 494 | + | popupW := rp.popupWidth | |||
| 495 | + | popupH := rp.popupHeight | |||
| 496 | + | sizeChanged := rp.popupSizeChanged | |||
| 497 | + | needsCreate := rp.popupTexture == 0 | |||
| 498 | + | ||||
| 499 | + | rp.mu.Unlock() | |||
| 500 | + | ||||
| 501 | + | // Perform GL operations without the lock. | |||
| 488 | gl := rp.gl | 502 | gl := rp.gl | ||
| 489 | 503 | ||||
| 490 | - | if rp.popupTexture == 0 { | 504 | + | if needsCreate { |
| 491 | - | gl.genTextures(1, &rp.popupTexture) | 505 | + | var tex uint32 |
| 492 | - | gl.bindTexture(glTexture2D, rp.popupTexture) | 506 | + | gl.genTextures(1, &tex) |
| 507 | + | gl.bindTexture(glTexture2D, tex) | |||
| 493 | gl.texParameteri(glTexture2D, glTextureMinFilter, int32(glLinear)) | 508 | gl.texParameteri(glTexture2D, glTextureMinFilter, int32(glLinear)) | ||
| 494 | gl.texParameteri(glTexture2D, glTextureMagFilter, int32(glLinear)) | 509 | gl.texParameteri(glTexture2D, glTextureMagFilter, int32(glLinear)) | ||
| 495 | gl.texParameteri(glTexture2D, glTextureWrapS, int32(glClampToEdge)) | 510 | gl.texParameteri(glTexture2D, glTextureWrapS, int32(glClampToEdge)) | ||
| 496 | gl.texParameteri(glTexture2D, glTextureWrapT, int32(glClampToEdge)) | 511 | gl.texParameteri(glTexture2D, glTextureWrapT, int32(glClampToEdge)) | ||
| 512 | + | ||||
| 513 | + | // Store the texture ID under lock. | |||
| 514 | + | rp.mu.Lock() | |||
| 515 | + | rp.popupTexture = tex | |||
| 516 | + | rp.mu.Unlock() | |||
| 497 | } else { | 517 | } else { | ||
| 498 | - | gl.bindTexture(glTexture2D, rp.popupTexture) | 518 | + | rp.mu.Lock() |
| 519 | + | tex := rp.popupTexture | |||
| 520 | + | rp.mu.Unlock() | |||
| 521 | + | gl.bindTexture(glTexture2D, tex) | |||
| 499 | } | 522 | } | ||
| 500 | 523 | ||||
| 501 | - | pixels := unsafe.Pointer(&rp.popupStaging[0]) | 524 | + | pixels := unsafe.Pointer(&pixelsCopy[0]) |
| 502 | - | if rp.popupSizeChanged { | 525 | + | if sizeChanged { |
| 503 | - | gl.texImage2D(glTexture2D, 0, int32(glRGBA), rp.popupWidth, rp.popupHeight, 0, glBGRA, glUnsignedByte, pixels) | 526 | + | gl.texImage2D(glTexture2D, 0, int32(glRGBA), popupW, popupH, 0, glBGRA, glUnsignedByte, pixels) |
| 504 | } else { | 527 | } else { | ||
| 505 | - | gl.texSubImage2D(glTexture2D, 0, 0, 0, rp.popupWidth, rp.popupHeight, glBGRA, glUnsignedByte, pixels) | 528 | + | gl.texSubImage2D(glTexture2D, 0, 0, 0, popupW, popupH, glBGRA, glUnsignedByte, pixels) |
| 506 | } | 529 | } | ||
| 507 | gl.bindTexture(glTexture2D, 0) | 530 | gl.bindTexture(glTexture2D, 0) | ||
| 508 | 531 | ||||
| 532 | + | // Update state under lock. | |||
| 533 | + | rp.mu.Lock() | |||
| 509 | rp.popupNeedsUpload = false | 534 | rp.popupNeedsUpload = false | ||
| 510 | rp.popupSizeChanged = false | 535 | rp.popupSizeChanged = false | ||
| 536 | + | rp.mu.Unlock() | |||
| 511 | 537 | ||||
| 512 | if rp.ctx != nil { | 538 | if rp.ctx != nil { | ||
| 513 | logging.FromContext(rp.ctx).Debug(). | 539 | logging.FromContext(rp.ctx).Debug(). | ||
| 514 | Uint64("render_seq", renderSeq). | 540 | Uint64("render_seq", renderSeq). | ||
| 515 | Uint64("paint_seq", paintSeq). | 541 | Uint64("paint_seq", paintSeq). | ||
| 516 | - | Int32("popup_width", rp.popupWidth). | 542 | + | Int32("popup_width", popupW). |
| 517 | - | Int32("popup_height", rp.popupHeight). | 543 | + | Int32("popup_height", popupH). |
| 518 | Msg("cef: popup texture uploaded") | 544 | Msg("cef: popup texture uploaded") | ||
| 519 | } | 545 | } | ||
| 520 | 546 | ||||
M internal/infrastructure/cef/scheme_handler.go +1 -1
| @@ -175,7 +175,7 @@ func (h *dumbSchemeHandler) handleTranscodeAPI(request purecef.Request) purecef. | |||
| 175 | 175 | headers["Origin"] = origin | |
| 176 | 176 | } | |
| 177 | 177 | ||
| 178 | - | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) | |
| 178 | + | ctx, cancel := context.WithTimeout(h.ctx, 5*time.Minute) | |
| 179 | 179 | h.logger.Info(). | |
| 180 | 180 | Str("source_url", logging.TruncateURL(sourceURL, 240)). | |
| 181 | 181 | Int("forwarded_header_count", len(headers)). | |
| @@ -175,7 +175,7 @@ func (h *dumbSchemeHandler) handleTranscodeAPI(request purecef.Request) purecef. | |||||
| 175 | headers["Origin"] = origin | 175 | headers["Origin"] = origin | ||
| 176 | } | 176 | } | ||
| 177 | 177 | ||||
| 178 | - | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) | 178 | + | ctx, cancel := context.WithTimeout(h.ctx, 5*time.Minute) |
| 179 | h.logger.Info(). | 179 | h.logger.Info(). | ||
| 180 | Str("source_url", logging.TruncateURL(sourceURL, 240)). | 180 | Str("source_url", logging.TruncateURL(sourceURL, 240)). | ||
| 181 | Int("forwarded_header_count", len(headers)). | 181 | Int("forwarded_header_count", len(headers)). | ||
M internal/infrastructure/cef/transcoding_handler.go +10 -1
| @@ -80,6 +80,9 @@ func (rh *transcodingResourceHandler) GetResponseHeaders(response purecef.Respon | |||
| 80 | 80 | response.SetStatusText("OK") | |
| 81 | 81 | response.SetMimeType("video/webm") | |
| 82 | 82 | response.SetHeaderByName("Accept-Ranges", "none", 1) | |
| 83 | + | // CORS wildcard is required because transcoded streams are loaded by <video> | |
| 84 | + | // elements on arbitrary origins (e.g. reddit.com). The streams contain no | |
| 85 | + | // sensitive data — they are re-encoded from publicly-accessible media URLs. | |
| 83 | 86 | response.SetHeaderByName("Access-Control-Allow-Origin", "*", 1) | |
| 84 | 87 | response.SetHeaderByName("Cache-Control", "no-store", 1) | |
| 85 | 88 | // Streaming — unknown length. | |
| @@ -264,7 +267,13 @@ func (h *transcodingRequestHandler) GetResourceHandler(_ purecef.Browser, _ pure | |||
| 264 | 267 | return nil | |
| 265 | 268 | } | |
| 266 | 269 | ||
| 267 | - | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) | |
| 270 | + | parentCtx := context.Background() | |
| 271 | + | if h.ctxf != nil { | |
| 272 | + | if provided := h.ctxf(); provided != nil { | |
| 273 | + | parentCtx = provided | |
| 274 | + | } | |
| 275 | + | } | |
| 276 | + | ctx, cancel := context.WithTimeout(parentCtx, 5*time.Minute) | |
| 268 | 277 | h.logger().Info(). | |
| 269 | 278 | Str("url", logging.TruncateURL(url, 240)). | |
| 270 | 279 | Str("source_url", logging.TruncateURL(sourceURL, 240)). | |
| @@ -80,6 +80,9 @@ func (rh *transcodingResourceHandler) GetResponseHeaders(response purecef.Respon | |||||
| 80 | response.SetStatusText("OK") | 80 | response.SetStatusText("OK") | ||
| 81 | response.SetMimeType("video/webm") | 81 | response.SetMimeType("video/webm") | ||
| 82 | response.SetHeaderByName("Accept-Ranges", "none", 1) | 82 | response.SetHeaderByName("Accept-Ranges", "none", 1) | ||
| 83 | + | // CORS wildcard is required because transcoded streams are loaded by <video> | |||
| 84 | + | // elements on arbitrary origins (e.g. reddit.com). The streams contain no | |||
| 85 | + | // sensitive data — they are re-encoded from publicly-accessible media URLs. | |||
| 83 | response.SetHeaderByName("Access-Control-Allow-Origin", "*", 1) | 86 | response.SetHeaderByName("Access-Control-Allow-Origin", "*", 1) | ||
| 84 | response.SetHeaderByName("Cache-Control", "no-store", 1) | 87 | response.SetHeaderByName("Cache-Control", "no-store", 1) | ||
| 85 | // Streaming — unknown length. | 88 | // Streaming — unknown length. | ||
| @@ -264,7 +267,13 @@ func (h *transcodingRequestHandler) GetResourceHandler(_ purecef.Browser, _ pure | |||||
| 264 | return nil | 267 | return nil | ||
| 265 | } | 268 | } | ||
| 266 | 269 | ||||
| 267 | - | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) | 270 | + | parentCtx := context.Background() |
| 271 | + | if h.ctxf != nil { | |||
| 272 | + | if provided := h.ctxf(); provided != nil { | |||
| 273 | + | parentCtx = provided | |||
| 274 | + | } | |||
| 275 | + | } | |||
| 276 | + | ctx, cancel := context.WithTimeout(parentCtx, 5*time.Minute) | |||
| 268 | h.logger().Info(). | 277 | h.logger().Info(). | ||
| 269 | Str("url", logging.TruncateURL(url, 240)). | 278 | Str("url", logging.TruncateURL(url, 240)). | ||
| 270 | Str("source_url", logging.TruncateURL(sourceURL, 240)). | 279 | Str("source_url", logging.TruncateURL(sourceURL, 240)). | ||
M internal/infrastructure/cef/webview_handlers.go +7 -1
| @@ -10,6 +10,12 @@ import ( | |||
| 10 | 10 | "github.com/bnema/dumber/internal/logging" | |
| 11 | 11 | ) | |
| 12 | 12 | ||
| 13 | + | // Console message markers used by injected JavaScript for log filtering. | |
| 14 | + | const ( | |
| 15 | + | consoleMarkerVideoDiag = "[VIDEO-DIAG]" | |
| 16 | + | consoleMarkerRedditVideoPatch = "[REDDIT-VIDEO-PATCH]" | |
| 17 | + | ) | |
| 18 | + | ||
| 13 | 19 | // handlerSet implements all CEF handler interfaces and dispatches events to the | |
| 14 | 20 | // owning WebView. A single struct is used so that the Client's Get*Handler | |
| 15 | 21 | // methods can return the same receiver, avoiding extra allocations. | |
| @@ -316,7 +322,7 @@ func (h *handlerSet) OnStatusMessage(_ purecef.Browser, value string) { | |||
| 316 | 322 | } | |
| 317 | 323 | ||
| 318 | 324 | func (h *handlerSet) OnConsoleMessage(_ purecef.Browser, level purecef.LogSeverity, message, source string, line int32) int32 { | |
| 319 | - | if h.wv != nil && h.wv.ctx != nil && (strings.Contains(message, "[VIDEO-DIAG]") || strings.Contains(message, "[REDDIT-VIDEO-PATCH]")) { | |
| 325 | + | if h.wv != nil && h.wv.ctx != nil && (strings.Contains(message, consoleMarkerVideoDiag) || strings.Contains(message, consoleMarkerRedditVideoPatch)) { | |
| 320 | 326 | log := logging.FromContext(h.wv.ctx).With(). | |
| 321 | 327 | Str("component", "cef-console"). | |
| 322 | 328 | Str("source", source). | |
| @@ -10,6 +10,12 @@ import ( | |||||
| 10 | "github.com/bnema/dumber/internal/logging" | 10 | "github.com/bnema/dumber/internal/logging" | ||
| 11 | ) | 11 | ) | ||
| 12 | 12 | ||||
| 13 | + | // Console message markers used by injected JavaScript for log filtering. | |||
| 14 | + | const ( | |||
| 15 | + | consoleMarkerVideoDiag = "[VIDEO-DIAG]" | |||
| 16 | + | consoleMarkerRedditVideoPatch = "[REDDIT-VIDEO-PATCH]" | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 13 | // handlerSet implements all CEF handler interfaces and dispatches events to the | 19 | // handlerSet implements all CEF handler interfaces and dispatches events to the | ||
| 14 | // owning WebView. A single struct is used so that the Client's Get*Handler | 20 | // owning WebView. A single struct is used so that the Client's Get*Handler | ||
| 15 | // methods can return the same receiver, avoiding extra allocations. | 21 | // methods can return the same receiver, avoiding extra allocations. | ||
| @@ -316,7 +322,7 @@ func (h *handlerSet) OnStatusMessage(_ purecef.Browser, value string) { | |||||
| 316 | } | 322 | } | ||
| 317 | 323 | ||||
| 318 | func (h *handlerSet) OnConsoleMessage(_ purecef.Browser, level purecef.LogSeverity, message, source string, line int32) int32 { | 324 | func (h *handlerSet) OnConsoleMessage(_ purecef.Browser, level purecef.LogSeverity, message, source string, line int32) int32 { | ||
| 319 | - | if h.wv != nil && h.wv.ctx != nil && (strings.Contains(message, "[VIDEO-DIAG]") || strings.Contains(message, "[REDDIT-VIDEO-PATCH]")) { | 325 | + | if h.wv != nil && h.wv.ctx != nil && (strings.Contains(message, consoleMarkerVideoDiag) || strings.Contains(message, consoleMarkerRedditVideoPatch)) { |
| 320 | log := logging.FromContext(h.wv.ctx).With(). | 326 | log := logging.FromContext(h.wv.ctx).With(). | ||
| 321 | Str("component", "cef-console"). | 327 | Str("component", "cef-console"). | ||
| 322 | Str("source", source). | 328 | Str("source", source). | ||
M internal/infrastructure/cef/webview_zoom_test.go +3 -4
| @@ -2,8 +2,9 @@ package cef | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "fmt" | |
| 5 | - | "math" | |
| 6 | 5 | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/assert" | |
| 7 | 8 | ) | |
| 8 | 9 | ||
| 9 | 10 | func TestZoomConversionsRoundTrip(t *testing.T) { | |
| @@ -12,9 +13,7 @@ func TestZoomConversionsRoundTrip(t *testing.T) { | |||
| 12 | 13 | t.Run(fmt.Sprintf("factor_%.1f", factor), func(t *testing.T) { | |
| 13 | 14 | level := cefZoomFromFactor(factor) | |
| 14 | 15 | got := factorFromCEFZoom(level) | |
| 15 | - | if math.Abs(got-factor) > 1e-9 { | |
| 16 | - | t.Errorf("round-trip mismatch for factor %.6f: level %.6f -> factor %.12f", factor, level, got) | |
| 17 | - | } | |
| 16 | + | assert.InDelta(t, factor, got, 1e-9, "round-trip mismatch for factor %.6f: level %.6f -> factor %.12f", factor, level, got) | |
| 18 | 17 | }) | |
| 19 | 18 | } | |
| 20 | 19 | } | |
| @@ -2,8 +2,9 @@ package cef | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "fmt" | 4 | "fmt" | ||
| 5 | - | "math" | |||
| 6 | "testing" | 5 | "testing" | ||
| 6 | + | ||||
| 7 | + | "github.com/stretchr/testify/assert" | |||
| 7 | ) | 8 | ) | ||
| 8 | 9 | ||||
| 9 | func TestZoomConversionsRoundTrip(t *testing.T) { | 10 | func TestZoomConversionsRoundTrip(t *testing.T) { | ||
| @@ -12,9 +13,7 @@ func TestZoomConversionsRoundTrip(t *testing.T) { | |||||
| 12 | t.Run(fmt.Sprintf("factor_%.1f", factor), func(t *testing.T) { | 13 | t.Run(fmt.Sprintf("factor_%.1f", factor), func(t *testing.T) { | ||
| 13 | level := cefZoomFromFactor(factor) | 14 | level := cefZoomFromFactor(factor) | ||
| 14 | got := factorFromCEFZoom(level) | 15 | got := factorFromCEFZoom(level) | ||
| 15 | - | if math.Abs(got-factor) > 1e-9 { | 16 | + | assert.InDelta(t, factor, got, 1e-9, "round-trip mismatch for factor %.6f: level %.6f -> factor %.12f", factor, level, got) |
| 16 | - | t.Errorf("round-trip mismatch for factor %.6f: level %.6f -> factor %.12f", factor, level, got) | |||
| 17 | - | } | |||
| 18 | }) | 17 | }) | ||
| 19 | } | 18 | } | ||
| 20 | } | 19 | } | ||
M internal/infrastructure/config/loader.go +3 -0
| @@ -414,6 +414,7 @@ func (m *Manager) setDefaults() { | |||
| 414 | 414 | m.setDebugDefaults(defaults) | |
| 415 | 415 | m.setAppearanceDefaults(defaults) | |
| 416 | 416 | m.setEngineDefaults(defaults) | |
| 417 | + | m.setTranscodingDefaults(defaults) | |
| 417 | 418 | m.setZoomAndScaleDefaults(defaults) | |
| 418 | 419 | m.setWorkspaceDefaults(defaults) | |
| 419 | 420 | m.setContentFilteringDefaults(defaults) | |
| @@ -606,7 +607,9 @@ func (m *Manager) setEngineDefaults(defaults *Config) { | |||
| 606 | 607 | m.viper.SetDefault("engine.webkit.network_process_memory_poll_interval_sec", wk.NetworkProcessMemoryPollIntervalSec) | |
| 607 | 608 | m.viper.SetDefault("engine.webkit.network_process_memory_conservative_threshold", wk.NetworkProcessMemoryConservativeThreshold) | |
| 608 | 609 | m.viper.SetDefault("engine.webkit.network_process_memory_strict_threshold", wk.NetworkProcessMemoryStrictThreshold) | |
| 610 | + | } | |
| 609 | 611 | ||
| 612 | + | func (m *Manager) setTranscodingDefaults(defaults *Config) { | |
| 610 | 613 | tc := defaults.Transcoding | |
| 611 | 614 | m.viper.SetDefault("transcoding.enabled", tc.Enabled) | |
| 612 | 615 | m.viper.SetDefault("transcoding.hwaccel", tc.HWAccel) | |
| @@ -414,6 +414,7 @@ func (m *Manager) setDefaults() { | |||||
| 414 | m.setDebugDefaults(defaults) | 414 | m.setDebugDefaults(defaults) | ||
| 415 | m.setAppearanceDefaults(defaults) | 415 | m.setAppearanceDefaults(defaults) | ||
| 416 | m.setEngineDefaults(defaults) | 416 | m.setEngineDefaults(defaults) | ||
| 417 | + | m.setTranscodingDefaults(defaults) | |||
| 417 | m.setZoomAndScaleDefaults(defaults) | 418 | m.setZoomAndScaleDefaults(defaults) | ||
| 418 | m.setWorkspaceDefaults(defaults) | 419 | m.setWorkspaceDefaults(defaults) | ||
| 419 | m.setContentFilteringDefaults(defaults) | 420 | m.setContentFilteringDefaults(defaults) | ||
| @@ -606,7 +607,9 @@ func (m *Manager) setEngineDefaults(defaults *Config) { | |||||
| 606 | m.viper.SetDefault("engine.webkit.network_process_memory_poll_interval_sec", wk.NetworkProcessMemoryPollIntervalSec) | 607 | m.viper.SetDefault("engine.webkit.network_process_memory_poll_interval_sec", wk.NetworkProcessMemoryPollIntervalSec) | ||
| 607 | m.viper.SetDefault("engine.webkit.network_process_memory_conservative_threshold", wk.NetworkProcessMemoryConservativeThreshold) | 608 | m.viper.SetDefault("engine.webkit.network_process_memory_conservative_threshold", wk.NetworkProcessMemoryConservativeThreshold) | ||
| 608 | m.viper.SetDefault("engine.webkit.network_process_memory_strict_threshold", wk.NetworkProcessMemoryStrictThreshold) | 609 | m.viper.SetDefault("engine.webkit.network_process_memory_strict_threshold", wk.NetworkProcessMemoryStrictThreshold) | ||
| 610 | + | } | |||
| 609 | 611 | ||||
| 612 | + | func (m *Manager) setTranscodingDefaults(defaults *Config) { | |||
| 610 | tc := defaults.Transcoding | 613 | tc := defaults.Transcoding | ||
| 611 | m.viper.SetDefault("transcoding.enabled", tc.Enabled) | 614 | m.viper.SetDefault("transcoding.enabled", tc.Enabled) | ||
| 612 | m.viper.SetDefault("transcoding.hwaccel", tc.HWAccel) | 615 | m.viper.SetDefault("transcoding.hwaccel", tc.HWAccel) | ||
M internal/infrastructure/config/writer_test.go +13 -2
| @@ -46,8 +46,19 @@ func TestWriteConfigOrdered(t *testing.T) { | |||
| 46 | 46 | if !strings.Contains(string(content), "[transcoding]") { | |
| 47 | 47 | t.Fatalf("expected transcoding section in generated config, got:\n%s", string(content)) | |
| 48 | 48 | } | |
| 49 | - | if !strings.Contains(string(content), "enabled = true") { | |
| 50 | - | t.Fatalf("expected transcoding.enabled default in generated config, got:\n%s", string(content)) | |
| 49 | + | // Match "enabled = true" specifically within the [transcoding] section to | |
| 50 | + | // avoid false positives from other sections that may also have an enabled key. | |
| 51 | + | transcodingIdx := strings.Index(string(content), "[transcoding]") | |
| 52 | + | afterTranscoding := string(content)[transcodingIdx:] | |
| 53 | + | nextSection := strings.Index(afterTranscoding[1:], "\n[") | |
| 54 | + | var transcodingBlock string | |
| 55 | + | if nextSection >= 0 { | |
| 56 | + | transcodingBlock = afterTranscoding[:nextSection+1] | |
| 57 | + | } else { | |
| 58 | + | transcodingBlock = afterTranscoding | |
| 59 | + | } | |
| 60 | + | if !strings.Contains(transcodingBlock, "enabled = true") { | |
| 61 | + | t.Fatalf("expected enabled = true in [transcoding] section, got:\n%s", transcodingBlock) | |
| 51 | 62 | } | |
| 52 | 63 | ||
| 53 | 64 | t.Logf("Found %d sections, all sorted", len(sections)) | |
| @@ -46,8 +46,19 @@ func TestWriteConfigOrdered(t *testing.T) { | |||||
| 46 | if !strings.Contains(string(content), "[transcoding]") { | 46 | if !strings.Contains(string(content), "[transcoding]") { | ||
| 47 | t.Fatalf("expected transcoding section in generated config, got:\n%s", string(content)) | 47 | t.Fatalf("expected transcoding section in generated config, got:\n%s", string(content)) | ||
| 48 | } | 48 | } | ||
| 49 | - | if !strings.Contains(string(content), "enabled = true") { | 49 | + | // Match "enabled = true" specifically within the [transcoding] section to |
| 50 | - | t.Fatalf("expected transcoding.enabled default in generated config, got:\n%s", string(content)) | 50 | + | // avoid false positives from other sections that may also have an enabled key. |
| 51 | + | transcodingIdx := strings.Index(string(content), "[transcoding]") | |||
| 52 | + | afterTranscoding := string(content)[transcodingIdx:] | |||
| 53 | + | nextSection := strings.Index(afterTranscoding[1:], "\n[") | |||
| 54 | + | var transcodingBlock string | |||
| 55 | + | if nextSection >= 0 { | |||
| 56 | + | transcodingBlock = afterTranscoding[:nextSection+1] | |||
| 57 | + | } else { | |||
| 58 | + | transcodingBlock = afterTranscoding | |||
| 59 | + | } | |||
| 60 | + | if !strings.Contains(transcodingBlock, "enabled = true") { | |||
| 61 | + | t.Fatalf("expected enabled = true in [transcoding] section, got:\n%s", transcodingBlock) | |||
| 51 | } | 62 | } | ||
| 52 | 63 | ||||
| 53 | t.Logf("Found %d sections, all sorted", len(sections)) | 64 | t.Logf("Found %d sections, all sorted", len(sections)) | ||
M internal/infrastructure/transcoder/audio.go +15 -7
| @@ -132,8 +132,13 @@ func newAudioTranscoder(inCodecPar, outFmtCtx unsafe.Pointer, inStreamIdx int) ( | |||
| 132 | 132 | ffmpeg.AVOptSet(swr, "in_sample_fmt", ffmpeg.GetSampleFmtName(inSampleFmt), 0) | |
| 133 | 133 | ffmpeg.AVOptSet(swr, "out_sample_fmt", ffmpeg.GetSampleFmtName(int32(ffmpeg.SampleFormatSampleFmtFltp)), 0) | |
| 134 | 134 | ||
| 135 | - | // Channel layout is inherited from the decoder context via av_opt. | |
| 136 | - | // SwrContext reads in_chlayout / out_chlayout — we keep them identical. | |
| 135 | + | // Modern FFmpeg (8.x) requires explicit channel layout configuration | |
| 136 | + | // on the SwrContext. Without it, swr_init may succeed but the resampler | |
| 137 | + | // operates with 0 channels and produces silence. Copy the decoder's | |
| 138 | + | // channel layout to both in and out (we keep them identical — same | |
| 139 | + | // number of channels, only sample rate/format changes). | |
| 140 | + | swrSetChLayoutFromCodecCtx(swr, decCtx, offsetSwrInChLayout) | |
| 141 | + | swrSetChLayoutFromCodecCtx(swr, decCtx, offsetSwrOutChLayout) | |
| 137 | 142 | ||
| 138 | 143 | if ret := ffmpeg.SwrInit(swr); ret < 0 { | |
| 139 | 144 | ffmpeg.SwrFree(unsafe.Pointer(&swr)) | |
| @@ -221,8 +226,11 @@ func (a *audioTranscoder) processPacket(pkt, outFmtCtx unsafe.Pointer) error { | |||
| 221 | 226 | if a.resampler != nil { | |
| 222 | 227 | encFrame = a.frame | |
| 223 | 228 | ||
| 224 | - | // Get the decoded frame's data and linesize pointers. | |
| 225 | - | // Frame data[0] is at offset 0, linesize is not needed for swr. | |
| 229 | + | // AVFrame.data[0] is the first field at offset 0, so casting the | |
| 230 | + | // frame pointer to *unsafe.Pointer yields &data[0]. This is the | |
| 231 | + | // standard FFmpeg pattern for passing data pointers to swr_convert. | |
| 232 | + | // purego-ffmpeg does not expose typed accessors for the data array, | |
| 233 | + | // so raw pointer arithmetic is the only option. | |
| 226 | 234 | inData := (*unsafe.Pointer)(frame) | |
| 227 | 235 | outData := (*unsafe.Pointer)(a.frame) | |
| 228 | 236 | inSamples := frameNbSamples(frame) | |
| @@ -291,7 +299,7 @@ func (a *audioTranscoder) processPacket(pkt, outFmtCtx unsafe.Pointer) error { | |||
| 291 | 299 | // flush drains remaining frames from the decoder and encoder. | |
| 292 | 300 | // The logger parameter is used to report non-fatal resampling errors | |
| 293 | 301 | // that occur during flush (we continue flushing rather than aborting). | |
| 294 | - | func (a *audioTranscoder) flush(outFmtCtx unsafe.Pointer, logger zerolog.Logger) error { | |
| 302 | + | func (a *audioTranscoder) flush(outFmtCtx unsafe.Pointer, logger zerolog.Logger) { | |
| 295 | 303 | // Flush decoder by sending NULL packet. | |
| 296 | 304 | ffmpeg.CodecSendPacket(a.decoder, nil) | |
| 297 | 305 | ||
| @@ -308,6 +316,8 @@ func (a *audioTranscoder) flush(outFmtCtx unsafe.Pointer, logger zerolog.Logger) | |||
| 308 | 316 | encFrame := frame | |
| 309 | 317 | if a.resampler != nil { | |
| 310 | 318 | encFrame = a.frame | |
| 319 | + | // See processPacket for why these casts are safe: AVFrame.data[0] | |
| 320 | + | // is at offset 0, so the frame pointer itself is &data[0]. | |
| 311 | 321 | inData := (*unsafe.Pointer)(frame) | |
| 312 | 322 | outData := (*unsafe.Pointer)(a.frame) | |
| 313 | 323 | inSamples := frameNbSamples(frame) | |
| @@ -364,8 +374,6 @@ func (a *audioTranscoder) flush(outFmtCtx unsafe.Pointer, logger zerolog.Logger) | |||
| 364 | 374 | ffmpeg.InterleavedWriteFrame(outFmtCtx, encPkt) | |
| 365 | 375 | ffmpeg.PacketUnref(encPkt) | |
| 366 | 376 | } | |
| 367 | - | ||
| 368 | - | return nil | |
| 369 | 377 | } | |
| 370 | 378 | ||
| 371 | 379 | // close frees all FFmpeg resources owned by the audio transcoder. | |
| @@ -132,8 +132,13 @@ func newAudioTranscoder(inCodecPar, outFmtCtx unsafe.Pointer, inStreamIdx int) ( | |||||
| 132 | ffmpeg.AVOptSet(swr, "in_sample_fmt", ffmpeg.GetSampleFmtName(inSampleFmt), 0) | 132 | ffmpeg.AVOptSet(swr, "in_sample_fmt", ffmpeg.GetSampleFmtName(inSampleFmt), 0) | ||
| 133 | ffmpeg.AVOptSet(swr, "out_sample_fmt", ffmpeg.GetSampleFmtName(int32(ffmpeg.SampleFormatSampleFmtFltp)), 0) | 133 | ffmpeg.AVOptSet(swr, "out_sample_fmt", ffmpeg.GetSampleFmtName(int32(ffmpeg.SampleFormatSampleFmtFltp)), 0) | ||
| 134 | 134 | ||||
| 135 | - | // Channel layout is inherited from the decoder context via av_opt. | 135 | + | // Modern FFmpeg (8.x) requires explicit channel layout configuration |
| 136 | - | // SwrContext reads in_chlayout / out_chlayout — we keep them identical. | 136 | + | // on the SwrContext. Without it, swr_init may succeed but the resampler |
| 137 | + | // operates with 0 channels and produces silence. Copy the decoder's | |||
| 138 | + | // channel layout to both in and out (we keep them identical — same | |||
| 139 | + | // number of channels, only sample rate/format changes). | |||
| 140 | + | swrSetChLayoutFromCodecCtx(swr, decCtx, offsetSwrInChLayout) | |||
| 141 | + | swrSetChLayoutFromCodecCtx(swr, decCtx, offsetSwrOutChLayout) | |||
| 137 | 142 | ||||
| 138 | if ret := ffmpeg.SwrInit(swr); ret < 0 { | 143 | if ret := ffmpeg.SwrInit(swr); ret < 0 { | ||
| 139 | ffmpeg.SwrFree(unsafe.Pointer(&swr)) | 144 | ffmpeg.SwrFree(unsafe.Pointer(&swr)) | ||
| @@ -221,8 +226,11 @@ func (a *audioTranscoder) processPacket(pkt, outFmtCtx unsafe.Pointer) error { | |||||
| 221 | if a.resampler != nil { | 226 | if a.resampler != nil { | ||
| 222 | encFrame = a.frame | 227 | encFrame = a.frame | ||
| 223 | 228 | ||||
| 224 | - | // Get the decoded frame's data and linesize pointers. | 229 | + | // AVFrame.data[0] is the first field at offset 0, so casting the |
| 225 | - | // Frame data[0] is at offset 0, linesize is not needed for swr. | 230 | + | // frame pointer to *unsafe.Pointer yields &data[0]. This is the |
| 231 | + | // standard FFmpeg pattern for passing data pointers to swr_convert. | |||
| 232 | + | // purego-ffmpeg does not expose typed accessors for the data array, | |||
| 233 | + | // so raw pointer arithmetic is the only option. | |||
| 226 | inData := (*unsafe.Pointer)(frame) | 234 | inData := (*unsafe.Pointer)(frame) | ||
| 227 | outData := (*unsafe.Pointer)(a.frame) | 235 | outData := (*unsafe.Pointer)(a.frame) | ||
| 228 | inSamples := frameNbSamples(frame) | 236 | inSamples := frameNbSamples(frame) | ||
| @@ -291,7 +299,7 @@ func (a *audioTranscoder) processPacket(pkt, outFmtCtx unsafe.Pointer) error { | |||||
| 291 | // flush drains remaining frames from the decoder and encoder. | 299 | // flush drains remaining frames from the decoder and encoder. | ||
| 292 | // The logger parameter is used to report non-fatal resampling errors | 300 | // The logger parameter is used to report non-fatal resampling errors | ||
| 293 | // that occur during flush (we continue flushing rather than aborting). | 301 | // that occur during flush (we continue flushing rather than aborting). | ||
| 294 | - | func (a *audioTranscoder) flush(outFmtCtx unsafe.Pointer, logger zerolog.Logger) error { | 302 | + | func (a *audioTranscoder) flush(outFmtCtx unsafe.Pointer, logger zerolog.Logger) { |
| 295 | // Flush decoder by sending NULL packet. | 303 | // Flush decoder by sending NULL packet. | ||
| 296 | ffmpeg.CodecSendPacket(a.decoder, nil) | 304 | ffmpeg.CodecSendPacket(a.decoder, nil) | ||
| 297 | 305 | ||||
| @@ -308,6 +316,8 @@ func (a *audioTranscoder) flush(outFmtCtx unsafe.Pointer, logger zerolog.Logger) | |||||
| 308 | encFrame := frame | 316 | encFrame := frame | ||
| 309 | if a.resampler != nil { | 317 | if a.resampler != nil { | ||
| 310 | encFrame = a.frame | 318 | encFrame = a.frame | ||
| 319 | + | // See processPacket for why these casts are safe: AVFrame.data[0] | |||
| 320 | + | // is at offset 0, so the frame pointer itself is &data[0]. | |||
| 311 | inData := (*unsafe.Pointer)(frame) | 321 | inData := (*unsafe.Pointer)(frame) | ||
| 312 | outData := (*unsafe.Pointer)(a.frame) | 322 | outData := (*unsafe.Pointer)(a.frame) | ||
| 313 | inSamples := frameNbSamples(frame) | 323 | inSamples := frameNbSamples(frame) | ||
| @@ -364,8 +374,6 @@ func (a *audioTranscoder) flush(outFmtCtx unsafe.Pointer, logger zerolog.Logger) | |||||
| 364 | ffmpeg.InterleavedWriteFrame(outFmtCtx, encPkt) | 374 | ffmpeg.InterleavedWriteFrame(outFmtCtx, encPkt) | ||
| 365 | ffmpeg.PacketUnref(encPkt) | 375 | ffmpeg.PacketUnref(encPkt) | ||
| 366 | } | 376 | } | ||
| 367 | - | ||||
| 368 | - | return nil | |||
| 369 | } | 377 | } | ||
| 370 | 378 | ||||
| 371 | // close frees all FFmpeg resources owned by the audio transcoder. | 379 | // close frees all FFmpeg resources owned by the audio transcoder. | ||
M internal/infrastructure/transcoder/detect_test.go +84 -13
| @@ -163,19 +163,90 @@ func TestIsEagerTranscodeURL(t *testing.T) { | |||
| 163 | 163 | } | |
| 164 | 164 | ||
| 165 | 165 | func TestParseSyntheticTranscodeURL(t *testing.T) { | |
| 166 | - | rawURL := "https://www.reddit.com/__dumber__/transcode.webm?src=https%3A%2F%2Fv.redd.it%2Fabc%2FHLSPlaylist.m3u8&referer=https%3A%2F%2Fwww.reddit.com%2Fr%2FOpenAI&origin=https%3A%2F%2Fwww.reddit.com" | |
| 167 | - | ||
| 168 | - | sourceURL, referer, origin, ok := ParseSyntheticTranscodeURL(rawURL) | |
| 169 | - | if !ok { | |
| 170 | - | t.Fatalf("ParseSyntheticTranscodeURL(%q) = !ok, want ok", rawURL) | |
| 171 | - | } | |
| 172 | - | if sourceURL != "https://v.redd.it/abc/HLSPlaylist.m3u8" { | |
| 173 | - | t.Fatalf("sourceURL = %q", sourceURL) | |
| 174 | - | } | |
| 175 | - | if referer != "https://www.reddit.com/r/OpenAI" { | |
| 176 | - | t.Fatalf("referer = %q", referer) | |
| 166 | + | tests := []struct { | |
| 167 | + | name string | |
| 168 | + | rawURL string | |
| 169 | + | wantSrc string | |
| 170 | + | wantRef string | |
| 171 | + | wantOrig string | |
| 172 | + | wantOK bool | |
| 173 | + | }{ | |
| 174 | + | { | |
| 175 | + | name: "valid with all params", | |
| 176 | + | rawURL: "https://www.reddit.com/__dumber__/transcode.webm?src=https%3A%2F%2Fv.redd.it%2Fabc%2FHLSPlaylist.m3u8&referer=https%3A%2F%2Fwww.reddit.com%2Fr%2FOpenAI&origin=https%3A%2F%2Fwww.reddit.com", | |
| 177 | + | wantSrc: "https://v.redd.it/abc/HLSPlaylist.m3u8", | |
| 178 | + | wantRef: "https://www.reddit.com/r/OpenAI", | |
| 179 | + | wantOrig: "https://www.reddit.com", | |
| 180 | + | wantOK: true, | |
| 181 | + | }, | |
| 182 | + | { | |
| 183 | + | name: "valid without referer and origin", | |
| 184 | + | rawURL: "https://example.com/__dumber__/transcode.webm?src=https%3A%2F%2Fcdn.example.com%2Fvideo.mp4", | |
| 185 | + | wantSrc: "https://cdn.example.com/video.mp4", | |
| 186 | + | wantRef: "", | |
| 187 | + | wantOrig: "", | |
| 188 | + | wantOK: true, | |
| 189 | + | }, | |
| 190 | + | { | |
| 191 | + | name: "empty URL", | |
| 192 | + | rawURL: "", | |
| 193 | + | wantOK: false, | |
| 194 | + | }, | |
| 195 | + | { | |
| 196 | + | name: "missing src parameter", | |
| 197 | + | rawURL: "https://example.com/__dumber__/transcode.webm?referer=https%3A%2F%2Fexample.com", | |
| 198 | + | wantOK: false, | |
| 199 | + | }, | |
| 200 | + | { | |
| 201 | + | name: "wrong path", | |
| 202 | + | rawURL: "https://example.com/other/path?src=https%3A%2F%2Fcdn.example.com%2Fvideo.mp4", | |
| 203 | + | wantOK: false, | |
| 204 | + | }, | |
| 205 | + | { | |
| 206 | + | name: "src with no host", | |
| 207 | + | rawURL: "https://example.com/__dumber__/transcode.webm?src=not-a-url", | |
| 208 | + | wantOK: false, | |
| 209 | + | }, | |
| 210 | + | { | |
| 211 | + | name: "src with non-http scheme", | |
| 212 | + | rawURL: "https://example.com/__dumber__/transcode.webm?src=ftp%3A%2F%2Fcdn.example.com%2Fvideo.mp4", | |
| 213 | + | wantOK: false, | |
| 214 | + | }, | |
| 215 | + | { | |
| 216 | + | name: "src is empty string", | |
| 217 | + | rawURL: "https://example.com/__dumber__/transcode.webm?src=", | |
| 218 | + | wantOK: false, | |
| 219 | + | }, | |
| 220 | + | { | |
| 221 | + | name: "src is whitespace", | |
| 222 | + | rawURL: "https://example.com/__dumber__/transcode.webm?src=%20%20", | |
| 223 | + | wantOK: false, | |
| 224 | + | }, | |
| 225 | + | { | |
| 226 | + | name: "unparseable URL", | |
| 227 | + | rawURL: "://invalid", | |
| 228 | + | wantOK: false, | |
| 229 | + | }, | |
| 177 | 230 | } | |
| 178 | - | if origin != "https://www.reddit.com" { | |
| 179 | - | t.Fatalf("origin = %q", origin) | |
| 231 | + | ||
| 232 | + | for _, tt := range tests { | |
| 233 | + | t.Run(tt.name, func(t *testing.T) { | |
| 234 | + | src, ref, orig, ok := ParseSyntheticTranscodeURL(tt.rawURL) | |
| 235 | + | if ok != tt.wantOK { | |
| 236 | + | t.Fatalf("ParseSyntheticTranscodeURL(%q) ok = %v, want %v", tt.rawURL, ok, tt.wantOK) | |
| 237 | + | } | |
| 238 | + | if !tt.wantOK { | |
| 239 | + | return | |
| 240 | + | } | |
| 241 | + | if src != tt.wantSrc { | |
| 242 | + | t.Errorf("sourceURL = %q, want %q", src, tt.wantSrc) | |
| 243 | + | } | |
| 244 | + | if ref != tt.wantRef { | |
| 245 | + | t.Errorf("referer = %q, want %q", ref, tt.wantRef) | |
| 246 | + | } | |
| 247 | + | if orig != tt.wantOrig { | |
| 248 | + | t.Errorf("origin = %q, want %q", orig, tt.wantOrig) | |
| 249 | + | } | |
| 250 | + | }) | |
| 180 | 251 | } | |
| 181 | 252 | } | |
| @@ -163,19 +163,90 @@ func TestIsEagerTranscodeURL(t *testing.T) { | |||||
| 163 | } | 163 | } | ||
| 164 | 164 | ||||
| 165 | func TestParseSyntheticTranscodeURL(t *testing.T) { | 165 | func TestParseSyntheticTranscodeURL(t *testing.T) { | ||
| 166 | - | rawURL := "https://www.reddit.com/__dumber__/transcode.webm?src=https%3A%2F%2Fv.redd.it%2Fabc%2FHLSPlaylist.m3u8&referer=https%3A%2F%2Fwww.reddit.com%2Fr%2FOpenAI&origin=https%3A%2F%2Fwww.reddit.com" | 166 | + | tests := []struct { |
| 167 | - | 167 | + | name string | |
| 168 | - | sourceURL, referer, origin, ok := ParseSyntheticTranscodeURL(rawURL) | 168 | + | rawURL string |
| 169 | - | if !ok { | 169 | + | wantSrc string |
| 170 | - | t.Fatalf("ParseSyntheticTranscodeURL(%q) = !ok, want ok", rawURL) | 170 | + | wantRef string |
| 171 | - | } | 171 | + | wantOrig string |
| 172 | - | if sourceURL != "https://v.redd.it/abc/HLSPlaylist.m3u8" { | 172 | + | wantOK bool |
| 173 | - | t.Fatalf("sourceURL = %q", sourceURL) | 173 | + | }{ |
| 174 | - | } | 174 | + | { |
| 175 | - | if referer != "https://www.reddit.com/r/OpenAI" { | 175 | + | name: "valid with all params", |
| 176 | - | t.Fatalf("referer = %q", referer) | 176 | + | rawURL: "https://www.reddit.com/__dumber__/transcode.webm?src=https%3A%2F%2Fv.redd.it%2Fabc%2FHLSPlaylist.m3u8&referer=https%3A%2F%2Fwww.reddit.com%2Fr%2FOpenAI&origin=https%3A%2F%2Fwww.reddit.com", |
| 177 | + | wantSrc: "https://v.redd.it/abc/HLSPlaylist.m3u8", | |||
| 178 | + | wantRef: "https://www.reddit.com/r/OpenAI", | |||
| 179 | + | wantOrig: "https://www.reddit.com", | |||
| 180 | + | wantOK: true, | |||
| 181 | + | }, | |||
| 182 | + | { | |||
| 183 | + | name: "valid without referer and origin", | |||
| 184 | + | rawURL: "https://example.com/__dumber__/transcode.webm?src=https%3A%2F%2Fcdn.example.com%2Fvideo.mp4", | |||
| 185 | + | wantSrc: "https://cdn.example.com/video.mp4", | |||
| 186 | + | wantRef: "", | |||
| 187 | + | wantOrig: "", | |||
| 188 | + | wantOK: true, | |||
| 189 | + | }, | |||
| 190 | + | { | |||
| 191 | + | name: "empty URL", | |||
| 192 | + | rawURL: "", | |||
| 193 | + | wantOK: false, | |||
| 194 | + | }, | |||
| 195 | + | { | |||
| 196 | + | name: "missing src parameter", | |||
| 197 | + | rawURL: "https://example.com/__dumber__/transcode.webm?referer=https%3A%2F%2Fexample.com", | |||
| 198 | + | wantOK: false, | |||
| 199 | + | }, | |||
| 200 | + | { | |||
| 201 | + | name: "wrong path", | |||
| 202 | + | rawURL: "https://example.com/other/path?src=https%3A%2F%2Fcdn.example.com%2Fvideo.mp4", | |||
| 203 | + | wantOK: false, | |||
| 204 | + | }, | |||
| 205 | + | { | |||
| 206 | + | name: "src with no host", | |||
| 207 | + | rawURL: "https://example.com/__dumber__/transcode.webm?src=not-a-url", | |||
| 208 | + | wantOK: false, | |||
| 209 | + | }, | |||
| 210 | + | { | |||
| 211 | + | name: "src with non-http scheme", | |||
| 212 | + | rawURL: "https://example.com/__dumber__/transcode.webm?src=ftp%3A%2F%2Fcdn.example.com%2Fvideo.mp4", | |||
| 213 | + | wantOK: false, | |||
| 214 | + | }, | |||
| 215 | + | { | |||
| 216 | + | name: "src is empty string", | |||
| 217 | + | rawURL: "https://example.com/__dumber__/transcode.webm?src=", | |||
| 218 | + | wantOK: false, | |||
| 219 | + | }, | |||
| 220 | + | { | |||
| 221 | + | name: "src is whitespace", | |||
| 222 | + | rawURL: "https://example.com/__dumber__/transcode.webm?src=%20%20", | |||
| 223 | + | wantOK: false, | |||
| 224 | + | }, | |||
| 225 | + | { | |||
| 226 | + | name: "unparseable URL", | |||
| 227 | + | rawURL: "://invalid", | |||
| 228 | + | wantOK: false, | |||
| 229 | + | }, | |||
| 177 | } | 230 | } | ||
| 178 | - | if origin != "https://www.reddit.com" { | 231 | + | |
| 179 | - | t.Fatalf("origin = %q", origin) | 232 | + | for _, tt := range tests { |
| 233 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 234 | + | src, ref, orig, ok := ParseSyntheticTranscodeURL(tt.rawURL) | |||
| 235 | + | if ok != tt.wantOK { | |||
| 236 | + | t.Fatalf("ParseSyntheticTranscodeURL(%q) ok = %v, want %v", tt.rawURL, ok, tt.wantOK) | |||
| 237 | + | } | |||
| 238 | + | if !tt.wantOK { | |||
| 239 | + | return | |||
| 240 | + | } | |||
| 241 | + | if src != tt.wantSrc { | |||
| 242 | + | t.Errorf("sourceURL = %q, want %q", src, tt.wantSrc) | |||
| 243 | + | } | |||
| 244 | + | if ref != tt.wantRef { | |||
| 245 | + | t.Errorf("referer = %q, want %q", ref, tt.wantRef) | |||
| 246 | + | } | |||
| 247 | + | if orig != tt.wantOrig { | |||
| 248 | + | t.Errorf("origin = %q, want %q", orig, tt.wantOrig) | |||
| 249 | + | } | |||
| 250 | + | }) | |||
| 180 | } | 251 | } | ||
| 181 | } | 252 | } | ||
M internal/infrastructure/transcoder/ffmpeg_offsets.go +34 -7
| @@ -1,13 +1,20 @@ | |||
| 1 | 1 | package transcoder | |
| 2 | 2 | ||
| 3 | 3 | // Offset-based accessors for FFmpeg struct fields not yet exposed | |
| 4 | - | // by purego-ffmpeg. These offsets are for FFmpeg 7.x (libavcodec 62, | |
| 5 | - | // libavformat 62). Verified against the same headers used by purego-ffmpeg. | |
| 4 | + | // by purego-ffmpeg. | |
| 5 | + | // | |
| 6 | + | // Validated against: | |
| 7 | + | // FFmpeg 8.1 (n8.1) | |
| 8 | + | // libavcodec 62.28.100 | |
| 9 | + | // libavformat 62.12.100 | |
| 10 | + | // libavutil 60.26.100 | |
| 11 | + | // | |
| 12 | + | // All offsets verified via offsetof() on the installed headers. | |
| 6 | 13 | ||
| 7 | 14 | import "unsafe" | |
| 8 | 15 | ||
| 9 | 16 | // --------------------------------------------------------------------------- | |
| 10 | - | // AVPacket field offsets (FFmpeg 7.x) | |
| 17 | + | // AVPacket field offsets (FFmpeg 8.x, libavcodec 62) | |
| 11 | 18 | // Layout: AVBufferRef* buf (0), int64 pts (8), int64 dts (16), | |
| 12 | 19 | // uint8* data (24), int size (32), int stream_index (36), int flags (40) | |
| 13 | 20 | // --------------------------------------------------------------------------- | |
| @@ -40,7 +47,6 @@ func pktSetStreamIndex(pkt unsafe.Pointer, idx int32) { | |||
| 40 | 47 | // sample_rate at offset 180 | |
| 41 | 48 | // hw_frames_ctx at offset 328 (AVBufferRef*) | |
| 42 | 49 | // ch_layout at offset 384 (AVChannelLayout, 24 bytes) | |
| 43 | - | // hw_frames_ctx at offset 328 (AVBufferRef*) | |
| 44 | 50 | // --------------------------------------------------------------------------- | |
| 45 | 51 | ||
| 46 | 52 | const ( | |
| @@ -106,8 +112,7 @@ func frameSetSampleRate(f unsafe.Pointer, v int32) { | |||
| 106 | 112 | } | |
| 107 | 113 | ||
| 108 | 114 | // --------------------------------------------------------------------------- | |
| 109 | - | // AVCodecContext additional offsets | |
| 110 | - | // Verified against the installed libavcodec headers via offsetof(). | |
| 115 | + | // AVCodecContext additional offsets (FFmpeg 8.x, libavcodec 62) | |
| 111 | 116 | // --------------------------------------------------------------------------- | |
| 112 | 117 | ||
| 113 | 118 | const ( | |
| @@ -150,7 +155,29 @@ func codecCtxCopyChLayout(dst, src unsafe.Pointer) { | |||
| 150 | 155 | } | |
| 151 | 156 | ||
| 152 | 157 | // --------------------------------------------------------------------------- | |
| 153 | - | // AVCodecParameters additional offsets | |
| 158 | + | // SwrContext channel layout offsets (FFmpeg 8.x, libswresample) | |
| 159 | + | // SwrContext is opaque; offsets verified by writing known AVChannelLayout | |
| 160 | + | // values via av_opt_set_chlayout and scanning the resulting memory. | |
| 161 | + | // --------------------------------------------------------------------------- | |
| 162 | + | ||
| 163 | + | const ( | |
| 164 | + | offsetSwrInChLayout = 192 // AVChannelLayout (24 bytes) | |
| 165 | + | offsetSwrOutChLayout = 216 // AVChannelLayout (24 bytes) | |
| 166 | + | ) | |
| 167 | + | ||
| 168 | + | // swrCopyChLayoutFromCodecCtx copies an AVChannelLayout (24 bytes) from | |
| 169 | + | // an AVCodecContext's ch_layout field into the SwrContext's in/out ch_layout. | |
| 170 | + | func swrSetChLayoutFromCodecCtx(swr, codecCtx unsafe.Pointer, swrOffset int) { | |
| 171 | + | dst := unsafe.Add(swr, swrOffset) | |
| 172 | + | src := unsafe.Add(codecCtx, offsetCodecCtxChLayout) | |
| 173 | + | copy( | |
| 174 | + | unsafe.Slice((*byte)(dst), 24), | |
| 175 | + | unsafe.Slice((*byte)(src), 24), | |
| 176 | + | ) | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | // --------------------------------------------------------------------------- | |
| 180 | + | // AVCodecParameters additional offsets (FFmpeg 8.x, libavcodec 62) | |
| 154 | 181 | // --------------------------------------------------------------------------- | |
| 155 | 182 | ||
| 156 | 183 | const ( | |
| @@ -1,13 +1,20 @@ | |||||
| 1 | package transcoder | 1 | package transcoder | ||
| 2 | 2 | ||||
| 3 | // Offset-based accessors for FFmpeg struct fields not yet exposed | 3 | // Offset-based accessors for FFmpeg struct fields not yet exposed | ||
| 4 | - | // by purego-ffmpeg. These offsets are for FFmpeg 7.x (libavcodec 62, | 4 | + | // by purego-ffmpeg. |
| 5 | - | // libavformat 62). Verified against the same headers used by purego-ffmpeg. | 5 | + | // |
| 6 | + | // Validated against: | |||
| 7 | + | // FFmpeg 8.1 (n8.1) | |||
| 8 | + | // libavcodec 62.28.100 | |||
| 9 | + | // libavformat 62.12.100 | |||
| 10 | + | // libavutil 60.26.100 | |||
| 11 | + | // | |||
| 12 | + | // All offsets verified via offsetof() on the installed headers. | |||
| 6 | 13 | ||||
| 7 | import "unsafe" | 14 | import "unsafe" | ||
| 8 | 15 | ||||
| 9 | // --------------------------------------------------------------------------- | 16 | // --------------------------------------------------------------------------- | ||
| 10 | - | // AVPacket field offsets (FFmpeg 7.x) | 17 | + | // AVPacket field offsets (FFmpeg 8.x, libavcodec 62) |
| 11 | // Layout: AVBufferRef* buf (0), int64 pts (8), int64 dts (16), | 18 | // Layout: AVBufferRef* buf (0), int64 pts (8), int64 dts (16), | ||
| 12 | // uint8* data (24), int size (32), int stream_index (36), int flags (40) | 19 | // uint8* data (24), int size (32), int stream_index (36), int flags (40) | ||
| 13 | // --------------------------------------------------------------------------- | 20 | // --------------------------------------------------------------------------- | ||
| @@ -40,7 +47,6 @@ func pktSetStreamIndex(pkt unsafe.Pointer, idx int32) { | |||||
| 40 | // sample_rate at offset 180 | 47 | // sample_rate at offset 180 | ||
| 41 | // hw_frames_ctx at offset 328 (AVBufferRef*) | 48 | // hw_frames_ctx at offset 328 (AVBufferRef*) | ||
| 42 | // ch_layout at offset 384 (AVChannelLayout, 24 bytes) | 49 | // ch_layout at offset 384 (AVChannelLayout, 24 bytes) | ||
| 43 | - | // hw_frames_ctx at offset 328 (AVBufferRef*) | |||
| 44 | // --------------------------------------------------------------------------- | 50 | // --------------------------------------------------------------------------- | ||
| 45 | 51 | ||||
| 46 | const ( | 52 | const ( | ||
| @@ -106,8 +112,7 @@ func frameSetSampleRate(f unsafe.Pointer, v int32) { | |||||
| 106 | } | 112 | } | ||
| 107 | 113 | ||||
| 108 | // --------------------------------------------------------------------------- | 114 | // --------------------------------------------------------------------------- | ||
| 109 | - | // AVCodecContext additional offsets | 115 | + | // AVCodecContext additional offsets (FFmpeg 8.x, libavcodec 62) |
| 110 | - | // Verified against the installed libavcodec headers via offsetof(). | |||
| 111 | // --------------------------------------------------------------------------- | 116 | // --------------------------------------------------------------------------- | ||
| 112 | 117 | ||||
| 113 | const ( | 118 | const ( | ||
| @@ -150,7 +155,29 @@ func codecCtxCopyChLayout(dst, src unsafe.Pointer) { | |||||
| 150 | } | 155 | } | ||
| 151 | 156 | ||||
| 152 | // --------------------------------------------------------------------------- | 157 | // --------------------------------------------------------------------------- | ||
| 153 | - | // AVCodecParameters additional offsets | 158 | + | // SwrContext channel layout offsets (FFmpeg 8.x, libswresample) |
| 159 | + | // SwrContext is opaque; offsets verified by writing known AVChannelLayout | |||
| 160 | + | // values via av_opt_set_chlayout and scanning the resulting memory. | |||
| 161 | + | // --------------------------------------------------------------------------- | |||
| 162 | + | ||||
| 163 | + | const ( | |||
| 164 | + | offsetSwrInChLayout = 192 // AVChannelLayout (24 bytes) | |||
| 165 | + | offsetSwrOutChLayout = 216 // AVChannelLayout (24 bytes) | |||
| 166 | + | ) | |||
| 167 | + | ||||
| 168 | + | // swrCopyChLayoutFromCodecCtx copies an AVChannelLayout (24 bytes) from | |||
| 169 | + | // an AVCodecContext's ch_layout field into the SwrContext's in/out ch_layout. | |||
| 170 | + | func swrSetChLayoutFromCodecCtx(swr, codecCtx unsafe.Pointer, swrOffset int) { | |||
| 171 | + | dst := unsafe.Add(swr, swrOffset) | |||
| 172 | + | src := unsafe.Add(codecCtx, offsetCodecCtxChLayout) | |||
| 173 | + | copy( | |||
| 174 | + | unsafe.Slice((*byte)(dst), 24), | |||
| 175 | + | unsafe.Slice((*byte)(src), 24), | |||
| 176 | + | ) | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | // --------------------------------------------------------------------------- | |||
| 180 | + | // AVCodecParameters additional offsets (FFmpeg 8.x, libavcodec 62) | |||
| 154 | // --------------------------------------------------------------------------- | 181 | // --------------------------------------------------------------------------- | ||
| 155 | 182 | ||||
| 156 | const ( | 183 | const ( | ||
M internal/infrastructure/transcoder/pipeline.go +31 -13
| @@ -191,9 +191,14 @@ func (p *pipeline) doRun(ctx context.Context) error { | |||
| 191 | 191 | audioIdx = -1 | |
| 192 | 192 | } | |
| 193 | 193 | } | |
| 194 | - | if audioTx != nil { | |
| 195 | - | defer audioTx.close() | |
| 196 | - | } | |
| 194 | + | // Use a closure so the defer sees the current value of audioTx at | |
| 195 | + | // cleanup time. This allows us to close and nil-out audioTx mid-loop | |
| 196 | + | // (e.g., on audio processing error) without double-closing. | |
| 197 | + | defer func() { | |
| 198 | + | if audioTx != nil { | |
| 199 | + | audioTx.close() | |
| 200 | + | } | |
| 201 | + | }() | |
| 197 | 202 | ||
| 198 | 203 | // --- Step 12: Write header --- | |
| 199 | 204 | if ret := ffmpeg.FormatWriteHeader(outFmtCtx, nil); ret < 0 { | |
| @@ -244,8 +249,16 @@ func (p *pipeline) doRun(ctx context.Context) error { | |||
| 244 | 249 | ||
| 245 | 250 | case audioTx != nil && streamIdx == audioIdx: | |
| 246 | 251 | if err := audioTx.processPacket(pkt, outFmtCtx); err != nil { | |
| 247 | - | ffmpeg.PacketUnref(pkt) | |
| 248 | - | return fmt.Errorf("audio transcode: %w", err) | |
| 252 | + | // Audio errors are non-fatal: log warning, disable audio, | |
| 253 | + | // and continue with video-only output. This matches the | |
| 254 | + | // non-fatal handling of audio init failure above. | |
| 255 | + | p.logger.Warn(). | |
| 256 | + | Str("session_id", p.sessionID). | |
| 257 | + | Err(err). | |
| 258 | + | Msg("audio processing failed, continuing with video only") | |
| 259 | + | audioTx.close() | |
| 260 | + | audioTx = nil | |
| 261 | + | audioIdx = -1 | |
| 249 | 262 | } | |
| 250 | 263 | } | |
| 251 | 264 | ||
| @@ -383,15 +396,20 @@ func (p *pipeline) openCustomIOInputContext(ctx context.Context) (unsafe.Pointer | |||
| 383 | 396 | return -1 | |
| 384 | 397 | } | |
| 385 | 398 | goBuf := unsafe.Slice((*byte)(buf), int(bufSize)) | |
| 386 | - | n, readErr := body.Read(goBuf) | |
| 387 | - | if n > 0 { | |
| 388 | - | return int32(n) | |
| 389 | - | } | |
| 390 | - | if readErr != nil { | |
| 391 | - | // Return AVERROR_EOF for EOF, generic error otherwise. | |
| 392 | - | return averrorEOF | |
| 399 | + | // Retry up to 3 times for the edge case where body.Read returns | |
| 400 | + | // n=0, err=nil (allowed by the io.Reader contract but unusual). | |
| 401 | + | for tries := 0; tries < 3; tries++ { | |
| 402 | + | n, readErr := body.Read(goBuf) | |
| 403 | + | if n > 0 { | |
| 404 | + | return int32(n) | |
| 405 | + | } | |
| 406 | + | if readErr != nil { | |
| 407 | + | // Return AVERROR_EOF for EOF, generic error otherwise. | |
| 408 | + | return averrorEOF | |
| 409 | + | } | |
| 410 | + | // n == 0 && err == nil: retry | |
| 393 | 411 | } | |
| 394 | - | return -1 | |
| 412 | + | return averrorEOF | |
| 395 | 413 | } | |
| 396 | 414 | p.readCb = purego.NewCallback(readFn) | |
| 397 | 415 | ||
| @@ -191,9 +191,14 @@ func (p *pipeline) doRun(ctx context.Context) error { | |||||
| 191 | audioIdx = -1 | 191 | audioIdx = -1 | ||
| 192 | } | 192 | } | ||
| 193 | } | 193 | } | ||
| 194 | - | if audioTx != nil { | 194 | + | // Use a closure so the defer sees the current value of audioTx at |
| 195 | - | defer audioTx.close() | 195 | + | // cleanup time. This allows us to close and nil-out audioTx mid-loop |
| 196 | - | } | 196 | + | // (e.g., on audio processing error) without double-closing. |
| 197 | + | defer func() { | |||
| 198 | + | if audioTx != nil { | |||
| 199 | + | audioTx.close() | |||
| 200 | + | } | |||
| 201 | + | }() | |||
| 197 | 202 | ||||
| 198 | // --- Step 12: Write header --- | 203 | // --- Step 12: Write header --- | ||
| 199 | if ret := ffmpeg.FormatWriteHeader(outFmtCtx, nil); ret < 0 { | 204 | if ret := ffmpeg.FormatWriteHeader(outFmtCtx, nil); ret < 0 { | ||
| @@ -244,8 +249,16 @@ func (p *pipeline) doRun(ctx context.Context) error { | |||||
| 244 | 249 | ||||
| 245 | case audioTx != nil && streamIdx == audioIdx: | 250 | case audioTx != nil && streamIdx == audioIdx: | ||
| 246 | if err := audioTx.processPacket(pkt, outFmtCtx); err != nil { | 251 | if err := audioTx.processPacket(pkt, outFmtCtx); err != nil { | ||
| 247 | - | ffmpeg.PacketUnref(pkt) | 252 | + | // Audio errors are non-fatal: log warning, disable audio, |
| 248 | - | return fmt.Errorf("audio transcode: %w", err) | 253 | + | // and continue with video-only output. This matches the |
| 254 | + | // non-fatal handling of audio init failure above. | |||
| 255 | + | p.logger.Warn(). | |||
| 256 | + | Str("session_id", p.sessionID). | |||
| 257 | + | Err(err). | |||
| 258 | + | Msg("audio processing failed, continuing with video only") | |||
| 259 | + | audioTx.close() | |||
| 260 | + | audioTx = nil | |||
| 261 | + | audioIdx = -1 | |||
| 249 | } | 262 | } | ||
| 250 | } | 263 | } | ||
| 251 | 264 | ||||
| @@ -383,15 +396,20 @@ func (p *pipeline) openCustomIOInputContext(ctx context.Context) (unsafe.Pointer | |||||
| 383 | return -1 | 396 | return -1 | ||
| 384 | } | 397 | } | ||
| 385 | goBuf := unsafe.Slice((*byte)(buf), int(bufSize)) | 398 | goBuf := unsafe.Slice((*byte)(buf), int(bufSize)) | ||
| 386 | - | n, readErr := body.Read(goBuf) | 399 | + | // Retry up to 3 times for the edge case where body.Read returns |
| 387 | - | if n > 0 { | 400 | + | // n=0, err=nil (allowed by the io.Reader contract but unusual). |
| 388 | - | return int32(n) | 401 | + | for tries := 0; tries < 3; tries++ { |
| 389 | - | } | 402 | + | n, readErr := body.Read(goBuf) |
| 390 | - | if readErr != nil { | 403 | + | if n > 0 { |
| 391 | - | // Return AVERROR_EOF for EOF, generic error otherwise. | 404 | + | return int32(n) |
| 392 | - | return averrorEOF | 405 | + | } |
| 406 | + | if readErr != nil { | |||
| 407 | + | // Return AVERROR_EOF for EOF, generic error otherwise. | |||
| 408 | + | return averrorEOF | |||
| 409 | + | } | |||
| 410 | + | // n == 0 && err == nil: retry | |||
| 393 | } | 411 | } | ||
| 394 | - | return -1 | 412 | + | return averrorEOF |
| 395 | } | 413 | } | ||
| 396 | p.readCb = purego.NewCallback(readFn) | 414 | p.readCb = purego.NewCallback(readFn) | ||
| 397 | 415 | ||||
M internal/infrastructure/transcoder/pool.go +16 -4
| @@ -88,13 +88,25 @@ func (p *sessionPool) count() int { | |||
| 88 | 88 | return len(p.sessions) | |
| 89 | 89 | } | |
| 90 | 90 | ||
| 91 | - | // closeAll cancels and closes every active session. | |
| 91 | + | // closeAll cancels and closes every active session. It collects sessions | |
| 92 | + | // under the lock, clears the map, then releases the lock before calling | |
| 93 | + | // Close on each session to avoid holding the lock during potentially | |
| 94 | + | // blocking I/O. | |
| 92 | 95 | func (p *sessionPool) closeAll() { | |
| 93 | 96 | p.mu.Lock() | |
| 94 | - | defer p.mu.Unlock() | |
| 95 | - | for id, s := range p.sessions { | |
| 97 | + | snapshot := make([]*session, 0, len(p.sessions)) | |
| 98 | + | for _, s := range p.sessions { | |
| 99 | + | snapshot = append(snapshot, s) | |
| 100 | + | } | |
| 101 | + | // Clear the map while holding the lock. | |
| 102 | + | for id := range p.sessions { | |
| 103 | + | delete(p.sessions, id) | |
| 104 | + | } | |
| 105 | + | p.mu.Unlock() | |
| 106 | + | ||
| 107 | + | // Close sessions outside the lock. | |
| 108 | + | for _, s := range snapshot { | |
| 96 | 109 | s.cancel() | |
| 97 | 110 | s.pr.Close() | |
| 98 | - | delete(p.sessions, id) | |
| 99 | 111 | } | |
| 100 | 112 | } | |
| @@ -88,13 +88,25 @@ func (p *sessionPool) count() int { | |||||
| 88 | return len(p.sessions) | 88 | return len(p.sessions) | ||
| 89 | } | 89 | } | ||
| 90 | 90 | ||||
| 91 | - | // closeAll cancels and closes every active session. | 91 | + | // closeAll cancels and closes every active session. It collects sessions |
| 92 | + | // under the lock, clears the map, then releases the lock before calling | |||
| 93 | + | // Close on each session to avoid holding the lock during potentially | |||
| 94 | + | // blocking I/O. | |||
| 92 | func (p *sessionPool) closeAll() { | 95 | func (p *sessionPool) closeAll() { | ||
| 93 | p.mu.Lock() | 96 | p.mu.Lock() | ||
| 94 | - | defer p.mu.Unlock() | 97 | + | snapshot := make([]*session, 0, len(p.sessions)) |
| 95 | - | for id, s := range p.sessions { | 98 | + | for _, s := range p.sessions { |
| 99 | + | snapshot = append(snapshot, s) | |||
| 100 | + | } | |||
| 101 | + | // Clear the map while holding the lock. | |||
| 102 | + | for id := range p.sessions { | |||
| 103 | + | delete(p.sessions, id) | |||
| 104 | + | } | |||
| 105 | + | p.mu.Unlock() | |||
| 106 | + | ||||
| 107 | + | // Close sessions outside the lock. | |||
| 108 | + | for _, s := range snapshot { | |||
| 96 | s.cancel() | 109 | s.cancel() | ||
| 97 | s.pr.Close() | 110 | s.pr.Close() | ||
| 98 | - | delete(p.sessions, id) | |||
| 99 | } | 111 | } | ||
| 100 | } | 112 | } | ||
M internal/infrastructure/transcoder/transcoder.go +7 -6
| @@ -36,13 +36,14 @@ func New(cfg config.TranscodingConfig, logger *zerolog.Logger) *Transcoder { | |||
| 36 | 36 | maxConc = 2 | |
| 37 | 37 | } | |
| 38 | 38 | ||
| 39 | - | quality := cfg.Quality | |
| 40 | - | if quality == "" { | |
| 41 | - | quality = "medium" | |
| 39 | + | // Work on a local copy to avoid mutating a potentially shared config. | |
| 40 | + | localCfg := cfg | |
| 41 | + | ||
| 42 | + | if localCfg.Quality == "" { | |
| 43 | + | localCfg.Quality = "medium" | |
| 42 | 44 | } | |
| 43 | - | cfg.Quality = quality | |
| 44 | 45 | ||
| 45 | - | hwaccel := cfg.HWAccel | |
| 46 | + | hwaccel := localCfg.HWAccel | |
| 46 | 47 | if hwaccel == "" { | |
| 47 | 48 | hwaccel = "auto" | |
| 48 | 49 | } | |
| @@ -62,7 +63,7 @@ func New(cfg config.TranscodingConfig, logger *zerolog.Logger) *Transcoder { | |||
| 62 | 63 | } | |
| 63 | 64 | ||
| 64 | 65 | return &Transcoder{ | |
| 65 | - | cfg: cfg, | |
| 66 | + | cfg: localCfg, | |
| 66 | 67 | hwCaps: hwCaps, | |
| 67 | 68 | pool: newSessionPool(maxConc), | |
| 68 | 69 | logger: l, | |
| @@ -36,13 +36,14 @@ func New(cfg config.TranscodingConfig, logger *zerolog.Logger) *Transcoder { | |||||
| 36 | maxConc = 2 | 36 | maxConc = 2 | ||
| 37 | } | 37 | } | ||
| 38 | 38 | ||||
| 39 | - | quality := cfg.Quality | 39 | + | // Work on a local copy to avoid mutating a potentially shared config. |
| 40 | - | if quality == "" { | 40 | + | localCfg := cfg |
| 41 | - | quality = "medium" | 41 | + | |
| 42 | + | if localCfg.Quality == "" { | |||
| 43 | + | localCfg.Quality = "medium" | |||
| 42 | } | 44 | } | ||
| 43 | - | cfg.Quality = quality | |||
| 44 | 45 | ||||
| 45 | - | hwaccel := cfg.HWAccel | 46 | + | hwaccel := localCfg.HWAccel |
| 46 | if hwaccel == "" { | 47 | if hwaccel == "" { | ||
| 47 | hwaccel = "auto" | 48 | hwaccel = "auto" | ||
| 48 | } | 49 | } | ||
| @@ -62,7 +63,7 @@ func New(cfg config.TranscodingConfig, logger *zerolog.Logger) *Transcoder { | |||||
| 62 | } | 63 | } | ||
| 63 | 64 | ||||
| 64 | return &Transcoder{ | 65 | return &Transcoder{ | ||
| 65 | - | cfg: cfg, | 66 | + | cfg: localCfg, |
| 66 | hwCaps: hwCaps, | 67 | hwCaps: hwCaps, | ||
| 67 | pool: newSessionPool(maxConc), | 68 | pool: newSessionPool(maxConc), | ||
| 68 | logger: l, | 69 | logger: l, | ||
M internal/ui/adapter/favicon.go +8 -8
| @@ -113,7 +113,7 @@ func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callbac | |||
| 113 | 113 | ||
| 114 | 114 | // Handle internal dumb:// scheme URLs - use app logo | |
| 115 | 115 | if a.isInternalURL != nil && a.isInternalURL(pageURL) { | |
| 116 | - | texture := a.textureFromBytesOnGTK(a.getLogoBytes()) | |
| 116 | + | texture := a.textureFromBytesOnGTK(ctx, a.getLogoBytes()) | |
| 117 | 117 | if texture != nil { | |
| 118 | 118 | a.setTexture(a.internalDomain, texture) | |
| 119 | 119 | } | |
| @@ -142,7 +142,7 @@ func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callbac | |||
| 142 | 142 | Str("domain", domain). | |
| 143 | 143 | Int("bytes", len(data)). | |
| 144 | 144 | Msg("favicon: service cache hit") | |
| 145 | - | texture := a.textureFromBytesOnGTK(data) | |
| 145 | + | texture := a.textureFromBytesOnGTK(ctx, data) | |
| 146 | 146 | if texture != nil { | |
| 147 | 147 | a.setTexture(domain, texture) | |
| 148 | 148 | // Ensure sized PNG exists for CLI tools (async, idempotent) | |
| @@ -188,7 +188,7 @@ func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callbac | |||
| 188 | 188 | Str("domain", domain). | |
| 189 | 189 | Int("bytes", len(data)). | |
| 190 | 190 | Msg("favicon: fetchViaService fetched data") | |
| 191 | - | texture := a.textureFromBytesOnGTK(data) | |
| 191 | + | texture := a.textureFromBytesOnGTK(ctx, data) | |
| 192 | 192 | if texture != nil { | |
| 193 | 193 | a.setTexture(domain, texture) | |
| 194 | 194 | } | |
| @@ -364,7 +364,7 @@ func (a *FaviconAdapter) PreloadFromCache(ctx context.Context, pageURL string) * | |||
| 364 | 364 | Str("domain", domain). | |
| 365 | 365 | Int("bytes", len(data)). | |
| 366 | 366 | Msg("favicon: PreloadFromCache service hit") | |
| 367 | - | texture := a.textureFromBytesOnGTK(data) | |
| 367 | + | texture := a.textureFromBytesOnGTK(ctx, data) | |
| 368 | 368 | if texture != nil { | |
| 369 | 369 | a.setTexture(domain, texture) | |
| 370 | 370 | return texture | |
| @@ -483,7 +483,7 @@ func (a *FaviconAdapter) logWarningDedup( | |||
| 483 | 483 | ||
| 484 | 484 | // getOrCreateLogoTexture returns the app logo texture, creating it if needed. | |
| 485 | 485 | // The texture is cached under the internalDomain key. | |
| 486 | - | func (a *FaviconAdapter) getOrCreateLogoTexture() *gdk.Texture { | |
| 486 | + | func (a *FaviconAdapter) getOrCreateLogoTexture(ctx context.Context) *gdk.Texture { | |
| 487 | 487 | if a.internalDomain == "" || a.getLogoBytes == nil { | |
| 488 | 488 | return nil | |
| 489 | 489 | } | |
| @@ -495,18 +495,18 @@ func (a *FaviconAdapter) getOrCreateLogoTexture() *gdk.Texture { | |||
| 495 | 495 | ||
| 496 | 496 | // Create texture from logo bytes | |
| 497 | 497 | logoBytes := a.getLogoBytes() | |
| 498 | - | texture := a.textureFromBytesOnGTK(logoBytes) | |
| 498 | + | texture := a.textureFromBytesOnGTK(ctx, logoBytes) | |
| 499 | 499 | if texture != nil { | |
| 500 | 500 | a.setTexture(a.internalDomain, texture) | |
| 501 | 501 | } | |
| 502 | 502 | return texture | |
| 503 | 503 | } | |
| 504 | 504 | ||
| 505 | - | func (a *FaviconAdapter) textureFromBytesOnGTK(data []byte) *gdk.Texture { | |
| 505 | + | func (a *FaviconAdapter) textureFromBytesOnGTK(ctx context.Context, data []byte) *gdk.Texture { | |
| 506 | 506 | if len(data) == 0 { | |
| 507 | 507 | return nil | |
| 508 | 508 | } | |
| 509 | - | log := logging.FromContext(context.Background()) | |
| 509 | + | log := logging.FromContext(ctx) | |
| 510 | 510 | log.Debug(). | |
| 511 | 511 | Int("bytes", len(data)). | |
| 512 | 512 | Msg("favicon: textureFromBytesOnGTK begin") | |
| @@ -113,7 +113,7 @@ func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callbac | |||||
| 113 | 113 | ||||
| 114 | // Handle internal dumb:// scheme URLs - use app logo | 114 | // Handle internal dumb:// scheme URLs - use app logo | ||
| 115 | if a.isInternalURL != nil && a.isInternalURL(pageURL) { | 115 | if a.isInternalURL != nil && a.isInternalURL(pageURL) { | ||
| 116 | - | texture := a.textureFromBytesOnGTK(a.getLogoBytes()) | 116 | + | texture := a.textureFromBytesOnGTK(ctx, a.getLogoBytes()) |
| 117 | if texture != nil { | 117 | if texture != nil { | ||
| 118 | a.setTexture(a.internalDomain, texture) | 118 | a.setTexture(a.internalDomain, texture) | ||
| 119 | } | 119 | } | ||
| @@ -142,7 +142,7 @@ func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callbac | |||||
| 142 | Str("domain", domain). | 142 | Str("domain", domain). | ||
| 143 | Int("bytes", len(data)). | 143 | Int("bytes", len(data)). | ||
| 144 | Msg("favicon: service cache hit") | 144 | Msg("favicon: service cache hit") | ||
| 145 | - | texture := a.textureFromBytesOnGTK(data) | 145 | + | texture := a.textureFromBytesOnGTK(ctx, data) |
| 146 | if texture != nil { | 146 | if texture != nil { | ||
| 147 | a.setTexture(domain, texture) | 147 | a.setTexture(domain, texture) | ||
| 148 | // Ensure sized PNG exists for CLI tools (async, idempotent) | 148 | // Ensure sized PNG exists for CLI tools (async, idempotent) | ||
| @@ -188,7 +188,7 @@ func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callbac | |||||
| 188 | Str("domain", domain). | 188 | Str("domain", domain). | ||
| 189 | Int("bytes", len(data)). | 189 | Int("bytes", len(data)). | ||
| 190 | Msg("favicon: fetchViaService fetched data") | 190 | Msg("favicon: fetchViaService fetched data") | ||
| 191 | - | texture := a.textureFromBytesOnGTK(data) | 191 | + | texture := a.textureFromBytesOnGTK(ctx, data) |
| 192 | if texture != nil { | 192 | if texture != nil { | ||
| 193 | a.setTexture(domain, texture) | 193 | a.setTexture(domain, texture) | ||
| 194 | } | 194 | } | ||
| @@ -364,7 +364,7 @@ func (a *FaviconAdapter) PreloadFromCache(ctx context.Context, pageURL string) * | |||||
| 364 | Str("domain", domain). | 364 | Str("domain", domain). | ||
| 365 | Int("bytes", len(data)). | 365 | Int("bytes", len(data)). | ||
| 366 | Msg("favicon: PreloadFromCache service hit") | 366 | Msg("favicon: PreloadFromCache service hit") | ||
| 367 | - | texture := a.textureFromBytesOnGTK(data) | 367 | + | texture := a.textureFromBytesOnGTK(ctx, data) |
| 368 | if texture != nil { | 368 | if texture != nil { | ||
| 369 | a.setTexture(domain, texture) | 369 | a.setTexture(domain, texture) | ||
| 370 | return texture | 370 | return texture | ||
| @@ -483,7 +483,7 @@ func (a *FaviconAdapter) logWarningDedup( | |||||
| 483 | 483 | ||||
| 484 | // getOrCreateLogoTexture returns the app logo texture, creating it if needed. | 484 | // getOrCreateLogoTexture returns the app logo texture, creating it if needed. | ||
| 485 | // The texture is cached under the internalDomain key. | 485 | // The texture is cached under the internalDomain key. | ||
| 486 | - | func (a *FaviconAdapter) getOrCreateLogoTexture() *gdk.Texture { | 486 | + | func (a *FaviconAdapter) getOrCreateLogoTexture(ctx context.Context) *gdk.Texture { |
| 487 | if a.internalDomain == "" || a.getLogoBytes == nil { | 487 | if a.internalDomain == "" || a.getLogoBytes == nil { | ||
| 488 | return nil | 488 | return nil | ||
| 489 | } | 489 | } | ||
| @@ -495,18 +495,18 @@ func (a *FaviconAdapter) getOrCreateLogoTexture() *gdk.Texture { | |||||
| 495 | 495 | ||||
| 496 | // Create texture from logo bytes | 496 | // Create texture from logo bytes | ||
| 497 | logoBytes := a.getLogoBytes() | 497 | logoBytes := a.getLogoBytes() | ||
| 498 | - | texture := a.textureFromBytesOnGTK(logoBytes) | 498 | + | texture := a.textureFromBytesOnGTK(ctx, logoBytes) |
| 499 | if texture != nil { | 499 | if texture != nil { | ||
| 500 | a.setTexture(a.internalDomain, texture) | 500 | a.setTexture(a.internalDomain, texture) | ||
| 501 | } | 501 | } | ||
| 502 | return texture | 502 | return texture | ||
| 503 | } | 503 | } | ||
| 504 | 504 | ||||
| 505 | - | func (a *FaviconAdapter) textureFromBytesOnGTK(data []byte) *gdk.Texture { | 505 | + | func (a *FaviconAdapter) textureFromBytesOnGTK(ctx context.Context, data []byte) *gdk.Texture { |
| 506 | if len(data) == 0 { | 506 | if len(data) == 0 { | ||
| 507 | return nil | 507 | return nil | ||
| 508 | } | 508 | } | ||
| 509 | - | log := logging.FromContext(context.Background()) | 509 | + | log := logging.FromContext(ctx) |
| 510 | log.Debug(). | 510 | log.Debug(). | ||
| 511 | Int("bytes", len(data)). | 511 | Int("bytes", len(data)). | ||
| 512 | Msg("favicon: textureFromBytesOnGTK begin") | 512 | Msg("favicon: textureFromBytesOnGTK begin") | ||
M internal/ui/deps.go +3 -0
| @@ -114,6 +114,9 @@ func (d *Dependencies) Validate() error { | |||
| 114 | 114 | if d.Engine == nil { | |
| 115 | 115 | return ErrMissingDependency("Engine") | |
| 116 | 116 | } | |
| 117 | + | if d.HandlerDeps.SaveConfig == nil { | |
| 118 | + | return ErrMissingDependency("HandlerDeps.SaveConfig") | |
| 119 | + | } | |
| 117 | 120 | // Use cases are optional - can be nil if not needed | |
| 118 | 121 | return nil | |
| 119 | 122 | } | |
| @@ -114,6 +114,9 @@ func (d *Dependencies) Validate() error { | |||||
| 114 | if d.Engine == nil { | 114 | if d.Engine == nil { | ||
| 115 | return ErrMissingDependency("Engine") | 115 | return ErrMissingDependency("Engine") | ||
| 116 | } | 116 | } | ||
| 117 | + | if d.HandlerDeps.SaveConfig == nil { | |||
| 118 | + | return ErrMissingDependency("HandlerDeps.SaveConfig") | |||
| 119 | + | } | |||
| 117 | // Use cases are optional - can be nil if not needed | 120 | // Use cases are optional - can be nil if not needed | ||
| 118 | return nil | 121 | return nil | ||
| 119 | } | 122 | } | ||
M webui/src/pages/ConfigPage.svelte +1 -1
| @@ -158,7 +158,7 @@ | |||
| 158 | 158 | } | |
| 159 | 159 | ||
| 160 | 160 | function isPerformanceSupported(cfg: ConfigDTO | null): boolean { | |
| 161 | - | return cfg?.engine_type !== "cef"; | |
| 161 | + | return cfg != null && cfg.engine_type !== "cef"; | |
| 162 | 162 | } | |
| 163 | 163 | ||
| 164 | 164 | // Load config from API | |
| @@ -158,7 +158,7 @@ | |||||
| 158 | } | 158 | } | ||
| 159 | 159 | ||||
| 160 | function isPerformanceSupported(cfg: ConfigDTO | null): boolean { | 160 | function isPerformanceSupported(cfg: ConfigDTO | null): boolean { | ||
| 161 | - | return cfg?.engine_type !== "cef"; | 161 | + | return cfg != null && cfg.engine_type !== "cef"; |
| 162 | } | 162 | } | ||
| 163 | 163 | ||||
| 164 | // Load config from API | 164 | // Load config from API | ||
M webui/vite-plugin-pages.ts +16 -11
| @@ -32,19 +32,24 @@ function getBridgeBootstrap(): string { | |||
| 32 | 32 | return nativeBridge.postMessage(msg); | |
| 33 | 33 | } | |
| 34 | 34 | ||
| 35 | - | const encoded = btoa(unescape(encodeURIComponent(JSON.stringify(msg)))); | |
| 36 | - | const response = await fetch("/api/message", { | |
| 37 | - | method: "POST", | |
| 38 | - | headers: { | |
| 39 | - | "Content-Type": "application/json", | |
| 40 | - | "X-Dumber-Body": encoded | |
| 35 | + | try { | |
| 36 | + | const encoded = btoa(unescape(encodeURIComponent(JSON.stringify(msg)))); | |
| 37 | + | const response = await fetch("/api/message", { | |
| 38 | + | method: "POST", | |
| 39 | + | headers: { | |
| 40 | + | "Content-Type": "application/json", | |
| 41 | + | "X-Dumber-Body": encoded | |
| 42 | + | } | |
| 43 | + | }); | |
| 44 | + | const payload = await response.json(); | |
| 45 | + | if (payload && payload._callback && typeof window[payload._callback] === "function") { | |
| 46 | + | window[payload._callback](payload.data); | |
| 41 | 47 | } | |
| 42 | - | }); | |
| 43 | - | const payload = await response.json(); | |
| 44 | - | if (payload && payload._callback && typeof window[payload._callback] === "function") { | |
| 45 | - | window[payload._callback](payload.data); | |
| 48 | + | return payload; | |
| 49 | + | } catch (err) { | |
| 50 | + | console.error("[dumber:bridge] fetch bridge error:", err); | |
| 51 | + | return { error: err instanceof Error ? err.message : String(err) }; | |
| 46 | 52 | } | |
| 47 | - | return payload; | |
| 48 | 53 | } | |
| 49 | 54 | }; | |
| 50 | 55 | ||
| @@ -32,19 +32,24 @@ function getBridgeBootstrap(): string { | |||||
| 32 | return nativeBridge.postMessage(msg); | 32 | return nativeBridge.postMessage(msg); | ||
| 33 | } | 33 | } | ||
| 34 | 34 | ||||
| 35 | - | const encoded = btoa(unescape(encodeURIComponent(JSON.stringify(msg)))); | 35 | + | try { |
| 36 | - | const response = await fetch("/api/message", { | 36 | + | const encoded = btoa(unescape(encodeURIComponent(JSON.stringify(msg)))); |
| 37 | - | method: "POST", | 37 | + | const response = await fetch("/api/message", { |
| 38 | - | headers: { | 38 | + | method: "POST", |
| 39 | - | "Content-Type": "application/json", | 39 | + | headers: { |
| 40 | - | "X-Dumber-Body": encoded | 40 | + | "Content-Type": "application/json", |
| 41 | + | "X-Dumber-Body": encoded | |||
| 42 | + | } | |||
| 43 | + | }); | |||
| 44 | + | const payload = await response.json(); | |||
| 45 | + | if (payload && payload._callback && typeof window[payload._callback] === "function") { | |||
| 46 | + | window[payload._callback](payload.data); | |||
| 41 | } | 47 | } | ||
| 42 | - | }); | 48 | + | return payload; |
| 43 | - | const payload = await response.json(); | 49 | + | } catch (err) { |
| 44 | - | if (payload && payload._callback && typeof window[payload._callback] === "function") { | 50 | + | console.error("[dumber:bridge] fetch bridge error:", err); |
| 45 | - | window[payload._callback](payload.data); | 51 | + | return { error: err instanceof Error ? err.message : String(err) }; |
| 46 | } | 52 | } | ||
| 47 | - | return payload; | |||
| 48 | } | 53 | } | ||
| 49 | }; | 54 | }; | ||
| 50 | 55 | ||||