Repository navigation
fix(logger): decide colors without a static guard a fork child inherits - #48
Conversation
use_color() kept its decision in a function-local static. When another thread was initializing it at the time of a fork(), the child inherited the initialization guard as taken, and its first call to color() waited for it forever: no thread of the child would ever release it. The decision is now an atomic, decided by the first caller; two threads deciding at once reach the same answer. Seen in coretrace-compiler, where a child forked while a thread logged hung: CoreTrace/coretrace-compiler#213
The test redefines isatty(), which the logger calls once to decide whether stderr takes colors, so that the decision lasts while the test forks; the child then asks for a color and must exit. Before the fix, the child hung on the decision's initialization guard every time.
Under NO_COLOR, the logger decides without calling isatty(), so the test waited forever for the decision to start: the wait now gives up after 5 s with a failed check, and the test unsets NO_COLOR, as test_colors_tty does. It also restores the default SIGCHLD action, which waitpid() needs to reap the child. The fake isatty() takes the exception specification of the C library's declaration, noexcept with glibc, instead of relying on the compiler to accept a mismatch with a system header. Threads::Threads already comes with coretrace_logger.
Two threads may decide the colors at once. The fake isatty() now tells the first caller, after a delay, that stderr is a terminal, and later callers at once that it is not: the main thread decides while the decider waits, and both must get the same color, as must later calls, which must not call isatty() again. The test fails: the decider's slower decision overwrites the one already stored.
… once Each thread that decided stored its answer, so the last one won: when NO_COLOR changed in between, or when SetConsoleMode() failed for one of them on Windows, a thread could color its lines while the decision kept for later said no. The decision is now stored only when none is, with a compare-exchange, and a thread that loses takes the one stored. The three states are named by an enum, and a static_assert checks that the atomic is lock-free: a child of fork() must not inherit a lock taken by the parent.
no-color.org asks programs to disable colors when NO_COLOR is present and not an empty string. The new test_colors_tty.empty_no_color case sets NO_COLOR to an empty string and expects the colors of the default case. It fails: the logger disables the colors whenever NO_COLOR is set.
no-color.org disables colors when NO_COLOR is present and not an empty string; the logger disabled them for an empty NO_COLOR too, as when a script sets NO_COLOR= to clear it. The README says which values disable the colors.
test_fork forks after the logger was used, and the child must be able to log: - before any initialization, fork() must finish it, so the environment defaults are those at the time of the fork; - the child's pid() must be its own; - while another thread writes a line, through a sink that takes 300 ms; - from inside a sink, where the forking thread holds the output lock itself. The reap and wait loops of test_fork_color move to fork_helpers.hpp, shared by both tests. All four checks fail: the environment is read after the fork, the child reports the parent's pid, and both children hang on the output lock they inherit.
pid() cached the process id in a function-local static, so after fork() the child's lines carried the parent's pid, and the child could also inherit the static's initialization guard taken by another thread of the parent, as with the color decision. pid() now asks the system each time: getpid() costs a fraction of a log line.
…ogger A child of fork() has only the thread that forked. It inherited the output lock, or the state lock, taken by another thread of the parent writing a line at the time of the fork, and its first line waited for them forever; with libc++, it also waited forever for an initialization another thread was running, since std::call_once does not survive fork(). pthread_atfork() handlers now make fork() wait for them: the forking thread finishes the initialization, takes the output lock then the state lock, the order of a sink that logs, and releases them in the parent and in the child. The environment defaults are thus read at the latest by the first fork(). The output lock is a std::mutex, with a thread-local count of the guards held, instead of a std::recursive_mutex: a recursive mutex checks the id of its owner, which the child's thread does not share with the forking thread, so the child could not release it. A sink can still log, and a sink that forks gives a child that can log.
The changelog lists the fork() fixes and the NO_COLOR change. The README and the API comments no longer call the output lock recursive or pid() cached, say that fork() waits for a line being written, and when the environment defaults are read.
SizzleUnrlsd
left a comment
There was a problem hiding this comment.
Review of 01fe73f: 14 points, one thread each, tagged [blocking], [important], [minor] or [nit]. Two of them are on lines outside the diff (pid() and the output/state mutexes in src/logger.cpp), so they are file-level comments.
CI was green on 01fe73f, and test_fork_color passes locally. The fixes follow on this branch.
|
|
||
| int main() { | ||
| std::thread decider([] { (void)coretrace::color(coretrace::Color::Red); }); | ||
| while (!g_deciding_colors.load()) { |
There was a problem hiding this comment.
[blocking] The test hangs when NO_COLOR is set in the environment.
use_color() checks NO_COLOR before stderr_supports_color(), so under NO_COLOR the fake isatty() is never called, g_deciding_colors stays false and this loop spins until ctest's 20 s timeout.
Reproduced: NO_COLOR=1 ctest -R fork_color gives (Timeout). Anyone exporting NO_COLOR in their shell profile (as no-color.org suggests) hits it.
Fix: unsetenv("NO_COLOR") at the start of main(), as test_colors_tty.cpp does.
There was a problem hiding this comment.
Fixed in f339a83: main() unsets NO_COLOR first. NO_COLOR=1 ctest -R fork_color now passes.
| while (!g_deciding_colors.load()) { | ||
| } |
There was a problem hiding this comment.
[minor] Unbounded busy-wait.
The loop has no deadline and no message: whenever the decision does not go through isatty() (the NO_COLOR case above, or a later refactor that checks TERM or uses ioctl), the test burns a core for 20 s and ends as (Timeout) instead of failing fast with a readable CHECK.
Fix: bound it by the same deadline as reap().
| const bool enabled = | ||
| env_var("NO_COLOR") == nullptr && platform::stderr_supports_color(); | ||
| decision = enabled ? 2 : 1; | ||
| g_color_decision.store(decision, std::memory_order_release); |
There was a problem hiding this comment.
[important] Last writer wins when two threads decide at once.
Each decider stores its own answer unconditionally, so the last store wins. The old static guard guaranteed a single decision. Scenario: T1 decides "colors", T2 decides "no colors" first (because NO_COLOR changed in between, or SetConsoleMode() failed once on Windows), then T1 overwrites it: T1's lines are colored, T2's are not, and the final value depends on scheduling.
Fix: compare_exchange_strong(Undecided → decision), and a thread that loses takes the stored value.
| const bool enabled = | ||
| env_var("NO_COLOR") == nullptr && platform::stderr_supports_color(); |
There was a problem hiding this comment.
[minor] An empty NO_COLOR disables colors.
no-color.org says colors are disabled when NO_COLOR is "present and not an empty string"; == nullptr also disables them for NO_COLOR= (e.g. a script clearing the variable). The README advertises NO_COLOR compliance. The line is rewritten by this PR, so it is a good time to fix it.
| // initialization guard as taken when another thread of the parent was deciding | ||
| // at the time of the fork, and wait for it forever. Two threads may decide at | ||
| // once; they reach the same answer. | ||
| std::atomic<int> g_color_decision{0}; |
There was a problem hiding this comment.
[minor] The fork safety relies on std::atomic<int> being lock-free, unchecked.
On a target where it is implemented with a lock (libatomic), a child forked during a load or store would inherit that lock taken. A static_assert(std::atomic<...>::is_always_lock_free); next to the declaration makes the requirement explicit.
There was a problem hiding this comment.
Fixed in a0c2f45: static_assert(std::atomic<ColorDecision>::is_always_lock_free);.
| while (!g_deciding_colors.load()) { | ||
| } | ||
|
|
||
| const pid_t child = fork(); |
There was a problem hiding this comment.
[minor] Nothing checks that the decision stays cached.
The PR promises one decision per process, but a regression that drops the store (deciding on every color() call: getenv + isatty 10 to 14 times per line) keeps the whole suite green. Counting the fake isatty() calls and checking that later calls do not add any would cover it.
There was a problem hiding this comment.
Fixed in 84d6819: the fake isatty() counts its calls, and the test checks that a color() call after the decision adds none.
| bool reap(pid_t child, int &status) { | ||
| const auto deadline = std::chrono::steady_clock::now() + kChildTimeout; | ||
| while (std::chrono::steady_clock::now() < deadline) { | ||
| if (waitpid(child, &status, WNOHANG) == child) |
There was a problem hiding this comment.
[minor] reap() reports an auto-reaped child as hung.
If the test is launched with SIGCHLD ignored (the disposition is inherited across exec), the child is reaped automatically, waitpid() returns -1/ECHILD, and the loop waits 5 s then fails CHECK(exited) although the child exited fine. Restoring SIG_DFL at the start of main() removes the case.
|
|
||
| std::atomic<bool> g_deciding_colors{false}; | ||
|
|
||
| extern "C" int isatty(int) { |
There was a problem hiding this comment.
[minor] Exception specification mismatch with glibc's declaration.
glibc declares isatty() with __THROW (noexcept(true) in C++), and the premise "kept apart from <unistd.h>" does not hold: with libstdc++ 13 in C++20, <atomic> includes <unistd.h> (checked with g++ -E on Ubuntu 24.04). The definition without noexcept only compiles because GCC tolerates the mismatch with a system-header declaration.
Fix: include <unistd.h> and copy the C library's specification, noexcept(noexcept(::isatty(0))), which is noexcept on glibc and nothing on macOS.
There was a problem hiding this comment.
Fixed in f339a83: the fake includes <unistd.h> and is declared noexcept(noexcept(::isatty(0))). Builds clean with -Wall -Wextra -Werror on g++ 13 and Apple clang 17.
| @@ -0,0 +1,17 @@ | |||
| // isatty() for test_fork_color.cpp. The logger calls it once, to decide whether | |||
There was a problem hiding this comment.
[nit] "The logger calls it once" is not accurate.
In this test the fake is called at least twice (the parent's decider, then the child, which decides again), and two threads may now call it concurrently.
There was a problem hiding this comment.
Fixed in 84d6819: the comment now describes the first slow call and the later fast ones.
| # fork() while another thread decides the colors: the test redefines isatty(), which the | ||
| # logger calls then, to make that decision last. | ||
| add_executable(coretrace_logger_test_fork_color test_fork_color.cpp fork_color_isatty.cpp) | ||
| target_link_libraries(coretrace_logger_test_fork_color PRIVATE coretrace_logger Threads::Threads) |
There was a problem hiding this comment.
[nit] Threads::Threads is redundant.
It is already a PUBLIC dependency of coretrace_logger (top-level CMakeLists.txt); the other test targets that use std::thread link only coretrace_logger.
There was a problem hiding this comment.
[blocking] pid() (line 597) keeps the static guard this PR removes from use_color().
static const int cached = platform::process_id();It is on the same first-line path (write_log_line(), write_prefix()), so a child forked while another thread runs that initializer waits for its guard forever, exactly like the color decision. Independently of the race, every line of a child carries the parent's pid (the PR description mentions it).
Fix: return platform::process_id();. getpid() is cheap next to a log line.
There was a problem hiding this comment.
[important] g_output_mutex and g_state_mutex (lines 49 and 52) can be inherited locked.
g_output_mutex is held for the whole write, sink call included (write_log_line(), line 774). If thread T1 is writing to a slow sink (file, pipe) when T2 calls fork(), the child's first line blocks on OutputLockGuard forever: the same symptom as the issue, with a much wider window than the color decision.
General fix: pthread_atfork() handlers that take the locks before the fork and release them in both processes. The order must be output then state, since a sink that logs takes them in that order. Note that the child cannot release a std::recursive_mutex taken by the parent: glibc and macOS check the owner's thread id, which differs in the child.
There was a problem hiding this comment.
Fixed in 9c8a615. pthread_atfork() handlers: before the fork, the forking thread runs init_once(), then takes the output lock and then the state lock; after it, both processes release them. The output lock is now a std::mutex with a thread-local depth, so a sink can still log, and a sink that forks gives a child that can log. 2c73405 adds the tests first: fork while another thread writes through a slow sink, and fork from inside a sink. Without the fix both children hang.
fork() calls the prepare handlers in the reverse order of their registration, and the parent and child handlers in that order. A handler registered before the logger's, here by the test's own initialization, thus runs while the forking thread holds the logger's locks. The new check logs and sets the sink from such handlers, in the prepare, parent and child phases, and expects the lines and the child to come through; alarm() ends the test if fork() hangs. It fails: the forking thread waits for the locks it holds, and fork() never returns.
Between the logger's prepare and parent or child handlers, fork() runs the handlers registered before the logger's, in the forking thread, which then held both locks: a log() or a set_sink() from such a handler waited for a lock its own thread held, and fork() never returned. A regression of the previous commit: without handlers, the logger never held a lock across the fork. The handlers' acquisitions now go through the reentrance of the guards: both locks keep a thread-local count, taken when it goes from 0 to 1 and released when it returns to 0, for the state lock as for the output lock. The forking thread can thus call the logger from any fork handler, while the other threads still wait.
An atexit() handler registered by the test's own initialization, before the logger's, runs after the logger's static destructors, and forks. The logger's fork handlers still run then. It fails on macOS: libc++ destroys the logger's mutexes, and the prepare handler aborts on "mutex lock failed: Invalid argument".
The fork handlers lock the logger's mutexes on every fork(), including one made by an atexit() handler or a static destructor that runs after this file's static destructors. libc++ destroys a std::mutex, and locking it then threw "mutex lock failed: Invalid argument" from the prepare handler, aborting the program. A regression of the fork handlers: without them, fork() never touched the logger. The two mutexes are now never destroyed: they live in a union whose destructor does nothing, still constant-initialized.
A child of
fork()could hang forever on its first log line: CoreTrace/coretrace-compiler#213.The first commits fixed the color decision, the state the issue points at. After review, the PR also covers every other state a child could inherit half-taken from another thread of the parent, and two details of the color decision.
Cause
A child of
fork()has only the thread that forked. Whatever another thread of the parent held at the time of the fork stays held in the child, and no thread of the child will ever release it:use_color()kept it in a function-local static (static const bool enabled = [](){...}();). When another thread was running that initializer, the child inherited the initialization guard marked "in progress", and its first call tocolor()waited in__cxa_guard_acquireforever.pid(). It cached the pid in the same kind of function-local static. Besides, the child's lines carried the parent's pid.g_output_mutexis held for the whole write of a line, sink call included: a child forked while another thread was writing blocked on its first line.init_once()usesstd::call_once. With libc++, a child forked while another thread runsinit_from_env()waits for a flag left "pending".In coretrace-compiler, the runtime builds the color arguments of a log line before it takes its log lock, so a fork could happen during that first initialization. Its fixture
ct_fork_threads.chung once in 1,000 runs on macOS, and once in CI. The issue gives the stack of the hung child, and the disassembly showing the guard is the one ofenabled.Change
Fork
std::atomic<ColorDecision>(Undecided,Off,On), with no lock and no initialization guard. Two threads may decide at once: the first decision stored holds (compare-exchange), and a thread that loses takes it. Astatic_assertchecks that the atomic is lock-free.pid()asks the system on each call.pthread_atfork()handlers, throughplatform::at_fork()(a no-op on Windows):init_once(), then takes the output lock and the state lock, in the order of a sink that logs;std::mutexwith a thread-local count of the guards held, taken when the count goes from 0 to 1. The output lock was astd::recursive_mutex, which the child cannot release when taken before the fork: glibc and macOS check the id of its owner, and the child's thread has another one. A sink can still log, and a sink that forks gives a child that can log.fork()still works from anatexit()handler or a static destructor that runs after the logger's.Colors
NO_COLORno longer disables the colors: no-color.org asks for "present and not an empty string".Docs
fork()waits for a line being written, when the environment defaults are read, and whichNO_COLORvalues disable the colors.Behavior changes
fork()waits for a line another thread is writing. A sink that waits for the thread callingfork()would now deadlock it.CT_LOG_LEVELandCT_DEBUGare read at the first logging call or at the firstfork(), whichever comes first. They were read only at the first logging call.NO_COLOR=(empty) keeps the colors.Tests
Each fix comes after a test that fails without it.
test_fork_colormain(f8bf453), the child hangs and is killed after 5 s; before a0c2f45, the decider gets a color and the main thread nonetest_forkfork()finishes the initialization; the child'spid(); fork while another thread writes through a 300 ms sink; fork from inside a sinktest_forkfork()never returns: the forking thread waits for the locks it holdstest_forkatexit()handler that runs after the logger's static destructors forksmutex lock failed: Invalid argumenttest_colors_tty.empty_no_colorNO_COLORset to an empty string keeps the colorstest_fork_colorredefinesisatty()in a translation unit of its own,fork_color_isatty.cpp, with the exception specification of the C library's declaration. The logger, a static library, calls that definition while it decides. The first call says stderr is a terminal after 300 ms; later calls say it is not, at once.SIGCHLDaction, andtest_fork_colorunsetsNO_COLOR. Their reap and wait helpers, intests/fork_helpers.hpp, give up after 5 s.test_forkregisters its fork handlers and itsatexit()handler during its own static initialization, which runs before the logger's: its prepare handler then runs after the logger's, its parent and child handlers before them, and itsatexit()handler after the logger's destructors. Without the fixes, both checks fail on macOS and on Linux.Verification
-fsanitize=threadand with-fsanitize=address,undefined. Under each sanitizer,test_forkandtest_fork_colorpass 30 times in a row.test_forkandtest_fork_color20 times in each mode:test_fork,test_fork_color×20-Wall -Wextra -Werror-fsanitize=thread-fsanitize=address,undefinedThe two failures under ASan+UBSan are
test_concurrency_smokeandtest_format_error_serialized, which time out after 20 s. They time out onmaintoo, in the same container, which emulates arm64.dlclose(): a shared library built with the logger, loaded, used, closed, thenfork(). glibc removes the library's fork handlers when it unloads it; macOS does not unload it.fork()works on both.Inside coretrace-compiler
The first
use_color()change (610446e), applied to the commit coretrace-compiler pins (624688a), was built into its runtime on macOS:ctest: 213 passed;The later commits were not built into coretrace-compiler yet.
Moving coretrace-compiler's pin to
mainwas blocked by another defect. Withmain, every instrumented program aborts at start-up:recursive_mutex lock failed: Invalid argument;ct_init_env_once → coretrace::set_sink → std::recursive_mutex::lock;set_sink()before the globalg_output_mutex, astd::recursive_mutexadded by 381e27b, is constructed, since libc++ initializes it dynamically.This PR makes
g_output_mutexastd::mutex, whoseconstexprconstructor makes it constant-initialized, before any static constructor runs. That should remove this failure as well; it is not checked in coretrace-compiler yet.