Skip to content

Make TEST_LOG async-signal-safe - #883

Merged
rkennke merged 2 commits into
mainfrom
fix/test-log-signal-safe
Oct 22, 2026
Merged

rkennke merged 2 commits into
mainfrom
fix/test-log-signal-safe

Conversation

@rkennke

@rkennke rkennke commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

What

TEST_LOG (debug builds only) no longer goes through stdio or libc formatting. testLog() in the new testLog.h:

  • formats into a 512-byte stack buffer with its own printf subset;
  • writes the line to fd 1 with a single write(2);
  • takes no lock and never allocates;
  • preserves errno;
  • cuts longer lines, keeping the newline.

Why

The old macro was fprintf(stdout, …); fflush(stdout), which takes glibc's stdout lock. TEST_LOG is also reached from signal handlers, e.g. Profiler::populateRemoteFrame() under CTimer::signalHandler(). When the signal lands on a thread that is itself inside stdio, the handler waits forever for a lock its own thread holds. Every other thread that samples or logs then blocks on the same lock, and the test JVM hangs.

That is exactly what the gdb watchdog caught on #880, in the test-linux-glibc-amd64 (8-orcl, debug, regular) cell during RemoteSymbolicationTest [cstack=fp]:

#1  __lll_lock_wait_private (futex=<_IO_stdfile_1_lock>)
#2  __vfprintf_internal (... "[TEST::INFO] populateRemoteFrame: lib=%s, ...")
#4  Profiler::populateRemoteFrame
#5  Profiler::convertNativeTrace
#7  Profiler::recordSample
#8  CTimer::signalHandler
#9  <signal handler called>
#10 _IO_fflush (stdout)
#11 LibraryPatcher::patch_libraries     <- TEST_LOG("%d libraries patched")
#14 Profiler::dlopen_hook (".../libddproftest.so")

A second sampled thread was blocked on the same lock. Both log calls have been there for months, so this race is old and just rare. It only needs the signal to land inside a stdio call on the same thread. The intermittent IBM SDK 8 hangs in DatadogSettingsTest (#868, #880) also stop right after libraries patched, so they are likely the same thing. That isn't confirmed, though: their watchdog dumps predate #880's fix.

Notes

  • Output format: unchanged, [TEST::INFO] <message>\n on stdout. Tests that parse these lines from child JVMs keep working.

  • Line length: capped at 512 bytes, newline included. That's the POSIX minimum of PIPE_BUF, and macOS's value. So one line is one atomic pipe write on every platform, and concurrent lines can't interleave.

  • Formatting: libc's formatters aren't async-signal-safe. Some conversions may allocate or read locale state (glibc's floating-point and wide-character paths; BSD's gdtoa, which also locks). The subset covers what the 130 call sites use:

    • integers of every length (hh h l ll z j t), x/X/o, p, s with precision (%.*s), c, %;
    • flags -0+ and widths;
    • fixed-point f, which rounds ties up, so the last digit can differ from libc's.

    Format checking is kept through __attribute__((format(printf, 1, 2))).

  • Ordering: output from other stdio writers to stdout is no longer ordered with these lines by stdio's buffer. The old TEST_LOG flushed after every line anyway, and nothing else in the library writes to stdout through stdio while tests run.

Tests

  • New testLog_ut:

    • the exact output line;
    • no wait while another thread holds the stdout lock (flockfile);
    • the 512-byte cap;
    • preserving errno;
    • output identical to libc snprintf for every conversion in use.
  • With the old macro, the lock test fails ("TEST_LOG waited for the stdout lock", after its 2 s timeout), and the long-line test fails because the old macro didn't cut lines.

  • All debug gtests pass.

  • The full debug Java suite on macOS had the same six failures with each version of this PR. Run on their own, they behave the same with the old and the new macro:

    • DynamicNativeThread, NativeThreadTest, StartWithChurningThreadsTest, StopWithChurningThreadsTest and VtableReceiverFrameTest fail both ways;
    • ThreadEntryDetectionTest passes both ways; it failed only in the full run.

    These are sampling and thread-churn tests that are flaky on macOS.

🤖 Generated with Claude Code

TEST_LOG wrote through stdio (fprintf + fflush on stdout), which takes
glibc's stdout lock. It is also called from code that signal handlers run,
e.g. Profiler::populateRemoteFrame() under CTimer::signalHandler(). When the
signal lands on a thread that is itself inside stdio, here
LibraryPatcher::patch_libraries() logging from the dlopen hook, the handler
waits forever for the lock its own thread holds, and every other thread that
samples or logs then blocks on it too. Debug builds only, since TEST_LOG is
a no-op otherwise; this is what hung the 8-orcl debug cell in
RemoteSymbolicationTest (gdb: both threads in __lll_lock_wait_private on
_IO_stdfile_1_lock).

testLog() formats into a stack buffer with vsnprintf() and emits the line
with one write(2) to fd 1, so no lock is taken; it preserves errno, and cuts
lines longer than its 1 KiB buffer. A single write of a short line stays
atomic on a pipe, which the tests reading these lines from child JVMs rely on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@rkennke
rkennke requested a review from a team as a code owner October 22, 2026 10:27
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-22T11:45:49.206660Z 656154d Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: cbd89a7121

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread ddprof-lib/src/main/cpp/common.h Outdated

@datadog-official datadog-official Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bits Code Review: FAIL

On macOS, concurrent debug log records longer than 512 bytes can interleave because the new buffer exceeds the platform's atomic pipe-write limit.

Open Bits AI session

🤖 Bits Code Review · Commit cbd89a7

Comment thread ddprof-lib/src/main/cpp/common.h Outdated
@dd-octo-sts

dd-octo-sts Bot commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

CI Test Results

Run: #39770840665 | Commit: 532cff7 | Duration: 20m 17s (longest job)

❌ 1 of 32 test jobs failed

Status Overview

JDK glibc-aarch64/debug glibc-amd64/debug musl-aarch64/debug musl-amd64/debug
8 - ✅ - -
8-ibm - ✅ - -
8-j9 ✅ ✅ - -
8-librca - - ✅ ✅
8-orcl - ✅ - -
11 - ✅ - -
11-j9 ✅ ✅ - -
11-librca - - ✅ ✅
17 ❌ ✅ - -
17-graal ✅ ✅ - -
17-j9 ✅ ✅ - -
17-librca - - ✅ ✅
21 ✅ ✅ - -
21-graal ✅ ✅ - -
21-librca - - ✅ ✅
25 ✅ ✅ - -
25-graal ✅ ✅ - -
25-librca - - ✅ ✅

Legend: ✅ passed | ❌ failed | ⚪ skipped | 🚫 cancelled

❔ Failing tests — flakiness not measured

Test Cells Message
LazyJmethodIdTest._3_ glibc-17-debug-aarch64 org.opentest4j.AssertionFailedError: Test execution #3 (of up to 3 with at least 1 successes) failed ~> test fails - see
LazyJmethodIdTest.framesOfUnloadedClassAreDroppedSafely__ glibc-17-debug-aarch64 org.opentest4j.AssertionFailedError: No raw frame was refused, so the unloaded class was never sampled ==> expected: <tr
LazyJmethodIdTest.traceIdOffsetIsCalibrated__ glibc-17-debug-aarch64 org.opentest4j.AssertionFailedError: Klass::_trace_id offset was not found: -1 ==> expected: <true> but was: <false>
LazyRedefinitionStressTest.concurrentRedefinitionDoesNotCrashOrMisresolve__ glibc-17-debug-aarch64 org.opentest4j.AssertionFailedError: lazy mode is not active: klass_trace_id_offset=-1 ==> expected: <true> but was: <fa

These fail the build. The suite was not retried (slow suites run once), so whether they are flaky or broken was never measured — the entry below is offered on the same terms as a flake's, and the judgement is still yours.

Consider quarantining these — click for ready-made entries

A quarantined test still runs and still reports; its failures just stop
turning CI red. To quarantine one:

  1. Open a PROF ticket for the test, linking the failing job.
  2. Append the line below to ddprof-test/quarantine.txt, replacing
    PROF-XXXXX with the ticket number.
  3. Check the cells and reason columns — the proposal only knows what
    failed in this run, and a narrower cells glob keeps the same test
    gating everywhere it has not misbehaved.

CI fails once review_by passes, so an entry expires instead of piling up.

# test | ticket | added | review_by | cells | reason
# WIDENED: observed `com.datadoghq.profiler.cpu.LazyJmethodIdTest.[3]`, which covers() cannot match exactly; this entry mutes the whole class
com.datadoghq.profiler.cpu.LazyJmethodIdTest.* | PROF-XXXXX | 2026-10-22 | 2027-01-20 | glibc-17-debug-aarch64 | org.opentest4j.AssertionFailedError: Test execution #3 (of up to 3 with at least 1 successes) failed ~> test fails - see cause for details (seen in: glibc-17-debug-aarch64)
com.datadoghq.profiler.cpu.LazyJmethodIdTest.framesOfUnloadedClassAreDroppedSafely | PROF-XXXXX | 2026-10-22 | 2027-01-20 | glibc-17-debug-aarch64 | org.opentest4j.AssertionFailedError: No raw frame was refused, so the unloaded class was never sampled ==> expected: <true> but was: <false> (seen in: glibc-17-debug-aarch64)
com.datadoghq.profiler.cpu.LazyJmethodIdTest.traceIdOffsetIsCalibrated | PROF-XXXXX | 2026-10-22 | 2027-01-20 | glibc-17-debug-aarch64 | org.opentest4j.AssertionFailedError: Klass::_trace_id offset was not found: -1 ==> expected: <true> but was: <false> (seen in: glibc-17-debug-aarch64)
com.datadoghq.profiler.cpu.LazyRedefinitionStressTest.concurrentRedefinitionDoesNotCrashOrMisresolve | PROF-XXXXX | 2026-10-22 | 2027-01-20 | glibc-17-debug-aarch64 | org.opentest4j.AssertionFailedError: lazy mode is not active: klass_trace_id_offset=-1 ==> expected: <true> but was: <false> (seen in: glibc-17-debug-aarch64)

Failed Jobs

Summary: Total: 32 | Passed: 31 | Failed: 1


Updated: 2026-10-22 11:51:40 UTC

@dd-octo-sts

dd-octo-sts Bot commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

✅ All 40 integration tests passed

📊 Dashboard · 👷 Pipeline · 📦 656154d5

vsnprintf() is not async-signal-safe: some conversions may allocate or read
locale state (glibc's floating-point and wide-character paths, BSD's gdtoa,
which also locks). testLog() now uses its own printf subset in testLog.h,
covering what TEST_LOG's callers use (integers of every length, x/X/o, p,
s with precision, c, f, flags and widths), with no allocation and no locks;
%f is fixed-point and rounds ties up.

Lines are now cut at 512 bytes, newline included: the POSIX minimum of
PIPE_BUF and macOS's value, so a single write() stays atomic on a pipe
everywhere, not only on Linux.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@rkennke

rkennke commented Oct 22, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Keep them coming!

Reviewed commit: 656154d538

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

@kaahos kaahos left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks for the work! Approved with a coupled of non-blocking comments. I also noticed that ddprof-lib/src/main/cpp/rcDebugLevel.h actually redefines TEST_LOG which might bypass this new writer so routing those macros through testLog() might be a follow-up.

s = "(null)";
}
size_t len = 0;
while (s[len] != '\0' && (spec.precision < 0 || len < (size_t)spec.precision)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would invert the 2 conditions here. Otherwise the loop evaluates s[len] before checking the precision. For %.1s, it reads a 2nd byte even though the caller only needs to supply 1 byte which could cause a segfault.

Comment on lines +292 to +296
default:
// Unknown conversion: copy it through, so the line shows what went
// unformatted. Its argument, if any, is not consumed.
out.put(start, (size_t)(fmt - start) + (*fmt != '\0' ? 1 : 0));
break;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could the fallback copy the rest of the format literally and return? Currently it skips the unsupported conversion’s argument, then continues parsing which could lead to segfault.

@rkennke
rkennke merged commit f9e881c into main Oct 22, 2026
194 of 196 checks passed
@rkennke
rkennke deleted the fix/test-log-signal-safe branch October 22, 2026 13:41
@github-actions github-actions Bot added this to the 1.52.0 milestone Oct 22, 2026
rkennke added a commit that referenced this pull request Oct 22, 2026
generate-test-summary.sh lists the run's jobs with `gh api --paginate`,
which asks for 100 items per page unless the URL says otherwise. GitHub
answers that request with HTTP 502 for a run with many jobs, e.g. once a job
has been re-run, so summarize-tests failed on every such run (#868, #883);
50 per page works. Ask for 50 explicitly, and retry a failed listing twice,
10 s apart, since GitHub's 5xx can also be transient. A listing that keeps
failing still fails the script, as before.

The hermetic test's gh stub now answers a listing without an explicit page
size below 100 with a 502, as GitHub does, and tests both retry outcomes.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants