mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-26 05:51:18 +02:00
fix(downloads): stage direct-mode outputs and serialize same-path writes
The non-SAF filesystem path (Android direct mode and all of iOS) had none of the safeguards the SAF path gained: extension downloads streamed straight into the final filename, every error path stranded the partial file there, and the ISRC duplicate check (exists + size>0 with a head-only FLAC parse) then accepted the truncated file as complete forever. On iOS this was routine, not rare: the background task grace is ~30s, after which the process is suspended mid-stream. - fileDownload/fileDownloadChunked now stream into a .partial sibling and promote to the final name with an atomic rename on success; failures remove the staged file and never touch the final name - fileWrite (full-content) uses the same stage-and-rename protocol - writes are serialized per final output path, so concurrent queue items that resolve to the same filename can no longer interleave bytes into one file The staged suffix keeps partials invisible to extension duplicate checks, which match on the final audio extension.
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// downloadPathLocks serializes writes per final output path so concurrent
|
||||
// downloads that resolve to the same file cannot interleave bytes into one
|
||||
// output or race the staged-promote rename. Keys are normalized case-folded
|
||||
// cleaned paths; entries live for the process lifetime (bounded by the number
|
||||
// of distinct output files in a session).
|
||||
var downloadPathLocks sync.Map
|
||||
|
||||
// lockDownloadOutputPath locks the given final output path and returns the
|
||||
// unlock function. Different paths keep downloading in parallel; a second
|
||||
// download of the same path blocks until the first finishes.
|
||||
func lockDownloadOutputPath(path string) func() {
|
||||
key := strings.ToLower(filepath.Clean(path))
|
||||
value, _ := downloadPathLocks.LoadOrStore(key, &sync.Mutex{})
|
||||
mu := value.(*sync.Mutex)
|
||||
mu.Lock()
|
||||
return mu.Unlock
|
||||
}
|
||||
|
||||
// stagedDownloadPath returns the sibling name downloads are streamed into
|
||||
// before being promoted to the final path with an atomic rename. The suffix
|
||||
// keeps the staged file invisible to extension-based duplicate checks, which
|
||||
// match on the final audio extension.
|
||||
func stagedDownloadPath(finalPath string) string {
|
||||
return finalPath + ".partial"
|
||||
}
|
||||
@@ -207,6 +207,9 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
return r.fileDownloadChunked(client, urlStr, fullPath, headers, ua, chunkSize, onProgress, trackItemBytes)
|
||||
}
|
||||
|
||||
unlock := lockDownloadOutputPath(fullPath)
|
||||
defer unlock()
|
||||
|
||||
req, err := http.NewRequest("GET", urlStr, nil)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]any{
|
||||
@@ -239,14 +242,25 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
out, err := os.Create(fullPath)
|
||||
// Stream into a staged sibling and promote via rename on success so a
|
||||
// killed process can never leave a partial file under the final name
|
||||
// (the duplicate check would then accept it as complete forever).
|
||||
stagedPath := stagedDownloadPath(fullPath)
|
||||
os.Remove(stagedPath)
|
||||
out, err := os.Create(stagedPath)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]any{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to create file: %v", err),
|
||||
})
|
||||
}
|
||||
defer out.Close()
|
||||
promoted := false
|
||||
defer func() {
|
||||
out.Close()
|
||||
if !promoted {
|
||||
os.Remove(stagedPath)
|
||||
}
|
||||
}()
|
||||
|
||||
activeItemID := r.getActiveDownloadItemID()
|
||||
if activeItemID != "" {
|
||||
@@ -319,6 +333,20 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
}
|
||||
|
||||
if err := out.Close(); err != nil {
|
||||
return r.vm.ToValue(map[string]any{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to finalize file: %v", err),
|
||||
})
|
||||
}
|
||||
if err := os.Rename(stagedPath, fullPath); err != nil {
|
||||
return r.vm.ToValue(map[string]any{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to publish file: %v", err),
|
||||
})
|
||||
}
|
||||
promoted = true
|
||||
|
||||
GoLog("[Extension:%s] Downloaded %d bytes to %s\n", r.extensionID, written, fullPath)
|
||||
|
||||
return r.vm.ToValue(map[string]any{
|
||||
@@ -332,6 +360,9 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
// This is needed for servers (like YouTube's googlevideo CDN) that reject
|
||||
// non-ranged or large-range requests with 403 and require small chunk downloads.
|
||||
func (r *extensionRuntime) fileDownloadChunked(client *http.Client, urlStr, fullPath string, headers map[string]string, ua string, chunkSize int64, onProgress goja.Callable, trackItemBytes bool) goja.Value {
|
||||
unlock := lockDownloadOutputPath(fullPath)
|
||||
defer unlock()
|
||||
|
||||
// First, get the total content length with a small probe request
|
||||
probeReq, err := http.NewRequest("GET", urlStr, nil)
|
||||
if err != nil {
|
||||
@@ -386,14 +417,24 @@ func (r *extensionRuntime) fileDownloadChunked(client *http.Client, urlStr, full
|
||||
GoLog("[Extension:%s] Chunked download: total size %d bytes, chunk size %d\n", r.extensionID, totalSize, chunkSize)
|
||||
}
|
||||
|
||||
out, err := os.Create(fullPath)
|
||||
// Same staged-write-then-promote protocol as fileDownload: never leave a
|
||||
// partial file under the final name.
|
||||
stagedPath := stagedDownloadPath(fullPath)
|
||||
os.Remove(stagedPath)
|
||||
out, err := os.Create(stagedPath)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]any{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to create file: %v", err),
|
||||
})
|
||||
}
|
||||
defer out.Close()
|
||||
promoted := false
|
||||
defer func() {
|
||||
out.Close()
|
||||
if !promoted {
|
||||
os.Remove(stagedPath)
|
||||
}
|
||||
}()
|
||||
|
||||
activeItemID := r.getActiveDownloadItemID()
|
||||
if activeItemID != "" {
|
||||
@@ -549,6 +590,20 @@ func (r *extensionRuntime) fileDownloadChunked(client *http.Client, urlStr, full
|
||||
}
|
||||
}
|
||||
|
||||
if err := out.Close(); err != nil {
|
||||
return r.vm.ToValue(map[string]any{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to finalize file: %v", err),
|
||||
})
|
||||
}
|
||||
if err := os.Rename(stagedPath, fullPath); err != nil {
|
||||
return r.vm.ToValue(map[string]any{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to publish file: %v", err),
|
||||
})
|
||||
}
|
||||
promoted = true
|
||||
|
||||
GoLog("[Extension:%s] Chunked download complete: %d bytes to %s\n", r.extensionID, totalWritten, fullPath)
|
||||
|
||||
return r.vm.ToValue(map[string]any{
|
||||
@@ -770,7 +825,18 @@ func (r *extensionRuntime) fileWrite(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
if err := os.WriteFile(fullPath, []byte(data), 0644); err != nil {
|
||||
// Full-content write: stage and rename so a kill mid-write cannot leave
|
||||
// a truncated file under the final name.
|
||||
stagedPath := stagedDownloadPath(fullPath)
|
||||
if err := os.WriteFile(stagedPath, []byte(data), 0644); err != nil {
|
||||
os.Remove(stagedPath)
|
||||
return r.vm.ToValue(map[string]any{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
if err := os.Rename(stagedPath, fullPath); err != nil {
|
||||
os.Remove(stagedPath)
|
||||
return r.vm.ToValue(map[string]any{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
|
||||
@@ -178,6 +178,95 @@ func TestExtensionRuntimeAuthAndPolyfills(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
type failingBodyReader struct {
|
||||
data []byte
|
||||
sent bool
|
||||
}
|
||||
|
||||
func (f *failingBodyReader) Read(p []byte) (int, error) {
|
||||
if !f.sent {
|
||||
f.sent = true
|
||||
n := copy(p, f.data)
|
||||
return n, nil
|
||||
}
|
||||
return 0, fmt.Errorf("connection reset")
|
||||
}
|
||||
|
||||
func newFileDownloadTestRuntime(t *testing.T, transport roundTripFunc) *extensionRuntime {
|
||||
t.Helper()
|
||||
return &extensionRuntime{
|
||||
extensionID: "dl-ext",
|
||||
manifest: &ExtensionManifest{
|
||||
Name: "dl-ext",
|
||||
Version: "1.0.0",
|
||||
Permissions: ExtensionPermissions{
|
||||
File: true,
|
||||
Network: []string{"cdn.example.com"},
|
||||
},
|
||||
},
|
||||
dataDir: t.TempDir(),
|
||||
vm: goja.New(),
|
||||
httpClient: &http.Client{Transport: transport},
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileDownloadStagesAndPromotes(t *testing.T) {
|
||||
runtime := newFileDownloadTestRuntime(t, func(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: 200,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader("audio-bytes")),
|
||||
ContentLength: int64(len("audio-bytes")),
|
||||
Request: req,
|
||||
}, nil
|
||||
})
|
||||
|
||||
result := runtime.fileDownload(goja.FunctionCall{Arguments: []goja.Value{
|
||||
runtime.vm.ToValue("https://cdn.example.com/track.flac"),
|
||||
runtime.vm.ToValue("out/track.flac"),
|
||||
}}).Export().(map[string]any)
|
||||
if result["success"] != true {
|
||||
t.Fatalf("download result = %#v", result)
|
||||
}
|
||||
|
||||
finalPath := filepath.Join(runtime.dataDir, "out", "track.flac")
|
||||
data, err := os.ReadFile(finalPath)
|
||||
if err != nil || string(data) != "audio-bytes" {
|
||||
t.Fatalf("final file = %q/%v", data, err)
|
||||
}
|
||||
if _, err := os.Stat(stagedDownloadPath(finalPath)); !os.IsNotExist(err) {
|
||||
t.Fatalf("staged file left behind: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileDownloadFailureLeavesNoFinalFile(t *testing.T) {
|
||||
runtime := newFileDownloadTestRuntime(t, func(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: 200,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(&failingBodyReader{data: []byte("partial-aud")}),
|
||||
ContentLength: 1 << 20,
|
||||
Request: req,
|
||||
}, nil
|
||||
})
|
||||
|
||||
result := runtime.fileDownload(goja.FunctionCall{Arguments: []goja.Value{
|
||||
runtime.vm.ToValue("https://cdn.example.com/track.flac"),
|
||||
runtime.vm.ToValue("out/track.flac"),
|
||||
}}).Export().(map[string]any)
|
||||
if result["success"] != false {
|
||||
t.Fatalf("expected failed download, got %#v", result)
|
||||
}
|
||||
|
||||
finalPath := filepath.Join(runtime.dataDir, "out", "track.flac")
|
||||
if _, err := os.Stat(finalPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("partial download visible at final path: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(stagedDownloadPath(finalPath)); !os.IsNotExist(err) {
|
||||
t.Fatalf("staged file left behind: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseExtensionTrackValueExplicit(t *testing.T) {
|
||||
vm := goja.New()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user