engine: begin the liboakengine C ABI facade with the IPC subsystem

- oakengine/export.h establishes the OAKENGINE_API visibility macros;
  include/oakengine/ipc.h is the first pure-C surface (41 functions:
  shm, frame slot pool, and the worker IPC messages as POD<->JSON
  build/parse), implemented in engine/src/capi/
- the IPC implementations move to engine/src/oliveimpl (namespace
  olive::engine::internal::ipc); engine/render/ipc/*.h are rebuilt as
  same-name/same-API wrapper classes forwarding across the C boundary
- FrameSlotMeta is shared with the C header verbatim so the app/worker
  wire format (v1) is bit-identical; static_asserts pin sizeof and
  field offsets
- spscringbuffer.h moves to include/oakengine/ as an inline-only
  header (no symbols, not ABI)
- new pure-C test oakengine_ipc_test (make_oakengine_test, no GL)
  covers shm, frame pool, message round-trips and the layout asserts;
  full gtest suite stays green (1986 tests)
This commit is contained in:
2026-07-20 04:12:58 +08:00
parent 28c4426236
commit 37845302f9
21 changed files with 2766 additions and 207 deletions
@@ -0,0 +1,182 @@
/***
Oak - Non-Linear Video Editor
Copyright (C) 2026 Oak Team
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
***/
#ifndef OAK_OLIVEIMPL_RENDER_IPC_FRAMESLOTPOOL_H
#define OAK_OLIVEIMPL_RENDER_IPC_FRAMESLOTPOOL_H
#include <cstddef>
#include <cstdint>
#include "oakengine/ipc.h"
#include "oakengine/spscringbuffer.h"
namespace olive
{
namespace engine
{
namespace internal
{
namespace ipc
{
/**
* @brief Per-slot metadata describing the frame currently occupying a slot.
*
* Trivially-copyable POD that lives in shared memory alongside the pixel data. Carries everything
* the consumer needs to reconstruct an olive::Frame without any out-of-band information. We store
* the Rational timestamp as an explicit numerator/denominator pair to stay POD (olive::Rational is
* not guaranteed shared-memory-safe).
*
* This is the C ABI oak_frame_slot_meta struct, aliased so the version-1 wire layout the app and
* the render worker agree on is defined exactly once, in oakengine/ipc.h.
*/
typedef oak_frame_slot_meta FrameSlotMeta;
/**
* @brief A fixed-size pool of equal-sized frame slots in shared memory, with lock-free hand-off.
*
* One pool models a single direction of frame flow (e.g. worker -> main for rendered output, or
* main -> worker for decoded input). Ownership of a slot is transferred via two SPSC ring buffers
* of slot indices, so no mutex is ever taken:
*
* - free_ring: indices of slots available to the FILLER. The drainer returns slots here.
* - ready_ring: indices of slots holding a published frame, produced by the FILLER for the
* DRAINER to consume.
*
* Lifecycle (filler = producer of frames, drainer = consumer of frames):
* filler: Acquire() -> pop a free index -> write meta + pixels -> Publish() -> push to ready
* drainer: Consume() -> pop a ready index -> read meta + pixels -> Release() -> push to free
*
* Because each ring has exactly one producer and one consumer (the filler owns free.Pop +
* ready.Push, the drainer owns ready.Pop + free.Push), the SPSC invariant holds and the whole
* exchange is lock-free.
*
* All slots are sized to `slot_data_bytes`, computed for the maximum supported frame (e.g. 8K RGBA
* half-float). Frames smaller than that simply use a prefix of the slot.
*
* The pool does NOT own the memory; it is constructed over a SharedMemoryRegion mapping. Use
* BytesNeeded() to size that region.
*/
class FrameSlotPool {
public:
/**
* @brief Total bytes a region must provide to back a pool of `slot_count` x `slot_data_bytes`.
*/
static size_t bytes_needed(uint32_t slot_count, size_t slot_data_bytes);
/**
* @brief Lay out and initialize a brand-new pool over `mem` (owner side, once).
*
* Initializes both rings, seeds the free ring with every slot index, and zeroes metadata.
* `mem` must provide at least BytesNeeded(slot_count, slot_data_bytes) bytes.
*/
static FrameSlotPool create(void *mem, uint32_t slot_count,
size_t slot_data_bytes);
/**
* @brief Map an existing, already-initialized pool (peer side).
*
* Reads slot_count/slot_data_bytes from the in-memory header written by Create().
*/
static FrameSlotPool attach(void *mem);
bool is_valid() const
{
return header_ != nullptr;
}
uint32_t slot_count() const;
size_t slot_data_bytes() const;
// ---- Filler side ----
/**
* @brief Take ownership of a free slot. Returns false (and leaves *index untouched) if none free.
*/
bool acquire(uint32_t *index);
/**
* @brief Pointer to a slot's pixel data block (slot_data_bytes available).
*/
void *slot_data(uint32_t index);
/**
* @brief Mutable metadata for a slot. Filler writes this before Publish().
*/
FrameSlotMeta *meta(uint32_t index);
/**
* @brief Publish a filled slot to the drainer. Must follow a successful Acquire() of `index`.
*/
bool publish(uint32_t index);
// ---- Drainer side ----
/**
* @brief Take the next published slot. Returns false if nothing is ready.
*/
bool consume(uint32_t *index);
/**
* @brief Return a consumed slot to the free pool for reuse. Must follow Consume() of `index`.
*/
bool release(uint32_t index);
const FrameSlotMeta *meta(uint32_t index) const;
const void *slot_data(uint32_t index) const;
public:
FrameSlotPool() = default;
private:
struct Header {
uint32_t magic;
uint32_t slot_count;
uint64_t slot_data_bytes;
// Byte offsets from the start of the segment to each sub-region.
uint64_t free_ring_offset;
uint64_t ready_ring_offset;
uint64_t meta_offset;
uint64_t data_offset;
};
static constexpr uint32_t k_magic = 0x4F4B5350; // 'OKSP'
// Ring capacity must exceed slot_count by one because a ring can hold at most capacity-1 entries
// and we need to be able to enqueue every slot at once.
static uint32_t ring_capacity(uint32_t slot_count)
{
return slot_count + 1;
}
uint8_t *base_ = nullptr;
Header *header_ = nullptr;
olive::ipc::SpscRingBuffer *free_ring_ = nullptr;
olive::ipc::SpscRingBuffer *ready_ring_ = nullptr;
FrameSlotMeta *meta_ = nullptr;
uint8_t *data_ = nullptr;
};
} // namespace ipc
} // namespace internal
} // namespace engine
} // namespace olive
#endif // OAK_OLIVEIMPL_RENDER_IPC_FRAMESLOTPOOL_H
@@ -0,0 +1,167 @@
/***
Oak - Non-Linear Video Editor
Copyright (C) 2026 Oak Team
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
***/
#ifndef OAK_OLIVEIMPL_RENDER_IPC_IPCMESSAGE_H
#define OAK_OLIVEIMPL_RENDER_IPC_IPCMESSAGE_H
#include <cstdint>
#include <QByteArray>
#include <QJsonObject>
#include <QString>
#include <QVector>
class QIODevice;
namespace olive
{
namespace engine
{
namespace internal
{
namespace ipc
{
/**
* @brief Control-plane protocol exchanged over stdio between main and render worker.
*
* The wire format is NDJSON: one compact QJsonObject per line, terminated by '\n'. This is
* deliberately human-readable so the channel can be inspected live with `tee`/`cat` and test
* messages can be injected by hand. The stdio channel carries only low-frequency control traffic;
* bulk pixel data travels through the shared-memory FrameSlotPool, and the (potentially large)
* serialized node graph travels via a temporary file referenced by path.
*
* Every message object has a "type" string field. Directionality (M = main, W = worker):
* "handshake" M<->W Negotiate protocol version and announce shared-memory key/geometry.
* "load_graph" M ->W Path to a temporary file holding the serialized node graph.
* "render_frame" M ->W Request a frame: node uuid, time, video params.
* "frame_ready" W ->M A rendered frame is published; carries the output-slot index + ticket.
* "cancel" M ->W Abandon an in-flight ticket by id.
* "graph_update" M ->W (Reserved, Phase 6) Incremental graph mutation, mirrors ProjectCopier.
* "shutdown" M ->W Finish current work and exit cleanly.
* "error" W ->M Worker-side failure report (human-readable "message" field).
*/
namespace msgtype
{
constexpr const char *k_handshake = "handshake";
constexpr const char *k_load_graph = "load_graph";
constexpr const char *k_render_frame = "render_frame";
constexpr const char *k_frame_ready = "frame_ready";
constexpr const char *k_cancel = "cancel";
constexpr const char *k_graph_update = "graph_update";
constexpr const char *k_shutdown = "shutdown";
constexpr const char *k_error = "error";
} // namespace msgtype
/**
* @brief Write one NDJSON message line to `device`.
*
* Serializes `obj` to compact JSON, appends '\n', and writes the whole line in one call. Returns
* true only if the full line was written.
*/
bool write_message(QIODevice *device, const QJsonObject &obj);
/**
* @brief Pull one complete NDJSON line out of `buffer` and parse it.
*
* If `buffer` contains at least one '\n', the leading line is removed, parsed as JSON, and returned
* via `out` (true). If no complete line is buffered yet, leaves `buffer` untouched and returns
* false. Malformed lines are skipped (removed) and reported via `*ok = false` so the reader can log
* and continue rather than wedge. Supports the typical "append bytes as they arrive, then drain
* complete lines" reader loop on a pipe.
*/
bool read_message(QByteArray *buffer, QJsonObject *out, bool *ok = nullptr);
// ---- Typed message builders / parsers -------------------------------------------------------
//
// Thin helpers that construct or read the QJsonObject for each message type, keeping field names in
// one place so main and worker agree. Fields use plain JSON numbers/strings; 64-bit ids are stored
// as JSON numbers (doubles exactly represent integers up to 2^53, ample for our counters).
struct HandshakeMsg {
int protocol_version = 0;
QString shm_key; ///< Worker->main output shared-memory segment key.
QString
input_shm_key; ///< Main->worker input shared-memory segment key (optional).
int input_slots = 0; ///< Number of main->worker input frame slots.
int output_slots = 0; ///< Number of worker->main output frame slots.
qint64 slot_data_bytes = 0; ///< Per-output-slot pixel block size.
qint64 input_slot_data_bytes = 0; ///< Per-input-slot pixel block size.
QJsonObject to_json() const;
static bool from_json(const QJsonObject &o, HandshakeMsg *out);
};
struct RenderFrameMsg {
qint64 ticket_id =
0; ///< Correlates this request with the eventual frame_ready.
QString
node_uuid; ///< Output/viewer node to render, by stable uuid in the loaded graph.
qint64 time_num = 0;
qint64 time_den = 1;
int width = 0; ///< Forced output size (0 = use graph default).
int height = 0;
int format = -1; ///< Forced PixelFormat::Format (-1 = default/INVALID).
int channel_count = 0; ///< 0 = default.
int mode = 0; ///< RenderMode::Mode.
int input_slot =
-1; ///< Optional main->worker decoded input slot for footage nodes.
QVector<int>
input_slots; ///< Optional ordered decoded input slots for footage nodes.
// Output color transform to apply before returning the frame. When empty,
// the worker returns the image in the project's reference space.
bool has_color_transform = false;
bool color_is_display = false;
QString color_output;
QString color_view;
QString color_look;
QJsonObject to_json() const;
static bool from_json(const QJsonObject &o, RenderFrameMsg *out);
};
struct FrameReadyMsg {
qint64 ticket_id = 0;
int output_slot = 0; ///< Index into the worker->main output FrameSlotPool.
QJsonObject to_json() const;
static bool from_json(const QJsonObject &o, FrameReadyMsg *out);
};
struct CancelMsg {
qint64 ticket_id = 0;
QJsonObject to_json() const;
static bool from_json(const QJsonObject &o, CancelMsg *out);
};
struct LoadGraphMsg {
QString path; ///< Temporary file holding the serialized node graph.
QJsonObject to_json() const;
static bool from_json(const QJsonObject &o, LoadGraphMsg *out);
};
} // namespace ipc
} // namespace internal
} // namespace engine
} // namespace olive
#endif // OAK_OLIVEIMPL_RENDER_IPC_IPCMESSAGE_H
@@ -0,0 +1,129 @@
/***
Oak - Non-Linear Video Editor
Copyright (C) 2026 Oak Team
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
***/
#ifndef OAK_OLIVEIMPL_RENDER_IPC_SHAREDMEMORYREGION_H
#define OAK_OLIVEIMPL_RENDER_IPC_SHAREDMEMORYREGION_H
#include <cstddef>
#include <QString>
namespace olive
{
namespace engine
{
namespace internal
{
namespace ipc
{
/**
* @brief A named, fixed-size shared memory segment mapped into the process address space.
*
* One process Create()s the segment (owner); the peer process Attach()es to it by the same key.
* The mapping is a raw contiguous byte range accessible via data() — the IPC ring buffers and frame
* slot pools are laid out inside it. Nothing here is locked; synchronization is entirely the
* caller's responsibility via the lock-free structures placed in the mapping.
*
* We deliberately use the raw OS primitives (POSIX shm_open + mmap, Windows CreateFileMapping +
* MapViewOfFile) rather than QSharedMemory: QSharedMemory carries an implicit semaphore and a 1-byte
* header convention, attaches/detaches with reference counting we don't want, and historically has
* cross-platform lifetime quirks. For a render pipeline pushing large frames we want a plain mmap.
*/
class SharedMemoryRegion {
public:
enum Mode {
/// Create (and own) the segment. Fails if it already exists; unlinks on destruction.
k_create,
/// Attach to a segment created by the peer. Does not unlink on destruction.
k_attach
};
SharedMemoryRegion();
~SharedMemoryRegion();
SharedMemoryRegion(const SharedMemoryRegion &) = delete;
SharedMemoryRegion &operator=(const SharedMemoryRegion &) = delete;
/**
* @brief Open the segment identified by `key` with the given `size` in bytes.
*
* `key` is a short identifier (no leading slash needed; the platform prefix is added internally).
* Returns true on success. On failure, error() carries a human-readable reason.
*/
bool open(const QString &key, size_t size, Mode mode);
/**
* @brief Unmap and (if owner) unlink the segment. Called automatically by the destructor.
*/
void close();
bool is_valid() const
{
return data_ != nullptr;
}
void *data() const
{
return data_;
}
size_t size() const
{
return size_;
}
const QString &key() const
{
return key_;
}
const QString &error() const
{
return error_;
}
/**
* @brief Build a unique segment key for a worker, e.g. "olive-rw-<pid>-<index>".
*
* Centralized so the owner and the spawned worker agree on the same name.
*/
static QString make_key(qint64 owner_pid, int worker_index);
private:
QString key_;
size_t size_;
void *data_;
Mode mode_;
QString error_;
#if defined(Q_OS_WIN)
void *handle_; // HANDLE from CreateFileMapping/OpenFileMapping
#else
int fd_; // file descriptor from shm_open
QString shm_name_; // the platform-prefixed name actually passed to shm_open
#endif
};
} // namespace ipc
} // namespace internal
} // namespace engine
} // namespace olive
#endif // OAK_OLIVEIMPL_RENDER_IPC_SHAREDMEMORYREGION_H