Skip to content

Commit ee9fe64

Browse files
authored
Merge pull request #41 from GyeongHoKim/fix/rpi
fix/rpi
2 parents 8e90a5f + ce21fb7 commit ee9fe64

6 files changed

Lines changed: 189 additions & 16 deletions

File tree

.goreleaser.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ builds:
4141
- "7"
4242
hooks:
4343
pre:
44-
- cmd: ./scripts/fetch-mtxrpicam.sh {{ if eq .Arch "arm" }}32{{ else }}64{{ end }} v2.4.3 internal/rpicamera/mtxrpicam_{{ if eq .Arch "arm" }}32{{ else }}64{{ end }}
44+
- cmd: ./scripts/fetch-mtxrpicam.sh {{ if eq .Arch "arm" }}32{{ else }}64{{ end }} v2.5.6 internal/rpicamera/mtxrpicam_{{ if eq .Arch "arm" }}32{{ else }}64{{ end }}
4545
ldflags:
4646
- -s -w
4747
- -X github.com/GyeongHoKim/onvif-simulator/internal/version.Version={{.Version}}

Makefile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,11 @@ FRONTEND_DIST := internal/gui/frontend/dist
2020

2121
# Pinned mediamtx-rpicamera release tag. mediamtx-rpicamera lives in its own
2222
# repo (bluenviron/mediamtx-rpicamera) with versioning independent from
23-
# mediamtx itself; mediamtx v1.13.1 internally pins this same v2.4.3 (see
23+
# mediamtx itself; mediamtx v1.18.1 internally pins this same v2.5.6 (see
2424
# its internal/staticsources/rpicamera/mtxrpicamdownloader/VERSION). Bump
2525
# together with scripts/mtxrpicam.sha256 so the Pi build channel ships a
2626
# reviewable, reproducible binary blob.
27-
MTXRPICAM_VERSION ?= v2.4.3
27+
MTXRPICAM_VERSION ?= v2.5.6
2828
RPICAM_DIR_32 := internal/rpicamera/mtxrpicam_32
2929
RPICAM_DIR_64 := internal/rpicamera/mtxrpicam_64
3030

internal/rpicamera/camera_rpicam.go

Lines changed: 87 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
package rpicamera
44

55
import (
6+
"bufio"
67
"debug/elf"
8+
"errors"
79
"fmt"
810
"io"
911
"log/slog"
@@ -23,6 +25,17 @@ import (
2325
"github.com/GyeongHoKim/onvif-simulator/internal/obs"
2426
)
2527

28+
// stderrTailLines bounds the post-mortem snapshot we keep of helper stderr.
29+
// libcamera prints a few dozen lines of init context plus any error detail;
30+
// 64 lines is enough to capture the tail that explains a STREAMON / IPA /
31+
// pipeline failure without unbounded memory if the helper goes haywire.
32+
const stderrTailLines = 64
33+
34+
// errTerminated is the sentinel runInner returns on a graceful Close(). It
35+
// lets run() suppress the post-mortem stderr WARN on normal shutdown without
36+
// muddling string comparison.
37+
var errTerminated = errors.New("rpicamera: terminated")
38+
2639
const (
2740
libraryToCheckArchitecture = "libc.so.6"
2841
dumpPrefix = "/dev/shm/onvif-simulator-rpicamera-"
@@ -51,10 +64,13 @@ type Camera struct {
5164
logger *slog.Logger
5265
onData OnDataFunc
5366

54-
cmd *exec.Cmd
55-
pipeOut *pipe
56-
pipeIn *pipe
57-
finalErr error
67+
cmd *exec.Cmd
68+
pipeOut *pipe
69+
pipeIn *pipe
70+
stderrPipe io.ReadCloser
71+
stderrDone chan struct{}
72+
tail *stderrTail
73+
finalErr error
5874

5975
terminate chan struct{}
6076
done chan struct{}
@@ -72,7 +88,7 @@ func Open(p Params, logger *slog.Logger, onData OnDataFunc) (*Camera, error) {
7288
logger = obs.Discard()
7389
}
7490

75-
c := &Camera{logger: logger, onData: onData}
91+
c := &Camera{logger: logger, onData: onData, tail: newStderrTail(stderrTailLines)}
7692

7793
if err := dumpComponent(); err != nil {
7894
return nil, err
@@ -99,11 +115,20 @@ func Open(p Params, logger *slog.Logger, onData OnDataFunc) (*Camera, error) {
99115
}
100116

101117
c.cmd = exec.Command(filepath.Join(dumpPath, executableName)) //nolint:gosec
102-
// Discard helper output so it does not leak into the simulator's stdout
103-
// (reserved for user-facing CLI output) or stderr. Helper-side errors
104-
// reach us via the 'e' control byte on the read pipe.
118+
// Discard helper stdout so it never leaks into the simulator's user-facing
119+
// stdout. Stderr is captured into a ring buffer (and forwarded to the
120+
// simulator logger at DEBUG); on terminal failure we flush the tail at
121+
// WARN so the operator can see libcamera/V4L2 detail behind the 'e'
122+
// control byte (e.g. why VIDIOC_STREAMON failed).
105123
c.cmd.Stdout = io.Discard
106-
c.cmd.Stderr = io.Discard
124+
stderrPipe, err := c.cmd.StderrPipe()
125+
if err != nil {
126+
c.pipeOut.close()
127+
c.pipeIn.close()
128+
freeComponent()
129+
return nil, fmt.Errorf("rpicamera: stderr pipe: %w", err)
130+
}
131+
c.stderrPipe = stderrPipe
107132
c.cmd.Env = env
108133
c.cmd.Dir = dumpPath
109134
// Detach the subprocess from the parent's process group so SIGINT/SIGTERM
@@ -112,12 +137,16 @@ func Open(p Params, logger *slog.Logger, onData OnDataFunc) (*Camera, error) {
112137
c.cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
113138

114139
if err := c.cmd.Start(); err != nil {
140+
_ = c.stderrPipe.Close()
115141
c.pipeOut.close()
116142
c.pipeIn.close()
117143
freeComponent()
118144
return nil, err
119145
}
120146

147+
c.stderrDone = make(chan struct{})
148+
go c.drainStderr()
149+
121150
c.terminate = make(chan struct{})
122151
c.done = make(chan struct{})
123152

@@ -159,6 +188,9 @@ func (c *Camera) ReloadParams(p Params) error {
159188
func (c *Camera) run() {
160189
defer close(c.done)
161190
c.finalErr = c.runInner()
191+
if c.finalErr != nil && !errors.Is(c.finalErr, errTerminated) {
192+
c.flushStderrTail(c.finalErr)
193+
}
162194
}
163195

164196
func (c *Camera) runInner() error {
@@ -178,13 +210,15 @@ func (c *Camera) runInner() error {
178210
c.pipeIn.close()
179211
c.pipeOut.close()
180212
<-readDone
213+
<-c.stderrDone
181214
return err
182215

183216
case err := <-readDone:
184217
_ = c.pipeOut.write([]byte{'e'})
185218
<-cmdDone
186219
c.pipeIn.close()
187220
c.pipeOut.close()
221+
<-c.stderrDone
188222
return err
189223

190224
case <-c.terminate:
@@ -193,11 +227,54 @@ func (c *Camera) runInner() error {
193227
c.pipeOut.close()
194228
c.pipeIn.close()
195229
<-readDone
196-
return fmt.Errorf("rpicamera: terminated")
230+
<-c.stderrDone
231+
return errTerminated
197232
}
198233
}
199234
}
200235

236+
// drainStderr reads helper stderr line-by-line, mirrors each into the
237+
// simulator log at DEBUG, and keeps the most recent lines in tail so a
238+
// terminal error can attach the relevant libcamera/V4L2 detail at WARN.
239+
//
240+
// A scanner failure (bufio.ErrTooLong on a >256 KiB line, or a read error
241+
// from the pipe) is itself diagnostic context — record it into the tail
242+
// and log it at WARN so the post-mortem flush carries the reason the
243+
// stderr stream stopped early.
244+
func (c *Camera) drainStderr() {
245+
defer close(c.stderrDone)
246+
scanner := bufio.NewScanner(c.stderrPipe)
247+
// libcamera lines occasionally exceed the default 64 KiB token cap on
248+
// verbose IPA banners. 256 KiB is overkill but cheap.
249+
scanner.Buffer(make([]byte, 64*1024), 256*1024)
250+
for scanner.Scan() {
251+
line := scanner.Text()
252+
c.tail.push(line)
253+
c.logger.Debug("rpicamera: helper stderr", "line", line)
254+
}
255+
if err := scanner.Err(); err != nil {
256+
c.tail.push(fmt.Sprintf("<stderr scanner error: %s>", err))
257+
c.logger.Warn("rpicamera: helper stderr scanner stopped", "err", err)
258+
}
259+
}
260+
261+
// flushStderrTail emits the captured stderr ring at WARN when the camera is
262+
// torn down with a non-nil error. Operator-visible: this is the load-bearing
263+
// diagnostic for "encoder_create()", "pipeline_handler" and similar helper
264+
// failures whose root cause libcamera prints to stderr but mtxrpicam relays
265+
// only as a short string over the 'e' control byte.
266+
func (c *Camera) flushStderrTail(cause error) {
267+
lines := c.tail.snapshot()
268+
if len(lines) == 0 {
269+
return
270+
}
271+
c.logger.Warn(
272+
"rpicamera: helper stderr tail",
273+
"err", cause.Error(),
274+
"lines", lines,
275+
)
276+
}
277+
201278
func (c *Camera) runReader() error {
202279
// Wait for the helper's "ready" sentinel before forwarding access units.
203280
for {

internal/rpicamera/stderrtail.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package rpicamera
2+
3+
import "sync"
4+
5+
// stderrTail keeps the most recent N lines emitted by the mtxrpicam helper on
6+
// its stderr stream. It is a ring buffer so a noisy helper cannot grow the
7+
// simulator's memory footprint, and Snapshot returns lines in arrival order so
8+
// a post-mortem WARN reads top-down like a tail of the helper log.
9+
type stderrTail struct {
10+
mu sync.Mutex
11+
cap int
12+
buf []string
13+
start int
14+
size int
15+
}
16+
17+
func newStderrTail(capacity int) *stderrTail {
18+
if capacity <= 0 {
19+
capacity = 1
20+
}
21+
return &stderrTail{cap: capacity, buf: make([]string, capacity)}
22+
}
23+
24+
func (t *stderrTail) push(line string) {
25+
t.mu.Lock()
26+
defer t.mu.Unlock()
27+
if t.size < t.cap {
28+
t.buf[(t.start+t.size)%t.cap] = line
29+
t.size++
30+
return
31+
}
32+
t.buf[t.start] = line
33+
t.start = (t.start + 1) % t.cap
34+
}
35+
36+
func (t *stderrTail) snapshot() []string {
37+
t.mu.Lock()
38+
defer t.mu.Unlock()
39+
out := make([]string, t.size)
40+
for i := range t.size {
41+
out[i] = t.buf[(t.start+i)%t.cap]
42+
}
43+
return out
44+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package rpicamera
2+
3+
import (
4+
"reflect"
5+
"testing"
6+
)
7+
8+
func TestStderrTailBelowCapacity(t *testing.T) {
9+
t.Parallel()
10+
tail := newStderrTail(4)
11+
tail.push("a")
12+
tail.push("b")
13+
if got, want := tail.snapshot(), []string{"a", "b"}; !reflect.DeepEqual(got, want) {
14+
t.Fatalf("snapshot=%v want=%v", got, want)
15+
}
16+
}
17+
18+
func TestStderrTailWrapsAroundCapacity(t *testing.T) {
19+
t.Parallel()
20+
tail := newStderrTail(3)
21+
for _, line := range []string{"a", "b", "c", "d", "e"} {
22+
tail.push(line)
23+
}
24+
if got, want := tail.snapshot(), []string{"c", "d", "e"}; !reflect.DeepEqual(got, want) {
25+
t.Fatalf("snapshot=%v want=%v", got, want)
26+
}
27+
}
28+
29+
func TestStderrTailZeroCapacityPromotesToOne(t *testing.T) {
30+
t.Parallel()
31+
tail := newStderrTail(0)
32+
tail.push("x")
33+
tail.push("y")
34+
if got, want := tail.snapshot(), []string{"y"}; !reflect.DeepEqual(got, want) {
35+
t.Fatalf("snapshot=%v want=%v", got, want)
36+
}
37+
}
38+
39+
func TestStderrTailSnapshotIsCopy(t *testing.T) {
40+
t.Parallel()
41+
tail := newStderrTail(2)
42+
tail.push("a")
43+
snap := tail.snapshot()
44+
tail.push("b")
45+
tail.push("c")
46+
if got, want := snap, []string{"a"}; !reflect.DeepEqual(got, want) {
47+
t.Fatalf("snapshot mutated under writer: %v want %v", got, want)
48+
}
49+
}

scripts/mtxrpicam.sha256

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@
1111
# so the two independent sources agree before recording them here
1212
# 3. commit Makefile + .goreleaser.yml + this file together
1313
#
14-
# Pinned to mediamtx-rpicamera v2.4.3 (matches mediamtx v1.13.1).
15-
db9310fe114fa09d8427e93331fe6aec9ff3ba4aa486c7e7edbcbab9eae043cf mtxrpicam_32.tar.gz
16-
f6073cad9fd6c8570141f08aed72237ec062d28883b8ab62a37dbce190409f0d mtxrpicam_64.tar.gz
14+
# Pinned to mediamtx-rpicamera v2.5.6 (matches mediamtx v1.18.1). v2.5.x adds
15+
# libcamera 0.7 support (#75/#77/#87) which v2.4.3 lacked, so older pins
16+
# crash on Pi OS images shipping libcamera v0.7.0+rpt2026* (encoder STREAMON
17+
# fails with "unable to activate output stream"); keep this in lockstep.
18+
306380abdba970296055e2cbca32ea968778157605d537892ae469bc639fafe9 mtxrpicam_32.tar.gz
19+
d12475fd4cb9b3f6c959a54e26f4d06b9752ab9c488e839b41fb1a895ae1f325 mtxrpicam_64.tar.gz

0 commit comments

Comments
 (0)