Skip to content

Render: Bounded the wait for clip sources so a missing source file fails the render - #434

Open
drowaudio wants to merge 2 commits into
developfrom
bugfix/issue_416_render_missing_source
Open

drowaudio wants to merge 2 commits into
developfrom
bugfix/issue_416_render_missing_source

Conversation

@drowaudio

Copy link
Copy Markdown
Contributor

Summary

Rendering an Edit that references a clip whose source file has been deleted or moved never returned - Renderer::renderToFile spun indefinitely with no error. The wait for clip sources is now bounded and reported as a render failure.

Root cause

NodeRenderContext::renderNextBlock checks that every leaf node in the graph is ready before rendering a block, which is how a render waits for proxies and rendered sources to be generated:

while (! (leafNodesReady || owner.shouldCancel()))
    return false;

Returning false makes RenderTask::runJob return jobNeedsRunningAgain, and the drivers (Renderer::renderToFile's while (task->runJob() == jobNeedsRunningAgain) {} and EditRenderer's render thread) simply call it again. There was no bound on that retry.

For a clip whose file is gone, WaveNode::isReadyToProcess() never returns true. AudioFileCache::getOrCreateCachedFile still hands out a CachedFile (the memory-mapped format manager matches on extension, not existence), so createReader succeeds, but the cached AudioFileInfo has a sample rate of 0, so updateFileSampleRate() fails on every call. WaveNode already bails out for an AudioFile with a null hash ("this will never return a valid reader and we should just bail") but has no equivalent for a file that has gone missing after the fact. The MIDI path (NodeRenderContext::renderMidi) had the same unbounded while (! leafNodesReady()) loop.

The fix

The wait is bounded rather than special-cased per node type, so it covers every leaf node, not just WaveNode:

  • New Renderer::Parameters::sourceReadyTimeout (10 seconds by default; zero or negative restores the old unbounded wait).
  • NodeRenderContext tracks how long it has been waiting for leaf nodes. The timer is reset for as long as any proxy or render job is running, so legitimately long waits for something the engine is actually generating (timestretch proxies, EditClip renders, freeze files) are still unbounded - only stalls where nothing is being generated count down.
  • On timeout, the audio path closes the writer, deletes the partial destination file, stops the playhead, puts the plugins back into realtime mode and sets RenderTask::errorMessage, so the job reports as finished and Renderer::renderToFile returns false. The MIDI path returns the same message.
  • New AudioProxyGenerator::isAnyProxyBeingGenerated() to support the reset check alongside RenderManager::getNumJobs().

Regression test

Renderer: missing source file fails the render instead of retrying forever in tracktion_Renderer.test.cpp inserts a wave clip, deletes the source from under the Edit, then renders with a 1 second sourceReadyTimeout. It asserts the render finishes, reports an error naming the source file, and leaves no output file behind. With the fix reverted it fails (the render never calls back inside a 30 second bound); with the fix it passes in ~1 second. Full TestRunner suite: 370/370 pass.

Notes

The *** Cache miss logging the reporter saw is a separate symptom of the same missing file (AudioFileCache::Reader::read can't map a section of a file that isn't there); the render itself stalls before reaching it in the case reproduced here. This PR doesn't change the cache's behaviour.

BREAKING-CHANGES.md has an entry, since an app that generates a clip's source file through a mechanism the engine doesn't know about will now see such a render fail after the timeout rather than wait.

Fixes #416

🤖 Generated with Claude Code

… render

NodeRenderContext waits for the graph's leaf nodes to become ready before
rendering each block, which is how it waits for proxies and rendered sources
to be generated. That wait had no bound, so a WaveNode whose source file had
been deleted or moved never became ready and the render retried forever:
Renderer::renderToFile never returned and no error was reported.

The wait is now bounded by Renderer::Parameters::sourceReadyTimeout (10s by
default) in both the audio and MIDI paths. The timer is reset for as long as
any proxy or render job is running, so waits for something the engine is
actually generating stay unbounded. On timeout the partial file is deleted
and the render finishes with an error message, so renderToFile returns false.

fixes #416

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

codecov Bot commented Sep 17, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.61702% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 59.84%. Comparing base (aa6301a) to head (0166817).
⚠️ Report is 1 commits behind head on develop.

Files with missing lines Patch % Lines
...on_engine/model/export/tracktion_Renderer.test.cpp 92.68% 3 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##           develop     #434      +/-   ##
===========================================
+ Coverage    59.81%   59.84%   +0.03%     
===========================================
  Files          568      568              
  Lines        79964    80011      +47     
  Branches     12381    12386       +5     
===========================================
+ Hits         47828    47881      +53     
+ Misses       32136    32130       -6     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@drowaudio

Copy link
Copy Markdown
Contributor Author

I'm not sure about this... I think the existing isReadyToProcess() should return true if the file isNull() which should be true for moved or missing files, so it should just render without them present.

Is this not what's happening?

@drowaudio

Copy link
Copy Markdown
Contributor Author

🤖 Good challenge - I checked, and that isn't what's happening, because isNull() is never true for a moved or missing file.

AudioFile's hash is derived purely from the path:

static inline HashCode getAudioFileHash (const juce::File& file) noexcept
{
    return file.getFullPathName().hashCode64();
}

so isNull() (hash == 0) is only true for a default-constructed juce::File. A clip whose sample has been deleted or moved still has its path, so the hash is non-zero and the existing guard doesn't fire. (createNodeForAudioClip also already bails on playFile.isNull() before building a node, so by the time WaveNode::isReadyToProcess() runs, that branch can't be hit from this path anyway.)

Where it actually sticks, confirmed by instrumenting the run:

  1. AudioFileCache::getOrCreateCachedFile still hands out a CachedFile for the deleted .wav, because it picks a format via canHandleFile(), which matches on extension, not existence. So createReader succeeds and reader != nullptr.
  2. The cached AudioFileInfo then has sampleRate == 0, so updateFileSampleRate() returns false on every call, and isReadyToProcess() returns false forever.

So the node never reports ready, renderNextBlock keeps returning false, and runJob keeps returning jobNeedsRunningAgain.

Why I didn't just bail on a missing file

The obvious version of what you're describing - return true when the file doesn't exist or its info is invalid - breaks the proxy wait, because that wait uses exactly the same two signals. createNodeForAudioClip calls beginRenderingNewProxyIfNeeded(), which reaches createNewProxyAsync()startTimer (600), so the RenderManager job isn't created until the message thread fires ~600ms later. In that window a proxy-needing clip is indistinguishable from a missing file: the proxy file doesn't exist yet and its sample rate is 0. getPlaybackFile() says as much in its own comment - "if it's a rendered file then obviously it won't exist but we need to return it anyway". Bailing on that state would silently render silence instead of waiting for the proxy.

That's why I bounded the wait instead: it doesn't need to tell the two cases apart, and it covers every leaf node rather than just WaveNode.

If you'd rather it rendered silence

I agree that's the nicer user-facing behaviour, and I think it can be done properly rather than by inference. At node-build time we already know whether a file is expected to be generated (canUseProxy(), usesTimeStretchedProxy(), getInfo().needsCachedProxy, shouldAttemptRender()). Passing that in as a flag would let isReadyToProcess() be honest - wait only if something is actually going to produce this file, otherwise bail immediately - with no timing race and no 10 second delay before the render completes.

Happy to redo it that way, either replacing the timeout or keeping it as a backstop for the other leaf node types. Which would you prefer?

@drowaudio

Copy link
Copy Markdown
Contributor Author

It would be much better the second way as some proxy files can take longer that 10s to generate.
Implement it that way so I can see what it would look like.

…iming out

Replaces the Renderer::Parameters::sourceReadyTimeout bound from the previous
commit, which capped every wait for a leaf node and so could also cut short a
proxy that legitimately takes longer than the timeout to generate.

Instead, decide at node-build time whether the playback file is ever going to
appear. AudioClipBase::isGeneratingPlaybackFile() reports whether the clip is
generating it (a proxy, or a source render it should attempt), mirroring the
conditions in beginRenderingNewProxyIfNeeded so it's true before the async job
has actually started. createNodeForAudioClip now returns no node when the
playback file is invalid and nothing is going to produce it, alongside the
existing bail for a null playback file.

WaveNode::isReadyToProcess keeps its current meaning, so waits for proxies stay
unbounded, and a deleted or moved source is simply left out of the graph and
renders as silence rather than stalling the render.

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

Copy link
Copy Markdown
Contributor Author

🤖 Done - reworked to the node-build-time approach, timeout removed.

What's there now

AudioClipBase::isGeneratingPlaybackFile() answers the question directly:

bool AudioClipBase::isGeneratingPlaybackFile()
{
    // A source the clip renders itself (an EditClip, say) only exists once that render
    // has run, and a failed render sets lastRenderJobFailed so we stop claiming it's coming
    if (shouldAttemptRender() && ! getAudioFile().isValid())
        return true;

    if (! canUseProxy())
        return false;

    // Proxies are generated by the timer started in beginRenderingNewProxyIfNeeded, so the
    // job may not exist yet - go by whether one is needed rather than whether one is running
    return usesTimeStretchedProxy() || getAudioFile().getInfo().needsCachedProxy;
}

It mirrors the conditions in beginRenderingNewProxyIfNeeded() rather than asking whether a job is running, which is what sidesteps the 600ms startTimer race I flagged.

createNodeForAudioClip then bails alongside the existing null-playback-file bail:

    // Trigger proxy render if it needs it
    clip.beginRenderingNewProxyIfNeeded();

    // A playback file that can't be read and that the clip isn't going to generate will never
    // appear - the source has been deleted, moved or is unreadable. Building a node for it would
    // leave a leaf that never reports itself ready, which makes an offline render wait forever
    if (! playFile.isValid() && ! clip.isGeneratingPlaybackFile())
        return {};

WaveNode::isReadyToProcess is untouched, so waits for proxies stay unbounded however long they take.

Test

Renderer: missing source file doesn't stall the render now builds a two-track Edit, deletes one clip's source, and renders. It asserts the render succeeds and that the surviving track's audio is still in the output, so it covers both the missing clip being silent and the rest of the Edit rendering normally. Without the createNodeForAudioClip bail it hangs (fails on the 30s bound); with it the test runs in 0.6s. Full TestRunner suite: 370/370.

One residual case

If a proxy or source render is started and then fails, the node has already been built and isReadyToProcess waits forever. lastRenderJobFailed stops the clip claiming it's still generating, but only on the next graph build. The old timeout covered that; this doesn't. Happy to handle it if you want - the natural place looks like rebuilding the graph on jobFinished when the render failed - but it's a different bug from the one in the issue, so I've left it.

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.

Renderer::renderToFile SPINS on a missing source file

1 participant