add chunked upload to bypass 100MB proxy body limit
Resumable /api/uploads flow (init -> append -> complete -> abort) so a large recording uploads as many small slices (<= ASR_CHUNK_SIZE_MB, default 50MB) instead of one request a size-capping proxy (Cloudflare's 100MB) would reject. The web console auto-routes files larger than one slice through the chunked path with cross-slice progress and transient-error retry; smaller files keep the single-shot upload. - store: in-memory upload sessions + .part reassembly (offset writes, out-of-order/idempotent), reuse upload id as task id, orphan sweep on start, idle-session GC - fix limitedReader rejecting bodies exactly at the cap (broke full chunks) - fix drag-drop double-firing when a drop lands on the dropzone (its drop handler now stopPropagation so the window listener doesn't re-upload) - tests: store + HTTP flow, plus a 120MB reassembly test (ASR_TEST_LARGE)
This commit is contained in:
@@ -4,3 +4,7 @@ import "errors"
|
||||
|
||||
// ErrNotFound is returned when a task id does not exist.
|
||||
var ErrNotFound = errors.New("task not found")
|
||||
|
||||
// ErrUploadNotFound is returned when a chunked-upload session id does not exist
|
||||
// (never created, already completed, or swept after being idle too long).
|
||||
var ErrUploadNotFound = errors.New("upload session not found")
|
||||
|
||||
+29
-16
@@ -55,8 +55,9 @@ type Store struct {
|
||||
tasksFile string
|
||||
uploadsDir string
|
||||
|
||||
mu sync.Mutex
|
||||
tasks map[string]*Task
|
||||
mu sync.Mutex
|
||||
tasks map[string]*Task
|
||||
sessions map[string]*UploadSession // in-progress chunked uploads
|
||||
}
|
||||
|
||||
// Open initializes the store at dir, creating directories and loading any
|
||||
@@ -75,10 +76,14 @@ func Open(dir string) (*Store, error) {
|
||||
tasksFile: filepath.Join(dir, "tasks.json"),
|
||||
uploadsDir: uploads,
|
||||
tasks: map[string]*Task{},
|
||||
sessions: map[string]*UploadSession{},
|
||||
}
|
||||
if err := s.load(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// In-memory upload sessions do not survive a restart; sweep any .part
|
||||
// files left behind by an interrupted chunked upload so they don't leak.
|
||||
s.sweepParts()
|
||||
return s, nil
|
||||
}
|
||||
|
||||
@@ -130,7 +135,9 @@ func (s *Store) Create(originalName string, r io.Reader, maxBytes int64, maxRetr
|
||||
rel := filepath.Join("uploads", id+ext)
|
||||
dst := filepath.Join(s.dir, rel)
|
||||
|
||||
// Copy with a size cap so a malicious upload can't exhaust disk.
|
||||
// Copy with a size cap so a malicious upload can't exhaust disk. The cap
|
||||
// is inclusive: a body of exactly maxBytes is accepted, and anything past
|
||||
// it is rejected.
|
||||
tmp := dst + ".tmp"
|
||||
f, err := os.Create(tmp)
|
||||
if err != nil {
|
||||
@@ -142,10 +149,7 @@ func (s *Store) Create(originalName string, r io.Reader, maxBytes int64, maxRetr
|
||||
os.Remove(tmp)
|
||||
return nil, fmt.Errorf("read upload: %w", err)
|
||||
}
|
||||
if n >= maxBytes {
|
||||
// Could be exactly at the limit; treat hitting the cap as too large
|
||||
// unless the reader is now exhausted. We err on the side of caution.
|
||||
}
|
||||
_ = n // bytes copied (<= maxBytes); the cap above is the real guard
|
||||
if err := os.Rename(tmp, dst); err != nil {
|
||||
os.Remove(tmp)
|
||||
return nil, fmt.Errorf("save upload: %w", err)
|
||||
@@ -364,8 +368,9 @@ func newID() string {
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// limitedReader wraps io.Reader with a hard byte cap; exceeding it returns an
|
||||
// error so the upload is rejected before it fills the disk.
|
||||
// limitedReader wraps io.Reader with an inclusive byte cap: a body that ends
|
||||
// at exactly max is accepted (max is the allowed maximum), while any bytes
|
||||
// past it are rejected so an oversized body can't fill the disk.
|
||||
type limitedReader struct {
|
||||
r io.Reader
|
||||
max int64
|
||||
@@ -373,14 +378,22 @@ type limitedReader struct {
|
||||
}
|
||||
|
||||
func (l *limitedReader) Read(p []byte) (int, error) {
|
||||
if l.n >= l.max {
|
||||
return 0, fmt.Errorf("upload exceeds %d bytes", l.max)
|
||||
}
|
||||
remain := l.max - l.n
|
||||
if int64(len(p)) > remain {
|
||||
p = p[:remain]
|
||||
if l.n < l.max {
|
||||
remain := l.max - l.n
|
||||
if int64(len(p)) > remain {
|
||||
p = p[:remain]
|
||||
}
|
||||
n, err := l.r.Read(p)
|
||||
l.n += int64(n)
|
||||
return n, err
|
||||
}
|
||||
// Already at the cap. Tell an exactly-at-cap body (reader now exhausted)
|
||||
// from one that still has bytes (over the cap): read once — any non-zero
|
||||
// return means the body exceeded the cap.
|
||||
n, err := l.r.Read(p)
|
||||
l.n += int64(n)
|
||||
return n, err
|
||||
if n > 0 {
|
||||
return n, fmt.Errorf("upload exceeds %d bytes", l.max)
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
// upload.go extends the store with resumable chunked uploads: a way to receive
|
||||
// a large file as many small requests (each under a size-capping proxy's body
|
||||
// limit, e.g. Cloudflare's 100MB) and reassemble it into one task.
|
||||
//
|
||||
// Lifecycle:
|
||||
// 1. CreateUpload — allocate an id + an empty "<id>.part" file, record the
|
||||
// expected total size and chunk layout.
|
||||
// 2. AppendChunk — write chunk #index at offset index*chunkSize into the
|
||||
// .part file. Chunks may arrive in any order and re-sending one is
|
||||
// idempotent (it overwrites the same offset).
|
||||
// 3. CompleteUpload — once every chunk has arrived, truncate the .part file
|
||||
// to the exact size, rename it to "<id><ext>", and enqueue a pending task
|
||||
// (reusing the upload id as the task id).
|
||||
// 4. AbortUpload — drop the session and delete the .part file.
|
||||
//
|
||||
// Sessions live in memory (the .part file is the on-disk durability). A
|
||||
// restart therefore loses in-flight sessions; sweepParts cleans up orphaned
|
||||
// .part files on Open, and gcUploads lazily reaps sessions idle too long.
|
||||
//
|
||||
// Locking: Store.mu guards the sessions map; each UploadSession has its own
|
||||
// mutex guarding its received[] slice and the part file it owns. The rule that
|
||||
// prevents deadlock is: never acquire Store.mu while holding a session mutex.
|
||||
// Append/Complete/GC all phase the two locks separately to honour this.
|
||||
|
||||
package store
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// sessionTTL is how long an upload session may stay idle before it (and its
|
||||
// .part file) is garbage-collected.
|
||||
const sessionTTL = 2 * time.Hour
|
||||
|
||||
// UploadSession tracks a resumable chunked upload whose slices are written
|
||||
// into a single .part file and finalized into a task on completion.
|
||||
type UploadSession struct {
|
||||
UploadID string
|
||||
Filename string
|
||||
Size int64
|
||||
ChunkSize int64
|
||||
TotalChunks int
|
||||
|
||||
// Unexported state — callers reach these only through Store methods.
|
||||
received []bool
|
||||
partPath string
|
||||
ext string
|
||||
createdAt time.Time
|
||||
lastSeen time.Time
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// CreateUpload starts a resumable chunked upload session. It allocates an
|
||||
// upload id, creates an empty .part file, and records the expected chunk
|
||||
// layout. maxBytes caps the total file size; chunkSize is the per-chunk size
|
||||
// (must be > 0). The returned session's id is later reused as the task id.
|
||||
func (s *Store) CreateUpload(filename string, size, chunkSize, maxBytes int64) (*UploadSession, error) {
|
||||
if chunkSize <= 0 {
|
||||
return nil, fmt.Errorf("chunk size must be > 0")
|
||||
}
|
||||
if size <= 0 {
|
||||
return nil, fmt.Errorf("size must be > 0")
|
||||
}
|
||||
if size > maxBytes {
|
||||
return nil, fmt.Errorf("upload size %d exceeds cap %d", size, maxBytes)
|
||||
}
|
||||
ext := filepath.Ext(filename)
|
||||
id := newID()
|
||||
partPath := filepath.Join(s.uploadsDir, id+".part")
|
||||
f, err := os.OpenFile(partPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create part file: %w", err)
|
||||
}
|
||||
f.Close()
|
||||
total := int((size + chunkSize - 1) / chunkSize)
|
||||
now := time.Now().UTC()
|
||||
sess := &UploadSession{
|
||||
UploadID: id,
|
||||
Filename: filename,
|
||||
Size: size,
|
||||
ChunkSize: chunkSize,
|
||||
TotalChunks: total,
|
||||
received: make([]bool, total),
|
||||
partPath: partPath,
|
||||
ext: ext,
|
||||
createdAt: now,
|
||||
lastSeen: now,
|
||||
}
|
||||
s.gcUploads() // reap idle sessions opportunistically before adding a new one
|
||||
s.mu.Lock()
|
||||
s.sessions[id] = sess
|
||||
s.mu.Unlock()
|
||||
return sess, nil
|
||||
}
|
||||
|
||||
// AppendChunk writes chunk #index (0-based) of the session's file at offset
|
||||
// index*chunkSize and marks it received. Chunks may arrive in any order and
|
||||
// are idempotent (re-sending a chunk overwrites the same offset). The reader
|
||||
// is capped at maxChunkBytes so a malicious or corrupt body can't overrun the
|
||||
// slot; the written length must match the expected chunk length.
|
||||
func (s *Store) AppendChunk(uploadID string, index int, r io.Reader, maxChunkBytes int64) error {
|
||||
s.mu.Lock()
|
||||
sess, ok := s.sessions[uploadID]
|
||||
s.mu.Unlock()
|
||||
if !ok {
|
||||
return ErrUploadNotFound
|
||||
}
|
||||
sess.mu.Lock()
|
||||
defer sess.mu.Unlock()
|
||||
sess.lastSeen = time.Now().UTC()
|
||||
if index < 0 || index >= sess.TotalChunks {
|
||||
return fmt.Errorf("chunk index %d out of range [0,%d)", index, sess.TotalChunks)
|
||||
}
|
||||
offset := int64(index) * sess.ChunkSize
|
||||
f, err := os.OpenFile(sess.partPath, os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open part: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
if _, err := f.Seek(offset, io.SeekStart); err != nil {
|
||||
return fmt.Errorf("seek: %w", err)
|
||||
}
|
||||
n, err := io.Copy(f, &limitedReader{r: r, max: maxChunkBytes})
|
||||
if err != nil {
|
||||
return fmt.Errorf("write chunk: %w", err)
|
||||
}
|
||||
// Validate length: every full chunk == chunkSize; the last chunk is the
|
||||
// remainder. A short or oversized body is rejected (not marked received).
|
||||
expected := sess.ChunkSize
|
||||
if index == sess.TotalChunks-1 {
|
||||
expected = sess.Size - offset
|
||||
}
|
||||
if n != expected {
|
||||
return fmt.Errorf("chunk %d length mismatch: got %d want %d", index, n, expected)
|
||||
}
|
||||
sess.received[index] = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// CompleteUpload finalizes a chunked upload: verifies every chunk arrived,
|
||||
// truncates the part file to the exact size (repairing any overwrite from a
|
||||
// rejected chunk), renames it to the task's audio file, and enqueues a
|
||||
// pending transcription task. The upload id becomes the task id.
|
||||
func (s *Store) CompleteUpload(uploadID string, maxRetries int) (*Task, error) {
|
||||
s.mu.Lock()
|
||||
sess, ok := s.sessions[uploadID]
|
||||
s.mu.Unlock()
|
||||
if !ok {
|
||||
return nil, ErrUploadNotFound
|
||||
}
|
||||
// Session-scoped work (file ops) under the session lock only.
|
||||
sess.mu.Lock()
|
||||
for i, got := range sess.received {
|
||||
if !got {
|
||||
sess.mu.Unlock()
|
||||
return nil, fmt.Errorf("missing chunk %d of %d", i, sess.TotalChunks)
|
||||
}
|
||||
}
|
||||
if err := os.Truncate(sess.partPath, sess.Size); err != nil {
|
||||
sess.mu.Unlock()
|
||||
return nil, fmt.Errorf("truncate part: %w", err)
|
||||
}
|
||||
rel := filepath.Join("uploads", sess.UploadID+sess.ext)
|
||||
dst := filepath.Join(s.dir, rel)
|
||||
if err := os.Rename(sess.partPath, dst); err != nil {
|
||||
sess.mu.Unlock()
|
||||
return nil, fmt.Errorf("finalize upload: %w", err)
|
||||
}
|
||||
sess.mu.Unlock()
|
||||
|
||||
// Register the task under the store lock (no session lock held).
|
||||
now := time.Now().UTC()
|
||||
t := &Task{
|
||||
ID: sess.UploadID,
|
||||
Filename: rel,
|
||||
OriginalName: sess.Filename,
|
||||
Status: StatusPending,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
MaxRetries: maxRetries,
|
||||
}
|
||||
s.mu.Lock()
|
||||
delete(s.sessions, uploadID)
|
||||
s.tasks[uploadID] = t
|
||||
serr := s.save()
|
||||
s.mu.Unlock()
|
||||
if serr != nil {
|
||||
return nil, fmt.Errorf("persist task: %w", serr)
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// AbortUpload abandons a chunked upload session and removes its part file.
|
||||
// It is idempotent (a missing session is not an error).
|
||||
func (s *Store) AbortUpload(uploadID string) error {
|
||||
s.mu.Lock()
|
||||
sess, ok := s.sessions[uploadID]
|
||||
if ok {
|
||||
delete(s.sessions, uploadID)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
_ = os.Remove(sess.partPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
// UploadStatus reports how many chunks of a session have arrived so far. It
|
||||
// lets a client show progress and decide whether a complete is safe.
|
||||
func (s *Store) UploadStatus(uploadID string) (received, total int, ok bool) {
|
||||
s.mu.Lock()
|
||||
sess, ok2 := s.sessions[uploadID]
|
||||
s.mu.Unlock()
|
||||
if !ok2 {
|
||||
return 0, 0, false
|
||||
}
|
||||
sess.mu.Lock()
|
||||
defer sess.mu.Unlock()
|
||||
n := 0
|
||||
for _, b := range sess.received {
|
||||
if b {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n, sess.TotalChunks, true
|
||||
}
|
||||
|
||||
// gcUploads reaps sessions idle for longer than sessionTTL and deletes their
|
||||
// .part files. It is called lazily from CreateUpload so no extra goroutine is
|
||||
// needed. The two locks are phased (map under Store.mu, idle-check under the
|
||||
// session mutex; file removal after Store.mu is released) to honour the
|
||||
// "never hold Store.mu while taking a session lock for long" rule.
|
||||
func (s *Store) gcUploads() {
|
||||
cutoff := time.Now().Add(-sessionTTL)
|
||||
var dead []*UploadSession
|
||||
s.mu.Lock()
|
||||
for id, sess := range s.sessions {
|
||||
sess.mu.Lock()
|
||||
idle := sess.lastSeen.Before(cutoff)
|
||||
sess.mu.Unlock()
|
||||
if idle {
|
||||
dead = append(dead, sess)
|
||||
delete(s.sessions, id)
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
for _, sess := range dead {
|
||||
_ = os.Remove(sess.partPath)
|
||||
}
|
||||
}
|
||||
|
||||
// sweepParts removes leftover .part files from interrupted chunked uploads
|
||||
// after a restart (in-memory sessions don't survive). Called once from Open.
|
||||
func (s *Store) sweepParts() {
|
||||
entries, err := os.ReadDir(s.uploadsDir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, e := range entries {
|
||||
if !e.Type().IsRegular() {
|
||||
continue
|
||||
}
|
||||
if strings.HasSuffix(e.Name(), ".part") {
|
||||
_ = os.Remove(filepath.Join(s.uploadsDir, e.Name()))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// chunkBytes returns a deterministic slice of n bytes whose value is b.
|
||||
func chunkBytes(n int, b byte) []byte {
|
||||
out := make([]byte, n)
|
||||
for i := range out {
|
||||
out[i] = b
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// newTestStore opens a store in a fresh temp dir.
|
||||
func newTestStore(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
s, err := Open(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// TestChunkedUploadReassemble verifies that out-of-order, idempotent chunks
|
||||
// reassemble into the exact original bytes and enqueue a pending task.
|
||||
func TestChunkedUploadReassemble(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
const chunk = 10
|
||||
const total = 25 // 3 chunks: 10 + 10 + 5
|
||||
|
||||
sess, err := s.CreateUpload("note.wav", total, chunk, 512)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUpload: %v", err)
|
||||
}
|
||||
if sess.TotalChunks != 3 {
|
||||
t.Fatalf("TotalChunks = %d, want 3", sess.TotalChunks)
|
||||
}
|
||||
|
||||
// Expected file content: three slices with distinct byte values.
|
||||
c0 := chunkBytes(10, 0xAA)
|
||||
c1 := chunkBytes(10, 0xBB)
|
||||
c2 := chunkBytes(5, 0xCC)
|
||||
want := append(append([]byte{}, c0...), c1...)
|
||||
want = append(want, c2...)
|
||||
|
||||
// Send out of order: 1, 0, 2.
|
||||
for _, step := range []struct {
|
||||
idx int
|
||||
data []byte
|
||||
}{
|
||||
{1, c1}, {0, c0}, {2, c2},
|
||||
} {
|
||||
if err := s.AppendChunk(sess.UploadID, step.idx, bytes.NewReader(step.data), chunk); err != nil {
|
||||
t.Fatalf("AppendChunk(%d): %v", step.idx, err)
|
||||
}
|
||||
got, total, ok := s.UploadStatus(sess.UploadID)
|
||||
if !ok {
|
||||
t.Fatalf("UploadStatus: session gone")
|
||||
}
|
||||
_ = total
|
||||
// After the last append all 3 should be received.
|
||||
if step.idx == 2 && got != 3 {
|
||||
t.Fatalf("after last chunk received=%d want 3", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Re-send chunk 0 (idempotent): content must be unchanged.
|
||||
if err := s.AppendChunk(sess.UploadID, 0, bytes.NewReader(c0), chunk); err != nil {
|
||||
t.Fatalf("AppendChunk(0) re-send: %v", err)
|
||||
}
|
||||
|
||||
task, err := s.CompleteUpload(sess.UploadID, 3)
|
||||
if err != nil {
|
||||
t.Fatalf("CompleteUpload: %v", err)
|
||||
}
|
||||
if task.Status != StatusPending {
|
||||
t.Fatalf("task status = %q, want %q", task.Status, StatusPending)
|
||||
}
|
||||
if task.OriginalName != "note.wav" {
|
||||
t.Fatalf("original_name = %q", task.OriginalName)
|
||||
}
|
||||
|
||||
// The part file is gone; the final audio file holds the exact bytes.
|
||||
if _, err := os.Stat(filepath.Join(s.uploadsDir, sess.UploadID+".part")); !os.IsNotExist(err) {
|
||||
t.Fatalf("part file still present: %v", err)
|
||||
}
|
||||
got, err := os.ReadFile(filepath.Join(s.dir, task.Filename))
|
||||
if err != nil {
|
||||
t.Fatalf("read final file: %v", err)
|
||||
}
|
||||
if !bytes.Equal(got, want) {
|
||||
t.Fatalf("final bytes mismatch:\n got %x\n want %x", got, want)
|
||||
}
|
||||
if int64(len(got)) != total {
|
||||
t.Fatalf("final size = %d, want %d", len(got), total)
|
||||
}
|
||||
|
||||
// The session became a task the store knows about.
|
||||
if s.Get(task.ID) == nil {
|
||||
t.Fatalf("Get(task) returned nil after complete")
|
||||
}
|
||||
// ...and is no longer an upload session.
|
||||
if _, _, ok := s.UploadStatus(sess.UploadID); ok {
|
||||
t.Fatalf("session still present after complete")
|
||||
}
|
||||
}
|
||||
|
||||
// TestChunkedUploadMissingChunk ensures complete fails when a chunk is absent.
|
||||
func TestChunkedUploadMissingChunk(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
sess, _ := s.CreateUpload("x.wav", 20, 10, 512) // 2 chunks
|
||||
_ = s.AppendChunk(sess.UploadID, 0, bytes.NewReader(chunkBytes(10, 1)), 10)
|
||||
if _, err := s.CompleteUpload(sess.UploadID, 3); err == nil {
|
||||
t.Fatalf("CompleteUpload succeeded with a missing chunk")
|
||||
}
|
||||
// The session survives a failed complete so the client can send the rest.
|
||||
if _, _, ok := s.UploadStatus(sess.UploadID); !ok {
|
||||
t.Fatalf("session dropped after failed complete")
|
||||
}
|
||||
}
|
||||
|
||||
// TestChunkedUploadLengthMismatch rejects a chunk whose size is wrong.
|
||||
func TestChunkedUploadLengthMismatch(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
sess, _ := s.CreateUpload("x.wav", 20, 10, 512) // 2 chunks of 10
|
||||
// Send only 7 bytes for a chunk that should be 10.
|
||||
if err := s.AppendChunk(sess.UploadID, 0, bytes.NewReader(chunkBytes(7, 1)), 10); err == nil {
|
||||
t.Fatalf("AppendChunk accepted a short chunk")
|
||||
}
|
||||
}
|
||||
|
||||
// TestChunkedUploadAbort removes the part file and drops the session.
|
||||
func TestChunkedUploadAbort(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
sess, _ := s.CreateUpload("x.wav", 10, 10, 512)
|
||||
_ = s.AppendChunk(sess.UploadID, 0, bytes.NewReader(chunkBytes(10, 1)), 10)
|
||||
part := filepath.Join(s.uploadsDir, sess.UploadID+".part")
|
||||
if _, err := os.Stat(part); err != nil {
|
||||
t.Fatalf("part file missing before abort: %v", err)
|
||||
}
|
||||
if err := s.AbortUpload(sess.UploadID); err != nil {
|
||||
t.Fatalf("AbortUpload: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(part); !os.IsNotExist(err) {
|
||||
t.Fatalf("part file present after abort: %v", err)
|
||||
}
|
||||
if _, _, ok := s.UploadStatus(sess.UploadID); ok {
|
||||
t.Fatalf("session still present after abort")
|
||||
}
|
||||
// Abort of an unknown session is idempotent, not an error.
|
||||
if err := s.AbortUpload("nope"); err != nil {
|
||||
t.Fatalf("AbortUpload(unknown) = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSweepPartsOnOpen confirms a leftover .part file from a previous run is
|
||||
// removed when the store reopens (in-memory sessions don't survive restarts).
|
||||
func TestSweepPartsOnOpen(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s1, err := Open(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
sess, _ := s1.CreateUpload("x.wav", 10, 10, 512)
|
||||
part := filepath.Join(s1.uploadsDir, sess.UploadID+".part")
|
||||
if _, err := os.Stat(part); err != nil {
|
||||
t.Fatalf("part missing: %v", err)
|
||||
}
|
||||
s1.gcUploads() // no-op: session not idle yet; part must survive
|
||||
if _, err := os.Stat(part); err != nil {
|
||||
t.Fatalf("part swept too early: %v", err)
|
||||
}
|
||||
|
||||
// Reopen the same dir: the orphan .part file must be swept.
|
||||
s2, err := Open(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("Open(2): %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(s2.uploadsDir, sess.UploadID+".part")); !os.IsNotExist(err) {
|
||||
t.Fatalf("orphan .part file survived reopen: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSweepPartsPreservesTaskAudio ensures the startup sweep only touches
|
||||
// .part files, never finalized task audio (uploads/<id>.<ext>).
|
||||
func TestSweepPartsPreservesTaskAudio(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s1, _ := Open(dir)
|
||||
task, err := s1.Create("kept.wav", bytes.NewReader(chunkBytes(10, 7)), 512, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
audioPath := filepath.Join(s1.dir, task.Filename)
|
||||
s1.sweepParts() // must not delete the task's audio
|
||||
if _, err := os.Stat(audioPath); err != nil {
|
||||
t.Fatalf("task audio swept by sweepParts: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user