diff --git a/Dockerfile b/Dockerfile index ce81ecb..8450e79 100644 --- a/Dockerfile +++ b/Dockerfile @@ -92,6 +92,8 @@ WORKDIR /app # openssl/ca-certificates: TLS cert generation + HTTPS validation. # wget/tar/unzip/xz-utils: required by toolchain-manager for on-demand downloads. +# clang: fallback C compiler for darwin/CGO agent builds (no toolchain mapping in +# toolchain-manager.ts, so build-process.ts falls back to the default `cc`). RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ --mount=type=cache,target=/var/lib/apt,sharing=locked \ apt-get update && apt-get install -y --no-install-recommends \ @@ -102,6 +104,7 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ unzip \ xz-utils \ git \ + clang \ && rm -rf /var/lib/apt/lists/* # Reuse Go + garble from the builder so we don't re-download. diff --git a/Overlord-Client/cmd/agent/capture/capture.go b/Overlord-Client/cmd/agent/capture/capture.go index 970156a..5b6dfbe 100644 --- a/Overlord-Client/cmd/agent/capture/capture.go +++ b/Overlord-Client/cmd/agent/capture/capture.go @@ -304,9 +304,10 @@ func safeActiveDisplays() int { } func safeCaptureDisplay(display int) (*image.RGBA, error) { - defer func() { - _ = recover() - }() + // Log panics with a stack trace so the actual culprit is captured in the + // crash log instead of being silently swallowed (returns nil image, which + // the caller treats as a transient capture failure). + defer recoverAndLog("safeCaptureDisplay", nil) img, err := captureDisplayFn(display) if err != nil { return nil, err diff --git a/Overlord-Client/cmd/agent/capture/win_duplication.go b/Overlord-Client/cmd/agent/capture/win_duplication.go index c5b7b97..c319e7c 100644 --- a/Overlord-Client/cmd/agent/capture/win_duplication.go +++ b/Overlord-Client/cmd/agent/capture/win_duplication.go @@ -701,12 +701,18 @@ func (s *duplicationState) capture(display int) (*image.RGBA, error) { } if s.lastBase != nil { img := s.composeFrame(s.lastBase, int(s.desc.ModeDesc.Width), int(s.desc.ModeDesc.Height)) - s.lastFrame = img + // composeFrame may return s.lastBase or s.cursorScratch directly; + // both are reused across captures, so the caller's PutRGBA would + // hand them to the pool and a subsequent capture would race. + if img == s.lastBase || img == s.cursorScratch { + img = cloneRGBA(img) + } + s.lastFrame = cloneRGBA(img) s.lastFrameAt = time.Now() return img, nil } if s.lastFrame != nil { - return s.lastFrame, nil + return cloneRGBA(s.lastFrame), nil } if waitMs >= 50 { return nil, errors.New("dxgi: frame timeout") @@ -749,22 +755,25 @@ func (s *duplicationState) capture(display int) (*image.RGBA, error) { _ = s.dup.ReleaseFrame() return nil, fmt.Errorf("dxgi: map desktop surface failed 0x%x", hr) } + // Unmap and release the frame on every exit, including a panic in + // the conversion code below. Leaving the surface mapped wedges the + // duplication object until the device is fully closed. + dup := s.dup + defer func() { + _ = dup.UnMapDesktopSurface() + _ = dup.ReleaseFrame() + }() + if mapped.Pitch <= 0 || mapped.Bits == nil { - _ = s.dup.UnMapDesktopSurface() - _ = s.dup.ReleaseFrame() return nil, errors.New("dxgi: invalid mapped surface") } pitch := int(mapped.Pitch) if pitch < width*4 { - _ = s.dup.UnMapDesktopSurface() - _ = s.dup.ReleaseFrame() return nil, fmt.Errorf("dxgi: pitch %d too small for width %d", pitch, width) } totalBytes := pitch * height if totalBytes/height != pitch { - _ = s.dup.UnMapDesktopSurface() - _ = s.dup.ReleaseFrame() return nil, fmt.Errorf("dxgi: pitch*height overflow (%d * %d)", pitch, height) } src := unsafe.Slice(mapped.Bits, totalBytes) @@ -774,8 +783,6 @@ func (s *duplicationState) capture(display int) (*image.RGBA, error) { if img == nil { img = convertBGRA(src, pitch, width, height, s.desc.Rotation) } - _ = s.dup.UnMapDesktopSurface() - _ = s.dup.ReleaseFrame() if img == nil { return nil, errors.New("dxgi: pixel conversion failed (buffer too small)") } @@ -788,9 +795,17 @@ func (s *duplicationState) capture(display int) (*image.RGBA, error) { } } - s.lastBase = img + // Cache an independent clone so the timeout path (which serves the cached + // frame back to callers) doesn't alias a buffer the caller will return to + // the RGBA pool via PutRGBA. + s.lastBase = cloneRGBA(img) img = s.composeFrame(img, width, height) - s.lastFrame = img + if img == s.cursorScratch { + // cursorScratch is reused across captures; clone before returning so + // the next compose doesn't write into a buffer the caller has pooled. + img = cloneRGBA(img) + } + s.lastFrame = cloneRGBA(img) s.lastFrameAt = time.Now() return img, nil