cheatah
Source

stdlib/thread/thread.hpp

1// Copyright (c) 2026 BigBrain LLC. MIT-licensed (see LICENSE).
2// Original work; see ACKNOWLEDGMENTS.md for the open-source ideas we build upon.
3#pragma once
4// cheatah-link: -pthread
6/**
7 * @file thread.hpp
8 * @brief cheatah `thread` — run a cheatah `fn` on another OS thread. `import thread` to use it.
9 *
10 * The whole module is one factory and one handle: `thread.spawn(f, args...)` starts `f(args...)`
11 * on a new thread and returns a `thread::Thread`, a move-only owning guard that JOINS at scope
12 * exit — every thread is joined before `main` returns. There is deliberately no `detach`: the
13 * cheatah host unloads the program's module right after `main`, so a detached thread would crash
14 * in unloaded code, and an unjoined thread would break the deterministic-cleanup guarantee.
15 *
16 * Sharing state between threads is the `memory` module's job, not this one's. Every COPYABLE
17 * argument is copied into the thread (the worker owns its own value; nothing points back at the
18 * caller), so the one way to share a mutable object is to pass a pinned `memory.Owner<T>` — it
19 * travels BY REFERENCE — and go through its request -> acquire -> lease flow. cheatah does not
20 * detect or prevent data races: what you do across threads is AT YOUR OWN RISK, and the
21 * `Owner`'s leases are the recommended safe path. The full contract lives on the module page (stdlib/thread/README.md).
22 *
23 * A worker that throws does not kill the program: the exception is caught in the thread and
24 * RE-THROWN at `t.join()` (catch it there with `try`). If the thread is never explicitly joined,
25 * the guard's destructor joins and reports the stored error on stderr instead (a destructor must
26 * not throw).
27 *
28 * `import thread` includes this header AND links `libcheatah_thread`. Unit tests:
29 * `stdlib/tests/thread_test.cpp`; the suite runs under AddressSanitizer + ThreadSanitizer and
30 * Valgrind on every QA-gate run.
31 */
32#include <concepts>
33#include <exception>
34#include <memory>
35#include <thread>
36#include <tuple>
37#include <type_traits>
38#include <utility>
40namespace cheatah::thread {
42namespace detail {
44/// Shared between the owning guard and the worker trampoline: the worker's escaped exception (if
45/// any) and whether `join()` already surfaced it (so the destructor stays silent). Written by the
46/// worker before it finishes and read only after a join — the join is the synchronization.
47struct State {
48 std::exception_ptr error; ///< set by the trampoline if the worker threw.
49 bool observed = false; ///< `join()` rethrew it; the destructor must not re-report.
50};
52/// How `spawn` carries one argument into the thread: a copyable argument is DECAY-COPIED (the
53/// thread owns its own value), a non-copyable RVALUE that can move (a guard returned by a
54/// factory) is MOVED in, and a non-copyable, non-movable LVALUE (a pinned `memory::Owner`)
55/// travels by address — the caller's object itself, which must outlive the thread.
56template <class A>
57inline constexpr bool holds_by_value =
58 std::copy_constructible<std::remove_cvref_t<A>> ||
59 (!std::is_lvalue_reference_v<A> && std::move_constructible<std::remove_cvref_t<A>>);
61} // namespace detail
63/**
64 * One argument `spawn` can carry into a thread: anything copyable, a movable rvalue, or a
65 * non-copyable LVALUE (passed by reference — the caller's object must outlive the thread, which
66 * the guard's join-on-destroy gives naturally when it is declared after the object).
67 *
68 * NOT satisfied by a non-copyable, non-movable TEMPORARY — e.g. `thread.spawn(f, memory.own(0))`
69 * does not compile: the temporary Owner would die before the thread ran. Bind it to a variable
70 * first (`let o = memory.own(0)`), then pass `o`.
71 */
72template <class A>
73concept SpawnArg = detail::holds_by_value<A> || std::is_lvalue_reference_v<A>;
75/**
76 * The callables `spawn` accepts: invocable with the spawned copies/references of the given
77 * arguments (each argument reaches the worker as an lvalue — the thread's own copy, or the
78 * caller's non-copyable object by reference). Satisfied by both lowerings purrc emits for a
79 * cheatah `fn` passed by name (the concrete function pointer and the generic forwarding lambda).
80 */
81template <class F, class... Args>
82concept SpawnCallable = std::invocable<std::decay_t<F>&, std::remove_cvref_t<Args>&...>;
84namespace detail {
86/// What the trampoline's closure stores for one argument: the decayed value, or a pointer to the
87/// caller's non-copyable object.
88template <SpawnArg A>
89using held_t = std::conditional_t<holds_by_value<A>, std::remove_cvref_t<A>,
90 std::remove_cvref_t<A>*>;
92/// Capture one argument for the thread (copy / move / take the lvalue's address — see
93/// `holds_by_value`). @complexity O(1) plus the copy/move itself. @alloc whatever the copy makes.
94template <SpawnArg A>
95held_t<A> hold(A&& a) {
96 if constexpr (holds_by_value<A>) {
97 return std::forward<A>(a);
98 } else {
99 return std::addressof(a);
100 }
103/// Hand a held argument to the worker as an lvalue reference (the thread's own copy, or the
104/// caller's object). @complexity O(1). @alloc none.
105template <SpawnArg A>
106std::remove_cvref_t<A>& unhold(held_t<A>& h) {
107 if constexpr (holds_by_value<A>) {
108 return h;
109 } else {
110 return *h;
111 }
114} // namespace detail
116/**
117 * The owning handle to one spawned thread — obtained from `thread.spawn`, never constructed
118 * directly by a cheatah program. Move-only (there is exactly one owner of a thread), and the
119 * destructor JOINS: dropping the handle — normally, via `with`, or during unwinding — always
120 * waits for the worker to finish, on every exit path. There is no `detach`.
121 *
122 * If the worker threw and `join()` never surfaced it, the destructor reports one line on stderr
123 * (`cheatah thread: unhandled exception in thread: ...`) — the honest fallback, since a
124 * destructor must not throw.
125 *
126 * @concurrency the handle itself is not internally synchronized — drive a given `Thread` from one thread
127 * at a time; the worker it owns is another thread, and `join()` is the synchronization point with it.
128 *
129 * @test CheatahThread.MoveTransfersOwnership
130 * @test CheatahThread.DestructorJoinsARunningThread
131 * @crtest ThreadCompileRun.SpawnJoin
132 * @systest StdlibE2E.Thread
133 */
134class Thread {
135public:
136 /**
137 * The grant path used by `spawn`: adopt a running thread and its shared error slot. Public
138 * but not part of the cheatah surface (no module factory returns the pieces), matching the
139 * memory module's no-`friend` stance.
140 * @param t the running thread to own.
141 * @param state the error slot the spawn trampoline writes into.
142 * @complexity O(1). @alloc none (moves the handles in).
143 * @concurrency the worker is already running when the handle adopts it.
144 * @test CheatahThread.SpawnRunsTheWorker
145 */
146 Thread(std::thread t, std::shared_ptr<detail::State> state) noexcept;
148 /// Threads have exactly one owner: moving transfers it, the source becomes non-joinable.
149 /// @param other the handle to take the thread from (left non-joinable).
150 /// @complexity O(1). @alloc none. @test CheatahThread.MoveTransfersOwnership
151 Thread(Thread&& other) noexcept;
153 /// Move-assign: the destination first settles its own thread (join + report an unobserved
154 /// error), then adopts the source's.
155 /// @param other the handle to take the thread from (left non-joinable).
156 /// @return this handle, now owning @p other's thread.
157 /// @complexity O(join).
158 /// @alloc none on the fast path; reporting an unobserved worker error copies its message into a string.
159 /// @concurrency may block: the destination joins its old worker before adopting the new one.
160 /// @test CheatahThread.MoveAssignSettlesTheOldThread
161 Thread& operator=(Thread&& other) noexcept;
163 Thread(const Thread&) = delete;
164 Thread& operator=(const Thread&) = delete;
166 /// Joins if still joinable; reports an unobserved worker exception on stderr (one line).
167 /// @complexity O(join) — blocks until the worker finishes.
168 /// @alloc none on the fast path; reporting an unobserved worker error copies its message into a string.
169 /// @concurrency blocks the destroying thread until the worker finishes — on every exit path,
170 /// including unwinding.
171 /// @test CheatahThread.DestructorJoinsARunningThread
172 /// @test CheatahThread.DestructorReportsAnUnobservedException
173 ~Thread();
175 /**
176 * Block until the worker finishes. If the worker escaped with an exception, RE-THROW it here
177 * — catch it with `try { t.join() } catch e { ... }`. Joining a thread that was already
178 * joined (or moved away) raises.
179 * @complexity O(join) — blocks until the worker finishes. @alloc none.
180 * @concurrency blocks the calling thread; the worker's writes happen-before `join()` returns
181 * (the join is the synchronization). One-shot — a second join raises.
182 * @test CheatahThread.JoinRethrowsTheWorkersException
183 * @test CheatahThread.JoinOnNothingRaises
184 * @crtest ThreadCompileRun.JoinCatchesWorkerRaise
185 * @systest StdlibE2E.Thread
186 */
187 void join();
189 /**
190 * Does this handle still own a running/unjoined thread?
191 * @return true until `join()` (or a move-away); false after.
192 * @complexity O(1). @alloc none.
193 * @test CheatahThread.JoinableLifecycle
194 * @crtest ThreadCompileRun.Joinable
195 */
196 bool joinable() const noexcept;
198private:
199 void settle() noexcept; // join if joinable; stderr-report an unobserved worker error.
201 // std::thread, deliberately, NOT std::jthread. This handle already joins in its own
202 // destructor (see settle()), so jthread's auto-join bought nothing — while costing
203 // portability: jthread lives in libc++'s EXPERIMENTAL library on Apple toolchains, gated
204 // behind _LIBCPP_ENABLE_EXPERIMENTAL, so the whole standard library failed to compile on
205 // macOS with "no type named jthread in namespace std". A standard library that claims
206 // cross-platform support must not depend on another standard library's experimental
207 // feature. Nothing here ever used a stop_token, which is the only thing jthread adds.
208 std::thread t_;
209 std::shared_ptr<detail::State> state_;
210};
212/**
213 * Start `f(args...)` on a new OS thread and return its owning `Thread` guard.
214 *
215 * `f` is a cheatah `fn` passed by name (or any C++ callable). Every copyable argument is COPIED
216 * into the thread — the worker owns its values, nothing refers back to the caller — so plain
217 * ints/floats/strings/lists/structs are always safe to pass. A non-copyable, pinned object (a
218 * `memory.Owner<T>`) is passed BY REFERENCE: it must outlive the thread, which the guard's
219 * join-on-destroy gives naturally when the `Thread` is declared after the `Owner`. The worker
220 * declares such a parameter with its full type (`o : memory.Owner<int>`).
221 *
222 * The worker runs to completion exactly once; an exception it escapes with is caught and
223 * re-thrown at `join()`. Sharing mutable state across threads is safe ONLY through a
224 * `memory.Owner`'s leases — anything else you share is at your own risk.
225 *
226 * @param f the cheatah `fn` (or callable) to run.
227 * @param args its arguments (copied in; a non-copyable lvalue by reference — see above).
228 * @return the owning `Thread` guard (joins at scope exit).
229 * @complexity O(1) plus copying the arguments and the OS thread start.
230 * @alloc one shared error slot + the thread's start-state block (the closure holding the argument
231 * copies — `std::thread` puts it on the heap) + the OS thread stack.
232 * @concurrency the worker may already be running before `spawn` returns. Copyable arguments are captured
233 * before the thread starts, so the caller may immediately mutate or destroy its originals; a by-reference
234 * argument (a pinned `memory.Owner`) is shared with the running worker from that moment on.
235 * @warning cheatah does not detect data races: mutable state shared any way other than through a
236 * `memory.Owner`'s leases is at your own risk.
237 * @test CheatahThread.SpawnRunsTheWorker
238 * @test CheatahThread.CopyableArgumentsAreCopied
239 * @test CheatahThread.SpawnPassesANonCopyableByReference
240 * @test CheatahThread.SpawnMovesANonCopyableRvalue
241 * @crtest ThreadCompileRun.SpawnJoin
242 * @systest StdlibE2E.Thread
243 * @systest StdlibE2E.ThreadSharedOwnerSum
244 */
245template <class F, class... Args>
246 requires SpawnCallable<F, Args...> && (SpawnArg<Args> && ...)
247Thread spawn(F f, Args&&... args) { // NOLINT(cppcoreguidelines-missing-std-forward): args ARE forwarded, inside the init-capture pack expansion below
248 auto state = std::make_shared<detail::State>();
249 std::thread t(
250 [state, fn = std::move(f),
251 held = std::tuple<detail::held_t<Args>...>(
252 detail::hold<Args>(std::forward<Args>(args))...)]() mutable {
253 try {
254 [&]<std::size_t... I>(std::index_sequence<I...>) {
255 fn(detail::unhold<Args>(std::get<I>(held))...);
256 }(std::index_sequence_for<Args...>{});
257 } catch (...) {
258 state->error = std::current_exception();
259 }
260 });
261 return {std::move(t), std::move(state)};
264} // namespace cheatah::thread