Skip to content

fix(moq-mux): hold TS export PSI until PMT-declared tracks resolve#1980

Closed
t0ms wants to merge 3 commits into
moq-dev:mainfrom
t0ms:claude/stoic-bhaskara-6d16f9
Closed

fix(moq-mux): hold TS export PSI until PMT-declared tracks resolve#1980
t0ms wants to merge 3 commits into
moq-dev:mainfrom
t0ms:claude/stoic-bhaskara-6d16f9

Conversation

@t0ms

@t0ms t0ms commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes #1979: MPEG-TS exporter PSI (program tables) race condition. When audio codec resolves before video, the exporter locked PSI on an audio-only layout, then crashed when video's rendition arrived.

Solution: Gate PSI construction on the PMT's elementary-stream count (known at parse time), not on whichever subset of tracks has resolved. The importer publishes this as expected_tracks in the mpegts catalog extension; the exporter waits for that many tracks before building PSI.

Changes

  • catalog.rs: Add Mpegts::expected_tracks: Option<u16> field carrying PMT-declared elementary stream count
  • import.rs: Publish expected_tracks when PMT is parsed (before any codec resolves)
  • export.rs: New layout_complete() gate; PSI build waits for declared count to resolve
  • export_test.rs: Four integration tests covering audio-first race, video-first order, non-TS sources, and real import→export roundtrip

Testing

  • ✅ All 340 moq-mux tests pass (337 pre-existing + 4 new)
  • ✅ No regressions in existing roundtrip/codec/import tests
  • ✅ Format/clippy/doc checks clean
  • ✅ Cross-package sync verified

Backward Compatibility

Non-TS sources (no `expected_tracks` hint) export immediately as before, preserving existing behavior.

🤖 Generated with Claude Code

Fixes moq-dev#1979. The MPEG-TS exporter's PSI (PAT/PMT program tables)
are locked synchronously and cannot be re-issued when new tracks arrive. This
raced against codec-specific catalog-rendition timing: AAC registers its config
on the first ADTS frame, while H.264/H.265 wait for the first keyframe's inline
SPS. When audio resolved before video, the exporter locked PSI on an
audio-only layout, then crashed with "TS track layout changed after PAT/PMT
was emitted: '<name>' added" when video's rendition finally arrived.

Solution: Use the PMT's elementary-stream count (known synchronously at
parse time, before any codec resolves) as a hint to gate PSI construction.
The importer publishes this hint into the `mpegts` catalog extension as
`expected_tracks`, and the exporter waits for that many tracks to resolve
before building PSI. This decouples readiness from codec-specific timing
while preserving backward compatibility: non-TS sources (no hint) export
immediately as before.

Changes:
- `catalog.rs`: Add `Mpegts::expected_tracks: Option<u16>` field
- `import.rs`: Set `expected_tracks` when the PMT is parsed
- `export.rs`: New `layout_complete()` gate; PSI build waits for declared count
- `export_test.rs`: Four integration tests covering audio-first race,
  video-first order, non-TS sources, and real import→export roundtrip

All 340 moq-mux tests pass; no regressions.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Sorry @t0ms, you have reached your weekly rate limit of 500000 diff characters.

Please try again later or upgrade to continue using Sourcery

@t0ms t0ms marked this pull request as ready for review July 1, 2026 14:09

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Sorry @t0ms, you have reached your weekly rate limit of 500000 diff characters.

Please try again later or upgrade to continue using Sourcery

@coderabbitai

coderabbitai Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

This change adds an expected_tracks: Option<u16> field to the MPEG-TS catalog section. During TS import, the value is derived from PMT elementary stream entries that map to publishable tracks and stored in the catalog. During TS export, this hint is propagated into the Export struct and used to gate PSI/program table emission via a new layout_complete() check, ensuring PSI is not locked before the declared number of tracks resolves. Mpegts::is_empty() was updated accordingly. Regression tests cover ordering scenarios, the unset-hint case, and an import/export roundtrip.

Changes

Cohort / File(s) Summary
Catalog fieldrs/moq-mux/src/container/ts/catalog.rs Added expected_tracks: Option<u16> field to Mpegts; updated is_empty() to include it.
Import populationrs/moq-mux/src/container/ts/import.rs Derives and stores expected_tracks from PMT ES entries during PMT handling.
Export gatingrs/moq-mux/src/container/ts/export.rs Added expected_tracks field to Export, wired from catalog updates; added layout_complete(); gated PSI emission on layout completeness.
Testsrs/moq-mux/src/container/ts/export_test.rs Added regression tests for PSI gating under different track resolution orders, no-hint behavior, and import/export roundtrip.

Sequence Diagram(s)

sequenceDiagram
  participant Import
  participant Catalog
  participant Export
  participant PSIBuilder

  Import->>Catalog: set mpegts.expected_tracks from PMT ES count
  Catalog->>Export: update_catalog copies expected_tracks
  Export->>Export: poll_next checks layout_complete()
  alt tracks.len() < expected_tracks
    Export-->>PSIBuilder: withhold PSI/program tables
  else layout complete
    Export->>PSIBuilder: build PSI/program tables
  end
Loading

Related issues: #1979

Estimated code review effort: Medium-High — the gating logic in export.rs and the PID-counting logic in import.rs require careful review to ensure correct track resolution semantics.

Suggested labels: moq-mux, mpeg-ts, bugfix

Suggested reviewers: (repository maintainers familiar with moq-mux TS container code)

Poem

A rabbit counts each track with care,
PSI held back till all are there,
Audio first, or video's turn,
The layout waits its time to learn,
Then locks the tables, clean and square. 🐇📺

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: delaying TS PSI export until PMT-declared tracks resolve.
Description check ✅ Passed The description matches the implemented TS export PSI gating and expected_tracks propagation changes.
Linked Issues check ✅ Passed The PR adds PMT-derived expected_tracks and uses it to gate PSI until the declared track count resolves, addressing #1979.
Out of Scope Changes check ✅ Passed All changes support the TS export race fix and associated tests; no unrelated scope is evident.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing Touches
✨ Simplify code
  • Create PR with simplified code

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
rs/moq-mux/src/container/ts/import.rs (1)

245-282: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Publish expected_tracks before any ensure_* side effects.

expected_tracks is only written after the loop, but ensure_section and the verbatim-producing ensure_stream paths can register tracks during that same PMT pass. That still leaves a window where an exporter can observe a partial layout with no hint and lock PSI on it, so the race remains for immediately-resolved section/verbatim tracks. Compute the count directly from pmt.es_info and store it before calling ensure_section / ensure_stream.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rs/moq-mux/src/container/ts/import.rs` around lines 245 - 282, The
`expected_tracks` update in the PMT handling flow needs to happen before any
`ensure_section` or `ensure_stream` side effects can register tracks. In the PMT
loop in `import.rs`, move the `expected` computation and the
`catalog::mpegts_mut` assignment ahead of the per-ES `ensure_*` calls, using
`pmt.es_info` and the existing `sections`/`streams` checks so exporters see the
full expected layout during the same pass. Keep the logic anchored around
`pmt.es_info`, `ensure_section`, `ensure_stream`, and `expected_tracks` so the
PSI state is published first.
🧹 Nitpick comments (1)
rs/moq-mux/src/container/ts/export_test.rs (1)

293-570: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚖️ Poor tradeoff

Keep these regressions inline with export.rs.

Adding more cases to export_test.rs continues the split from the repo's Rust test convention. Please move them under #[cfg(test)] mod tests in rs/moq-mux/src/container/ts/export.rs. As per coding guidelines, "Keep Rust tests inline as #[cfg(test)] mod tests in the source file".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rs/moq-mux/src/container/ts/export_test.rs` around lines 293 - 570, The new
regression tests were added in the standalone export_test module, but they
should live inline with the production code. Move the cases into the
`#[cfg(test)] mod tests` block inside `export.rs`, keeping the existing test
names and helpers like `export_holds_psi_until_pmt_declared_tracks_resolve`,
`export_holds_psi_until_all_declared_tracks_resolve_video_first`, and
`import_export_roundtrip_carries_expected_tracks_hint` so they remain alongside
`Export::with_ts` and related TS export logic.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@rs/moq-mux/src/container/ts/export_test.rs`:
- Around line 507-510: The test around exporter.next() is too loose because it
only checks that the future completes, which allows Ok(Err(_)) and Ok(Ok(None))
to pass. Update the assertion in export_test::... to explicitly match
Ok(Ok(Some(_))) as the only success case, and make the other Ok(...) branches
fail so the test verifies an actual emitted frame instead of just completion.
- Around line 541-546: The test currently skips validation when expected_tracks
is None, which lets the importer regression pass unnoticed. Update the assertion
in this export test to require expected_tracks to be set and at least 1, so the
test fails immediately if the importer never populates the hint. Keep the check
in the same test block where expected_tracks is examined, and make sure it
enforces both presence and value instead of only validating Some(count).

---

Outside diff comments:
In `@rs/moq-mux/src/container/ts/import.rs`:
- Around line 245-282: The `expected_tracks` update in the PMT handling flow
needs to happen before any `ensure_section` or `ensure_stream` side effects can
register tracks. In the PMT loop in `import.rs`, move the `expected` computation
and the `catalog::mpegts_mut` assignment ahead of the per-ES `ensure_*` calls,
using `pmt.es_info` and the existing `sections`/`streams` checks so exporters
see the full expected layout during the same pass. Keep the logic anchored
around `pmt.es_info`, `ensure_section`, `ensure_stream`, and `expected_tracks`
so the PSI state is published first.

---

Nitpick comments:
In `@rs/moq-mux/src/container/ts/export_test.rs`:
- Around line 293-570: The new regression tests were added in the standalone
export_test module, but they should live inline with the production code. Move
the cases into the `#[cfg(test)] mod tests` block inside `export.rs`, keeping
the existing test names and helpers like
`export_holds_psi_until_pmt_declared_tracks_resolve`,
`export_holds_psi_until_all_declared_tracks_resolve_video_first`, and
`import_export_roundtrip_carries_expected_tracks_hint` so they remain alongside
`Export::with_ts` and related TS export logic.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 6ca2435c-7b2e-4ec6-a4ed-a70488b775b1

📥 Commits

Reviewing files that changed from the base of the PR and between 00ff1a5 and 5378f6f.

📒 Files selected for processing (4)
  • rs/moq-mux/src/container/ts/catalog.rs
  • rs/moq-mux/src/container/ts/export.rs
  • rs/moq-mux/src/container/ts/export_test.rs
  • rs/moq-mux/src/container/ts/import.rs

Comment on lines +507 to +510
match tokio::time::timeout(std::time::Duration::from_millis(50), exporter.next()).await {
Ok(_) => {} // got output, as expected (no waiting for missing video)
Err(_) => panic!("exporter must not wait when expected_tracks is None"),
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Assert that a frame was emitted, not just that the future completed.

This accepts Ok(Err(_)) and Ok(Ok(None)), so the test can pass on exporter failure or EOF. Match Ok(Ok(Some(_))) explicitly and fail the other Ok(...) cases.

Suggested fix
-	match tokio::time::timeout(std::time::Duration::from_millis(50), exporter.next()).await {
-		Ok(_) => {} // got output, as expected (no waiting for missing video)
-		Err(_) => panic!("exporter must not wait when expected_tracks is None"),
-	}
+	match tokio::time::timeout(std::time::Duration::from_millis(50), exporter.next()).await {
+		Ok(Ok(Some(_))) => {}
+		Ok(other) => panic!("expected an output frame, got {other:?}"),
+		Err(_) => panic!("exporter must not wait when expected_tracks is None"),
+	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
match tokio::time::timeout(std::time::Duration::from_millis(50), exporter.next()).await {
Ok(_) => {} // got output, as expected (no waiting for missing video)
Err(_) => panic!("exporter must not wait when expected_tracks is None"),
}
match tokio::time::timeout(std::time::Duration::from_millis(50), exporter.next()).await {
Ok(Ok(Some(_))) => {}
Ok(other) => panic!("expected an output frame, got {other:?}"),
Err(_) => panic!("exporter must not wait when expected_tracks is None"),
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rs/moq-mux/src/container/ts/export_test.rs` around lines 507 - 510, The test
around exporter.next() is too loose because it only checks that the future
completes, which allows Ok(Err(_)) and Ok(Ok(None)) to pass. Update the
assertion in export_test::... to explicitly match Ok(Ok(Some(_))) as the only
success case, and make the other Ok(...) branches fail so the test verifies an
actual emitted frame instead of just completion.

Comment on lines +541 to +546
if let Some(count) = expected {
assert!(
count >= 1,
"expected_tracks should be at least 1 for this file with audio+video"
);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Fail when the importer never sets expected_tracks.

The if let makes this regression succeed when the hint is still None, which is exactly the behavior this test is supposed to catch.

Suggested fix
-	if let Some(count) = expected {
-		assert!(
-			count >= 1,
-			"expected_tracks should be at least 1 for this file with audio+video"
-		);
-	}
+	let count = expected.expect("importer should populate expected_tracks from the PMT");
+	assert!(
+		count >= 1,
+		"expected_tracks should be at least 1 for this file with audio+video"
+	);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if let Some(count) = expected {
assert!(
count >= 1,
"expected_tracks should be at least 1 for this file with audio+video"
);
}
let count = expected.expect("importer should populate expected_tracks from the PMT");
assert!(
count >= 1,
"expected_tracks should be at least 1 for this file with audio+video"
);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rs/moq-mux/src/container/ts/export_test.rs` around lines 541 - 546, The test
currently skips validation when expected_tracks is None, which lets the importer
regression pass unnoticed. Update the assertion in this export test to require
expected_tracks to be set and at least 1, so the test fails immediately if the
importer never populates the hint. Keep the check in the same test block where
expected_tracks is examined, and make sure it enforces both presence and value
instead of only validating Some(count).

/// complete one instead of locking PSI on whichever subset has resolved first.
/// `None` when the catalog wasn't produced from an MPEG-TS source.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expected_tracks: Option<u16>,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I think this is the wrong fix.

I think export should instead wait IDK like 100ms for the catalog to stabilize, since TS doesn't support reinitializing (just like fMP4).

On the import side, we could wait until all tracks are initialized before publishing the catalog too, but it's still racey.

@t0ms t0ms closed this Jul 2, 2026
@t0ms

t0ms commented Jul 2, 2026

Copy link
Copy Markdown
Contributor Author

superseded by #1991

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.

moq-mux TS export: catalog convergence race locks PSI before video track resolves

2 participants