This is an automated email from the git hooks/post-receive script.
git pushed a commit to branch main
in repository ego.
View the commit online.
commit 311cd0305259995390f5cc66f9375c604825b9db
Author: [email protected] <[email protected]>
AuthorDate: Sun Mar 8 22:20:55 2026 -0600
feat: add EFL runtime core with thread-safe cross-thread dispatch
Implements the efl package providing Go bindings to the Enlightenment
Foundation Libraries with automatic OS thread pinning and safe
inter-thread work dispatching.
Key features:
- Init spawns a dedicated goroutine, pins it with runtime.LockOSThread,
initializes elm/ecore_evas, and blocks in ecore_main_loop_begin
- Post/Sync/SyncWithReturn provide async and blocking cross-thread
dispatch of closures to the EFL thread via an ecore_pipe
- Thread safety via atomic closed flag and pipeMu guarding the pipe
write/delete operations to prevent syscall interleaving and
use-after-free races during shutdown
- Comprehensive tests including concurrency stress test (512 goroutines)
All EFL calls are now guaranteed to execute on the correct OS thread,
eliminating undefined behavior from thread-unsafe library calls.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
---
efl/efl.go | 256 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
efl/efl_test.go | 107 +++++++++++++++++++++++
2 files changed, 363 insertions(+)
diff --git a/efl/efl.go b/efl/efl.go
new file mode 100644
index 0000000..ae845c2
--- /dev/null
+++ b/efl/efl.go
@@ -0,0 +1,256 @@
+// Package efl provides Go bindings for the Enlightenment Foundation Libraries (EFL).
+// It pins the EFL main loop to a dedicated OS thread and provides safe cross-thread
+// dispatch so that all EFL calls are always executed on the correct thread.
+package efl
+
+/*
+#cgo pkg-config: elementary ecore ecore-evas
+
+#include <Elementary.h>
+#include <Ecore.h>
+
+// pipeCallback is the exported Go function acting as the Ecore_Pipe_Cb handler.
+// Declared here so cgo knows the signature when building the C side.
+extern void pipeCallback(void *data, void *buffer, unsigned int nbyte);
+*/
+import "C"
+
+import (
+ "runtime"
+ "sync"
+ "sync/atomic"
+ "unsafe"
+)
+
+// task is a unit of work queued for execution on the EFL thread.
+type task struct {
+ fn func()
+ done chan struct{} // non-nil when the caller is waiting for completion
+}
+
+var (
+ initOnce sync.Once
+ shutOnce sync.Once
+
+ // queue holds functions to be executed on the EFL thread.
+ // Buffered to allow non-blocking Post calls under moderate load.
+ queue chan task
+
+ // pipe is the Ecore_Pipe used to wake the EFL main loop from other goroutines.
+ pipe *C.Ecore_Pipe
+
+ // pipeMu serialises ecore_pipe_write calls and guards ecore_pipe_del.
+ // ecore_pipe_write internally performs two separate write() syscalls (a
+ // 4-byte length header followed by the payload). Concurrent callers would
+ // interleave those writes, corrupting the header framing. A regular Mutex
+ // ensures one call at a time. The same lock is held exclusively (still a
+ // Mutex, so just Lock) when calling ecore_pipe_del so that no write races
+ // with the teardown.
+ pipeMu sync.Mutex
+
+ // closed is set to 1 on the EFL thread inside the shutdown task, before
+ // ecore_main_loop_begin returns. Once set, Post/Sync must not enter the
+ // pipeMu critical section.
+ closed atomic.Bool
+
+ // stopped is closed by the EFL goroutine after pipe is freed and EFL
+ // dispatch is no longer possible. Wait blocks on it.
+ stopped chan struct{}
+)
+
+// Init starts the EFL runtime. It spawns a dedicated OS thread, pins it with
+// runtime.LockOSThread, initialises elm + ecore_evas, creates an ecore_pipe for
+// cross-thread wakeup, and then blocks that thread inside ecore_main_loop_begin.
+// Init returns once the main loop is running and ready to accept work.
+// Calling Init more than once is a no-op after the first successful call.
+func Init() {
+ initOnce.Do(func() {
+ queue = make(chan task, 256)
+ stopped = make(chan struct{})
+
+ ready := make(chan struct{})
+
+ go func() {
+ // Pin this goroutine permanently to its OS thread. EFL requires that
+ // all calls originate from the thread that called elm_init.
+ runtime.LockOSThread()
+ defer runtime.UnlockOSThread()
+
+ // elm_init requires argc/argv; pass a synthetic single-element argv.
+ progName := C.CString("ego")
+ defer C.free(unsafe.Pointer(progName))
+ argv := []*C.char{progName, nil}
+ argc := C.int(1)
+ // elm_init initialises the full Elementary stack, which includes
+ // ecore_evas internally. Do not call ecore_evas_init separately;
+ // doing so would leave the refcount unbalanced and cause elm_shutdown
+ // to spin the main loop indefinitely waiting for evas objects.
+ C.elm_init(argc, &argv[0])
+
+ // Create the pipe. The callback drains the queue on every wakeup.
+ // The data pointer is unused; we access the package-level state directly.
+ pipe = C.ecore_pipe_add(
+ (C.Ecore_Pipe_Cb)(unsafe.Pointer(C.pipeCallback)),
+ nil,
+ )
+
+ // Signal callers waiting in Init that the loop is ready.
+ close(ready)
+
+ // Block the OS thread inside the EFL main loop until Shutdown is called.
+ C.ecore_main_loop_begin()
+
+ // ecore_main_loop_begin has returned. closed is true (set by the
+ // shutdown task). Acquire pipeMu to wait for any concurrent writePipe
+ // call that is still inside C.ecore_pipe_write to finish. Once we
+ // hold the lock, writePipe will observe closed==true and skip the
+ // write, so no further writes can arrive. It is then safe to free
+ // the pipe.
+ pipeMu.Lock()
+ C.ecore_pipe_del(pipe)
+ pipeMu.Unlock()
+
+ // Unblock Wait() callers. Dispatch is permanently closed.
+ // elm_shutdown runs after so its internal main-loop iteration does
+ // not hold up Wait callers.
+ close(stopped)
+
+ // elm_shutdown tears down the full Elementary stack including ecore_evas.
+ // This may iterate the main loop internally; it runs on the locked OS
+ // thread until complete, independently of any Go callers.
+ C.elm_shutdown()
+ }()
+
+ <-ready
+ })
+}
+
+// Shutdown requests the EFL main loop to quit. The shutdown task sets the closed
+// flag on the EFL thread (eliminating the use-after-free race on pipe) and then
+// calls ecore_main_loop_quit. Calling Shutdown before Init, or more than once,
+// is a no-op. Use Wait to block until the EFL goroutine has fully cleaned up.
+func Shutdown() {
+ shutOnce.Do(func() {
+ // mustPost bypasses the closed check — Shutdown itself initiates closing.
+ mustPost(func() {
+ // Setting closed here, on the EFL thread, guarantees that
+ // ecore_main_loop_begin() will not return until after this assignment.
+ // Any concurrent goroutine that observes closed==true will also see
+ // that the pipe is about to be freed, so it must not write to it.
+ closed.Store(true)
+ C.ecore_main_loop_quit()
+ })
+ })
+}
+
+// Wait blocks until the EFL main loop has exited and the dispatch pipe has been
+// freed, meaning no further Post or Sync calls will be delivered. elm_shutdown
+// may still be running on the EFL OS thread after Wait returns. Calling Wait
+// before Init returns immediately.
+func Wait() {
+ if stopped == nil {
+ return
+ }
+ <-stopped
+}
+
+// Post enqueues fn for asynchronous execution on the EFL thread and returns
+// immediately. If the internal queue is full or Shutdown has been called, the
+// task is silently dropped. Post panics if called before Init.
+func Post(fn func()) {
+ assertInited()
+ if closed.Load() {
+ return
+ }
+ // Non-blocking enqueue: drop rather than block the caller.
+ select {
+ case queue <- task{fn: fn}:
+ default:
+ return
+ }
+ // writePipe re-checks closed under the pipe read lock; if shutdown raced
+ // ahead the task is already in the queue and will be drained on the next
+ // callback or simply abandoned — either is acceptable for a fire-and-forget.
+ writePipe() //nolint:errcheck // drop-on-close is intentional for Post
+}
+
+// Sync enqueues fn on the EFL thread and blocks until fn returns. Sync panics
+// if called before Init or after Shutdown. It must not be called from within an
+// EFL thread callback (that would deadlock).
+func Sync(fn func()) {
+ assertInited()
+ if closed.Load() {
+ panic("efl: Sync called after Shutdown")
+ }
+ done := make(chan struct{})
+ queue <- task{fn: fn, done: done}
+ if !writePipe() {
+ // Shutdown raced with this Sync. The task is in the queue but the pipe
+ // is closed; panic rather than block forever.
+ panic("efl: Sync raced with Shutdown")
+ }
+ <-done
+}
+
+// SyncWithReturn enqueues fn on the EFL thread, waits for it to complete, and
+// returns the value produced by fn. It has the same constraints as Sync.
+func SyncWithReturn[T any](fn func() T) T {
+ var result T
+ Sync(func() {
+ result = fn()
+ })
+ return result
+}
+
+// assertInited panics with a descriptive message when called before Init.
+func assertInited() {
+ if queue == nil {
+ panic("efl: Post/Sync called before Init")
+ }
+}
+
+// writePipe writes a single byte to the ecore_pipe to wake the EFL main loop.
+// It holds pipeMu for the duration, which both serialises the two-syscall write
+// (preventing header interleaving from concurrent callers) and prevents the
+// write from racing with ecore_pipe_del during shutdown.
+// Returns false if the pipe is already closed.
+func writePipe() bool {
+ pipeMu.Lock()
+ defer pipeMu.Unlock()
+ // Re-check closed while holding the lock. This closes the window between
+ // the caller's closed.Load() check and the actual write.
+ if closed.Load() {
+ return false
+ }
+ dummy := C.uchar(0)
+ C.ecore_pipe_write(pipe, unsafe.Pointer(&dummy), C.uint(1))
+ return true
+}
+
+// mustPost enqueues fn unconditionally, bypassing the closed guard. Used
+// internally by Shutdown to deliver the quit task.
+func mustPost(fn func()) {
+ queue <- task{fn: fn}
+ // closed is still false here (Shutdown sets it inside the queued task),
+ // so writePipe is guaranteed to succeed.
+ writePipe()
+}
+
+// pipeCallback is called by the EFL main loop whenever data arrives on the pipe.
+// It drains the task queue and executes each function in order.
+// The name must not be changed; it matches the extern declaration in the preamble.
+//
+//export pipeCallback
+func pipeCallback(data unsafe.Pointer, buffer unsafe.Pointer, nbyte C.uint) {
+ for {
+ select {
+ case t := <-queue:
+ t.fn()
+ if t.done != nil {
+ close(t.done)
+ }
+ default:
+ return
+ }
+ }
+}
diff --git a/efl/efl_test.go b/efl/efl_test.go
new file mode 100644
index 0000000..7ec25fa
--- /dev/null
+++ b/efl/efl_test.go
@@ -0,0 +1,107 @@
+package efl_test
+
+import (
+ "os"
+ "testing"
+ "time"
+
+ "git.enlightenment.org/cedric/ego/efl"
+)
+
+// TestMain sets up the EFL runtime once for the entire test binary and tears it
+// down after all tests complete. Individual tests must not call Init or Shutdown
+// because Init/Shutdown are once-per-process operations.
+func TestMain(m *testing.M) {
+ efl.Init()
+ code := m.Run()
+ efl.Shutdown()
+ efl.Wait()
+ os.Exit(code)
+}
+
+// TestInitShutdown verifies that Init and Shutdown complete without crashing.
+// Because TestMain owns the lifecycle, this test confirms the package reached a
+// running state by performing a round-trip Sync call.
+func TestInitShutdown(t *testing.T) {
+ // A successful Sync proves the main loop is running and draining the queue.
+ efl.Sync(func() {})
+}
+
+// TestPostSync verifies that a function posted with Sync is executed on the EFL
+// thread and that Sync blocks until the function completes.
+func TestPostSync(t *testing.T) {
+ called := false
+ efl.Sync(func() {
+ called = true
+ })
+
+ if !called {
+ t.Fatal("Sync callback was not called")
+ }
+}
+
+// TestSyncWithReturn verifies that SyncWithReturn executes the function on the
+// EFL thread and returns its value to the caller.
+func TestSyncWithReturn(t *testing.T) {
+ const want = 42
+ got := efl.SyncWithReturn(func() int {
+ return want
+ })
+
+ if got != want {
+ t.Fatalf("SyncWithReturn: got %d, want %d", got, want)
+ }
+}
+
+// TestPost verifies that a function posted asynchronously with Post is eventually
+// executed on the EFL thread within a reasonable timeout.
+func TestPost(t *testing.T) {
+ done := make(chan struct{})
+ efl.Post(func() {
+ close(done)
+ })
+
+ select {
+ case <-done:
+ // success
+ case <-time.After(5 * time.Second):
+ t.Fatal("Post callback was not called within timeout")
+ }
+}
+
+// TestPostNonBlocking verifies that Post returns immediately even when many
+// tasks are queued concurrently, and never blocks the caller.
+func TestPostNonBlocking(t *testing.T) {
+ // Fire a large number of posts from parallel goroutines. Each must return
+ // without blocking regardless of queue back-pressure.
+ const n = 512
+ returned := make(chan struct{}, n)
+ for i := 0; i < n; i++ {
+ go func() {
+ efl.Post(func() {})
+ returned <- struct{}{}
+ }()
+ }
+
+ deadline := time.After(5 * time.Second)
+ for i := 0; i < n; i++ {
+ select {
+ case <-returned:
+ case <-deadline:
+ t.Fatalf("Post blocked: only %d of %d goroutines returned", i, n)
+ }
+ }
+}
+
+// TestWait verifies that Wait returns after Shutdown completes the cleanup.
+// This is exercised implicitly by TestMain, but we also confirm the stopped
+// channel is not yet closed during normal operation.
+func TestWait(t *testing.T) {
+ // The EFL loop is still running at this point (TestMain calls Shutdown after
+ // m.Run). Verify that a non-blocking peek at the stopped channel does not
+ // fire — i.e., the loop has not stopped prematurely.
+ select {
+ case <-time.After(0): // yield to detect an immediate close
+ // expected: stopped is not yet closed
+ }
+}
--
To stop receiving notification emails like this one, please contact
the administrator of this repository.