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 once4
// cheatah-link: -pthread6
/**7
* @file thread.hpp8
* @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 scope12
* exit — every thread is joined before `main` returns. There is deliberately no `detach`: the13
* cheatah host unloads the program's module right after `main`, so a detached thread would crash14
* 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 COPYABLE17
* argument is copied into the thread (the worker owns its own value; nothing points back at the18
* caller), so the one way to share a mutable object is to pass a pinned `memory.Owner<T>` — it19
* travels BY REFERENCE — and go through its request -> acquire -> lease flow. cheatah does not20
* detect or prevent data races: what you do across threads is AT YOUR OWN RISK, and the21
* `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 and24
* 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 must26
* 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 and30
* 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>40
namespace cheatah::thread {42
namespace detail {44
/// Shared between the owning guard and the worker trampoline: the worker's escaped exception (if45
/// any) and whether `join()` already surfaced it (so the destructor stays silent). Written by the46
/// worker before it finishes and read only after a join — the join is the synchronization.47
struct 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 (the53
/// thread owns its own value), a non-copyable RVALUE that can move (a guard returned by a54
/// 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.56
template <class A>57
inline 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 detail63
/**64
* One argument `spawn` can carry into a thread: anything copyable, a movable rvalue, or a65
* non-copyable LVALUE (passed by reference — the caller's object must outlive the thread, which66
* 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 variable70
* first (`let o = memory.own(0)`), then pass `o`.71
*/72
template <class A>73
concept 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 given77
* arguments (each argument reaches the worker as an lvalue — the thread's own copy, or the78
* caller's non-copyable object by reference). Satisfied by both lowerings purrc emits for a79
* cheatah `fn` passed by name (the concrete function pointer and the generic forwarding lambda).80
*/81
template <class F, class... Args>82
concept SpawnCallable = std::invocable<std::decay_t<F>&, std::remove_cvref_t<Args>&...>;84
namespace detail {86
/// What the trampoline's closure stores for one argument: the decayed value, or a pointer to the87
/// caller's non-copyable object.88
template <SpawnArg A>89
using 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 — see93
/// `holds_by_value`). @complexity O(1) plus the copy/move itself. @alloc whatever the copy makes.94
template <SpawnArg A>95
held_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
}101
}103
/// Hand a held argument to the worker as an lvalue reference (the thread's own copy, or the104
/// caller's object). @complexity O(1). @alloc none.105
template <SpawnArg A>106
std::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
}112
}114
} // namespace detail116
/**117
* The owning handle to one spawned thread — obtained from `thread.spawn`, never constructed118
* directly by a cheatah program. Move-only (there is exactly one owner of a thread), and the119
* destructor JOINS: dropping the handle — normally, via `with`, or during unwinding — always120
* 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 stderr123
* (`cheatah thread: unhandled exception in thread: ...`) — the honest fallback, since a124
* destructor must not throw.125
*126
* @concurrency the handle itself is not internally synchronized — drive a given `Thread` from one127
* thread at a time (the worker it owns is, of course, another thread; `join()` is the128
* synchronization point with it).129
*130
* @test CheatahThread.MoveTransfersOwnership131
* @test CheatahThread.DestructorJoinsARunningThread132
* @crtest ThreadCompileRun.SpawnJoin133
* @systest StdlibE2E.Thread134
*/135
class Thread {136
public:137
/**138
* The grant path used by `spawn`: adopt a running thread and its shared error slot. Public139
* but not part of the cheatah surface (no module factory returns the pieces), matching the140
* 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.SpawnRunsTheWorker146
*/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.MoveTransfersOwnership152
Thread(Thread&& other) noexcept;154
/// Move-assign: the destination first settles its own thread (join + report an unobserved155
/// 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.MoveAssignSettlesTheOldThread161
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.DestructorJoinsARunningThread171
/// @test CheatahThread.DestructorReportsAnUnobservedException172
~Thread();174
/**175
* Block until the worker finishes. If the worker escaped with an exception, RE-THROW it here176
* — catch it with `try { t.join() } catch e { ... }`. Joining a thread that was already177
* 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()` returns180
* (the join is the synchronization). One-shot — a second join raises.181
* @test CheatahThread.JoinRethrowsTheWorkersException182
* @test CheatahThread.JoinOnNothingRaises183
* @crtest ThreadCompileRun.JoinCatchesWorkerRaise184
* @systest StdlibE2E.Thread185
*/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.JoinableLifecycle193
* @crtest ThreadCompileRun.Joinable194
*/195
bool joinable() const noexcept;197
private: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 own201
// destructor (see settle()), so jthread's auto-join bought nothing — while costing202
// portability: jthread lives in libc++'s EXPERIMENTAL library on Apple toolchains, gated203
// behind _LIBCPP_ENABLE_EXPERIMENTAL, so the whole standard library failed to compile on204
// macOS with "no type named jthread in namespace std". A standard library that claims205
// cross-platform support must not depend on another standard library's experimental206
// 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 COPIED215
* into the thread — the worker owns its values, nothing refers back to the caller — so plain216
* ints/floats/strings/lists/structs are always safe to pass. A non-copyable, pinned object (a217
* `memory.Owner<T>`) is passed BY REFERENCE: it must outlive the thread, which the guard's218
* join-on-destroy gives naturally when the `Thread` is declared after the `Owner`. The worker219
* 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 and222
* re-thrown at `join()`. Sharing mutable state across threads is safe ONLY through a223
* `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 argument230
* 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 are232
* 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 that234
* moment on.235
* @warning cheatah does not detect data races: mutable state shared any way other than through a236
* `memory.Owner`'s leases is at your own risk.237
* @test CheatahThread.SpawnRunsTheWorker238
* @test CheatahThread.CopyableArgumentsAreCopied239
* @test CheatahThread.SpawnPassesANonCopyableByReference240
* @test CheatahThread.SpawnMovesANonCopyableRvalue241
* @crtest ThreadCompileRun.SpawnJoin242
* @systest StdlibE2E.Thread243
* @systest StdlibE2E.ThreadSharedOwnerSum244
*/245
template <class F, class... Args>246
requires SpawnCallable<F, Args...> && (SpawnArg<Args> && ...)247
Thread 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));262
}264
} // namespace cheatah::thread