Files
MobileGL/MobileGL/MG_Util/Async/JobNode.cpp
T

187 lines
9.3 KiB
C++

// MobileGL - MobileGL/MG_Util/Async/JobNode.cpp
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
#include "JobNode.h"
#include "ShaderCompilePool.h"
#include <MG_State/GLState/Core.h>
namespace MobileGL::MG_Util::Async {
namespace {
Bool IsTerminalState(const JobState state) {
return state == JobState::Complete || state == JobState::Cancelled;
}
// Job BODIES have been contained since stage 1 (JobNode::Run); continuations were
// not, and stage 4 introduces the first real ones. A continuation runs on whichever
// thread drove the node terminal - for a compile that finished on a worker, that is
// inside an Asio handler, where an escaping exception means thread_pool::run()
// rethrows and the process terminates. It would also skip every continuation after
// it in the list, stranding unrelated dependents.
//
// Containing it here is a backstop, not the contract: a continuation cannot be
// repaired from the outside (the dispatcher has no idea what the callback was for),
// so the registrar still owns "this cannot fail". See JobNode::OnTerminal.
void RunContinuation(const std::function<void()>& continuation) {
if (!continuation) return;
try {
continuation();
} catch (const std::exception& e) {
MGLOG_E_ONCE("JobNode: a terminal continuation threw (%s); it has been contained, but whatever it "
"was going to do did not happen",
e.what());
} catch (...) {
MGLOG_E_ONCE("JobNode: a terminal continuation threw a non-std exception; it has been contained, "
"but whatever it was going to do did not happen");
}
}
} // namespace
Bool JobNode::IsTerminal() const { return IsTerminalState(m_state.load(std::memory_order_acquire)); }
Bool JobNode::IsComplete() const { return m_state.load(std::memory_order_acquire) == JobState::Complete; }
Bool JobNode::IsCancelled() const { return m_state.load(std::memory_order_acquire) == JobState::Cancelled; }
Bool JobNode::IsCancellationRequested() const { return m_cancelled.load(std::memory_order_acquire); }
JobState JobNode::State() const { return m_state.load(std::memory_order_acquire); }
// The single place a node changes state. Keeping every transition here is what makes the
// continuation list exactly-once: the same critical section that publishes the terminal
// state also takes ownership of the callbacks, so a concurrent OnTerminal either lands in
// the list before the swap or sees the terminal state and runs inline - never neither and
// never both.
Bool JobNode::TryTransition(const JobState from, const JobState to) {
const Bool terminal = IsTerminalState(to);
Vector<std::function<void()>> continuations;
{
const std::lock_guard<std::mutex> lock(m_mutex);
if (m_state.load(std::memory_order_relaxed) != from) return false;
m_state.store(to, std::memory_order_release);
if (terminal) continuations.swap(m_continuations);
}
if (!terminal) return true;
m_cv.notify_all();
// Run continuations OUTSIDE the lock: a continuation is free to call back into this
// node (IsComplete, State) and, in the link-dependency case, to post the dependent
// job to the pool from whichever thread drove this node terminal. Individually
// contained, so one broken dependent cannot strand the rest of the list.
for (auto& continuation : continuations) {
RunContinuation(continuation);
}
return true;
}
void JobNode::Run() {
if (m_cancelled.load(std::memory_order_acquire)) {
TryTransition(JobState::Pending, JobState::Cancelled);
return;
}
// Loses to a concurrent Cancel() that already took the node terminal, and to a second
// dispatch of the same node. Either way there is nothing left to do.
if (!TryTransition(JobState::Pending, JobState::Running)) return;
try {
RunBody();
} catch (const std::exception& e) {
// Asio propagates an exception escaping a handler out of thread_pool::run(),
// which means std::terminate for the whole process. Every job boundary contains
// it and reports the job as Cancelled; the joining GL thread then sees a node
// that produced no result, which is the same shape as an abandoned node.
diagnostics.logLines.push_back(std::format("Job body threw: {}", e.what()));
TryTransition(JobState::Running, JobState::Cancelled);
return;
} catch (...) {
diagnostics.logLines.emplace_back("Job body threw a non-std exception");
TryTransition(JobState::Running, JobState::Cancelled);
return;
}
// Debug-only tripwire for the design's section 6 invariant: a compile or link body
// must not need to raise a GL error. Anything that does belongs in the GL-thread
// prologue of CompileShader_State / LinkProgram_State, next to the active-XFB relink
// rejection that already works that way.
MOBILEGL_ASSERT(diagnostics.errors.empty(),
"JobNode: a job body recorded %zu deferred GL error(s); compile and link bodies must not "
"raise GL errors (see the P1 design, section 6)",
diagnostics.errors.size());
TryTransition(JobState::Running,
m_cancelled.load(std::memory_order_acquire) ? JobState::Cancelled : JobState::Complete);
}
void JobNode::RunInline() { Run(); }
void JobNode::Wait() {
// Invariant I4, mechanically enforced: no job body ever blocks on another job, so the
// pool can never deadlock with all its workers waiting on each other.
MOBILEGL_ASSERT(!ShaderCompilePool::IsPoolThread(),
"JobNode::Wait() called from a pool thread; job dependencies must be resolved by posting "
"late (SubmitAfter), never by waiting from inside a body");
std::unique_lock<std::mutex> lock(m_mutex);
m_cv.wait(lock, [this] { return IsTerminalState(m_state.load(std::memory_order_relaxed)); });
}
void JobNode::Cancel() {
m_cancelled.store(true, std::memory_order_release);
// A node that never reached a worker settles right here. Doing this rather than
// waiting for a dispatch that may never come is what lets every cancel site
// (glShaderSource over a pending compile, glDeleteProgram, teardown) drop the node
// without a wait and without stranding a dependent link job behind it.
TryTransition(JobState::Pending, JobState::Cancelled);
}
void JobNode::OnTerminal(std::function<void()> fn) {
if (!fn) return;
{
const std::lock_guard<std::mutex> lock(m_mutex);
if (!IsTerminalState(m_state.load(std::memory_order_relaxed))) {
m_continuations.push_back(Move(fn));
return;
}
}
// Already terminal: the caller's thread runs it, through the same guard the deferred
// path uses. OnTerminal is reached from Link()'s GL-thread prologue as well as from a
// worker, and glLinkProgram is not a place an exception may escape from either.
RunContinuation(fn);
}
void ApplyDeferredDiagnostics(JobNode& node) {
MOBILEGL_ASSERT(!ShaderCompilePool::IsPoolThread(),
"ApplyDeferredDiagnostics() called from a pool thread; deferred diagnostics exist precisely "
"so that a worker never touches the GL error state");
MOBILEGL_ASSERT(node.IsTerminal(),
"ApplyDeferredDiagnostics() called on a job that has not settled; its diagnostics are still "
"being written");
if (!node.diagnostics.logLines.empty()) {
Vector<String> lines;
lines.swap(node.diagnostics.logLines);
for (const String& line : lines) {
MGLOG_D("%s", line.c_str());
}
}
if (node.diagnostics.errors.empty()) return;
Vector<DeferredError> errors;
errors.swap(node.diagnostics.errors);
// Ascending sequence == job-enqueue order == the order a serial implementation would
// have recorded them in, which is what decides WHICH payload the application sees:
// MobileGL implements GL's sticky-flag semantics, so a repeat of an already-pending
// code is discarded and only the first occurrence of each code survives.
std::sort(errors.begin(), errors.end(),
[](const DeferredError& a, const DeferredError& b) { return a.sequence < b.sequence; });
if (!MG_State::pGLContext) return;
for (DeferredError& error : errors) {
MG_State::pGLContext->RecordError(error.code, Move(error.info));
}
}
} // namespace MobileGL::MG_Util::Async