Repository navigation
Fail instrumentation tests on logged stack overflows #13142
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -73,6 +73,7 @@ public abstract class AbstractInstrumentationTest { | |
| protected static ListWriter writer; | ||
| private static ClassFileTransformer activeTransformer; | ||
| private static ClassFileTransformerListener transformerListener; | ||
| private LoggedStackOverflowCheck loggedStackOverflow; | ||
|
|
||
| @BeforeAll | ||
| static void initAll() { | ||
|
|
@@ -116,17 +117,25 @@ static void initAll() { | |
|
|
||
| @BeforeEach | ||
| public void init() { | ||
| loggedStackOverflow = new LoggedStackOverflowCheck(); | ||
| InstrumentationErrors.resetErrors(); // reset for each test | ||
| tracer.flush(); | ||
| writer.start(); | ||
| } | ||
|
|
||
| @AfterEach | ||
| public void tearDown() { | ||
| tracer.flush(); | ||
|
|
||
| // check for instrumentation issues while running each test | ||
| assertTrue(InstrumentationErrors.noErrors(), InstrumentationErrors::describeErrors); | ||
| try { | ||
| tracer.flush(); | ||
|
|
||
| // check for instrumentation issues while running each test | ||
| assertTrue(InstrumentationErrors.noErrors(), InstrumentationErrors::describeErrors); | ||
| loggedStackOverflow.check(); | ||
| } finally { | ||
| if (loggedStackOverflow != null) { | ||
| loggedStackOverflow.close(); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Existing R2DBC subclasses override tearDown() without calling super, so JUnit skips this base cleanup while inherited init() still installs the logging hooks. Those tests never check captured stack overflows and retain another handler/filter after each invocation. Put hook checking and detachment in dedicated lifecycle callbacks that cannot be bypassed by these overrides, or update all affected subclasses. React: 👍 useful · 👎 wrong · 😕 unclear · 👀 right, but not acting on it What each reaction means
🤖 Bits Code Review · Open Bits AI session |
||
| } | ||
| } | ||
| } | ||
|
|
||
| @AfterAll | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,100 @@ | ||
| package datadog.trace.agent.test; | ||
|
|
||
| import java.util.concurrent.atomic.AtomicReference; | ||
| import java.util.logging.Handler; | ||
| import java.util.logging.LogRecord; | ||
| import java.util.logging.Logger; | ||
| import org.slf4j.LoggerFactory; | ||
|
|
||
| /** Captures logged stack overflows so framework exception handling cannot silently pass a test. */ | ||
| public final class LoggedStackOverflowCheck implements AutoCloseable { | ||
| private final AtomicReference<StackOverflowError> firstError = new AtomicReference<>(); | ||
| private final Logger julRoot = Logger.getLogger(""); | ||
| private final Handler julHandler = | ||
| new Handler() { | ||
| @Override | ||
| public void publish(LogRecord record) { | ||
| capture(record.getThrown()); | ||
| } | ||
|
|
||
| @Override | ||
| public void flush() {} | ||
|
|
||
| @Override | ||
| public void close() {} | ||
| }; | ||
| private final Runnable detachLogback; | ||
|
|
||
| public LoggedStackOverflowCheck() { | ||
| Object factory = LoggerFactory.getILoggerFactory(); | ||
| // Some instrumentation suites deliberately exclude Logback from their runtime classpath. | ||
| detachLogback = | ||
| factory.getClass().getName().equals("ch.qos.logback.classic.LoggerContext") | ||
| ? LogbackCapture.install(factory, this) | ||
| : () -> {}; | ||
| julRoot.addHandler(julHandler); | ||
| } | ||
|
|
||
| private void capture(Throwable error) { | ||
| // Do not format a stack trace or log from the thread whose stack is exhausted. Bound traversal | ||
| // because frameworks may wrap the error, and Throwable cause chains can contain cycles. | ||
| for (int depth = 0; error != null && depth < 16; depth++) { | ||
| if (error instanceof StackOverflowError) { | ||
| firstError.compareAndSet(null, (StackOverflowError) error); | ||
| return; | ||
| } | ||
| error = error.getCause(); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a framework aggregates failures by attaching the original Useful? React with 👍 / 👎. |
||
| } | ||
| } | ||
|
|
||
| public void check() { | ||
| StackOverflowError error = firstError.get(); | ||
| if (error != null) { | ||
| throw new AssertionError("A StackOverflowError was logged during the test", error); | ||
| } | ||
| } | ||
|
|
||
| @Override | ||
| public void close() { | ||
| julRoot.removeHandler(julHandler); | ||
| detachLogback.run(); | ||
| } | ||
|
|
||
| private static final class LogbackCapture extends ch.qos.logback.classic.turbo.TurboFilter { | ||
| private final LoggedStackOverflowCheck check; | ||
|
|
||
| private LogbackCapture(LoggedStackOverflowCheck check) { | ||
| this.check = check; | ||
| } | ||
|
|
||
| static Runnable install(Object factory, LoggedStackOverflowCheck check) { | ||
| ch.qos.logback.classic.LoggerContext context = (ch.qos.logback.classic.LoggerContext) factory; | ||
| LogbackCapture filter = new LogbackCapture(check); | ||
| filter.start(); | ||
| context.addTurboFilter(filter); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
SpringBootApplicationTest starts Spring applications inside its feature methods, after the harness installs this filter. Spring Boot logging initialization resets LoggerContext, which clears its turbo filters. Subsequent Logback stack overflows therefore escape detection and can still pass cleanup. Add reset-resistant listener handling that reinstalls and restarts the filter, and remove both listener and filter during close. React: 👍 useful · 👎 wrong · 😕 unclear · 👀 right, but not acting on it What each reaction means
🤖 Bits Code Review · Open Bits AI session There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a test initializes or reconfigures Logback after the base Useful? React with 👍 / 👎. |
||
| return () -> { | ||
| context.getTurboFilterList().remove(filter); | ||
| filter.stop(); | ||
| }; | ||
| } | ||
|
|
||
| @Override | ||
| public ch.qos.logback.core.spi.FilterReply decide( | ||
| org.slf4j.Marker marker, | ||
| ch.qos.logback.classic.Logger logger, | ||
| ch.qos.logback.classic.Level level, | ||
| String format, | ||
| Object[] params, | ||
| Throwable throwable) { | ||
| check.capture(throwable); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When code passes a Useful? React with 👍 / 👎. |
||
| // Logback can extract the trailing throwable from SLF4J arguments after this callback. | ||
| if (params != null && params.length > 0) { | ||
| Object last = params[params.length - 1]; | ||
| if (last instanceof Throwable) { | ||
| check.capture((Throwable) last); | ||
| } | ||
| } | ||
| return ch.qos.logback.core.spi.FilterReply.NEUTRAL; | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,79 @@ | ||
| package datadog.trace.agent.test; | ||
|
|
||
| import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; | ||
| import static org.junit.jupiter.api.Assertions.assertSame; | ||
| import static org.junit.jupiter.api.Assertions.assertThrows; | ||
|
|
||
| import java.util.logging.Level; | ||
| import java.util.logging.Logger; | ||
| import org.junit.jupiter.api.Test; | ||
| import org.slf4j.LoggerFactory; | ||
|
|
||
| class LoggedStackOverflowCheckTest { | ||
| @Test | ||
| void julBackgroundErrorFailsOnTheTestThread() throws Exception { | ||
| StackOverflowError overflow = new StackOverflowError("background JUL error"); | ||
| try (LoggedStackOverflowCheck check = new LoggedStackOverflowCheck()) { | ||
| Thread background = | ||
| new Thread( | ||
| () -> Logger.getLogger(getClass().getName()).log(Level.SEVERE, "caught", overflow)); | ||
| background.start(); | ||
| background.join(); | ||
|
|
||
| assertSame(overflow, assertThrows(AssertionError.class, check::check).getCause()); | ||
| } | ||
| } | ||
|
|
||
| @Test | ||
| void logbackBackgroundErrorFailsOnTheTestThread() throws Exception { | ||
| StackOverflowError overflow = new StackOverflowError("background Logback error"); | ||
| try (LoggedStackOverflowCheck check = new LoggedStackOverflowCheck()) { | ||
| Thread background = | ||
| new Thread(() -> LoggerFactory.getLogger(getClass()).error("caught", overflow)); | ||
| background.start(); | ||
| background.join(); | ||
|
|
||
| assertSame(overflow, assertThrows(AssertionError.class, check::check).getCause()); | ||
| } | ||
| } | ||
|
|
||
| @Test | ||
| void capturesWrappedAndParameterizedErrorsAndRetainsTheFirst() { | ||
| StackOverflowError first = new StackOverflowError("first"); | ||
| try (LoggedStackOverflowCheck check = new LoggedStackOverflowCheck()) { | ||
| LoggerFactory.getLogger(getClass()) | ||
| .error("caught in {}", "handler", new RuntimeException(first)); | ||
| Logger.getLogger(getClass().getName()) | ||
| .log(Level.SEVERE, "caught", new StackOverflowError("second")); | ||
|
|
||
| assertSame(first, assertThrows(AssertionError.class, check::check).getCause()); | ||
| } | ||
| } | ||
|
|
||
| @Test | ||
| void ignoresOrdinaryErrorsAndMessagesMentioningStackOverflow() { | ||
| RuntimeException first = new RuntimeException(); | ||
| RuntimeException second = new RuntimeException(first); | ||
| first.initCause(second); | ||
| try (LoggedStackOverflowCheck check = new LoggedStackOverflowCheck()) { | ||
| Logger.getLogger(getClass().getName()).log(Level.SEVERE, "StackOverflowError", first); | ||
| LoggerFactory.getLogger(getClass()).error("java.lang.StackOverflowError"); | ||
|
|
||
| assertDoesNotThrow(check::check); | ||
| } | ||
| } | ||
|
|
||
| @Test | ||
| void closeDetachesBothLoggingHooksAndNextTestStartsClean() { | ||
| LoggedStackOverflowCheck closed = new LoggedStackOverflowCheck(); | ||
| closed.close(); | ||
| StackOverflowError overflow = new StackOverflowError("after close"); | ||
| Logger.getLogger(getClass().getName()).log(Level.SEVERE, "caught", overflow); | ||
| LoggerFactory.getLogger(getClass()).error("caught", overflow); | ||
| assertDoesNotThrow(closed::check); | ||
|
|
||
| try (LoggedStackOverflowCheck next = new LoggedStackOverflowCheck()) { | ||
| assertDoesNotThrow(next::check); | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Because the capture is created only in per-iteration
setup()and closed after every cleanup,setupSpec()andcleanupSpec()execute with no logging hook. Many instrumentation specifications start and stop asynchronous servers, clients, or containers in those suite fixtures; if a framework catches and logs aStackOverflowErrorfrom that work without propagating it, the entire suite can still pass. Keep a suite-level capture active across the fixture windows and report any retained error as a suite failure.Useful? React with 👍 / 👎.