Files
tailscale/feature/taildrop/send.go
T
codinget e32520659d fix(taildrop): restore incoming file progress notifications
The io.Copy in PutFile was writing directly to wc, bypassing the
incomingFile wrapper whose Write method increments f.copied and fires
a throttled sendFileNotify on progress. As a result, notifyIncomingFiles
on the JS side only ever fired once (on completion) with received=0,
making progress UI impossible. The original inFile wrapping was lost
during the Android SAF refactor.

Also surface the PartialFile.Done flag through jsIncomingFile so JS can
distinguish the final "transfer complete" notification from in-progress
updates.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-16 19:04:02 +00:00

173 lines
4.7 KiB
Go

// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package taildrop
import (
"fmt"
"io"
"sync"
"time"
"tailscale.com/envknob"
"tailscale.com/ipn"
"tailscale.com/tstime"
"tailscale.com/version/distro"
)
type incomingFileKey struct {
id clientID
name string // e.g., "foo.jpeg"
}
type incomingFile struct {
clock tstime.DefaultClock
started time.Time
size int64 // or -1 if unknown; never 0
w io.Writer // underlying writer
sendFileNotify func() // called when done
partialPath string // non-empty in direct mode
finalPath string // not used in direct mode
mu sync.Mutex
copied int64
done bool
lastNotify time.Time
}
func (f *incomingFile) Write(p []byte) (n int, err error) {
n, err = f.w.Write(p)
var needNotify bool
defer func() {
if needNotify {
f.sendFileNotify()
}
}()
if n > 0 {
f.mu.Lock()
defer f.mu.Unlock()
f.copied += int64(n)
now := f.clock.Now()
if f.lastNotify.IsZero() || now.Sub(f.lastNotify) > time.Second {
f.lastNotify = now
needNotify = true
}
}
return n, err
}
// PutFile stores a file into [manager.Dir] from a given client id.
// The baseName must be a base filename without any slashes.
// The length is the expected length of content to read from r,
// it may be negative to indicate that it is unknown.
// It returns the length of the entire file.
//
// If there is a failure reading from r, then the partial file is not deleted
// for some period of time. The [manager.PartialFiles] and [manager.HashPartialFile]
// methods may be used to list all partial files and to compute the hash for a
// specific partial file. This allows the client to determine whether to resume
// a partial file. While resuming, PutFile may be called again with a non-zero
// offset to specify where to resume receiving data at.
func (m *manager) PutFile(id clientID, baseName string, r io.Reader, offset, length int64) (fileLength int64, err error) {
switch {
case m == nil || m.opts.fileOps == nil:
return 0, ErrNoTaildrop
case !envknob.CanTaildrop():
return 0, ErrNoTaildrop
case distro.Get() == distro.Unraid && !m.opts.DirectFileMode:
return 0, ErrNotAccessible
}
if err := validateBaseName(baseName); err != nil {
return 0, err
}
// and make sure we don't delete it while uploading:
m.deleter.Remove(baseName)
// Create (if not already) the partial file with read-write permissions.
partialName := baseName + id.partialSuffix()
wc, partialPath, err := m.opts.fileOps.OpenWriter(partialName, offset, 0o666)
if err != nil {
return 0, m.redactAndLogError("Create", err)
}
defer func() {
wc.Close()
if err != nil {
m.deleter.Insert(partialName) // mark partial file for eventual deletion
}
}()
// Check whether there is an in-progress transfer for the file.
inFileKey := incomingFileKey{id, baseName}
inFile, loaded := m.incomingFiles.LoadOrInit(inFileKey, func() *incomingFile {
inFile := &incomingFile{
clock: m.opts.Clock,
started: m.opts.Clock.Now(),
size: length,
sendFileNotify: m.opts.SendFileNotify,
}
if m.opts.DirectFileMode {
inFile.partialPath = partialPath
}
return inFile
})
inFile.w = wc
if loaded {
return 0, ErrFileExists
}
defer m.incomingFiles.Delete(inFileKey)
// Record that we have started to receive at least one file.
// This is used by the deleter upon a cold-start to scan the directory
// for any files that need to be deleted.
if st := m.opts.State; st != nil {
if b, _ := st.ReadState(ipn.TaildropReceivedKey); len(b) == 0 {
if werr := st.WriteState(ipn.TaildropReceivedKey, []byte{1}); werr != nil {
m.opts.Logf("WriteState error: %v", werr) // non-fatal error
}
}
}
// Copy via inFile (which wraps wc) so [incomingFile.Write] can track
// progress and fire periodic sendFileNotify callbacks.
copyLength, err := io.Copy(inFile, r)
if err != nil {
return 0, m.redactAndLogError("Copy", err)
}
if length >= 0 && copyLength != length {
return 0, m.redactAndLogError("Copy", fmt.Errorf("copied %d bytes; expected %d", copyLength, length))
}
if err := wc.Close(); err != nil {
return 0, m.redactAndLogError("Close", err)
}
fileLength = offset + copyLength
inFile.mu.Lock()
inFile.done = true
inFile.mu.Unlock()
// 6) Finalize (rename/move) the partial into place via FileOps.Rename
finalPath, err := m.opts.fileOps.Rename(partialPath, baseName)
if err != nil {
return 0, m.redactAndLogError("Rename", err)
}
inFile.finalPath = finalPath
m.totalReceived.Add(1)
m.opts.SendFileNotify()
return fileLength, nil
}
func (m *manager) redactAndLogError(stage string, err error) error {
err = redactError(err)
m.opts.Logf("put %s error: %v", stage, err)
return err
}