fix: improve extension runtime safety, HTTP response URL, SongLink parsing, and recommended service for extensions

- Add 'url' field (final URL after redirects) to all extension HTTP responses and fix fetch polyfill to return final URL instead of original request URL
- Fix RunWithTimeout race condition: increase force-timeout from 1s to 60s to prevent concurrent VM access crashes, add nil guards
- Use lockReadyVM() for thread-safe VM access in GetPlaylistWithExtensionJSON and InvokeAction
- Handle mixed JSON types (string, null, array) in SongLink resolve API SongUrls field
- Fix recommended download service not showing for extension-based searches in download picker
This commit is contained in:
zarzet
2026-04-02 23:16:37 +07:00
parent 042937a8ed
commit f2f45fa31d
9 changed files with 112 additions and 16 deletions
+1
View File
@@ -77,6 +77,7 @@ flutter_*.log
# Development tools
tool/
.claude/settings.local.json
.playwright-mcp/
# FVM Version Cache
.fvm/
+9 -2
View File
@@ -3212,8 +3212,15 @@ func GetPlaylistWithExtensionJSON(extensionID, playlistID string) (string, error
if !ext.Manifest.IsMetadataProvider() {
return "", fmt.Errorf("extension '%s' is not a metadata provider", extensionID)
}
if !ext.Enabled {
return "", fmt.Errorf("extension '%s' is disabled", extensionID)
}
provider := NewExtensionProviderWrapper(ext)
vm, err := ext.lockReadyVM()
if err != nil {
return "", err
}
defer ext.VMMu.Unlock()
script := fmt.Sprintf(`
(function() {
@@ -3227,7 +3234,7 @@ func GetPlaylistWithExtensionJSON(extensionID, playlistID string) (string, error
})()
`, playlistID, playlistID)
result, err := RunWithTimeoutAndRecover(provider.vm, script, DefaultJSTimeout)
result, err := RunWithTimeoutAndRecover(vm, script, DefaultJSTimeout)
if err != nil {
return "", fmt.Errorf("getPlaylist failed: %w", err)
}
+6 -5
View File
@@ -1044,13 +1044,14 @@ func (m *ExtensionManager) InvokeAction(extensionID string, actionName string) (
return nil, fmt.Errorf("extension not found: %s", extensionID)
}
if err := ext.ensureRuntimeReady(); err != nil {
return nil, err
}
if !ext.Enabled {
return nil, fmt.Errorf("extension is disabled")
}
vm, err := ext.lockReadyVM()
if err != nil {
return nil, err
}
defer ext.VMMu.Unlock()
script := fmt.Sprintf(`
(function() {
@@ -1070,7 +1071,7 @@ func (m *ExtensionManager) InvokeAction(extensionID string, actionName string) (
})()
`, actionName, actionName, actionName)
result, err := RunWithTimeoutAndRecover(ext.VM, script, DefaultJSTimeout)
result, err := RunWithTimeoutAndRecover(vm, script, DefaultJSTimeout)
if err != nil {
GoLog("[Extension] InvokeAction error for %s.%s: %v\n", extensionID, actionName, err)
return nil, fmt.Errorf("action failed: %v", err)
+4
View File
@@ -118,6 +118,7 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
"statusCode": resp.StatusCode,
"status": resp.StatusCode,
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
"url": resp.Request.URL.String(),
"body": string(body),
"headers": respHeaders,
})
@@ -214,6 +215,7 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
"statusCode": resp.StatusCode,
"status": resp.StatusCode,
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
"url": resp.Request.URL.String(),
"body": string(body),
"headers": respHeaders,
})
@@ -322,6 +324,7 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
"statusCode": resp.StatusCode,
"status": resp.StatusCode,
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
"url": resp.Request.URL.String(),
"body": string(body),
"headers": respHeaders,
})
@@ -446,6 +449,7 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
"statusCode": resp.StatusCode,
"status": resp.StatusCode,
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
"url": resp.Request.URL.String(),
"body": string(body),
"headers": respHeaders,
})
+1 -1
View File
@@ -105,7 +105,7 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
responseObj.Set("status", resp.StatusCode)
responseObj.Set("statusText", http.StatusText(resp.StatusCode))
responseObj.Set("headers", respHeaders)
responseObj.Set("url", urlStr)
responseObj.Set("url", resp.Request.URL.String())
bodyString := string(body)
+16 -2
View File
@@ -20,6 +20,10 @@ func (e *JSExecutionError) Error() string {
}
func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) {
if vm == nil {
return nil, fmt.Errorf("extension runtime unavailable")
}
if timeout <= 0 {
timeout = DefaultJSTimeout
}
@@ -69,6 +73,11 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
vm.Interrupt("execution timeout")
// MUST wait for the goroutine to finish before returning.
// The Goja VM is NOT thread-safe — if we return while the goroutine
// is still executing JS (e.g. blocked on an HTTP call), the next
// caller will access the VM concurrently and crash with a nil
// pointer dereference.
select {
case res := <-resultCh:
if res.err != nil {
@@ -78,7 +87,10 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
Message: "execution timeout exceeded",
IsTimeout: true,
}
case <-time.After(1 * time.Second):
case <-time.After(60 * time.Second):
// Goroutine is truly stuck (e.g. HTTP read with no timeout).
// Log a warning — the VM should NOT be reused after this.
GoLog("[ExtensionRuntime] WARNING: JS goroutine did not exit within 60s after interrupt, VM may be unsafe\n")
return nil, &JSExecutionError{
Message: "execution timeout exceeded (force)",
IsTimeout: true,
@@ -93,7 +105,9 @@ func RunWithTimeoutAndRecover(vm *goja.Runtime, script string, timeout time.Dura
result, err := RunWithTimeout(vm, script, timeout)
// Clear any interrupt state so VM can be reused
vm.ClearInterrupt()
if vm != nil {
vm.ClearInterrupt()
}
return result, err
}
+32 -5
View File
@@ -164,9 +164,9 @@ func (s *SongLinkClient) doResolveRequest(payload []byte) (map[string]songLinkPl
}
var resolveResp struct {
Success bool `json:"success"`
ISRC string `json:"isrc"`
SongUrls map[string]string `json:"songUrls"`
Success bool `json:"success"`
ISRC string `json:"isrc"`
SongUrls map[string]json.RawMessage `json:"songUrls"`
}
if err := json.Unmarshal(body, &resolveResp); err != nil {
return nil, fmt.Errorf("failed to decode resolve response: %w", err)
@@ -189,8 +189,12 @@ func (s *SongLinkClient) doResolveRequest(payload []byte) (map[string]songLinkPl
links := make(map[string]songLinkPlatformLink)
for resolveKey, platformKey := range keyMap {
if u, ok := resolveResp.SongUrls[resolveKey]; ok && strings.TrimSpace(u) != "" {
links[platformKey] = songLinkPlatformLink{URL: strings.TrimSpace(u)}
rawValue, ok := resolveResp.SongUrls[resolveKey]
if !ok {
continue
}
if u := extractResolveURLValue(rawValue); u != "" {
links[platformKey] = songLinkPlatformLink{URL: u}
}
}
@@ -201,6 +205,29 @@ func (s *SongLinkClient) doResolveRequest(payload []byte) (map[string]songLinkPl
return links, nil
}
func extractResolveURLValue(raw json.RawMessage) string {
trimmed := bytes.TrimSpace(raw)
if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
return ""
}
var direct string
if err := json.Unmarshal(trimmed, &direct); err == nil {
return strings.TrimSpace(direct)
}
var list []string
if err := json.Unmarshal(trimmed, &list); err == nil {
for _, candidate := range list {
if cleaned := strings.TrimSpace(candidate); cleaned != "" {
return cleaned
}
}
}
return ""
}
// songLinkByTargetURL calls the SongLink API with a target URL (for non-Spotify URLs).
func (s *SongLinkClient) songLinkByTargetURL(targetURL string) (map[string]songLinkPlatformLink, error) {
songLinkRateLimiter.WaitForSlot()
+41
View File
@@ -114,6 +114,47 @@ func TestCheckTrackAvailabilityFromSpotifyResolveAPIFailure(t *testing.T) {
}
}
func TestCheckTrackAvailabilityFromSpotifyViaResolveAPIMixedSongURLShapes(t *testing.T) {
origRetryConfig := songLinkRetryConfig
defer func() { songLinkRetryConfig = origRetryConfig }()
client := &SongLinkClient{
client: &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
if req.URL.Host == "api.zarz.moe" && req.URL.Path == "/v1/resolve" && req.Method == "POST" {
body := `{"success":true,"isrc":"TCAHA2367688","songUrls":{"Spotify":"https://open.spotify.com/track/5glgyj6zH0irbNGfukHacv","Deezer":"https://www.deezer.com/track/2248583177","Tidal":"https://tidal.com/browse/track/290565315","AppleMusic":"https://geo.music.apple.com/us/album/example?i=1","YouTubeMusic":null,"YouTube":"https://www.youtube.com/watch?v=wD_e59XUNdQ","AmazonMusic":"https://music.amazon.com/tracks/B0C35TG38Y/?ref=dm_ff_amazonmusic_3p","Beatport":null,"BeatSource":null,"SoundCloud":null,"Qobuz":null,"Other":[]}}`
return &http.Response{
StatusCode: 200,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(body)),
Request: req,
}, nil
}
t.Fatalf("unexpected request: %s %s", req.Method, req.URL.String())
return nil, nil
}),
},
}
availability, err := client.CheckTrackAvailability("5glgyj6zH0irbNGfukHacv", "")
if err != nil {
t.Fatalf("CheckTrackAvailability() error = %v", err)
}
if availability.SpotifyID != "5glgyj6zH0irbNGfukHacv" {
t.Fatalf("SpotifyID = %q, want %q", availability.SpotifyID, "5glgyj6zH0irbNGfukHacv")
}
if !availability.Deezer || availability.DeezerID != "2248583177" {
t.Fatalf("Deezer availability = %+v, want DeezerID 2248583177", availability)
}
if !availability.Tidal || availability.TidalID != "290565315" {
t.Fatalf("Tidal availability = %+v, want TidalID 290565315", availability)
}
if availability.Qobuz {
t.Fatalf("Qobuz should remain false when resolve response contains null, got %+v", availability)
}
}
func TestCheckAvailabilityFromDeezerUsesSongLink(t *testing.T) {
origRetryConfig := songLinkRetryConfig
songLinkRetryConfig = func() RetryConfig {
+2 -1
View File
@@ -757,7 +757,8 @@ class _HomeTabState extends ConsumerState<HomeTab>
trackName: track.name,
artistName: track.artistName,
coverUrl: track.coverUrl,
recommendedService: trackState.searchSource,
recommendedService:
trackState.searchExtensionId ?? trackState.searchSource,
onSelect: (quality, service) {
ref
.read(downloadQueueProvider.notifier)