Skip to content

HDFS-17972. TestDFSClientRetries: restore the fault injector and release its latch when the deadlock test dies - #8712

Open
joseluisll wants to merge 1 commit into
apache:trunkfrom
joseluisll:hdfs-testdfsclientretries-injector-leak
Open

HDFS-17972. TestDFSClientRetries: restore the fault injector and release its latch when the deadlock test dies#8712
joseluisll wants to merge 1 commit into
apache:trunkfrom
joseluisll:hdfs-testdfsclientretries-injector-leak

Conversation

@joseluisll

Copy link
Copy Markdown
Contributor

Description of PR

https://issues.apache.org/jira/browse/HDFS-17972

TestDFSClientRetries#testLeaseRenewAndDFSOutputStreamDeadLock installs an anonymous DFSClientFaultInjector into the static DFSClientFaultInjector.instance and never restores it. The injector's delayWhenRenewLeaseTimeout() waits on a CountDownLatch with an unbounded await(), and that latch is counted down in exactly one place - SleepFixedTimeAnswer.answer() - which runs only if the mocked NameNode.complete() is invoked. The test's finally block only calls cluster.shutdown().

LeaseRenewer.run() calls the injector from inside synchronized (this) on the SocketTimeoutException abort path, so a renewer waiting there holds the LeaseRenewer monitor for the whole wait. The test normally passes because complete() releases the latch while out1.close() is still running.

If the write pipeline stalls, closeImpl() never reaches completeFile(), complete() is never invoked, the latch stays at 1, and the renewer holds the monitor indefinitely. out1.close() then deadlocks against it on its own exit path:

DFSOutputStream.close -> closeImpl -> closeThreads -> setClosed
  -> DFSClient.endFileLease -> LeaseRenewer.addClient   (synchronized)

@Timeout(120) does not recover this. In JUnit's default SAME_THREAD mode the deadline interrupts the test thread and then waits for the method to return, and a thread blocked on a monitor is not interruptible. The surefire fork therefore hangs until forkedProcessTimeoutInSeconds kills it, and the results for the remaining tests in the class are lost rather than reported. The static injector is also left pointing at the test's own subclass.

Changes, all test-only:

  1. Bound the wait to 30 seconds. This is what breaks the deadlock - the finally runs only after out1.close() returns, which is exactly where the thread is stuck, so cleanup alone cannot help. With the bound, the renewer releases the monitor and a stalled run reports one ordinary test failure instead of hanging the fork.
  2. Count the latch down in the finally, ahead of restoring the injector and ahead of cluster.shutdown(), so the renewer is released promptly on the normal path.
  3. Save and restore the previous injector in that same finally, matching the convention in TestPread, TestClientProtocolForPipelineRecovery, TestDFSInputStream and TestPipelineCloseRecoveryByteArrayLeak. Restoring alone is insufficient: it does nothing for a renewer already inside the old injector.
  4. Re-assert the interrupt flag instead of e.printStackTrace().

No production code is touched, and the deadlock this test guards (HDFS-9294) is unaffected.

How was this patch tested?

mvn -pl hadoop-hdfs-project/hadoop-hdfs test -Dtest=TestDFSClientRetries - 13 tests, 0 failures, 0 errors (JDK 21, Maven 3.9.16). testLeaseRenewAndDFSOutputStreamDeadLock and testLeaseRenewSocketTimeout also pass together in that order in a single JVM.

The failure was reproduced by driving the unmodified test method while withholding the DataNode's ack (DataNodeFaultInjector.delaySendingAckToUpstream), so DataStreamer.waitForAckedSeqno blocks and complete() is unreachable. Without this change the test method never returns after the JUnit interrupt and DFSClientFaultInjector.get() is still the test's own subclass; with it the method returns and the injector is restored.

For code changes:

  • Does the title of this PR start with the corresponding JIRA issue id (e.g. 'HADOOP-17799. Your PR title ...')?
  • Object storage: Have the integration tests been executed and the endpoint declared according to the connector-specific documentation?
  • If adding new dependencies to the code, are these dependencies licensed in a way that is compatible for inclusion under ASF 2.0?
  • If applicable, have you updated the LICENSE, LICENSE-binary, NOTICE-binary files?

AI Tooling

Contains content generated by Claude Code.

🤖 Generated with Claude Code

…ase its latch when the deadlock test dies

testLeaseRenewAndDFSOutputStreamDeadLock installs an anonymous
DFSClientFaultInjector into the static DFSClientFaultInjector.instance and
never restores it. That injector's delayWhenRenewLeaseTimeout() waits on a
CountDownLatch that is only counted down from SleepFixedTimeAnswer.answer(),
i.e. only if the mocked NameNode.complete() actually runs. The test's finally
block only shut the cluster down.

LeaseRenewer.run() calls delayWhenRenewLeaseTimeout() from inside
synchronized (this), on the SocketTimeoutException abort path, so a renewer
waiting in that injector holds the LeaseRenewer monitor for as long as it
waits. The test works because complete() releases the latch while
out1.close() is still running, before close() needs that monitor again.

If the write pipeline stalls, out1.close() never reaches completeFile(), so
complete() is never invoked and the latch stays at 1. The renewer then holds
the LeaseRenewer monitor indefinitely, and out1.close() deadlocks against it
on its own way out:

  closeImpl() -> closeThreads() -> setClosed() -> DFSClient.endFileLease()
    -> LeaseRenewer.addClient()   (synchronized)

@timeout does not rescue this. In SAME_THREAD mode JUnit interrupts the test
thread at the deadline and then waits for the method to return, and a thread
blocked on a monitor is not interruptible, so the method never returns. The
surefire fork hangs until forkedProcessTimeoutInSeconds kills it, which loses
the results for the rest of the class.

Fix the test isolation:

- Bound the wait to 30 seconds. This is the part that actually breaks the
  deadlock: the finally block below runs only after out1.close() returns, so
  it cannot help a thread that is stuck inside close(). With the bound, the
  renewer releases the monitor, close() completes, and a stalled run fails as
  one ordinary test failure instead of hanging the fork. Also re-assert the
  interrupt flag rather than printing the trace.
- Count the latch down in the finally, before restoring the injector and
  before cluster.shutdown(), so the renewer is released immediately on the
  normal path instead of waiting out the bound.
- Save the previous injector and restore it in that same finally, so the
  static does not stay pointed at this test's subclass, matching the
  convention used by TestPread, TestClientProtocolForPipelineRecovery,
  TestDFSInputStream and TestPipelineCloseRecoveryByteArrayLeak. Restoring
  alone would not be enough: it does nothing for a renewer already waiting
  inside the old injector.

Verified by simulating the stall so that complete() is never reached: without
the bound the test hangs in out1.close() on the renewer monitor and never
returns; with it the test completes, the injector is restored and the latch
released. testLeaseRenewAndDFSOutputStreamDeadLock and
testLeaseRenewSocketTimeout both still pass in that order, in 10.3s and 4.1s,
so the normal path is unaffected.

This is a test-only change; the deadlock the test covers is unaffected.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant