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:
zarzet
2026-07-10 09:26:38 +07:00
parent 823b050744
commit d819878ec7
3 changed files with 193 additions and 5 deletions
+33
View File
@@ -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"
}
+71 -5
View File
@@ -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()