Refactor runner and installation scripts for improved functionality

- Removed the `--disable-hiprt` flag from the runner command, simplifying the rendering options for users.
- Updated the `jiggablend-runner` script and README to reflect the removal of the HIPRT control flag, enhancing clarity in usage instructions.
- Enhanced the installation script to provide clearer examples for running the jiggablend manager and runner, improving user experience during setup.
- Implemented a more robust GPU backend detection mechanism, allowing for better compatibility with various hardware configurations.
This commit is contained in:
2026-03-14 21:08:06 -05:00
parent 28cb50492c
commit 16d6a95058
30 changed files with 1041 additions and 782 deletions

View File

@@ -241,8 +241,8 @@ func (m *ManagerClient) DownloadContext(contextPath, jobToken string) (io.ReadCl
}
if resp.StatusCode != http.StatusOK {
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
return nil, fmt.Errorf("context download failed with status %d: %s", resp.StatusCode, string(body))
}
@@ -435,8 +435,8 @@ func (m *ManagerClient) DownloadBlender(version string) (io.ReadCloser, error) {
}
if resp.StatusCode != http.StatusOK {
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
return nil, fmt.Errorf("failed to download blender: status %d, body: %s", resp.StatusCode, string(body))
}

View File

@@ -1,45 +1,116 @@
// Package blender: GPU backend detection for HIP vs NVIDIA.
// Package blender: host GPU backend detection for AMD/NVIDIA/Intel.
package blender
import (
"bufio"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"jiggablend/pkg/scripts"
)
// DetectGPUBackends runs a minimal Blender script to detect whether HIP (AMD) and/or
// NVIDIA (CUDA/OptiX) devices are available. Use this to decide whether to force CPU
// for Blender < 4.x (only force when HIP is present, since HIP has no official support pre-4).
func DetectGPUBackends(blenderBinary, scriptDir string) (hasHIP, hasNVIDIA bool, err error) {
scriptPath := filepath.Join(scriptDir, "detect_gpu_backends.py")
if err := os.WriteFile(scriptPath, []byte(scripts.DetectGPUBackends), 0644); err != nil {
return false, false, fmt.Errorf("write detection script: %w", err)
}
defer os.Remove(scriptPath)
// DetectGPUBackends detects whether AMD, NVIDIA, and/or Intel GPUs are available
// using host-level hardware probing only.
func DetectGPUBackends() (hasAMD, hasNVIDIA, hasIntel bool, ok bool) {
return detectGPUBackendsFromHost()
}
env := TarballEnv(blenderBinary, os.Environ())
cmd := exec.Command(blenderBinary, "-b", "--python", scriptPath)
cmd.Env = env
cmd.Dir = scriptDir
out, err := cmd.CombinedOutput()
func detectGPUBackendsFromHost() (hasAMD, hasNVIDIA, hasIntel bool, ok bool) {
if amd, nvidia, intel, found := detectGPUBackendsFromDRM(); found {
return amd, nvidia, intel, true
}
if amd, nvidia, intel, found := detectGPUBackendsFromLSPCI(); found {
return amd, nvidia, intel, true
}
return false, false, false, false
}
func detectGPUBackendsFromDRM() (hasAMD, hasNVIDIA, hasIntel bool, ok bool) {
entries, err := os.ReadDir("/sys/class/drm")
if err != nil {
return false, false, fmt.Errorf("run blender detection: %w (output: %s)", err, string(out))
return false, false, false, false
}
for _, entry := range entries {
name := entry.Name()
if !isDRMCardNode(name) {
continue
}
vendorPath := filepath.Join("/sys/class/drm", name, "device", "vendor")
vendorRaw, err := os.ReadFile(vendorPath)
if err != nil {
continue
}
vendor := strings.TrimSpace(strings.ToLower(string(vendorRaw)))
switch vendor {
case "0x1002":
hasAMD = true
ok = true
case "0x10de":
hasNVIDIA = true
ok = true
case "0x8086":
hasIntel = true
ok = true
}
}
return hasAMD, hasNVIDIA, hasIntel, ok
}
func isDRMCardNode(name string) bool {
if !strings.HasPrefix(name, "card") {
return false
}
if strings.Contains(name, "-") {
// Connector entries like card0-DP-1 are not GPU device nodes.
return false
}
if len(name) <= len("card") {
return false
}
_, err := strconv.Atoi(strings.TrimPrefix(name, "card"))
return err == nil
}
func detectGPUBackendsFromLSPCI() (hasAMD, hasNVIDIA, hasIntel bool, ok bool) {
if _, err := exec.LookPath("lspci"); err != nil {
return false, false, false, false
}
out, err := exec.Command("lspci").CombinedOutput()
if err != nil {
return false, false, false, false
}
scanner := bufio.NewScanner(strings.NewReader(string(out)))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
switch line {
case "HAS_HIP":
hasHIP = true
case "HAS_NVIDIA":
line := strings.ToLower(strings.TrimSpace(scanner.Text()))
if !isGPUControllerLine(line) {
continue
}
if strings.Contains(line, "nvidia") {
hasNVIDIA = true
ok = true
}
if strings.Contains(line, "amd") || strings.Contains(line, "ati") || strings.Contains(line, "radeon") {
hasAMD = true
ok = true
}
if strings.Contains(line, "intel") {
hasIntel = true
ok = true
}
}
return hasHIP, hasNVIDIA, scanner.Err()
return hasAMD, hasNVIDIA, hasIntel, ok
}
func isGPUControllerLine(line string) bool {
return strings.Contains(line, "vga compatible controller") ||
strings.Contains(line, "3d controller") ||
strings.Contains(line, "display controller")
}

View File

@@ -1,143 +1,19 @@
package blender
import (
"compress/gzip"
"fmt"
"io"
"os"
"os/exec"
"jiggablend/pkg/blendfile"
)
// ParseVersionFromFile parses the Blender version that a .blend file was saved with.
// Returns major and minor version numbers.
// Delegates to the shared pkg/blendfile implementation.
func ParseVersionFromFile(blendPath string) (major, minor int, err error) {
file, err := os.Open(blendPath)
if err != nil {
return 0, 0, fmt.Errorf("failed to open blend file: %w", err)
}
defer file.Close()
// Read the first 12 bytes of the blend file header
// Format: BLENDER-v<major><minor><patch> or BLENDER_v<major><minor><patch>
// The header is: "BLENDER" (7 bytes) + pointer size (1 byte: '-' for 64-bit, '_' for 32-bit)
// + endianness (1 byte: 'v' for little-endian, 'V' for big-endian)
// + version (3 bytes: e.g., "402" for 4.02)
header := make([]byte, 12)
n, err := file.Read(header)
if err != nil || n < 12 {
return 0, 0, fmt.Errorf("failed to read blend file header: %w", err)
}
// Check for BLENDER magic
if string(header[:7]) != "BLENDER" {
// Might be compressed - try to decompress
file.Seek(0, 0)
return parseCompressedVersion(file)
}
// Parse version from bytes 9-11 (3 digits)
versionStr := string(header[9:12])
// Version format changed in Blender 3.0
// Pre-3.0: "279" = 2.79, "280" = 2.80
// 3.0+: "300" = 3.0, "402" = 4.02, "410" = 4.10
if len(versionStr) == 3 {
// First digit is major version
fmt.Sscanf(string(versionStr[0]), "%d", &major)
// Next two digits are minor version
fmt.Sscanf(versionStr[1:3], "%d", &minor)
}
return major, minor, nil
}
// parseCompressedVersion handles gzip and zstd compressed blend files.
func parseCompressedVersion(file *os.File) (major, minor int, err error) {
magic := make([]byte, 4)
if _, err := file.Read(magic); err != nil {
return 0, 0, err
}
file.Seek(0, 0)
if magic[0] == 0x1f && magic[1] == 0x8b {
// gzip compressed
gzReader, err := gzip.NewReader(file)
if err != nil {
return 0, 0, fmt.Errorf("failed to create gzip reader: %w", err)
}
defer gzReader.Close()
header := make([]byte, 12)
n, err := gzReader.Read(header)
if err != nil || n < 12 {
return 0, 0, fmt.Errorf("failed to read compressed blend header: %w", err)
}
if string(header[:7]) != "BLENDER" {
return 0, 0, fmt.Errorf("invalid blend file format")
}
versionStr := string(header[9:12])
if len(versionStr) == 3 {
fmt.Sscanf(string(versionStr[0]), "%d", &major)
fmt.Sscanf(versionStr[1:3], "%d", &minor)
}
return major, minor, nil
}
// Check for zstd magic (Blender 3.0+): 0x28 0xB5 0x2F 0xFD
if magic[0] == 0x28 && magic[1] == 0xb5 && magic[2] == 0x2f && magic[3] == 0xfd {
return parseZstdVersion(file)
}
return 0, 0, fmt.Errorf("unknown blend file format")
}
// parseZstdVersion handles zstd-compressed blend files (Blender 3.0+).
// Uses zstd command line tool since Go doesn't have native zstd support.
func parseZstdVersion(file *os.File) (major, minor int, err error) {
file.Seek(0, 0)
cmd := exec.Command("zstd", "-d", "-c")
cmd.Stdin = file
stdout, err := cmd.StdoutPipe()
if err != nil {
return 0, 0, fmt.Errorf("failed to create zstd stdout pipe: %w", err)
}
if err := cmd.Start(); err != nil {
return 0, 0, fmt.Errorf("failed to start zstd decompression: %w", err)
}
// Read just the header (12 bytes)
header := make([]byte, 12)
n, readErr := io.ReadFull(stdout, header)
// Kill the process early - we only need the header
cmd.Process.Kill()
cmd.Wait()
if readErr != nil || n < 12 {
return 0, 0, fmt.Errorf("failed to read zstd compressed blend header: %v", readErr)
}
if string(header[:7]) != "BLENDER" {
return 0, 0, fmt.Errorf("invalid blend file format in zstd archive")
}
versionStr := string(header[9:12])
if len(versionStr) == 3 {
fmt.Sscanf(string(versionStr[0]), "%d", &major)
fmt.Sscanf(versionStr[1:3], "%d", &minor)
}
return major, minor, nil
return blendfile.ParseVersionFromFile(blendPath)
}
// VersionString returns a formatted version string like "4.2".
func VersionString(major, minor int) string {
return fmt.Sprintf("%d.%d", major, minor)
}

View File

@@ -46,23 +46,22 @@ type Runner struct {
gpuLockedOut bool
gpuLockedOutMu sync.RWMutex
// hasHIP/hasNVIDIA are set at startup by running latest Blender to detect GPU backends.
// Used to force CPU only for Blender < 4.x when HIP is present (no official HIP support pre-4).
// gpuDetectionFailed is true when detection could not run; we then force CPU for all versions (we could not determine HIP vs NVIDIA).
// hasAMD/hasNVIDIA/hasIntel are set at startup by hardware/Blender GPU backend detection.
// Used to force CPU only for Blender < 4.x when AMD is present (no official HIP support pre-4).
// gpuDetectionFailed is true when detection could not run; we then force CPU for all versions.
gpuBackendMu sync.RWMutex
hasHIP bool
hasAMD bool
hasNVIDIA bool
hasIntel bool
gpuBackendProbed bool
gpuDetectionFailed bool
// forceCPURendering forces CPU rendering for all jobs regardless of metadata/backend detection.
forceCPURendering bool
// disableHIPRT disables HIPRT acceleration when configuring Cycles HIP devices.
disableHIPRT bool
}
// New creates a new runner.
func New(managerURL, name, hostname string, forceCPURendering, disableHIPRT bool) *Runner {
func New(managerURL, name, hostname string, forceCPURendering bool) *Runner {
manager := api.NewManagerClient(managerURL)
r := &Runner{
@@ -74,7 +73,6 @@ func New(managerURL, name, hostname string, forceCPURendering, disableHIPRT bool
processors: make(map[string]tasks.Processor),
forceCPURendering: forceCPURendering,
disableHIPRT: disableHIPRT,
}
// Generate fingerprint
@@ -93,25 +91,25 @@ func (r *Runner) CheckRequiredTools() error {
return nil
}
var cachedCapabilities map[string]interface{} = nil
var (
cachedCapabilities map[string]interface{}
capabilitiesOnce sync.Once
)
// ProbeCapabilities detects hardware capabilities.
func (r *Runner) ProbeCapabilities() map[string]interface{} {
if cachedCapabilities != nil {
return cachedCapabilities
}
capabilitiesOnce.Do(func() {
caps := make(map[string]interface{})
caps := make(map[string]interface{})
if err := exec.Command("ffmpeg", "-version").Run(); err == nil {
caps["ffmpeg"] = true
} else {
caps["ffmpeg"] = false
}
// Check for ffmpeg and probe encoding capabilities
if err := exec.Command("ffmpeg", "-version").Run(); err == nil {
caps["ffmpeg"] = true
} else {
caps["ffmpeg"] = false
}
cachedCapabilities = caps
return caps
cachedCapabilities = caps
})
return cachedCapabilities
}
// Register registers the runner with the manager.
@@ -141,52 +139,66 @@ func (r *Runner) Register(apiKey string) (int64, error) {
return id, nil
}
// DetectAndStoreGPUBackends downloads the latest Blender from the manager (if needed),
// runs a detection script to see if HIP (AMD) and/or NVIDIA devices are available,
// and stores the result. Call after Register. Used so we only force CPU for Blender < 4.x
// when the runner has HIP (no official HIP support pre-4); NVIDIA is allowed.
// DetectAndStoreGPUBackends runs host-level backend detection and stores AMD/NVIDIA/Intel results.
// Call after Register. Used so we only force CPU for Blender < 4.x when AMD is present.
func (r *Runner) DetectAndStoreGPUBackends() {
r.gpuBackendMu.Lock()
defer r.gpuBackendMu.Unlock()
if r.gpuBackendProbed {
return
}
latestVer, err := r.manager.GetLatestBlenderVersion()
if err != nil {
log.Printf("GPU backend detection failed (could not get latest Blender version: %v). All jobs will use CPU because we could not determine HIP vs NVIDIA.", err)
hasAMD, hasNVIDIA, hasIntel, ok := blender.DetectGPUBackends()
if !ok {
log.Printf("GPU backend detection failed (host probe unavailable). All jobs will use CPU because backend availability is unknown.")
r.gpuBackendProbed = true
r.gpuDetectionFailed = true
return
}
binaryPath, err := r.blender.GetBinaryPath(latestVer)
if err != nil {
log.Printf("GPU backend detection failed (could not get Blender binary: %v). All jobs will use CPU because we could not determine HIP vs NVIDIA.", err)
r.gpuBackendProbed = true
r.gpuDetectionFailed = true
return
detectedTypes := 0
if hasAMD {
detectedTypes++
}
hasHIP, hasNVIDIA, err := blender.DetectGPUBackends(binaryPath, r.workspace.BaseDir())
if err != nil {
log.Printf("GPU backend detection failed (script error: %v). All jobs will use CPU because we could not determine HIP vs NVIDIA.", err)
r.gpuBackendProbed = true
r.gpuDetectionFailed = true
return
if hasNVIDIA {
detectedTypes++
}
r.hasHIP = hasHIP
if hasIntel {
detectedTypes++
}
if detectedTypes > 1 {
log.Printf("mixed GPU vendors detected (AMD=%v NVIDIA=%v INTEL=%v): multi-vendor setups may not work reliably, but runner will continue with GPU enabled", hasAMD, hasNVIDIA, hasIntel)
}
r.hasAMD = hasAMD
r.hasNVIDIA = hasNVIDIA
r.hasIntel = hasIntel
r.gpuBackendProbed = true
r.gpuDetectionFailed = false
log.Printf("GPU backend detection: HIP=%v NVIDIA=%v (Blender < 4.x will force CPU only when HIP is present)", hasHIP, hasNVIDIA)
log.Printf("GPU backend detection: AMD=%v NVIDIA=%v INTEL=%v (Blender < 4.x will force CPU only when AMD is present)", hasAMD, hasNVIDIA, hasIntel)
}
// HasHIP returns whether the runner detected HIP (AMD) devices. Used to force CPU for Blender < 4.x only when HIP is present.
func (r *Runner) HasHIP() bool {
// HasAMD returns whether the runner detected AMD devices. Used to force CPU for Blender < 4.x only when AMD is present.
func (r *Runner) HasAMD() bool {
r.gpuBackendMu.RLock()
defer r.gpuBackendMu.RUnlock()
return r.hasHIP
return r.hasAMD
}
// GPUDetectionFailed returns true when startup GPU backend detection could not run or failed. When true, all jobs use CPU because we could not determine HIP vs NVIDIA.
// HasNVIDIA returns whether the runner detected NVIDIA GPUs.
func (r *Runner) HasNVIDIA() bool {
r.gpuBackendMu.RLock()
defer r.gpuBackendMu.RUnlock()
return r.hasNVIDIA
}
// HasIntel returns whether the runner detected Intel GPUs (e.g. Arc).
func (r *Runner) HasIntel() bool {
r.gpuBackendMu.RLock()
defer r.gpuBackendMu.RUnlock()
return r.hasIntel
}
// GPUDetectionFailed returns true when startup GPU backend detection could not run or failed. When true, all jobs use CPU because backend availability is unknown.
func (r *Runner) GPUDetectionFailed() bool {
r.gpuBackendMu.RLock()
defer r.gpuBackendMu.RUnlock()
@@ -313,10 +325,11 @@ func (r *Runner) executeJob(job *api.NextJobResponse) (err error) {
r.encoder,
r.processes,
r.IsGPULockedOut(),
r.HasHIP(),
r.HasAMD(),
r.HasNVIDIA(),
r.HasIntel(),
r.GPUDetectionFailed(),
r.forceCPURendering,
r.disableHIPRT,
func() { r.SetGPULockedOut(true) },
)

View File

@@ -298,6 +298,9 @@ func (p *EncodeProcessor) Process(ctx *Context) error {
ctx.Info(line)
}
}
if err := scanner.Err(); err != nil {
log.Printf("Error reading encode stdout: %v", err)
}
}()
// Stream stderr
@@ -311,6 +314,9 @@ func (p *EncodeProcessor) Process(ctx *Context) error {
ctx.Warn(line)
}
}
if err := scanner.Err(); err != nil {
log.Printf("Error reading encode stderr: %v", err)
}
}()
err = cmd.Wait()

View File

@@ -11,8 +11,6 @@ import (
"jiggablend/pkg/executils"
"jiggablend/pkg/types"
"os/exec"
"strconv"
"strings"
"sync"
"time"
)
@@ -43,23 +41,25 @@ type Context struct {
// GPULockedOut is set when the runner has detected a GPU error (e.g. HIP) and disables GPU for all jobs.
GPULockedOut bool
// HasHIP is true when the runner detected HIP (AMD) devices at startup. Used to force CPU for Blender < 4.x only when HIP is present.
HasHIP bool
// GPUDetectionFailed is true when startup GPU backend detection could not run; we force CPU for all versions (could not determine HIP vs NVIDIA).
// HasAMD is true when the runner detected AMD devices at startup.
HasAMD bool
// HasNVIDIA is true when the runner detected NVIDIA GPUs at startup.
HasNVIDIA bool
// HasIntel is true when the runner detected Intel GPUs (e.g. Arc) at startup.
HasIntel bool
// GPUDetectionFailed is true when startup GPU backend detection could not run; we force CPU for all versions (backend availability unknown).
GPUDetectionFailed bool
// OnGPUError is called when a GPU error line is seen in render logs; typically sets runner GPU lockout.
OnGPUError func()
// ForceCPURendering is a runner-level override that forces CPU rendering for all jobs.
ForceCPURendering bool
// DisableHIPRT is a runner-level override that disables HIPRT acceleration in Blender.
DisableHIPRT bool
}
// ErrJobCancelled indicates the manager-side job was cancelled during execution.
var ErrJobCancelled = errors.New("job cancelled")
// NewContext creates a new task context. frameEnd should be >= frame; if 0 or less than frame, it is treated as single-frame (frameEnd = frame).
// gpuLockedOut is the runner's current GPU lockout state; hasHIP means the runner has HIP (AMD) devices (force CPU for Blender < 4.x only when true); gpuDetectionFailed means detection failed at startup (force CPU for all versions—could not determine HIP vs NVIDIA); onGPUError is called when a GPU error is detected in logs (may be nil).
// gpuLockedOut is the runner's current GPU lockout state; gpuDetectionFailed means detection failed at startup (force CPU for all versions); onGPUError is called when a GPU error is detected in logs (may be nil).
func NewContext(
taskID, jobID int64,
jobName string,
@@ -75,10 +75,11 @@ func NewContext(
encoder *encoding.Selector,
processes *executils.ProcessTracker,
gpuLockedOut bool,
hasHIP bool,
hasAMD bool,
hasNVIDIA bool,
hasIntel bool,
gpuDetectionFailed bool,
forceCPURendering bool,
disableHIPRT bool,
onGPUError func(),
) *Context {
if frameEnd < frameStart {
@@ -101,10 +102,11 @@ func NewContext(
Encoder: encoder,
Processes: processes,
GPULockedOut: gpuLockedOut,
HasHIP: hasHIP,
HasAMD: hasAMD,
HasNVIDIA: hasNVIDIA,
HasIntel: hasIntel,
GPUDetectionFailed: gpuDetectionFailed,
ForceCPURendering: forceCPURendering,
DisableHIPRT: disableHIPRT,
OnGPUError: onGPUError,
}
}
@@ -187,8 +189,7 @@ func (c *Context) ShouldEnableExecution() bool {
}
// ShouldForceCPU returns true if GPU should be disabled and CPU rendering forced
// (runner GPU lockout, GPU detection failed at startup for any version, metadata force_cpu,
// or Blender < 4.x when the runner has HIP).
// (runner GPU lockout, GPU detection failed at startup, or metadata force_cpu).
func (c *Context) ShouldForceCPU() bool {
if c.ForceCPURendering {
return true
@@ -196,17 +197,10 @@ func (c *Context) ShouldForceCPU() bool {
if c.GPULockedOut {
return true
}
// Detection failed at startup: we could not determine HIP vs NVIDIA, so force CPU for all versions.
// Detection failed at startup: backend availability unknown, so force CPU for all versions.
if c.GPUDetectionFailed {
return true
}
v := c.GetBlenderVersion()
major := parseBlenderMajor(v)
isPre4 := v != "" && major >= 0 && major < 4
// Blender < 4.x: force CPU when runner has HIP (no official HIP support).
if isPre4 && c.HasHIP {
return true
}
if c.Metadata != nil && c.Metadata.RenderSettings.EngineSettings != nil {
if v, ok := c.Metadata.RenderSettings.EngineSettings["force_cpu"]; ok {
if b, ok := v.(bool); ok && b {
@@ -217,21 +211,6 @@ func (c *Context) ShouldForceCPU() bool {
return false
}
// parseBlenderMajor returns the major version number from a string like "4.2.3" or "3.6".
// Returns -1 if the version cannot be parsed.
func parseBlenderMajor(version string) int {
version = strings.TrimSpace(version)
if version == "" {
return -1
}
parts := strings.SplitN(version, ".", 2)
major, err := strconv.Atoi(parts[0])
if err != nil {
return -1
}
return major
}
// IsJobCancelled checks whether the manager marked this job as cancelled.
func (c *Context) IsJobCancelled() (bool, error) {
if c.Manager == nil {

View File

@@ -104,15 +104,10 @@ func (p *RenderProcessor) Process(ctx *Context) error {
renderFormat := "EXR"
if ctx.ShouldForceCPU() {
v := ctx.GetBlenderVersion()
major := parseBlenderMajor(v)
isPre4 := v != "" && major >= 0 && major < 4
if ctx.ForceCPURendering {
ctx.Info("Runner compatibility flag is enabled: forcing CPU rendering for this job")
} else if ctx.GPUDetectionFailed {
ctx.Info("GPU backend detection failed at startup—we could not determine whether this machine has HIP (AMD) or NVIDIA GPUs, so rendering will use CPU to avoid compatibility issues")
} else if isPre4 && ctx.HasHIP {
ctx.Info("Blender < 4.x has no official HIP support: using CPU rendering only")
ctx.Info("GPU backend detection failed at startup—we could not determine available GPU backends, so rendering will use CPU to avoid compatibility issues")
} else {
ctx.Info("GPU lockout active: using CPU rendering only")
}
@@ -195,7 +190,6 @@ func (p *RenderProcessor) createRenderScript(ctx *Context, renderFormat string)
settingsMap = make(map[string]interface{})
}
settingsMap["force_cpu"] = ctx.ShouldForceCPU()
settingsMap["disable_hiprt"] = ctx.DisableHIPRT
settingsJSON, err := json.Marshal(settingsMap)
if err == nil {
if err := os.WriteFile(renderSettingsFilePath, settingsJSON, 0644); err != nil {
@@ -277,6 +271,9 @@ func (p *RenderProcessor) runBlender(ctx *Context, blenderBinary, blendFile, out
}
}
}
if err := scanner.Err(); err != nil {
log.Printf("Error reading stdout: %v", err)
}
}()
// Stream stderr and watch for GPU error lines
@@ -297,6 +294,9 @@ func (p *RenderProcessor) runBlender(ctx *Context, blenderBinary, blendFile, out
}
}
}
if err := scanner.Err(); err != nil {
log.Printf("Error reading stderr: %v", err)
}
}()
// Wait for completion

View File

@@ -99,6 +99,11 @@ func ExtractTarStripPrefix(reader io.Reader, destDir string) error {
targetPath := filepath.Join(destDir, name)
// Sanitize path to prevent directory traversal
if !strings.HasPrefix(filepath.Clean(targetPath), filepath.Clean(destDir)+string(os.PathSeparator)) {
return fmt.Errorf("invalid file path in tar: %s", header.Name)
}
switch header.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(targetPath, os.FileMode(header.Mode)); err != nil {

View File

@@ -0,0 +1,101 @@
package workspace
import (
"archive/tar"
"bytes"
"os"
"path/filepath"
"testing"
)
func createTarBuffer(files map[string]string) *bytes.Buffer {
var buf bytes.Buffer
tw := tar.NewWriter(&buf)
for name, content := range files {
hdr := &tar.Header{
Name: name,
Mode: 0644,
Size: int64(len(content)),
}
tw.WriteHeader(hdr)
tw.Write([]byte(content))
}
tw.Close()
return &buf
}
func TestExtractTar(t *testing.T) {
destDir := t.TempDir()
buf := createTarBuffer(map[string]string{
"hello.txt": "world",
"sub/a.txt": "nested",
})
if err := ExtractTar(buf, destDir); err != nil {
t.Fatalf("ExtractTar: %v", err)
}
data, err := os.ReadFile(filepath.Join(destDir, "hello.txt"))
if err != nil {
t.Fatalf("read hello.txt: %v", err)
}
if string(data) != "world" {
t.Errorf("hello.txt = %q, want %q", data, "world")
}
data, err = os.ReadFile(filepath.Join(destDir, "sub", "a.txt"))
if err != nil {
t.Fatalf("read sub/a.txt: %v", err)
}
if string(data) != "nested" {
t.Errorf("sub/a.txt = %q, want %q", data, "nested")
}
}
func TestExtractTarStripPrefix(t *testing.T) {
destDir := t.TempDir()
buf := createTarBuffer(map[string]string{
"toplevel/": "",
"toplevel/foo.txt": "bar",
})
if err := ExtractTarStripPrefix(buf, destDir); err != nil {
t.Fatalf("ExtractTarStripPrefix: %v", err)
}
data, err := os.ReadFile(filepath.Join(destDir, "foo.txt"))
if err != nil {
t.Fatalf("read foo.txt: %v", err)
}
if string(data) != "bar" {
t.Errorf("foo.txt = %q, want %q", data, "bar")
}
}
func TestExtractTarStripPrefix_PathTraversal(t *testing.T) {
destDir := t.TempDir()
buf := createTarBuffer(map[string]string{
"prefix/../../../etc/passwd": "pwned",
})
err := ExtractTarStripPrefix(buf, destDir)
if err == nil {
t.Fatal("expected error for path traversal, got nil")
}
}
func TestExtractTar_PathTraversal(t *testing.T) {
destDir := t.TempDir()
buf := createTarBuffer(map[string]string{
"../../../etc/passwd": "pwned",
})
err := ExtractTar(buf, destDir)
if err == nil {
t.Fatal("expected error for path traversal, got nil")
}
}