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
127 * thread at a time (the worker it owns is, of course, another thread; `join()` is the
128 * synchronization point with it).
129 *
130 * @test CheatahThread.MoveTransfersOwnership
131 * @test CheatahThread.DestructorJoinsARunningThread
132 * @crtest ThreadCompileRun.SpawnJoin
133 * @systest StdlibE2E.Thread
134 */
135class Thread {
136public:
137 /**
138 * The grant path used by `spawn`: adopt a running thread and its shared error slot. Public
139 * but not part of the cheatah surface (no module factory returns the pieces), matching the
140 * memory module's no-`friend` stance.
141 * @param t the running thread to own.
142 * @param state the error slot the spawn trampoline writes into.
143 * @complexity O(1). @alloc none (moves the handles in).
144 * @concurrency the worker is already running when the handle adopts it.
145 * @test CheatahThread.SpawnRunsTheWorker
146 */
147 Thread(std::thread t, std::shared_ptr<detail::State> state) noexcept;
149 /// Threads have exactly one owner: moving transfers it, the source becomes non-joinable.
150 /// @param other the handle to take the thread from (left non-joinable).
151 /// @complexity O(1). @alloc none. @test CheatahThread.MoveTransfersOwnership
152 Thread(Thread&& other) noexcept;
154 /// Move-assign: the destination first settles its own thread (join + report an unobserved
155 /// error), then adopts the source's.
156 /// @param other the handle to take the thread from (left non-joinable).
157 /// @return this handle, now owning @p other's thread.
158 /// @complexity O(join). @alloc none.
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. @alloc none.
168 /// @concurrency blocks the destroying thread until the worker finishes — on every exit path,
169 /// including unwinding.
170 /// @test CheatahThread.DestructorJoinsARunningThread
171 /// @test CheatahThread.DestructorReportsAnUnobservedException
172 ~Thread();
174 /**
175 * Block until the worker finishes. If the worker escaped with an exception, RE-THROW it here
176 * — catch it with `try { t.join() } catch e { ... }`. Joining a thread that was already
177 * joined (or moved away) raises.
178 * @complexity O(join) — blocks until the worker finishes. @alloc none.
179 * @concurrency blocks the calling thread; the worker's writes happen-before `join()` returns
180 * (the join is the synchronization). One-shot — a second join raises.
181 * @test CheatahThread.JoinRethrowsTheWorkersException
182 * @test CheatahThread.JoinOnNothingRaises
183 * @crtest ThreadCompileRun.JoinCatchesWorkerRaise
184 * @systest StdlibE2E.Thread
185 */
186 void join();
188 /**
189 * Does this handle still own a running/unjoined thread?
190 * @return true until `join()` (or a move-away); false after.
191 * @complexity O(1). @alloc none.
192 * @test CheatahThread.JoinableLifecycle
193 * @crtest ThreadCompileRun.Joinable
194 */
195 bool joinable() const noexcept;
197private:
198 void settle() noexcept; // join if joinable; stderr-report an unobserved worker error.
200 // std::thread, deliberately, NOT std::jthread. This handle already joins in its own
201 // destructor (see settle()), so jthread's auto-join bought nothing — while costing
202 // portability: jthread lives in libc++'s EXPERIMENTAL library on Apple toolchains, gated
203 // behind _LIBCPP_ENABLE_EXPERIMENTAL, so the whole standard library failed to compile on
204 // macOS with "no type named jthread in namespace std". A standard library that claims
205 // cross-platform support must not depend on another standard library's experimental
206 // feature. Nothing here ever used a stop_token, which is the only thing jthread adds.
207 std::thread t_;
208 std::shared_ptr<detail::State> state_;
209};
211/**
212 * Start `f(args...)` on a new OS thread and return its owning `Thread` guard.
213 *
214 * `f` is a cheatah `fn` passed by name (or any C++ callable). Every copyable argument is COPIED
215 * into the thread — the worker owns its values, nothing refers back to the caller — so plain
216 * ints/floats/strings/lists/structs are always safe to pass. A non-copyable, pinned object (a
217 * `memory.Owner<T>`) is passed BY REFERENCE: it must outlive the thread, which the guard's
218 * join-on-destroy gives naturally when the `Thread` is declared after the `Owner`. The worker
219 * declares such a parameter with its full type (`o : memory.Owner<int>`).
220 *
221 * The worker runs to completion exactly once; an exception it escapes with is caught and
222 * re-thrown at `join()`. Sharing mutable state across threads is safe ONLY through a
223 * `memory.Owner`'s leases — anything else you share is at your own risk.
224 *
225 * @param f the cheatah `fn` (or callable) to run.
226 * @param args its arguments (copied in; a non-copyable lvalue by reference — see above).
227 * @return the owning `Thread` guard (joins at scope exit).
228 * @complexity O(1) plus copying the arguments and the OS thread start.
229 * @alloc one shared error slot + the thread's start-state block (the closure holding the argument
230 * copies — `std::thread` puts it on the heap) + the OS thread stack.
231 * @concurrency the worker may already be running before `spawn` returns. Copyable arguments are
232 * captured before the thread starts, so the caller may immediately mutate or destroy its originals;
233 * a by-reference argument (a pinned `memory.Owner`) is shared with the running worker from that
234 * 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) {
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 Thread(std::move(t), std::move(state));
264} // namespace cheatah::thread