mirror of
https://github.com/MobileGL-Dev/MobileGL
synced 2026-09-07 19:58:32 +09:00
187 lines
9.3 KiB
C++
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
|