tast: Add dynamic timeout for ephemeral devserver downloads

Query the size of GCS objects using gsutil stat first, and dynamically
calculate the gsutil download timeout assuming a minimum download speed
of 100 KB/s plus a buffer. Fallback to 20 minutes on failure.

BUG=b:522573839
TEST=CQ

Change-Id: I383012eda838aa82a10a8cccebed5da3cceb27fc
Reviewed-on: https://chromium-review.googlesource.com/c/chromiumos/platform/tast/+/7945023
Commit-Queue: See Wai Fu <seewaifu@google.com>
Auto-Submit: See Wai Fu <seewaifu@google.com>
Reviewed-by: Terry Yan <yichiyan@google.com>
Tested-by: See Wai Fu <seewaifu@google.com>
diff --git a/src/go.chromium.org/tast/core/internal/run/devserver/ephemeral.go b/src/go.chromium.org/tast/core/internal/run/devserver/ephemeral.go
index cea3c7e..eb27386 100644
--- a/src/go.chromium.org/tast/core/internal/run/devserver/ephemeral.go
+++ b/src/go.chromium.org/tast/core/internal/run/devserver/ephemeral.go
@@ -6,16 +6,20 @@
 package devserver
 
 import (
+	"context"
 	"fmt"
 	"html"
 	"io"
+	"log"
 	"net"
 	"net/http"
 	"net/url"
 	"os"
 	"os/exec"
 	"path/filepath"
+	"strconv"
 	"strings"
+	"time"
 )
 
 // Ephemeral is a minimal devserver implementation using local credentials.
@@ -144,8 +148,26 @@
 		tf.Close()
 		defer os.Remove(tf.Name())
 
+		// Determine timeout based on file size.
+		timeout := 20 * time.Minute // default fallback timeout
+		if size, err := getGCSFileSize(r.Context(), gsURL); err == nil {
+			// Calculate timeout assuming minimum download speed of 100 KB/s
+			// timeout = (size / 100,000) seconds + 60 seconds buffer.
+			calcSeconds := size/100000 + 60
+			// Cap the timeout between 1 minute and 6 hours.
+			if calcSeconds < 60 {
+				calcSeconds = 60
+			} else if calcSeconds > 6*3600 {
+				calcSeconds = 6 * 3600
+			}
+			timeout = time.Duration(calcSeconds) * time.Second
+		}
+
 		// Use gsutil command to download a file to use the locally installed credentials.
-		cmd := exec.Command("gsutil", "-m", "cp", gsURL, tf.Name())
+		ctx, cancel := context.WithTimeout(r.Context(), timeout)
+		defer cancel()
+		log.Printf("Downloading %s with timeout %v", gsURL, timeout)
+		cmd := exec.CommandContext(ctx, "gsutil", "-m", "cp", gsURL, tf.Name())
 		out, err := cmd.CombinedOutput()
 		if err != nil {
 			if strings.Contains(string(out), "No URLs matched") {
@@ -276,3 +298,23 @@
 	}
 	return strings.TrimLeft(p.Path, "/"), nil
 }
+
+// getGCSFileSize queries the size of a GCS object using gsutil stat.
+// It returns the size in bytes, or an error if the query fails or times out.
+func getGCSFileSize(ctx context.Context, gsURL string) (int64, error) {
+	ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
+	defer cancel()
+	cmd := exec.CommandContext(ctx, "gsutil", "stat", gsURL)
+	out, err := cmd.Output()
+	if err != nil {
+		return 0, err
+	}
+	for _, line := range strings.Split(string(out), "\n") {
+		line = strings.TrimSpace(line)
+		if strings.HasPrefix(line, "Content-Length:") {
+			sizeStr := strings.TrimSpace(strings.TrimPrefix(line, "Content-Length:"))
+			return strconv.ParseInt(sizeStr, 10, 64)
+		}
+	}
+	return 0, fmt.Errorf("Content-Length not found in gsutil stat output")
+}