fix: node graph edge display, teardown crashes, and event/audio lifetime bugs

- capi: oakengine_node_output_connection_at/_at_ex returned the source
  node as the connection destination; the actual destination is
  conn.second.node(). Out-edge enumeration was useless, so the node
  view could only draw in-edges and randomly lost whichever edges
  needed the out-edge path (random per context build order).
  Regression test in oakengine_node_test
- project teardown: Project::clear() pre-notifies node removal while
  nodes are fully constructed (observers used to crash on
  half-destroyed nodes); childEvent suppresses the removal dance while
  clearing; Node::disconnect_all/disconnect_edge get a silent mode for
  teardown so no invalidation/events touch dying members
  (is_being_cleared); ClipBlock marker disconnect guarded against
  dead viewer/markers; ProjectCopier and PreviewAutoCacher drop
  project references on Project::destroyed instead of disconnecting
  dead objects at shutdown
- preview: add oakengine_preview_request_get_audio_sample_count; the
  viewer queried sample count by passing nullptr to get_audio_samples
  which rejects it, so all playback audio was silently dropped
- app: fix unterminated input-id memcpy in ResolveGroupInput
  (nodeparamviewitem, widgetbridge) that corrupted every parameter id
- app: unsubscribe raw C-API event subscriptions in destructors of
  NodeParamViewKeyframeControl, NodeParamViewConnectedLabel and
  ExportDialog; playhead events used to fire into dead widgets
  (crash when dragging the playhead)
- tests: preview request roundtrip (video frame + audio range) and
  free-while-active teardown coverage; env-gated OAK_DEBUG_EDGES /
  OAK_DEBUG_INVALID_INPUT diagnostics
- docs: investigation notes in docs/zh/
This commit is contained in:
2026-08-02 14:29:49 +08:00
parent 30853cbcfd
commit a59c33715f
25 changed files with 441 additions and 32 deletions
+4
View File
@@ -222,6 +222,10 @@ OAKENGINE_API int oakengine_preview_request_get_frame(
OAKENGINE_API int oakengine_preview_request_get_audio_channel_count(
const OakEnginePreviewRequest *req);
/** @brief Samples per channel in the result, or 0 if none. */
OAKENGINE_API int oakengine_preview_request_get_audio_sample_count(
const OakEnginePreviewRequest *req);
/** @brief Sample rate of the audio result, or 0 if none. */
OAKENGINE_API int oakengine_preview_request_get_audio_sample_rate(
const OakEnginePreviewRequest *req);
+24 -18
View File
@@ -435,30 +435,36 @@ void ClipBlock::invalidate_cache(const TimeRange &range, const QString &from,
viewers.isEmpty() ? nullptr : viewers.first();
if (new_connected_viewer != connected_viewer_) {
TimelineMarkerList *old_markers =
connected_viewer_ ? connected_viewer_->get_markers() : nullptr;
if (old_markers) {
disconnect(old_markers, &TimelineMarkerList::marker_added, this,
&ClipBlock::preview_changed);
disconnect(old_markers, &TimelineMarkerList::marker_removed,
this, &ClipBlock::preview_changed);
disconnect(old_markers, &TimelineMarkerList::marker_modified,
this, &ClipBlock::preview_changed);
}
if (connected_viewer_) {
disconnect(connected_viewer_->get_markers(),
&TimelineMarkerList::marker_added, this,
&ClipBlock::preview_changed);
disconnect(connected_viewer_->get_markers(),
&TimelineMarkerList::marker_removed, this,
&ClipBlock::preview_changed);
disconnect(connected_viewer_->get_markers(),
&TimelineMarkerList::marker_modified, this,
&ClipBlock::preview_changed);
disconnect(connected_viewer_, &ViewerOutput::destroyed, this,
nullptr);
}
connected_viewer_ = new_connected_viewer;
TimelineMarkerList *new_markers =
connected_viewer_ ? connected_viewer_->get_markers() : nullptr;
if (new_markers) {
connect(new_markers, &TimelineMarkerList::marker_added, this,
&ClipBlock::preview_changed);
connect(new_markers, &TimelineMarkerList::marker_removed, this,
&ClipBlock::preview_changed);
connect(new_markers, &TimelineMarkerList::marker_modified,
this, &ClipBlock::preview_changed);
}
if (connected_viewer_) {
connect(connected_viewer_->get_markers(),
&TimelineMarkerList::marker_added, this,
&ClipBlock::preview_changed);
connect(connected_viewer_->get_markers(),
&TimelineMarkerList::marker_removed, this,
&ClipBlock::preview_changed);
connect(connected_viewer_->get_markers(),
&TimelineMarkerList::marker_modified, this,
&ClipBlock::preview_changed);
connect(connected_viewer_, &ViewerOutput::destroyed, this,
[this]() { connected_viewer_ = nullptr; });
}
}
+4
View File
@@ -265,6 +265,10 @@ private:
TransitionBlock *in_transition_;
TransitionBlock *out_transition_;
// Cleared via the viewer's destroyed() signal (see invalidate_cache);
// during project teardown the viewer can die before this clip, and a
// raw dangling pointer here led to disconnecting signals on a dead
// TimelineMarkerList.
ViewerOutput *connected_viewer_;
private:
+48 -3
View File
@@ -21,6 +21,8 @@
#include "node.h"
#include <execinfo.h>
#include <QApplication>
#include <QGuiApplication>
#include <QDebug>
@@ -216,7 +218,7 @@ void Node::connect_edge(Node *output, const NodeInput &input)
}
}
void Node::disconnect_edge(Node *output, const NodeInput &input)
void Node::disconnect_edge(Node *output, const NodeInput &input, bool silent)
{
// Ensure graph is the same
Q_ASSERT(input.node()->parent() == output->parent());
@@ -232,6 +234,18 @@ void Node::disconnect_edge(Node *output, const NodeInput &input)
outputs.erase(std::find(outputs.begin(), outputs.end(),
std::pair<Node *, NodeInput>({ output, input })));
if (silent) {
// Teardown-only mode: just drop the edge from both maps. No events,
// no signals, no invalidation — the whole graph is being destroyed
// and handlers would touch half-destroyed nodes.
return;
}
if (qEnvironmentVariableIsSet("OAK_DEBUG_EDGES")) {
qWarning("EDGE-DEBUG: disconnect_edge %p -> %p (%s)", (void *)output,
(void *)input.node(), qPrintable(input.input()));
}
// Call internal events
input.node()->InputDisconnectedEvent(input.input(), input.element(),
output);
@@ -1239,6 +1253,14 @@ Node *Node::copy_node_in_graph(Node *node, MultiUndoCommand *command)
void Node::send_invalidate_cache(const TimeRange &range,
const InvalidateCacheOptions &options)
{
// During project teardown, don't propagate invalidation across edges:
// the nodes on the other side may already be half-destroyed.
if (Project *p = project()) {
if (p->is_being_cleared()) {
return;
}
}
for (const OutputConnection &conn : output_connections_) {
// Send clear cache signal to the Node
const NodeInput &in = conn.second;
@@ -1249,6 +1271,12 @@ void Node::send_invalidate_cache(const TimeRange &range,
void Node::invalidate_all(const QString &input, int element)
{
if (Project *p = project()) {
if (p->is_being_cleared()) {
return;
}
}
invalidate_cache(TimeRange(RATIONAL_MIN, RATIONAL_MAX), input, element);
}
@@ -1910,6 +1938,18 @@ void Node::report_invalid_input(const char *attempted_action, const QString &id,
qWarning()
<< "Failed to" << attempted_action << "parameter" << id << "element"
<< element << "in node" << this->id() << "- input doesn't exist";
if (qEnvironmentVariableIsSet("OAK_DEBUG_INVALID_INPUT")) {
void *frames[32];
const int n = backtrace(frames, 32);
char **symbols = backtrace_symbols(frames, n);
if (symbols) {
for (int i = 0; i < n; i++) {
qWarning("INVALID-INPUT-BT: %s", symbols[i]);
}
free(symbols);
}
}
}
NodeInputImmediate *Node::create_immediate(const QString &input)
@@ -2284,15 +2324,20 @@ bool Node::inputs_from(const QString &id, bool recursively) const
void Node::disconnect_all()
{
// During project teardown, skip the disconnect events/signals: the
// other side's handlers touch this node's members which are already
// destroyed at ~Node time (e.g. caches), which is a use-after-free.
const bool silent = project() && project()->is_being_cleared();
// Disconnect inputs (copy map since internal map will change as we disconnect)
InputConnections copy = input_connections_;
for (auto it = copy.cbegin(); it != copy.cend(); it++) {
disconnect_edge(it->second, it->first);
disconnect_edge(it->second, it->first, silent);
}
while (!output_connections_.empty()) {
OutputConnection conn = output_connections_.back();
disconnect_edge(conn.first, conn.second);
disconnect_edge(conn.first, conn.second, silent);
}
}
+2 -1
View File
@@ -417,7 +417,8 @@ public:
static void connect_edge(Node *output, const NodeInput &input);
static void disconnect_edge(Node *output, const NodeInput &input);
static void disconnect_edge(Node *output, const NodeInput &input,
bool silent = false);
void copy_cache_uuids_from(Node *n);
+24
View File
@@ -134,6 +134,12 @@ NodeEdgeAddCommand::~NodeEdgeAddCommand()
void NodeEdgeAddCommand::redo()
{
if (qEnvironmentVariableIsSet("OAK_DEBUG_EDGES")) {
qWarning("EDGE-DEBUG: NodeEdgeAddCommand::redo %p -> %p (%s)",
(void *)output_, (void *)input_.node(),
qPrintable(input_.input()));
}
if (input_.is_connected()) {
if (!remove_command_) {
remove_command_ =
@@ -148,6 +154,12 @@ void NodeEdgeAddCommand::redo()
void NodeEdgeAddCommand::undo()
{
if (qEnvironmentVariableIsSet("OAK_DEBUG_EDGES")) {
qWarning("EDGE-DEBUG: NodeEdgeAddCommand::undo %p -> %p (%s)",
(void *)output_, (void *)input_.node(),
qPrintable(input_.input()));
}
Node::disconnect_edge(output_, input_);
if (remove_command_) {
@@ -169,11 +181,23 @@ NodeEdgeRemoveCommand::NodeEdgeRemoveCommand(Node *output,
void NodeEdgeRemoveCommand::redo()
{
if (qEnvironmentVariableIsSet("OAK_DEBUG_EDGES")) {
qWarning("EDGE-DEBUG: NodeEdgeRemoveCommand::redo %p -> %p (%s)",
(void *)output_, (void *)input_.node(),
qPrintable(input_.input()));
}
Node::disconnect_edge(output_, input_);
}
void NodeEdgeRemoveCommand::undo()
{
if (qEnvironmentVariableIsSet("OAK_DEBUG_EDGES")) {
qWarning("EDGE-DEBUG: NodeEdgeRemoveCommand::undo %p -> %p (%s)",
(void *)output_, (void *)input_.node(),
qPrintable(input_.input()));
}
Node::connect_edge(output_, input_);
}
+27
View File
@@ -86,6 +86,23 @@ void Project::initialize()
void Project::clear()
{
is_being_cleared_ = true;
// Notify observers about each node while it is still fully constructed.
// The childEvent() removal path fires from ~Node, when derived-class
// members are already destroyed; observers touching the node there
// (e.g. querying a Sequence's tracks) used to crash.
const QVector<Node *> notify_copy = node_children_;
for (Node *node : notify_copy) {
if (!node_children_.contains(node)) {
// An observer already removed it from the graph
continue;
}
emit node_removed(node);
emit node->removed_from_graph(this);
node->RemovedFromGraphEvent(this);
}
// By deleting the last nodes first, we assume that nodes that are most important are deleted last
// (e.g. Project's ColorManager or ProjectSettingsNode.
for (auto it = node_children_.cbegin(); it != node_children_.cend(); it++) {
@@ -95,6 +112,8 @@ void Project::clear()
while (!node_children_.isEmpty()) {
delete node_children_.last();
}
is_being_cleared_ = false;
}
SerializedData Project::load(QXmlStreamReader *reader)
@@ -369,6 +388,14 @@ void Project::childEvent(QChildEvent *event)
} else if (event->type() == QEvent::ChildRemoved) {
node_children_.removeOne(node);
if (is_being_cleared_) {
// Teardown: everything is being destroyed anyway. The
// disconnect/emit dance below touches the half-destroyed
// child (this fires from ~Node) and has repeatedly crashed;
// Qt auto-disconnects the rest when destruction completes.
return;
}
// Disconnect signals
disconnect(node, &Node::input_connected, this,
&Project::input_connected);
+14
View File
@@ -103,6 +103,18 @@ public:
}
void set_modified(bool e);
/**
* @brief True while clear()/teardown is deleting all nodes.
*
* Nodes use this to suppress cache invalidation storms while the graph
* is being torn down: invalidating during destruction walks edges into
* half-destroyed nodes and has repeatedly caused use-after-free crashes.
*/
bool is_being_cleared() const
{
return is_being_cleared_;
}
bool has_autorecovery_been_saved() const;
void set_autorecovery_saved(bool e);
@@ -259,6 +271,8 @@ private:
bool autorecovery_saved_;
bool is_being_cleared_ = false;
ColorManager *color_manager_;
QVector<Node *> node_children_;
+19
View File
@@ -855,6 +855,25 @@ void PreviewAutoCacher::set_project(Project *project)
project_ = project;
if (project_) {
// If the project dies while we're still using it (e.g. shutdown order:
// project freed before the RenderManager), drop all state that
// references its nodes/caches without touching them. Otherwise the
// next set_project(nullptr) would disconnect signals on dead caches.
connect(project_, &Project::destroyed, this, [this]() {
project_ = nullptr;
delayed_requeue_timer_.stop();
single_frame_render_ = nullptr;
video_immediate_passthroughs_.clear();
pending_video_jobs_.clear();
pending_audio_jobs_.clear();
video_cache_data_.clear();
audio_cache_data_.clear();
multicam_ = nullptr;
// The copier's own destroyed-guard nulls its original_; this just
// clears its copy maps without touching the dead project.
copier_->set_project(nullptr);
});
// Copy graph (this should always be a Project)
set_renders_paused(true);
+5
View File
@@ -63,6 +63,11 @@ void ProjectCopier::set_project(Project *project)
original_ = project;
if (original_) {
// Don't keep a dangling pointer if the project dies while we're
// still around (e.g. RenderManager outlives the project at shutdown)
connect(original_, &Project::destroyed, this,
[this]() { original_ = nullptr; });
// The copied project is only used as an in-memory render proxy. Mark it so
// downstream code (e.g. RenderWorkerPool) knows it is safe to reset its
// modified flag after serializing a snapshot.
+6 -4
View File
@@ -5172,9 +5172,10 @@ int oakengine_node_output_connection_at(
return OAKENGINE_E_NOT_FOUND;
}
const auto &conn = conns[size_t(index)];
// conn.first = input Node*, conn.second = NodeInput on that node
// output_connections() stores {source (== self), NodeInput}; the
// connection's destination is the NodeInput's node, NOT conn.first.
if (input_node) {
*input_node = wrap(conn.first);
*input_node = wrap(conn.second.node());
}
if (input_id_buf && input_id_size > 0) {
string_to_buf(conn.second.input(), input_id_buf, input_id_size);
@@ -5197,9 +5198,10 @@ int oakengine_node_output_connection_at_ex(
return OAKENGINE_E_NOT_FOUND;
}
const auto &conn = conns[size_t(index)];
// conn.first = input Node*, conn.second = NodeInput on that node
// output_connections() stores {source (== self), NodeInput}; the
// connection's destination is the NodeInput's node, NOT conn.first.
if (input_node) {
*input_node = wrap(conn.first);
*input_node = wrap(conn.second.node());
}
if (input_id_buf && input_id_size > 0) {
string_to_buf(conn.second.input(), input_id_buf, input_id_size);
+19
View File
@@ -619,6 +619,25 @@ int oakengine_preview_request_get_audio_channel_count(
return s->samples.is_allocated() ? s->samples.channel_count() : 0;
}
int oakengine_preview_request_get_audio_sample_count(
const OakEnginePreviewRequest *req)
{
if (!req) {
return 0;
}
const OakEnginePreviewRequestState *s =
reinterpret_cast<const OakEnginePreviewRequestState *>(req);
if (!s->ticket || !s->ticket->has_result() || !s->finished->load()) {
return 0;
}
if (!s->has_audio) {
const_cast<OakEnginePreviewRequestState *>(s)->samples =
s->ticket->get().value<olive::SampleBuffer>();
const_cast<OakEnginePreviewRequestState *>(s)->has_audio = true;
}
return s->samples.is_allocated() ? int(s->samples.sample_count()) : 0;
}
int oakengine_preview_request_get_audio_sample_rate(
const OakEnginePreviewRequest *req)
{
+16
View File
@@ -280,6 +280,22 @@ static void test_edges(OakEngineProject *project, OakEngineNode *solid,
EXPECT_TRUE(oakengine_node_connect(solid, lut, "tex_in") == OAKENGINE_OK);
EXPECT_TRUE(oakengine_node_input_is_connected(lut, "tex_in") == 1);
// Output-side enumeration must report the DESTINATION node (the
// connection's input side), never the source itself — the node view
// builds half its edges through this path.
EXPECT_TRUE(oakengine_node_output_connection_count(solid) > 0);
{
OakEngineNode *dst = NULL;
char id_buf[64];
int element = -99, hidden = -1;
EXPECT_TRUE(oakengine_node_output_connection_at_ex(
solid, 0, &dst, id_buf, sizeof(id_buf), &element,
&hidden) == OAKENGINE_OK);
EXPECT_TRUE(dst == lut);
EXPECT_TRUE(strcmp(id_buf, "tex_in") == 0);
EXPECT_TRUE(element == -1);
}
// Already-connected input is refused; unknown ids and unconnectable
// inputs fail.
EXPECT_TRUE(oakengine_node_connect(solid, lut, "tex_in") ==
+64
View File
@@ -36,6 +36,7 @@
#include <unistd.h>
#endif
#include "oakengine/app.h"
#include "oakengine/footage.h"
#include "oakengine/init.h"
#include "oakengine/preview.h"
@@ -44,6 +45,9 @@
#include "oakengine/timeline.h"
#include "oakengine/viewer.h"
#include <QCoreApplication>
#include <QElapsedTimer>
#ifndef OAK_TEST_SOURCE_DIR
#define OAK_TEST_SOURCE_DIR "."
#endif
@@ -213,6 +217,7 @@ static void test_preview_request_null(void)
memset(&frame, 0, sizeof(frame));
EXPECT_TRUE(oakengine_preview_request_get_frame(NULL, &frame) == OAKENGINE_E_INVALID);
EXPECT_TRUE(oakengine_preview_request_get_audio_channel_count(NULL) == 0);
EXPECT_TRUE(oakengine_preview_request_get_audio_sample_count(NULL) == 0);
EXPECT_TRUE(oakengine_preview_request_get_audio_sample_rate(NULL) == 0);
EXPECT_TRUE(oakengine_preview_request_get_audio_samples(NULL, 0, NULL, 0) == OAKENGINE_E_INVALID);
oakengine_preview_request_free(NULL);
@@ -227,6 +232,61 @@ static void test_render_manager_null(void)
EXPECT_TRUE(len >= 0);
}
// Drive the request the way ViewerWidget's playback loop does: pump the
// event loop until the ticket finishes (bounded).
static bool pump_until_done(OakEnginePreviewRequest *req, int timeout_ms)
{
QElapsedTimer t;
t.start();
while (!oakengine_preview_request_is_done(req) &&
!t.hasExpired(timeout_ms)) {
QCoreApplication::processEvents(QEventLoop::AllEvents, 50);
}
return oakengine_preview_request_is_done(req) == 1;
}
// End-to-end preview request round trip through the RenderManager's worker
// pool: one video frame + one audio range, with the project active. This is
// the exact flow ViewerWidget's playback drives; it also exercises project
// teardown while the RenderManager/copier still reference the project.
static void test_preview_request_roundtrip(OakEngineProject *project,
OakEngineSequence *seq)
{
EXPECT_TRUE(oakengine_app_set_active_project(project) == OAKENGINE_OK);
OakEnginePreviewRequest *vreq =
oakengine_preview_request_single_frame((OakEngineNode *)seq, 0, 1, 0);
EXPECT_TRUE(vreq != NULL);
ASSERT_TRUE(pump_until_done(vreq, 60000));
EXPECT_TRUE(oakengine_preview_request_has_result(vreq) == 1);
oak_playback_frame frame;
memset(&frame, 0, sizeof(frame));
ASSERT_TRUE(oakengine_preview_request_get_frame(vreq, &frame) ==
OAKENGINE_OK);
EXPECT_TRUE(frame.width > 0 && frame.height > 0);
EXPECT_TRUE(frame.data != NULL && frame.linesize > 0);
// The frame must carry a valid timestamp: the viewer's display queue
// orders frames by it (all-zero timestamps froze playback).
EXPECT_TRUE(frame.timestamp_den != 0);
oakengine_preview_request_free(vreq);
OakEnginePreviewRequest *areq =
oakengine_preview_request_audio_range((OakEngineNode *)seq, 0, 1, 1, 1);
EXPECT_TRUE(areq != NULL);
ASSERT_TRUE(pump_until_done(areq, 60000));
EXPECT_TRUE(oakengine_preview_request_has_result(areq) == 1);
// The tone clip is stereo 48 kHz, one second long.
EXPECT_TRUE(oakengine_preview_request_get_audio_channel_count(areq) == 2);
EXPECT_TRUE(oakengine_preview_request_get_audio_sample_rate(areq) > 0);
const int sample_count =
oakengine_preview_request_get_audio_sample_count(areq);
EXPECT_TRUE(sample_count > 0);
float buf[48000 * 2];
EXPECT_TRUE(oakengine_preview_request_get_audio_samples(
areq, 0, buf, qMin(sample_count, 48000 * 2)) > 0);
oakengine_preview_request_free(areq);
}
static void test_playback_cache_null(void)
{
EXPECT_TRUE(oakengine_viewer_get_playback_cache(NULL) == NULL);
@@ -292,10 +352,14 @@ TEST(OakEnginePreview, Main)
OAKENGINE_OK);
test_levels(seq);
test_waveform(tone, demo, probed);
test_preview_request_roundtrip(project, seq);
oakengine_footage_free(probed);
oakengine_footage_free(demo);
oakengine_footage_free(tone);
// Deliberately free the project while it is still the active project:
// the RenderManager/ProjectCopier must survive its destruction, and
// teardown itself must not crash in edge-disconnect invalidation.
oakengine_project_free(project);
EXPECT_TRUE(oakengine_shutdown() == OAKENGINE_OK);