Skip to content

Commit dd3376e

Browse files
authored
chore(workers): improve logging, set header timeouts (#9171)
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
1 parent 520e1ce commit dd3376e

4 files changed

Lines changed: 242 additions & 21 deletions

File tree

core/cli/worker.go

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -476,9 +476,22 @@ func (s *backendSupervisor) startBackend(backend, backendPath string) (string, e
476476
return clientAddr, nil
477477
}
478478
cancel()
479+
480+
// Check if the process died (e.g. OOM, CUDA error, missing libs)
481+
if !proc.IsAlive() {
482+
stderrTail := readLastLinesFromFile(proc.StderrPath(), 20)
483+
xlog.Warn("Backend process died during startup", "backend", backend, "stderr", stderrTail)
484+
s.mu.Lock()
485+
delete(s.processes, backend)
486+
s.freePorts = append(s.freePorts, port)
487+
s.mu.Unlock()
488+
return "", fmt.Errorf("backend process %s died during startup. Last stderr:\n%s", backend, stderrTail)
489+
}
479490
}
480491

481-
xlog.Warn("Backend gRPC server not ready after waiting, proceeding anyway", "backend", backend, "addr", clientAddr)
492+
// Log stderr to help diagnose why the backend isn't responding
493+
stderrTail := readLastLinesFromFile(proc.StderrPath(), 20)
494+
xlog.Warn("Backend gRPC server not ready after waiting, proceeding anyway", "backend", backend, "addr", clientAddr, "stderr", stderrTail)
482495
return clientAddr, nil
483496
}
484497

@@ -525,6 +538,20 @@ func (s *backendSupervisor) stopAllBackends() {
525538
}
526539
}
527540

541+
// readLastLinesFromFile reads the last n lines from a file.
542+
// Returns an empty string if the file cannot be read.
543+
func readLastLinesFromFile(path string, n int) string {
544+
data, err := os.ReadFile(path)
545+
if err != nil {
546+
return ""
547+
}
548+
lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
549+
if len(lines) > n {
550+
lines = lines[len(lines)-n:]
551+
}
552+
return strings.Join(lines, "\n")
553+
}
554+
528555
// isRunning returns whether a specific backend process is currently running.
529556
func (s *backendSupervisor) isRunning(backend string) bool {
530557
s.mu.Lock()

core/services/nodes/file_stager_http.go

Lines changed: 204 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,17 @@ package nodes
33
import (
44
"context"
55
"encoding/json"
6+
"errors"
67
"fmt"
78
"io"
9+
"net"
810
"net/http"
911
"os"
1012
"path/filepath"
13+
"strconv"
1114
"strings"
15+
"sync"
16+
"syscall"
1217
"time"
1318

1419
"github.com/mudler/LocalAI/core/services/storage"
@@ -19,25 +24,58 @@ import (
1924
// Files are transferred between the frontend and backend nodes over a small
2025
// HTTP server running alongside the gRPC backend process.
2126
type HTTPFileStager struct {
22-
httpAddrFor func(nodeID string) (string, error)
23-
token string
24-
client *http.Client
27+
httpAddrFor func(nodeID string) (string, error)
28+
token string
29+
client *http.Client
30+
responseTimeout time.Duration // timeout waiting for server response after upload
31+
maxRetries int // number of retry attempts for transient failures
2532
}
2633

2734
// NewHTTPFileStager creates a new HTTP file stager.
2835
// httpAddrFor should return the HTTP address (host:port) for the given node ID.
2936
// token is the registration token used for authentication.
3037
func NewHTTPFileStager(httpAddrFor func(nodeID string) (string, error), token string) *HTTPFileStager {
31-
timeout := 30 * time.Minute
38+
responseTimeout := 30 * time.Minute
3239
if v := os.Getenv("LOCALAI_FILE_TRANSFER_TIMEOUT"); v != "" {
3340
if d, err := time.ParseDuration(v); err == nil {
34-
timeout = d
41+
responseTimeout = d
3542
}
3643
}
44+
45+
maxRetries := 3
46+
if v := os.Getenv("LOCALAI_FILE_TRANSFER_RETRIES"); v != "" {
47+
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
48+
maxRetries = n
49+
}
50+
}
51+
52+
transport := &http.Transport{
53+
DialContext: (&net.Dialer{
54+
Timeout: 30 * time.Second,
55+
KeepAlive: 15 * time.Second, // aggressive keepalive for LAN transfers
56+
}).DialContext,
57+
ForceAttemptHTTP2: false, // HTTP/2 flow control can stall large uploads
58+
MaxIdleConns: 10,
59+
IdleConnTimeout: 90 * time.Second,
60+
TLSHandshakeTimeout: 10 * time.Second,
61+
ExpectContinueTimeout: 1 * time.Second,
62+
WriteBufferSize: 256 << 10, // 256 KB
63+
ReadBufferSize: 256 << 10, // 256 KB
64+
}
65+
3766
return &HTTPFileStager{
3867
httpAddrFor: httpAddrFor,
3968
token: token,
40-
client: &http.Client{Timeout: timeout},
69+
client: &http.Client{
70+
// No Timeout set — for large uploads, http.Client.Timeout covers the
71+
// entire request lifecycle including the body upload. If it fires
72+
// mid-write, Go closes the connection causing "connection reset by peer"
73+
// on the server. Instead we use ResponseHeaderTimeout on the transport
74+
// to cover only the wait-for-server-response phase.
75+
Transport: transport,
76+
},
77+
responseTimeout: responseTimeout,
78+
maxRetries: maxRetries,
4179
}
4280
}
4381

@@ -49,39 +87,85 @@ func (h *HTTPFileStager) EnsureRemote(ctx context.Context, nodeID, localPath, ke
4987
return "", fmt.Errorf("resolving HTTP address for node %s: %w", nodeID, err)
5088
}
5189

90+
fi, err := os.Stat(localPath)
91+
if err != nil {
92+
return "", fmt.Errorf("stat local file %s: %w", localPath, err)
93+
}
94+
fileSize := fi.Size()
95+
96+
url := fmt.Sprintf("http://%s/v1/files/%s", addr, key)
97+
xlog.Info("Uploading file to remote node", "node", nodeID, "file", filepath.Base(localPath), "size", humanFileSize(fileSize), "url", url)
98+
99+
var lastErr error
100+
attempts := h.maxRetries + 1 // maxRetries=3 means 4 total attempts (1 initial + 3 retries)
101+
for attempt := 1; attempt <= attempts; attempt++ {
102+
if attempt > 1 {
103+
backoff := time.Duration(5<<(attempt-2)) * time.Second // 5s, 10s, 20s
104+
xlog.Warn("Retrying file upload", "node", nodeID, "file", filepath.Base(localPath),
105+
"attempt", attempt, "of", attempts, "backoff", backoff, "lastError", lastErr)
106+
select {
107+
case <-ctx.Done():
108+
return "", fmt.Errorf("upload cancelled during retry backoff: %w", ctx.Err())
109+
case <-time.After(backoff):
110+
}
111+
}
112+
113+
result, err := h.doUpload(ctx, addr, nodeID, localPath, key, url, fileSize)
114+
if err == nil {
115+
if attempt > 1 {
116+
xlog.Info("File upload succeeded after retry", "node", nodeID, "file", filepath.Base(localPath), "attempt", attempt)
117+
}
118+
return result, nil
119+
}
120+
lastErr = err
121+
122+
if !isTransientError(err) {
123+
xlog.Error("File upload failed with non-transient error", "node", nodeID, "file", filepath.Base(localPath), "error", err)
124+
return "", err
125+
}
126+
xlog.Warn("File upload failed with transient error", "node", nodeID, "file", filepath.Base(localPath),
127+
"attempt", attempt, "of", attempts, "error", err)
128+
}
129+
130+
return "", fmt.Errorf("uploading %s to node %s failed after %d attempts: %w", localPath, nodeID, attempts, lastErr)
131+
}
132+
133+
// doUpload performs a single upload attempt.
134+
func (h *HTTPFileStager) doUpload(ctx context.Context, addr, nodeID, localPath, key, url string, fileSize int64) (string, error) {
52135
f, err := os.Open(localPath)
53136
if err != nil {
54137
return "", fmt.Errorf("opening local file %s: %w", localPath, err)
55138
}
56139
defer f.Close()
57140

58-
fi, _ := f.Stat()
59-
var fileSize int64
60-
if fi != nil {
61-
fileSize = fi.Size()
141+
var body io.Reader = f
142+
// For files > 100MB, wrap with progress logging
143+
const progressThreshold = 100 << 20
144+
if fileSize > progressThreshold {
145+
body = newProgressReader(f, fileSize, filepath.Base(localPath), nodeID)
62146
}
63147

64-
url := fmt.Sprintf("http://%s/v1/files/%s", addr, key)
65-
xlog.Debug("HTTP upload starting", "node", nodeID, "url", url, "localPath", localPath, "fileSize", fileSize)
66-
67-
req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, f)
148+
req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, body)
68149
if err != nil {
69150
return "", fmt.Errorf("creating request: %w", err)
70151
}
152+
req.ContentLength = fileSize // explicit Content-Length for progress tracking
71153
req.Header.Set("Content-Type", "application/octet-stream")
72154
if h.token != "" {
73155
req.Header.Set("Authorization", "Bearer "+h.token)
74156
}
75157

76158
resp, err := h.client.Do(req)
77159
if err != nil {
160+
xlog.Error("File upload failed", "node", nodeID, "file", filepath.Base(localPath), "size", humanFileSize(fileSize), "error", err)
78161
return "", fmt.Errorf("uploading %s to node %s: %w", localPath, nodeID, err)
79162
}
80163
defer resp.Body.Close()
81164

82165
if resp.StatusCode != http.StatusOK {
83-
body, _ := io.ReadAll(resp.Body)
84-
return "", fmt.Errorf("upload to node %s failed with status %d: %s", nodeID, resp.StatusCode, string(body))
166+
respBody, _ := io.ReadAll(resp.Body)
167+
xlog.Error("File upload rejected by remote node", "node", nodeID, "file", filepath.Base(localPath), "status", resp.StatusCode, "response", string(respBody))
168+
return "", fmt.Errorf("upload to node %s failed with status %d: %s", nodeID, resp.StatusCode, string(respBody))
85169
}
86170

87171
var result struct {
@@ -91,10 +175,113 @@ func (h *HTTPFileStager) EnsureRemote(ctx context.Context, nodeID, localPath, ke
91175
return "", fmt.Errorf("decoding upload response: %w", err)
92176
}
93177

94-
xlog.Debug("HTTP upload complete", "node", nodeID, "remotePath", result.LocalPath, "fileSize", fileSize)
178+
xlog.Info("File upload complete", "node", nodeID, "file", filepath.Base(localPath), "size", humanFileSize(fileSize), "remotePath", result.LocalPath)
95179
return result.LocalPath, nil
96180
}
97181

182+
// isTransientError returns true if the error is likely transient and worth retrying.
183+
func isTransientError(err error) bool {
184+
if err == nil {
185+
return false
186+
}
187+
// Connection reset by peer
188+
if errors.Is(err, syscall.ECONNRESET) {
189+
return true
190+
}
191+
// Broken pipe
192+
if errors.Is(err, syscall.EPIPE) {
193+
return true
194+
}
195+
// Connection refused (worker might be restarting)
196+
if errors.Is(err, syscall.ECONNREFUSED) {
197+
return true
198+
}
199+
// Context deadline exceeded (but not cancelled — cancelled means the caller gave up)
200+
if errors.Is(err, context.DeadlineExceeded) {
201+
return true
202+
}
203+
// net.Error timeout
204+
var netErr net.Error
205+
if errors.As(err, &netErr) && netErr.Timeout() {
206+
return true
207+
}
208+
// Check for "connection reset" in the error string as a fallback
209+
// (some wrapped errors lose the syscall.Errno)
210+
msg := err.Error()
211+
if strings.Contains(msg, "connection reset") ||
212+
strings.Contains(msg, "broken pipe") ||
213+
strings.Contains(msg, "connection refused") ||
214+
strings.Contains(msg, "EOF") {
215+
return true
216+
}
217+
return false
218+
}
219+
220+
// progressReader wraps an io.Reader and logs upload progress periodically.
221+
type progressReader struct {
222+
reader io.Reader
223+
total int64
224+
read int64
225+
file string
226+
node string
227+
lastLog time.Time
228+
lastPct int
229+
start time.Time
230+
mu sync.Mutex
231+
}
232+
233+
func newProgressReader(r io.Reader, total int64, file, node string) *progressReader {
234+
return &progressReader{
235+
reader: r,
236+
total: total,
237+
file: file,
238+
node: node,
239+
start: time.Now(),
240+
lastLog: time.Now(),
241+
}
242+
}
243+
244+
func (pr *progressReader) Read(p []byte) (int, error) {
245+
n, err := pr.reader.Read(p)
246+
if n > 0 {
247+
pr.mu.Lock()
248+
pr.read += int64(n)
249+
pct := int(pr.read * 100 / pr.total)
250+
now := time.Now()
251+
// Log every 10% or every 30 seconds
252+
if pct/10 > pr.lastPct/10 || now.Sub(pr.lastLog) >= 30*time.Second {
253+
elapsed := now.Sub(pr.start)
254+
var speed string
255+
if elapsed > 0 {
256+
bytesPerSec := float64(pr.read) / elapsed.Seconds()
257+
speed = humanFileSize(int64(bytesPerSec)) + "/s"
258+
}
259+
xlog.Info("Upload progress", "node", pr.node, "file", pr.file,
260+
"progress", fmt.Sprintf("%d%%", pct),
261+
"sent", humanFileSize(pr.read), "total", humanFileSize(pr.total),
262+
"speed", speed)
263+
pr.lastLog = now
264+
pr.lastPct = pct
265+
}
266+
pr.mu.Unlock()
267+
}
268+
return n, err
269+
}
270+
271+
// humanFileSize returns a human-readable file size string.
272+
func humanFileSize(b int64) string {
273+
const unit = 1024
274+
if b < unit {
275+
return fmt.Sprintf("%d B", b)
276+
}
277+
div, exp := int64(unit), 0
278+
for n := b / unit; n >= unit; n /= unit {
279+
div *= unit
280+
exp++
281+
}
282+
return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "KMGTPE"[exp])
283+
}
284+
98285
func (h *HTTPFileStager) FetchRemote(ctx context.Context, nodeID, remotePath, localDst string) error {
99286
// For staging files (not under models/ or data/), the worker's file transfer
100287
// server resolves the key as a relative path under its staging directory.

core/services/nodes/file_transfer_server.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,10 @@ func StartFileTransferServerWithListener(lis net.Listener, stagingDir, modelsDir
9797
}
9898

9999
addr := lis.Addr().String()
100-
server := &http.Server{Handler: mux}
100+
server := &http.Server{
101+
Handler: mux,
102+
ReadHeaderTimeout: 30 * time.Second, // prevent slowloris; does not affect body reads
103+
}
101104

102105
go func() {
103106
xlog.Info("HTTP file transfer server started", "addr", addr, "stagingDir", stagingDir, "modelsDir", modelsDir, "dataDir", dataDir)
@@ -119,6 +122,8 @@ func handleUpload(w http.ResponseWriter, r *http.Request, stagingDir, modelsDir,
119122
r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize)
120123
}
121124

125+
xlog.Info("Receiving file upload", "key", key, "contentLength", r.ContentLength, "remote", r.RemoteAddr)
126+
122127
// Route keyed files to the appropriate directory
123128
targetDir, relName := resolveKeyToDir(key, stagingDir, modelsDir, dataDir)
124129

@@ -144,11 +149,12 @@ func handleUpload(w http.ResponseWriter, r *http.Request, stagingDir, modelsDir,
144149
n, err := io.Copy(f, r.Body)
145150
if err != nil {
146151
os.Remove(dstPath)
152+
xlog.Error("File upload failed", "key", key, "bytesReceived", n, "contentLength", r.ContentLength, "remote", r.RemoteAddr, "error", err)
147153
http.Error(w, fmt.Sprintf("writing file: %v", err), http.StatusInternalServerError)
148154
return
149155
}
150156

151-
xlog.Debug("HTTP file upload complete", "key", key, "path", dstPath, "size", n)
157+
xlog.Info("File upload complete", "key", key, "path", dstPath, "size", n)
152158

153159
w.Header().Set("Content-Type", "application/json")
154160
if err := json.NewEncoder(w).Encode(map[string]string{"local_path": dstPath}); err != nil {

core/services/nodes/router.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -368,7 +368,7 @@ func (r *SmartRouter) buildClientForAddr(node *BackendNode, addr string, paralle
368368
// simply remove the {ModelsPath}/{trackingKey}/ directory.
369369
func (r *SmartRouter) stageModelFiles(ctx context.Context, node *BackendNode, opts *pb.ModelOptions, trackingKey string) (*pb.ModelOptions, error) {
370370
opts = proto.Clone(opts).(*pb.ModelOptions)
371-
xlog.Debug("Staging model files for remote node", "node", node.Name, "modelFile", opts.ModelFile, "trackingKey", trackingKey)
371+
xlog.Info("Staging model files for remote node", "node", node.Name, "modelFile", opts.ModelFile, "trackingKey", trackingKey)
372372

373373
// Derive the frontend models directory from ModelFile and Model.
374374
// Example: ModelFile="/models/sd-cpp/models/flux.gguf", Model="sd-cpp/models/flux.gguf"
@@ -419,6 +419,7 @@ func (r *SmartRouter) stageModelFiles(ctx context.Context, node *BackendNode, op
419419
if err != nil {
420420
// ModelFile is required — fail the whole operation
421421
if f.name == "ModelFile" {
422+
xlog.Error("Failed to stage model file for remote node", "node", node.Name, "field", f.name, "path", localPath, "error", err)
422423
return nil, fmt.Errorf("staging model file: %w", err)
423424
}
424425
// Optional files: clear the path so the backend doesn't try a non-existent frontend path

0 commit comments

Comments
 (0)