Skip to content

Rotate hevc recordings, and stop forcing a keyframe to do it - #125

Merged
snokvist merged 4 commits into
OpenIPC:masterfrom
snokvist:feature/hevc-recorder-rotation
Sep 6, 2026
Merged

Rotate hevc recordings, and stop forcing a keyframe to do it#125
snokvist merged 4 commits into
OpenIPC:masterfrom
snokvist:feature/hevc-recorder-rotation

Conversation

@snokvist

@snokvist snokvist commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #124. Until that merges this PR shows both commits. Upstream
merges are squashes, so once #124 lands this branch must be rebased onto
the new master
before merging. Only the second commit belongs to this PR.

Summary

Closes #123. Two defects, and the second is the more interesting one.

record.format="hevc" ignored maxSeconds and maxMB completely. The raw
recorder had no rotation code at all — no segment counter, no threshold check,
no second open(). A raw recording was one file that grew until the card
filled, silently: the config validated and /api/v1/get echoed it back. It is
also why the "lower maxMB" workaround in #118 did not generalise — on that
path there was no threshold to lower.

$ git show master:src/star6e_recorder.c | grep -cE 'max_bytes|max_seconds|segments|rotat'
0

Rotation used to force a keyframe, and no longer does. Since 0.70.0 the TS
recorder asked the encoder for an IDR when a threshold was crossed and none was
coming. It works, but an IDR is a large frame and one per segment raises the
bitrate the link has to carry. On an intra-refresh craft that undoes exactly
what the mode exists for, and under record.mode=mirror the recorder taps the
live channel — so the spike went out over the air for the benefit of a
file. The whole ask (grace period, 1 Hz pacing, bound, and the take/requeue
hand-off at six sites across three backends) is deleted.

Where a segment opens now

A crossed threshold makes rotation due; the next point a decoder can start
from is where it lands. The encoder produces one or the other without being
asked:

  • an IRAP (19/20) where one exists. Normal GOP recording is
    unchanged
    and still cuts on its keyframe — the encoder emits VPS/SPS/PPS
    immediately before each IDR (measured 1/1), so it is the same boundary
    either way.
  • a parameter-set boundary (32/33/34), the head of a refresh wave,
    which an intra-refresh stream emits once per GOP. A raw elementary stream has
    no container to hold codec config, so this is also the only place a .hevc
    segment can begin and still decode; the picture converges over one wave —
    what the ground already does on every tune-in.

Both cases are measured on Star6E, not assumed — the same craft, demuxed at
each resilience setting:

racing (800 frames) off (601 frames)
IRAP access units 1 (startup, then never) 4
parameter-set groups 5 — every ~200 frames 4 — every ~200 frames
groups detached from an IDR 5 (that is the wave head) 0/4

The right-hand column is what makes accepting parameter sets safe for normal
GOP: they never appear without an IDR there, so the cut still lands on the
IRAP — which is where it has to land. The left-hand column is what makes it
necessary: waiting for an IRAP alone would leave rotation permanently inert on
the shipped FPV config, which is the defect being fixed, not a hypothetical.

Also fixed, all found reviewing the above

  • Maruko's own writer bypassed rotation entirely.
    maruko_recorder_write_frame() is the dual and synchronous-fallback path on
    that backend, and it never reached the rotation policy — so a change whose
    whole point is that hevc rotates left one of three backends exactly as
    broken as before. It now takes the same shared cut. Maruko's TS adapter also
    only accepted 19/20, which would have left TS rotation inert on an
    intra-refresh craft there.
  • Segments opened O_TRUNC. The name carries only uptime seconds plus 16
    bits of nanosecond clock, and after a reboot the uptime restarts — so a
    repeat is reachable, and the open destroyed whatever was there. Now O_EXCL
    with a retry, at start() as well as on rotation. This PR multiplies the
    exposure (one name per segment rather than one per recording), which makes
    it this PR's to fix.
  • fdatasync()/close() results were discarded when finalising the old
    segment, so a delayed write surfacing there read as a clean rotation. Both
    are checked; a failure stops the recorder like a failed reopen.

Evidence

Star6E (SSC338Q, resilience=racing, gopSize 2.0, sliceCount 6, 100 fps),
maxMB=2, patched binary run from the SD card so the stock service and config
were restored untouched:

before after
segments in 20 s 1 (no rotation exists) 10
status.segments vs files on disk 10 vs 10, matching
every segment opens with VPS, SPS, PPS
slice NALs per segment 1206 = 201 frames x 6 = one wave
IRAP NALs in the sampled segments 0

Zero IRAPs is the whole point: rotation is driven entirely by boundaries the
encoder was producing anyway, so recording no longer perturbs the live stream.

Control: record.format is restart_required, so no other recorder path
changed; the craft was verified back on format: "ts" with venc running
afterwards. Host suite 3034 passed / 0 failed.

Not device-verified: the Maruko writer. That bench has no storage meeting
the recorder's 50 MB free-space precondition (1 MB free, no SD card), so its
fix rests on calling the same shared cut that is verified on Star6E, plus a
pack scan mirroring the accessor Maruko's TS adapter already uses in
production. Saying so rather than letting the table imply all three.

Operator note

On an intra-refresh craft the cut point arrives once per GOP, so segment
granularity is one GOP
. A maxMB far below one GOP of data still yields
one-GOP segments — the threshold decides whether to rotate, the wave head
decides when. Documented in SD_CARD_RECORDING.md.

If a stream produces neither kind of point, rotation waits rather than forcing
anything, and logs that it is waiting once, so the case is visible rather than
silent.

Verification

  • Build: make SOC_BUILD=star6e|maruko|cv610 build, all clean from
    make clean. Warnings introduced: none.
  • Tests: make test3034 passed, 0 failed. Seven ask-specific TS tests were
    deleted as obsolete and replaced with cut-point coverage on both recorders,
    including the GDR case (no IRAP ever, rotation still fires).
    Mutation-checked: restricting the cut point back to IRAP-only — the old
    behaviour — fails exactly the 3 GDR assertions.
  • AGENTS.md:553 (~80-line functions): write_au() 72, recorder_rotation_due()
    78.

Blast radius

  • RecorderRotation is new and shared; the TS recorder's threshold behaviour
    is otherwise unchanged.
  • Six IDR-request call sites across the three backends are removed, along
    with the two selector helpers they needed. Net for the runtimes is a
    simplification.
  • star6e_recorder_write_au() gains an is_idr parameter. All three call
    sites already had that value in scope — they were passing it to the TS
    recorder in the same if/else.
  • contract_version 0.30.00.31.0, VERSION 0.83.00.84.0.

Review

Qodo raised seven findings. Three were already resolved by the redesign that
landed after the review ran. Three were real and are fixed above — the Maruko
one was reported as resolved but was not, and is the most consequential of the
set. One (rotation progress on stderr) I declined: every line this recorder
emits goes to stderr including started:, so moving one would split a
recording's progress across two streams; the worthwhile fix is a consistency
pass over the whole module, not part of this issue.

A test I added for the O_TRUNC finding turned out not to discriminate —
mutation-checking it by reverting to O_TRUNC left it green, because a test
cannot provoke a nanosecond-clock name collision. It is kept for what it does
prove and the gap is written into its own comment; O_EXCL rests on open(2)
semantics, not on that test.

Not in this change

The cut-point test reads packType.h265Nalu only — deliberately the same
h265-only reading the TS recorder already used, so the two recorders cannot
disagree about where a segment may start. If H.264 recording matters, that is
one change for both rather than a divergence introduced here.

🤖 Generated with Claude Code

https://claude.ai/code/session_01U1w7gRc6ASpa7P7HRCBQEw

A 32-bit glibc build leaves off_t 32-bit and open() without O_LARGEFILE, so
the kernel refuses any write crossing 2^31-1 with EFBIG no matter what the
filesystem allows.  A recording on exFAT died at exactly 2147483647 bytes,
twice, and reported "write_error" -- which reads as bad media and sent the
diagnosis to the SD card.  `record.maxMB` above ~2047 was therefore a knob
that validated, applied over MUT_RESTART, and then silently could not work.

Build with -D_FILE_OFFSET_BITS=64.  That is the whole fix: it removes the
ceiling from open(), and equally from the stat()/fstat() calls in
venc_recordings.c and venc_httpd.c that return EOVERFLOW for a >2 GB file
and would otherwise hide such a recording from the listing and the download
path.  The flag has to be on every translation unit or the link silently
mixes two off_t layouts, so it goes in COMMON_CFLAGS and is mirrored into
HOST_CFLAGS to keep the tested ABI equal to the shipped one.  Note that a
CFLAGS change does not invalidate objects: this needs a clean build.

The rest is so the knob cannot lie again if that flag is ever lost:

- check_rotation() rotates on whichever binds first, the operator's limit or
  the one off_t can reach.  A configured max_mb above the ceiling now yields
  more segments instead of a dead recorder.  Inert at 64-bit off_t.
- A segment can only be cut on an IRAP, and the IDR request is bounded, so a
  GDR stream can carry one past any threshold with no cut coming.  Rather
  than walk into an EFBIG that truncates mid-AU, stop on the frame boundary
  with the file intact.
- EFBIG gets its own arm in all three recorders and its own stop reason,
  RECORDER_STOP_SIZE_LIMIT ("size_limit"), because nothing failed -- a
  ceiling is not an I/O error and should not read as one.
- The inactive branch of the record status filled in only stop_reason, so a
  recorder that stopped by itself answered {path:"", frames:0, bytes:0}.  It
  now carries the path, bytes, frames and segments from the snapshot the
  reason came from -- for a non-manual stop that is the entire diagnosis.

contract_version 0.29.0 -> 0.30.0: the stop_reason enum gains a value and the
inactive status payload changes meaning.  Both are additive under the
contract's own governance rules.  VERSION 0.82.0 -> 0.83.0.

Docs: maxSeconds/maxMB of 0 were documented as "no limit" but the runtime
only overrides its compiled-in default when the value is > 0, so 0 means
300 s / 500 MB.  Also state that rotation is TS-only; format "hevc" ignores
both thresholds, which is filed separately.

Device-verified on SSC338Q (.232) against the real recorder core -- three
arms of one harness, same 58 GB FAT32 card, same deterministic input, only
the build differing:

  stock master, no flag   EFBIG "File too large" at 34084 frames
                          file on disk exactly 2147483647 -- the report,
                          reproduced byte for byte
  patched, no flag        segment cut at 2147212672, second segment opened,
                          2306867536 bytes over 2 segments, exit 0
  patched + flag          2306867536 bytes in ONE segment, exit 0

The last two wrote identical totals from identical input, so segmentation is
the only variable between them.  Note the stock arm's counter stopped at
2147464968 while the file is 2147483647: the failing write was partial, which
is the mid-AU truncation the new frame-boundary stop exists to prevent.

Separately verified on .232 that a 32-bit off_t build cannot stat() or fstat()
a 3 GB file at all (EOVERFLOW), so fixing only open() would have produced
recordings the API then hides.

CV610 was checked and is not affected: measured on .181, musl is 64-bit off_t
and forces O_LARGEFILE regardless of the flag.  That negative comes from a
probe whose raw-openat control DID fail with EFBIG on the same kernel, so the
instrument was shown able to detect the positive before its negative was
trusted.

Fixes the root cause reported in OpenIPC#118.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U1w7gRc6ASpa7P7HRCBQEw
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Rotate raw HEVC recordings by configured limits

🐞 Bug fix 🧪 Tests 📝 Documentation ⚙️ Configuration changes 🕐 40+ Minutes

Grey Divider

AI Description

• Applies maxSeconds and maxMB rotation to raw HEVC recordings.
• Shares IRAP-aware, bounded-IDR rotation policy across raw and TS recorders.
• Carries stacked large-file safeguards pending rebase after #124 merges.
Diagram

graph TD
  CFG["Record config"] --> RT["Backend runtime"] --> SEL{"Record format"} --> RAW["HEVC recorder"] --> ROT["Rotation policy"] --> IDR["Encoder IDR"] --> RT
  SEL --> TS["TS recorder"] --> ROT
  RAW --> STATUS["Record status"]
  TS --> STATUS
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Duplicate rotation logic in the raw recorder
  • ➕ Keeps each recorder implementation locally self-contained.
  • ➕ Avoids changing existing TS recorder state layout.
  • ➖ Duplicates subtle IRAP, grace-period, pacing, and retry behavior.
  • ➖ Creates a high risk of TS and HEVC policies drifting over time.
  • ➖ Requires parallel tests and fixes for every rotation change.
2. Wait only for natural IRAP frames
  • ➕ Avoids requesting encoder keyframes.
  • ➕ Simplifies backend integration.
  • ➖ Rotation never completes for deployed GDR configurations without natural IRAPs.
  • ➖ Leaves configured size and duration thresholds effectively ignored.
  • ➖ Allows raw files to grow without bound.
3. Introduce recorder operation callbacks
  • ➕ Could encapsulate format-specific open and close operations behind one generic engine.
  • ➕ May reduce future duplication if additional recording formats are added.
  • ➖ Adds callback lifecycle and error-propagation complexity across threaded backends.
  • ➖ Provides little benefit while only segment opening differs between two recorders.
  • ➖ Would broaden an already stacked and cross-platform change.

Recommendation: Keep the PR's shared RecorderRotation policy with recorder-specific segment open/close behavior. It centralizes the failure-prone decision logic without introducing a larger callback framework, while backend-serviced IDR requests correctly support GDR streams and dual-channel recording. After #124 is squash-merged, rebase this branch and verify that only the HEVC rotation commit remains before merging.

Files changed (18) +1140 / -322

Bug fix (7) +551 / -151
cv610_runtime.cRoute CV610 rotation and status by active format +62/-13

Route CV610 rotation and status by active format

• Passes IRAP information to the raw recorder and applies thresholds to both recorder formats. The runtime services the active recorder's IDR requests and reports accurate HEVC segments, retained counters, and size-limit stops.

src/cv610_runtime.c

maruko_pipeline.cIntegrate shared rotation into Maruko pipelines +47/-11

Integrate shared rotation into Maruko pipelines

• Selects the active recorder's rotation state in mirror and dual modes, including the correct encoder channel. Both recorder formats receive configured thresholds and raw access units receive their IRAP flag.

src/maruko_pipeline.c

maruko_recorder.cClassify Maruko EFBIG failures as size limits +13/-0

Classify Maruko EFBIG failures as size limits

• Handles file-size ceiling failures separately from generic write errors and emits a targeted diagnostic instead of blaming the storage medium.

src/maruko_recorder.c

maruko_runtime.cReport accurate Maruko recording outcomes +33/-6

Report accurate Maruko recording outcomes

• Replaces the hardcoded HEVC segment count with the recorder snapshot. Inactive status now comes from the configured recorder and retains its counters, path, segments, and 'size_limit' reason.

src/maruko_runtime.c

star6e_recorder.cAdd IRAP-safe rotation to raw HEVC recording +231/-3

Add IRAP-safe rotation to raw HEVC recording

• Implements the shared rotation policy and raw segment reopen flow, including paced bounded IDR requests and per-segment accounting. It also detects Star6E IRAP frames, reports segment counts, preserves frame boundaries on failures, and classifies EFBIG as a size limit.

src/star6e_recorder.c

star6e_runtime.cConnect Star6E raw rotation to the encoder +67/-15

Connect Star6E raw rotation to the encoder

• Passes keyframe metadata to raw recording and services IDR requests from whichever recorder is active. Threshold configuration and status reporting now apply consistently to TS and HEVC formats.

src/star6e_runtime.c

star6e_ts_recorder.cReuse shared rotation and harden size failures +98/-103

Reuse shared rotation and harden size failures

• Moves TS rotation decisions into 'RecorderRotation' while retaining format-specific PAT/PMT segment creation. Adds file-offset guards, distinct size-limit reporting, and rollback of partially written access units.

src/star6e_ts_recorder.c

Refactor (2) +163 / -88
star6e_recorder.hDefine shared recorder rotation state and API +152/-5

Define shared recorder rotation state and API

• Introduces 'RecorderRotation', shared thresholds, IDR request helpers, file-offset ceilings, and the 'size_limit' stop reason. Raw recorder state now tracks rotation and segment counts, while 'write_au' accepts keyframe information.

include/star6e_recorder.h

star6e_ts_recorder.hEmbed the shared rotation policy in TS state +11/-83

Embed the shared rotation policy in TS state

• Replaces TS-specific threshold, counter, and IDR fields with 'RecorderRotation'. Existing TS constants now alias shared defaults and request bounds.

include/star6e_ts_recorder.h

Tests (2) +261 / -68
test_star6e_recorder.cTest raw HEVC rotation behavior +127/-8

Test raw HEVC rotation behavior

• Updates callers for the new keyframe argument and adds coverage for threshold-plus-IRAP rotation, IDR requests when no IRAP arrives, and avoiding cuts below thresholds.

tests/test_star6e_recorder.c

test_star6e_ts_recorder.cValidate shared TS rotation and size rollback +134/-60

Validate shared TS rotation and size rollback

• Migrates existing rotation assertions to the embedded shared policy. Adds an RLIMIT-based test proving EFBIG is classified as a size limit and partial TS access units are truncated back to a complete frame boundary.

tests/test_star6e_ts_recorder.c

Documentation (4) +146 / -11
HISTORY.mdDocument releases 0.83.0 and 0.84.0 +82/-0

Document releases 0.83.0 and 0.84.0

• Adds release notes for the stacked large-file fix and raw HEVC rotation. Documents shared rotation behavior, IDR requests, segment reporting, retained status counters, and size-limit handling.

HISTORY.md

README.mdRefresh API version response example +1/-1

Refresh API version response example

• Updates the sample application and contract versions to 0.84.0 and 0.31.0.

README.md

HTTP_API_CONTRACT.mdDocument recording status contract changes +26/-4

Document recording status contract changes

• Bumps the contract to 0.31.0 and documents 'size_limit', retained inactive recording counters, and meaningful HEVC segment counts. Version response examples are updated accordingly.

documentation/HTTP_API_CONTRACT.md

SD_CARD_RECORDING.mdClarify rotation and file-size behavior +37/-6

Clarify rotation and file-size behavior

• Corrects zero-threshold semantics and states that both TS and HEVC formats rotate. It also documents IRAP boundaries, bounded IDR requests, 64-bit file offsets, and self-stop status retention.

documentation/SD_CARD_RECORDING.md

Other (3) +19 / -4
MakefileEnable 64-bit file offsets in all builds +17/-2

Enable 64-bit file offsets in all builds

• Adds '_FILE_OFFSET_BITS=64' to target and host compiler flags. This prevents 32-bit glibc builds from failing writes, stats, listings, or downloads above 2 GB.

Makefile

VERSIONBump application version to 0.84.0 +1/-1

Bump application version to 0.84.0

• Advances the packaged application version from 0.82.0 to 0.84.0 for the two stacked releases.

VERSION

venc_api.hBump API contract version to 0.31.0 +1/-1

Bump API contract version to 0.31.0

• Updates the compiled contract identifier to reflect HEVC segment reporting and the stacked recording-status changes.

include/venc_api.h

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Sep 6, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (2) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Merging also ships another feature 📘 Rule violation ⚙ Maintainability
Description
COMMON_CFLAGS adds 64-bit file offsets and the same diff adds size-limit status semantics from
stacked change #124, although the PR description says only the HEVC rotation commit belongs here.
Merging this branch before the required rebase would deploy large-file and API behavior beyond issue
#123 alongside rotation.
Code

Makefile[75]

+COMMON_CFLAGS := -Os -Iinclude -Ilib -DVENC_VERSION=\"$(VENC_VERSION)\" -D_GNU_SOURCE -D_FILE_OFFSET_BITS=64 -MMD -MP
Evidence
Compliance rule 3 requires every changed file and behavior to be necessary for the requested HEVC
rotation. The build flag and size-limit contract changes implement the separately stacked large-file
feature rather than issue #123.

AGENTS.md: Keep Changes Minimal and Within the Requested Scope: AGENTS.md: Keep Changes Minimal and Within the Requested Scope: AGENTS.md: Keep Changes Minimal and Within the Requested Scope: AGENTS.md: Keep Changes Minimal and Within the Requested Scope: AGENTS.md: Keep Changes Minimal and Within the Requested Scope: AGENTS.md: Keep Changes Minimal and Within the Requested Scope: AGENTS.md: Keep Changes Minimal and Within the Requested Scope: AGENTS.md: Keep Changes Minimal and Within the Requested Scope: AGENTS.md: Keep Changes Minimal and Within the Requested Scope: AGENTS.md: Keep Changes Minimal and Within the Requested Scope: AGENTS.md: Keep Changes Minimal and Within the Requested Scope: AGENTS.md: Keep Changes Minimal and Within the Requested Scope: AGENTS.md: Keep Changes Minimal and Within the Requested Scope: AGENTS.md: Keep Changes Minimal and Within the Requested Scope: AGENTS.md: Keep Changes Minimal and Within the Requested Scope: AGENTS.md: Keep Changes Minimal and Within the Requested Scope: AGENTS.md: Keep Changes Minimal and Within the Requested Scope: AGENTS.md: Keep Changes Minimal and Within the Requested Scope: AGENTS.md: Keep Changes Minimal and Within the Requested Scope: AGENTS.md: Keep Changes Minimal and Within the Requested Scope: AGENTS.md: Keep Changes Minimal and Within the Requested Scope: AGENTS.md: Keep Changes Minimal and Within the Requested Scope: AGENTS.md: Keep Changes Minimal and Within the Requested Scope: AGENTS.md: Keep Changes Minimal and Within the Requested Scope: AGENTS.md: Keep Changes Minimal and Within the Requested Scope: AGENTS.md: Keep Changes Minimal and Within the Requested Scope: AGENTS.md: Keep Changes Minimal and Within the Requested Scope: AGENTS.md: Keep Changes Minimal and Within the Requested Scope: AGENTS.md: Keep Changes Minimal and Within the Requested Scope: AGENTS.md: Keep Changes Minimal and Within the Requested Scope: AGENTS.md: Keep Changes Minimal and Within the Requested Scope: AGENTS.md: Keep Changes Minimal and Within the Requested Scope: AGENTS.md: Keep Changes Minimal and Within the Requested Scope: AGENTS.md: Keep Changes Minimal and Within the Requested Scope: AGENTS.md: Keep Changes Minimal and Within the Requested Scope: AGENTS.md: Keep Changes Minimal and Within the Requested Scope: AGENTS.md: Keep Changes Minimal and Within the Requested Scope: AGENTS.md: Keep Changes Minimal and Within the Requested Scope: AGENTS.md: Keep Changes Minimal and Within the Requested Scope: AGENTS.md: Keep Changes Minimal and Within the Requested Scope: AGENTS.md: Keep Changes Minimal and Within the Requested Scope: AGENTS.md: Keep Changes Minimal and Within the Requested Scope: AGENTS.md: Keep Changes Minimal and Within the Requested Scope: AGENTS.md: Keep Changes Minimal and Within the Requested Scope: AGENTS.md: Keep Changes Minimal and Within the Requested Scope: AGENTS.md: Keep Changes Minimal and Within the Requested Scope: AGENTS.md: Keep Changes Minimal and Within the Requested Scope: AGENTS.md: Keep Changes Minimal and Within the Requested Scope: AGENTS.md: Keep Changes Minimal and Within the Requested Scope: AGENTS.md: Keep Changes Minimal and Within the Requested Scope: AGENTS.md: Keep Changes Minimal and Within the Requested Scope: AGENTS.md: Keep Changes Minimal and Within the Requested Scope: AGENTS.md: Keep Changes Minimal and Within the Requested Scope: AGENTS.md: Keep Changes Minimal and Within the Requested Scope: AGENTS.md: Keep Changes Minimal and Within the Requested Scope: AGENTS.md: Keep Changes Minimal and Within the Requested Scope: AGENTS.md: Keep Changes Minimal and Within the Requested Scope: AGENTS.md: Keep Changes Minimal and Within the Requested Scope: AGENTS.md: Keep Changes Minimal and Within the Requested Scope: AGENTS.md: Keep Changes Minimal and Within the Requested Scope: AGENTS.md: Keep Changes Minimal and Within the Requested Scope: AGENTS.md: Keep Changes Minimal and Within the Requested Scope: AGENTS.md: Keep Changes Minimal and Within the Requested Scope: AGENTS.md: Keep Changes Minimal and Within the Requested Scope
Makefile[62-75]
documentation/HTTP_API_CONTRACT.md[1277-1299]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The PR includes the unrelated large-file support and status-contract changes from stacked change #124.
## Issue Context
The PR description states that only the HEVC rotation commit belongs to this PR and requires rebasing after #124 is squashed into the target branch.
## Fix Focus Areas
- Makefile[62-75]
- documentation/HTTP_API_CONTRACT.md[1277-1299]
- HISTORY.md[30-83]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Maruko recordings still grow unbounded ✓ Resolved 📘 Rule violation ≡ Correctness
Description
maruko_recorder_write_frame() bypasses recorder_rotation_due(), never advances
state->rot.segment_bytes, and retains an EFBIG branch stating that the raw format does not
rotate, while the dual loop gates keyframe-request consumption on d->ts_recorder instead of the
selected raw recorder. Maruko dual HEVC and synchronous-fallback paths use this writer, so crossing
maxSeconds or maxMB does not cut a segment and streams without natural keyframes cannot receive
the IDR request needed to rotate before another limit stops recording.
Code

src/maruko_recorder.c[R100-103]

+			"[maruko_recorder] file size limit reached at %llu bytes "
+			"(EFBIG); this format does not rotate -- use "
+			"record.format=ts for long recordings\n",
+			(unsigned long long)state->bytes_written);
Evidence
The dual graph selects the raw recorder for HEVC, and both the dual stream thread and synchronous
fallback call maruko_recorder_write_frame() rather than the shared star6e_recorder_write_au()
path. The direct Maruko writer proceeds from disk checking to frame writes without invoking the
shared rotation policy, updates lifetime counters but not the per-segment byte counter, and includes
error text confirming that the raw format remains non-rotating; additionally, the IDR-consumption
path requires d->ts_recorder even though the selected HEVC path uses the raw recorder, making
rotation requests unreachable there.

AGENTS.md: Shared Config and HTTP API Features Must Support Both Backends: AGENTS.md: Shared Config and HTTP API Features Must Support Both Backends: AGENTS.md: Shared Config and HTTP API Features Must Support Both Backends: AGENTS.md: Shared Config and HTTP API Features Must Support Both Backends: AGENTS.md: Shared Config and HTTP API Features Must Support Both Backends: AGENTS.md: Shared Config and HTTP API Features Must Support Both Backends: AGENTS.md: Shared Config and HTTP API Features Must Support Both Backends: AGENTS.md: Shared Config and HTTP API Features Must Support Both Backends: AGENTS.md: Shared Config and HTTP API Features Must Support Both Backends: AGENTS.md: Shared Config and HTTP API Features Must Support Both Backends: AGENTS.md: Shared Config and HTTP API Features Must Support Both Backends: AGENTS.md: Shared Config and HTTP API Features Must Support Both Backends: AGENTS.md: Shared Config and HTTP API Features Must Support Both Backends: AGENTS.md: Shared Config and HTTP API Features Must Support Both Backends: AGENTS.md: Shared Config and HTTP API Features Must Support Both Backends: AGENTS.md: Shared Config and HTTP API Features Must Support Both Backends: AGENTS.md: Shared Config and HTTP API Features Must Support Both Backends: AGENTS.md: Shared Config and HTTP API Features Must Support Both Backends: AGENTS.md: Shared Config and HTTP API Features Must Support Both Backends: AGENTS.md: Shared Config and HTTP API Features Must Support Both Backends: AGENTS.md: Shared Config and HTTP API Features Must Support Both Backends: AGENTS.md: Shared Config and HTTP API Features Must Support Both Backends: AGENTS.md: Shared Config and HTTP API Features Must Support Both Backends: AGENTS.md: Shared Config and HTTP API Features Must Support Both Backends: AGENTS.md: Shared Config and HTTP API Features Must Support Both Backends: AGENTS.md: Shared Config and HTTP API Features Must Support Both Backends: AGENTS.md: Shared Config and HTTP API Features Must Support Both Backends: AGENTS.md: Shared Config and HTTP API Features Must Support Both Backends: AGENTS.md: Shared Config and HTTP API Features Must Support Both Backends: AGENTS.md: Shared Config and HTTP API Features Must Support Both Backends: AGENTS.md: Shared Config and HTTP API Features Must Support Both Backends: AGENTS.md: Shared Config and HTTP API Features Must Support Both Backends: AGENTS.md: Shared Config and HTTP API Features Must Support Both Backends: AGENTS.md: Shared Config and HTTP API Features Must Support Both Backends: AGENTS.md: Shared Config and HTTP API Features Must Support Both Backends: AGENTS.md: Shared Config and HTTP API Features Must Support Both Backends: AGENTS.md: Shared Config and HTTP API Features Must Support Both Backends: AGENTS.md: Shared Config and HTTP API Features Must Support Both Backends: AGENTS.md: Shared Config and HTTP API Features Must Support Both Backends: AGENTS.md: Shared Config and HTTP API Features Must Support Both Backends: AGENTS.md: Shared Config and HTTP API Features Must Support Both Backends: AGENTS.md: Shared Config and HTTP API Features Must Support Both Backends: AGENTS.md: Shared Config and HTTP API Features Must Support Both Backends: AGENTS.md: Shared Config and HTTP API Features Must Support Both Backends: AGENTS.md: Shared Config and HTTP API Features Must Support Both Backends: AGENTS.md: Shared Config and HTTP API Features Must Support Both Backends: AGENTS.md: Shared Config and HTTP API Features Must Support Both Backends: AGENTS.md: Shared Config and HTTP API Features Must Support Both Backends: AGENTS.md: Shared Config and HTTP API Features Must Support Both Backends: AGENTS.md: Shared Config and HTTP API Features Must Support Both Backends: AGENTS.md: Shared Config and HTTP API Features Must Support Both Backends: AGENTS.md: Shared Config and HTTP API Features Must Support Both Backends: AGENTS.md: Shared Config and HTTP API Features Must Support Both Backends: AGENTS.md: Shared Config and HTTP API Features Must Support Both Backends: AGENTS.md: Shared Config and HTTP API Features Must Support Both Backends: AGENTS.md: Shared Config and HTTP API Features Must Support Both Backends: AGENTS.md: Shared Config and HTTP API Features Must Support Both Backends: AGENTS.md: Shared Config and HTTP API Features Must Support Both Backends: AGENTS.md: Shared Config and HTTP API Features Must Support Both Backends: AGENTS.md: Shared Config and HTTP API Features Must Support Both Backends: AGENTS.md: Shared Config and HTTP API Features Must Support Both Backends: AGENTS.md: Shared Config and HTTP API Features Must Support Both Backends: AGENTS.md: Shared Config and HTTP API Features Must Support Both Backends: AGENTS.md: Shared Config and HTTP API Features Must Support Both Backends
src/maruko_recorder.c[94-103]
src/maruko_recorder.c[121-145]
src/maruko_recorder.c[206-212]
src/maruko_pipeline.c[3140-3171]
src/maruko_recorder.c[121-220]
src/maruko_pipeline.c[2965-2993]
src/maruko_pipeline.c[3135-3172]
src/maruko_pipeline.c[4836-4876]
src/maruko_pipeline.c[3286-3295]
src/star6e_recorder.c[355-377]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Connect Maruko's SDK-native raw HEVC writer to `RecorderRotation`. Detect keyframes, run the shared rotation policy before writing, maintain per-segment byte accounting, close and open segments at eligible IRAP frames, and allow dual mode to service IDR requests for the selected raw recorder.
## Issue Context
Shared recording behavior must be consistent across Star6E and Maruko, including native Maruko stream packets rather than only flattened access units. Mirror mode uses `star6e_recorder_write_au()` and receives the shared behavior, but Maruko dual HEVC selects `d->recorder`, and both dual mode and the synchronous fallback write through `maruko_recorder_write_frame()`, which retains the old non-rotating implementation. The request-consumption loop is also guarded by `d->ts_recorder` even though `dual_rotation()` supports either recorder, so raw HEVC streams that need an IDR request cannot rotate.
## Fix Focus Areas
- src/maruko_recorder.c[121-232]
- src/maruko_pipeline.c[2981-2993]
- src/maruko_pipeline.c[3135-3172]
- src/maruko_pipeline.c[3286-3295]
- src/maruko_pipeline.c[4836-4876]
- src/star6e_recorder.c[355-377]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Segment progress is sent as an error 📘 Rule violation ◔ Observability
Description
open_next_segment() writes its successful segment announcement with fprintf(stderr, ...) instead
of sending ordinary progress to stdout. Every successful raw recording rotation reaches this
statement, so scripts and operators monitoring the error stream receive normal progress mixed with
failures.
Code

src/star6e_recorder.c[R350-351]

+	fprintf(stderr, "[recorder] segment %u: %s\n",
+		state->segments, state->path);
Evidence
Compliance rule 13 requires informational output on stdout and failures on stderr. These lines emit
an ordinary successful-rotation message through stderr.

AGENTS.md: Send Errors to stderr and Informational Output to stdout: AGENTS.md: Send Errors to stderr and Informational Output to stdout: AGENTS.md: Send Errors to stderr and Informational Output to stdout: AGENTS.md: Send Errors to stderr and Informational Output to stdout: AGENTS.md: Send Errors to stderr and Informational Output to stdout: AGENTS.md: Send Errors to stderr and Informational Output to stdout: AGENTS.md: Send Errors to stderr and Informational Output to stdout: AGENTS.md: Send Errors to stderr and Informational Output to stdout: AGENTS.md: Send Errors to stderr and Informational Output to stdout: AGENTS.md: Send Errors to stderr and Informational Output to stdout: AGENTS.md: Send Errors to stderr and Informational Output to stdout: AGENTS.md: Send Errors to stderr and Informational Output to stdout: AGENTS.md: Send Errors to stderr and Informational Output to stdout: AGENTS.md: Send Errors to stderr and Informational Output to stdout: AGENTS.md: Send Errors to stderr and Informational Output to stdout: AGENTS.md: Send Errors to stderr and Informational Output to stdout: AGENTS.md: Send Errors to stderr and Informational Output to stdout: AGENTS.md: Send Errors to stderr and Informational Output to stdout: AGENTS.md: Send Errors to stderr and Informational Output to stdout: AGENTS.md: Send Errors to stderr and Informational Output to stdout: AGENTS.md: Send Errors to stderr and Informational Output to stdout: AGENTS.md: Send Errors to stderr and Informational Output to stdout: AGENTS.md: Send Errors to stderr and Informational Output to stdout: AGENTS.md: Send Errors to stderr and Informational Output to stdout: AGENTS.md: Send Errors to stderr and Informational Output to stdout: AGENTS.md: Send Errors to stderr and Informational Output to stdout: AGENTS.md: Send Errors to stderr and Informational Output to stdout: AGENTS.md: Send Errors to stderr and Informational Output to stdout: AGENTS.md: Send Errors to stderr and Informational Output to stdout: AGENTS.md: Send Errors to stderr and Informational Output to stdout: AGENTS.md: Send Errors to stderr and Informational Output to stdout: AGENTS.md: Send Errors to stderr and Informational Output to stdout: AGENTS.md: Send Errors to stderr and Informational Output to stdout: AGENTS.md: Send Errors to stderr and Informational Output to stdout: AGENTS.md: Send Errors to stderr and Informational Output to stdout: AGENTS.md: Send Errors to stderr and Informational Output to stdout: AGENTS.md: Send Errors to stderr and Informational Output to stdout: AGENTS.md: Send Errors to stderr and Informational Output to stdout: AGENTS.md: Send Errors to stderr and Informational Output to stdout: AGENTS.md: Send Errors to stderr and Informational Output to stdout: AGENTS.md: Send Errors to stderr and Informational Output to stdout: AGENTS.md: Send Errors to stderr and Informational Output to stdout: AGENTS.md: Send Errors to stderr and Informational Output to stdout: AGENTS.md: Send Errors to stderr and Informational Output to stdout: AGENTS.md: Send Errors to stderr and Informational Output to stdout: AGENTS.md: Send Errors to stderr and Informational Output to stdout: AGENTS.md: Send Errors to stderr and Informational Output to stdout: AGENTS.md: Send Errors to stderr and Informational Output to stdout: AGENTS.md: Send Errors to stderr and Informational Output to stdout: AGENTS.md: Send Errors to stderr and Informational Output to stdout: AGENTS.md: Send Errors to stderr and Informational Output to stdout: AGENTS.md: Send Errors to stderr and Informational Output to stdout: AGENTS.md: Send Errors to stderr and Informational Output to stdout: AGENTS.md: Send Errors to stderr and Informational Output to stdout: AGENTS.md: Send Errors to stderr and Informational Output to stdout: AGENTS.md: Send Errors to stderr and Informational Output to stdout: AGENTS.md: Send Errors to stderr and Informational Output to stdout: AGENTS.md: Send Errors to stderr and Informational Output to stdout: AGENTS.md: Send Errors to stderr and Informational Output to stdout: AGENTS.md: Send Errors to stderr and Informational Output to stdout: AGENTS.md: Send Errors to stderr and Informational Output to stdout: AGENTS.md: Send Errors to stderr and Informational Output to stdout: AGENTS.md: Send Errors to stderr and Informational Output to stdout: AGENTS.md: Send Errors to stderr and Informational Output to stdout
src/star6e_recorder.c[350-351]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A successful segment-opening message is emitted on stderr even though it is informational output.
## Issue Context
Error streams must remain distinguishable from normal recorder progress for callers and monitoring scripts.
## Fix Focus Areas
- src/star6e_recorder.c[350-351]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View action required (2)
4. Transport streams ignore size limits ✓ Resolved 🐞 Bug ≡ Correctness
Description
star6e_ts_recorder_write_video() delegates successful muxed writes to ts_write_muxed() but
updates only lifetime bytes and frame count, leaving state->rot.segment_bytes at the initial
PAT/PMT table byte count. Every TS recording therefore bypasses maxMB size rotation and the
file-ceiling guard, while a later partial-write rollback truncates to the stale offset and erases
previously completed frames.
Code

src/star6e_ts_recorder.c[438]

-		state->segment_bytes += (uint64_t)written;
Evidence
A segment initializes rot.segment_bytes, and both recorder_rotation_due() and the pre-write
file-ceiling check make decisions from that value, while rollback also uses it as the truncation
boundary. The successful-write path receives the byte count from ts_write_muxed() but updates only
lifetime bytes_written and frame count; the raw recorder and the pre-refactor implementation
demonstrate that the per-segment counter should be incremented immediately after a complete
successful write.

src/star6e_ts_recorder.c[186-205]
src/star6e_ts_recorder.c[329-364]
src/star6e_ts_recorder.c[429-448]
src/star6e_recorder.c[475-481]
src/star6e_recorder.c[79-97]
src/star6e_ts_recorder.c[324-346]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Restore `state->rot.segment_bytes` accounting after each successful muxed-frame write so size-based rotation, the pre-write file ceiling, and rollback boundaries use the actual current segment size.
## Issue Context
`ts_write_muxed()` returns the number of bytes written, but `star6e_ts_recorder_write_video()` does not add that value to `state->rot.segment_bytes`. The old `state->segment_bytes += written` update was removed when the field moved into `RecorderRotation`, with no equivalent update added. Apply the increment only after a complete successful write, following the raw recorder/pre-refactor behavior, and add a regression test covering both `maxMB` size rotation and preservation of earlier frames during a later partial-write rollback.
## Fix Focus Areas
- src/star6e_ts_recorder.c[429-448]
- src/star6e_recorder.c[79-97]
- src/star6e_ts_recorder.c[324-346]
- tests/test_star6e_ts_recorder.c[797-865]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. Restarted recordings inherit old asks ✓ Resolved 🐞 Bug ☼ Reliability
Description
star6e_recorder_start() resets recording counters but preserves the prior session’s
idr_request_pending, idr_request_last_sec, idr_requests_unanswered, and rotation_due_since,
while the stop path also leaves the pending flag set. When a recording restarts after rotation
thresholds were crossed or requests went unanswered, it can skip the grace period, send a stale or
prematurely paced keyframe request, or inherit an exhausted eight-request budget that prevents a
keyframe-free stream from requesting an IDR and rotating.
Code

src/star6e_recorder.c[R303-307]

+	recorder_rotation_segment_opened(&state->rot, 0);
star6e_recorder_status_lock(state);
state->bytes_written = 0;
state->frames_written = 0;
+	state->segments = 1;
Evidence
The raw recorder’s start path resets counters without resetting the rotation-request state, and its
stop path does not clear the pending flag. The shared policy reads these preserved fields to
determine grace timing, request pacing, and whether the bounded unanswered-request limit has been
reached, so state from one recording directly controls the next; by contrast, the transport-stream
start path explicitly resets every corresponding field, confirming that they are intended to be
per-recording state.

src/star6e_recorder.c[117-147]
src/star6e_recorder.c[239-257]
src/star6e_recorder.c[303-315]
src/star6e_ts_recorder.c[242-249]
src/star6e_recorder.c[303-316]
src/star6e_recorder.c[117-145]
src/star6e_ts_recorder.c[242-250]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Reset all per-recording raw-recorder rotation request and pacing state when a new recording starts, including the atomic pending flag, unanswered-request count, request pacing timestamp, and rotation-due timestamp. Clear pending requests when recording stops, matching the lifecycle guarantees already implemented by the transport-stream recorder.
## Issue Context
A raw recording can stop while a keyframe request is pending or after consuming part or all of its bounded request budget. These values currently survive into the next recording in the same process; in particular, eight unanswered requests in a prior session can suppress all rotation-triggered IDR requests in the restarted session. The transport-stream recorder already treats the corresponding values as per-recording state and resets them before opening its first segment.
## Fix Focus Areas
- src/star6e_recorder.c[117-145]
- src/star6e_recorder.c[239-257]
- src/star6e_recorder.c[303-316]
- src/star6e_ts_recorder.c[242-250]
- tests/test_star6e_recorder.c[541-582]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

6. A rotation can erase an older segment ✓ Resolved 🐞 Bug ☼ Reliability
Description
open_next_segment() derives its path from uptime and only 16 bits of the current microsecond
value, then opens that path with O_TRUNC rather than rejecting an existing file. If that suffix
repeats, particularly after a reboot while previous recordings remain, rotation silently replaces
the older segment's contents.
Code

src/star6e_recorder.c[R333-336]

+	build_recording_path(newpath, sizeof(newpath), state->dir);
+
+	state->fd = open(newpath,
+		O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC, 0644);
Evidence
Path generation has only a timestamp-derived 16-bit suffix and no existence check. The newly added
rotation open uses O_CREAT | O_TRUNC, so an identical generated name destroys the existing file
instead of selecting another name.

src/star6e_recorder.c[161-180]
src/star6e_recorder.c[327-348]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Prevent raw segment rotation from truncating a pre-existing recording when filename generation collides. Create files exclusively and retry with a different suffix, sequence, or otherwise guaranteed-unique path.
## Issue Context
The filename is based on monotonic uptime and a truncated timestamp suffix, both of which can repeat across boots. `O_TRUNC` turns such a collision into silent recording loss.
## Fix Focus Areas
- src/star6e_recorder.c[161-180]
- src/star6e_recorder.c[327-348]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


7. Rotation hides segment write failures ✓ Resolved 🐞 Bug ☼ Reliability
Description
check_rotation() discards the return values from both fdatasync() and close() before
publishing a newly opened segment. A delayed write or durability failure while closing the old raw
segment is therefore reported as a successful rotation, leaving clients unaware that the preceding
file may be incomplete.
Code

src/star6e_recorder.c[R362-364]

+	fdatasync(state->fd);
+	close(state->fd);
+	state->fd = -1;
Evidence
The added raw rotation path calls fdatasync() and close() without inspecting either result,
immediately marks the descriptor closed, and opens the next segment. Only failure of the subsequent
open() can affect the stop reason.

src/star6e_recorder.c[355-376]
src/star6e_recorder.c[327-352]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Check and preserve errors from finalizing the old raw segment during rotation. Do not report a normal transition when `fdatasync()` or `close()` reports a write or durability failure.
## Issue Context
The rotation path currently ignores both calls and proceeds to the next file. Finalize the descriptor safely, retain the first relevant error, stop or otherwise expose the failure through recorder status, and avoid leaking the descriptor.
## Fix Focus Areas
- src/star6e_recorder.c[355-376]
- src/star6e_recorder.c[239-261]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Tip of the day
💡 Did you know, you can show, collapse, or hide each part of a finding: code, evidence, and all

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread Makefile
# already 64-bit off_t and ignore this; it is here for all of them because
# the flag must be identical across every translation unit -- a partial
# application silently mixes two struct stat/off_t layouts across a link.
COMMON_CFLAGS := -Os -Iinclude -Ilib -DVENC_VERSION=\"$(VENC_VERSION)\" -D_GNU_SOURCE -D_FILE_OFFSET_BITS=64 -MMD -MP

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Merging also ships another feature 📘 Rule violation ⚙ Maintainability

COMMON_CFLAGS adds 64-bit file offsets and the same diff adds size-limit status semantics from
stacked change #124, although the PR description says only the HEVC rotation commit belongs here.
Merging this branch before the required rebase would deploy large-file and API behavior beyond issue
#123 alongside rotation.
Agent Prompt
## Issue description
The PR includes the unrelated large-file support and status-contract changes from stacked change #124.

## Issue Context
The PR description states that only the HEVC rotation commit belongs to this PR and requires rebasing after #124 is squashed into the target branch.

## Fix Focus Areas
- Makefile[62-75]
- documentation/HTTP_API_CONTRACT.md[1277-1299]
- HISTORY.md[30-83]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment thread src/maruko_recorder.c Outdated
Comment thread src/star6e_recorder.c
Comment on lines +350 to +351
fprintf(stderr, "[recorder] segment %u: %s\n",
state->segments, state->path);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

3. Segment progress is sent as an error 📘 Rule violation ◔ Observability

open_next_segment() writes its successful segment announcement with fprintf(stderr, ...) instead
of sending ordinary progress to stdout. Every successful raw recording rotation reaches this
statement, so scripts and operators monitoring the error stream receive normal progress mixed with
failures.
Agent Prompt
## Issue description
A successful segment-opening message is emitted on stderr even though it is informational output.

## Issue Context
Error streams must remain distinguishable from normal recorder progress for callers and monitoring scripts.

## Fix Focus Areas
- src/star6e_recorder.c[350-351]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment thread src/star6e_ts_recorder.c
Comment thread src/star6e_recorder.c
Comment thread src/star6e_recorder.c Outdated
Comment thread src/star6e_recorder.c Outdated
@snokvist
snokvist force-pushed the feature/hevc-recorder-rotation branch from 5cf5213 to 949cd64 Compare September 6, 2026 10:15
@snokvist snokvist changed the title record.format="hevc" ignored maxSeconds and maxMB entirely Rotate hevc recordings, and stop forcing a keyframe to do it Sep 6, 2026
@snokvist
snokvist force-pushed the feature/hevc-recorder-rotation branch from 949cd64 to faa3bb1 Compare September 6, 2026 10:40
Two defects, one of them pre-existing and the more interesting.

`record.format="hevc"` ignored maxSeconds and maxMB completely -- the raw
recorder had no rotation code at all, no segment counter, no threshold check,
no second open().  A raw recording was one file that grew until the card
filled, and it did so silently: the config validated and /api/v1/get echoed it
back.  It is also why the "lower maxMB" workaround for the 2 GB ceiling did
not generalise; on that path there was no threshold to lower.

The second is that rotation used to ASK the encoder for a keyframe when a
threshold was crossed and none was coming (TS recorder, since 0.70.0).  That
works, but an IDR is a large frame and one per segment raises the bitrate the
link has to carry.  On an intra-refresh craft it undoes exactly what the mode
exists for, and under record.mode=mirror the recorder taps the LIVE channel,
so the spike went out over the air for the benefit of a file.  The entire ask
-- grace period, 1 Hz pacing, bound, and the take/requeue hand-off at six
sites across three backends -- is deleted.

A segment now opens on a point the stream already produces:

  - an IRAP (19/20) where one exists.  Normal GOP recording is unchanged and
    still cuts on its keyframe.  Measured at resilience=off, 601 frames: 4
    parameter-set groups, 4 IRAP access units, 0/4 groups detached from an IDR
    -- so accepting parameter sets cannot move a normal-GOP cut off its IRAP.
  - a parameter-set boundary (32/33/34), the head of a refresh wave, which is
    what an intra-refresh stream emits once per GOP.  A raw elementary stream
    has no container to hold codec config, so this is also the only place a
    .hevc segment can begin and still decode; the picture converges over one
    wave, as the ground already does on every tune-in.  Measured at
    resilience=racing, 800 frames: ONE IRAP at startup and never another, so
    this is the only boundary that ever arrives there.

When to cut is shared rather than copied: RecorderRotation in
star6e_recorder.h holds it once and both recorders embed one, each supplying
only its own open/close.

Also fixed, all found in review of the above:

- Maruko's own SDK-typed writer bypassed rotation entirely, so format="hevc"
  on its dual and synchronous-fallback paths would still have grown one
  unbounded file.  It now takes the same shared cut, and Maruko's TS adapter
  accepts parameter-set boundaries too -- without which rotation stayed inert
  on an intra-refresh craft there.
- Segments opened O_TRUNC on a name carrying only uptime seconds plus 16 bits
  of nanosecond clock.  After a reboot the uptime restarts, so a name can
  repeat and the open destroyed whatever was there.  Now O_EXCL with a retry,
  at start as well as on rotation; rotation multiplied the exposure because it
  is one name per segment rather than one per recording.
- fdatasync() and close() results were discarded when finalising the old
  segment, so a delayed write surfacing there was reported as a clean
  rotation.  Both are checked; a failure stops the recorder like a failed
  reopen.
- `segments` is reported on the raw path.  Two backends left it 0 while
  segments were on disk and Maruko hardcoded 1.

Device-verified on Star6E (SSC338Q, resilience=racing, gopSize 2.0,
sliceCount 6, 100 fps) with maxMB=2, run from the SD card so the stock service
and config were restored untouched:

  10 segments in 20 s; status segments:10 against 10 files, matching
  every segment opens VPS, SPS, PPS
  every segment holds exactly 1206 slice NALs = 201 frames x 6 = one wave
  IRAP NALs across the sampled segments: 0

Zero IRAPs is the point: rotation runs entirely on boundaries the encoder was
producing anyway.  Before this change the same craft produced one file.

NOT device-verified: the Maruko writer.  That backend's bench has no storage
meeting the recorder's 50 MB free-space precondition (1 MB free, no SD card),
so its fix rests on calling the same shared cut that is verified on Star6E,
plus a pack scan mirroring the accessor Maruko's TS adapter already uses in
production.

Operator note: on an intra-refresh craft the cut point arrives once per GOP,
so segment granularity IS one GOP -- a maxMB far below one GOP of data still
yields one-GOP segments.  If a stream produces neither kind of point, rotation
waits rather than forcing anything, and logs that it is waiting.

contract_version 0.30.0 -> 0.31.0, VERSION 0.83.0 -> 0.84.0.

Closes OpenIPC#123.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U1w7gRc6ASpa7P7HRCBQEw
@snokvist
snokvist force-pushed the feature/hevc-recorder-rotation branch from faa3bb1 to aba48e7 Compare September 6, 2026 10:54
@snokvist

snokvist commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator Author

Qodo review addressed. Seven findings: three were already resolved by the
redesign that landed after the review ran, three were real and are fixed, one
I am pushing back on with reasoning.

Note on ordering: the review ran against 5cf5213, before the change that
removed the forced IDR entirely. Findings 4 (transport streams ignore size
limits) and 5 (restarted recordings inherit old asks) are gone with the ask
machinery they described. Finding 2 is marked ✓ Resolved but was not — see
below.

2. Maruko recordings still grow unbounded — real, and the most important one
here.
Marked resolved, but verified still live at faa3bb1:

$ rg -n 'check_rotation|recorder_rotation_due|rot\.segment_bytes' src/maruko_recorder.c
(no matches)

maruko_recorder_write_frame() is the dual and synchronous-fallback writer on
that backend (maruko_pipeline.c:3120, :4807) and it bypassed the rotation
policy completely — so a PR whose entire point is that format="hevc" rotates
left one of three backends exactly as broken as before. Fixed: it now takes the
same shared cut (star6e_recorder_check_rotation(), exported for this rather
than reimplemented), advances rot.segment_bytes, and drops the stale "this
format does not rotate" text. Maruko's TS adapter also only accepted 19/20,
which would have left TS rotation inert on an intra-refresh craft there; it now
takes parameter-set boundaries too.

6. A rotation can erase an older segment — real, fixed. The name carries
only uptime seconds plus 16 bits of nanosecond clock, and after a reboot the
uptime restarts, so a repeat is reachable. O_TRUNC destroyed whatever was
there. Now O_EXCL with a retry — and applied at start() as well, not only
on rotation, since the same hazard was already there. This PR does multiply the
exposure (one name per segment rather than one per recording), which is what
makes it mine to fix.

7. Rotation hides segment write failures — real, fixed. fdatasync() and
close() results were discarded, so a delayed write surfacing at the end of a
segment read as a clean rotation. Both are checked now; a failure stops the
recorder the same way a failed reopen does. Test added
(..._reports_finalise_failure), which puts a pipe under the descriptor so
fdatasync() fails with EINVAL — no double-close/fd-reuse hazard.
Mutation-checked: ignoring the results fails 3 assertions.

3. Segment progress is sent as an error — not taking this one. The rule is
real, but every line this recorder emits goes to stderr, including
[recorder] started: four lines above. Moving only the rotation line would
split one recording's progress across two streams, so an operator following
stderr would see the recording start and then nothing. The fix worth making is
all of the recorder's output at once, which is a consistency pass, not part of
issue #123. Flagged in the code rather than silently ignored.

1. Merging also ships another feature — correct, and already disclosed at
the top of the PR body: this is stacked on #124 and must be rebased onto master
once that squashes. Nothing to change until then.

One thing I want to be explicit about: I added a test for finding 6 and it
was a bad test. Mutation-checking it — reverting the open to O_TRUNC — left it
green, because a test cannot provoke a nanosecond-clock name collision. I
have kept it for what it does prove (rotation hands out fresh names and loses
no segment) and written the gap into the test's own comment. O_EXCL rests on
open(2) semantics, not on that test, and I would rather say so than let a
passing test imply coverage it does not have.

make test 3034 passed / 0 failed; all three backends build clean from
make clean.

Not device-verified: the Maruko writer. That bench has no storage meeting
the recorder's 50 MB free-space precondition (1 MB free, no SD card), so the
fix there rests on calling the same shared cut that is device-verified on
Star6E, plus a pack scan mirroring the accessor Maruko's TS adapter already
uses in production.

snokvist and others added 2 commits September 6, 2026 13:42
Review of the two commits below found a regression introduced by extracting
the TS write path into ts_write_muxed(): the `segment_bytes += written` that
lived beside the write was not carried across, so the counter stayed pinned at
the PAT/PMT bytes a segment opens with.

Two consequences, both silent:
  - record.maxMB never fired for format="ts" -- the default recording path on
    every backend -- because the threshold is tested against that counter;
  - the new write-failure rollback truncates to that counter, so an ENOSPC or
    EFBIG would have cut the whole segment back to its ~376-byte header
    instead of to the last complete frame.  Strictly worse than the bug the
    rollback was added to fix.

It shipped green because every rotation test set max_bytes to 1, which the
header alone already satisfies, and the rollback assertion compared the file
size against the same frozen counter ftruncate had just used -- tautological.
The new test crosses a 32 KB threshold by accumulation instead, and fails on
both counts if the increment is removed again.

Device-verified on Star6E against the SD card, max_mb=20 over 100 MB: five
segments of 20980988 bytes each.  Before the fix, one file of 100 MB.

Also from the same review:
  - runtime_rotate_idr_on() lost its last caller when the IDR ask was deleted;
    a static function with no callers fails `make lint`, which is -Wall
    -Werror.  make lint now runs clean on all three backends -- it is a gate I
    had not been running.
  - a rotation that cannot reopen because the card filled reports disk_full
    rather than a write error; the space check only runs every 300 frames, so
    ENOSPC genuinely arrives at the segment open().
  - maruko_recorder.c stopped itself without clearing `recording`, so the
    status reported a stopped recording as active indefinitely.
  - star6e_recorder_write_frame() still said "this format does not rotate" on
    EFBIG, which stopped being true when this branch gave it rotation.
  - the raw recorder now clears rotation_due_since/warned_no_cut_point on
    start, as the TS one already did, and takes the status lock around
    segment_bytes like the other two writers.
  - two comments describing the deleted IDR-ask machinery.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U1w7gRc6ASpa7P7HRCBQEw
Verification pass over the three commits below. The fixes that landed in
cdfa1f2 were correct but each covered only one of two symmetric cases, and
several new assertions could not fail.

Code:

- A TS rotation that could not reopen still reported write_error when the card
  filled -- the defect fixed on the raw path in cdfa1f2, left in place on the
  one that matters more, since format="ts" is the default.  Both now classify
  ENOSPC as disk_full, and both preserve errno across the diagnostic.
- maruko_recorder.c check_disk_space() stopped without clearing `recording`.
  cdfa1f2 fixed the stop_with_error() half of the same file; this is the other
  exit, reachable in dual mode when free space drops mid-recording.  Left set,
  the status reports a closed recording as active for the rest of the process.
- cv610_rotate_idr() lost its last caller when the IDR ask was deleted, exactly
  as runtime_rotate_idr_on() did.  Worth noting `make lint` cannot see either:
  it is -fsyntax-only, which suppresses -Wunused-function.  `make test-werror`
  does compile these files with -Wall -Wextra -Werror and is the gate that
  catches it.
- The cut-point byte scan looked only at the FIRST NAL of an access unit while
  the SDK-pack scanners looked at all of them.  An access unit that leads with
  an AUD or a prefix SEI would have left rotation inert on the mirror path
  while the synchronous path rotated normally.  Now scans the whole unit via
  h26x_util_annexb_next(), which also removes the hand-rolled NAL parsing this
  had duplicated in two files.
- write_stream() went back to setting is_idr for IRAP only.  That value also
  becomes the TS random_access_indicator, so widening it to parameter-set
  boundaries was an unintended wire-format change; rotation never needed it,
  because write_video() scans the access unit itself.
- A start refused for low space published the PREVIOUS recording's path and
  counters next to stop_reason "disk_full", reading as though that file had
  filled the card.  Now cleared with the reason.
- maruko_runtime.c never named the format on the inactive status branch, so it
  answered a fully populated record with "format":"" where the other two
  backends answer ts/hevc.
- Four comments describing the deleted IDR-ask machinery, and the
  SD_CARD_RECORDING rotation steps still opening with "waits for the next IDR".

Tests -- four assertions that could not fail, all confirmed by mutation:

- Two "did not cut on a non-cut-point" checks passed because the threshold had
  not been crossed yet, so the cut-point gate was never consulted: forcing the
  gate open failed zero raw assertions.  Primed, they now fail 4.
- "noerase rotated" compared a loop-controlled counter with its own bound.  It
  now asserts the recorder cut once per captured path; disabling rotation
  entirely now fails 18 assertions where it used to fail 17.
- "efbig rolled back to the frame boundary" compared the file against the same
  counter ftruncate had just used as its target, so any wrong target was
  self-consistent -- it stayed green through the exact regression cdfa1f2
  fixed.  It now compares against a byte total accumulated from write_video()
  return values, and fails when that increment is removed.
- The restart test asserted a warning latch it never raised.  It raises it now,
  and removing the reset fails 2 assertions instead of 0.

make test-werror, lint and build clean on all three backends; 3044 passed,
0 failed under test, test-asan and test-tsan.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U1w7gRc6ASpa7P7HRCBQEw
@snokvist
snokvist merged commit 0c880d8 into OpenIPC:master Sep 6, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

record.format="hevc" silently ignores maxSeconds and maxMB — the raw recorder has no rotation at all

1 participant