He-Pin opened a new issue, #3162:
URL: https://github.com/apache/pekko/issues/3162

   ### Summary
   
   `UnfoldResourceSource` can invoke the user-provided `close` function twice 
on the same resource when the first `close()` call throws a `NonFatal` 
exception. This can corrupt or leak external resources that are not 
idempotent-close-safe (file descriptors, reference-counted handles, distributed 
locks, etc.).
   
   ### Symptom
   
   A stream using `Source.unfoldResource` that completes (normally or via 
downstream cancel) can end up calling `close(resource)` twice if the first 
`close` invocation throws.
   
   ### Location
   
   
`stream/src/main/scala/org/apache/pekko/stream/impl/UnfoldResourceSource.scala`
   
   In `closeStage()`:
   
   ```scala
   private def closeStage(): Unit =
     try {
       close(resource)     // close called FIRST
       open = false        // guard set AFTER close — skipped if close throws
       completeStage()
     } catch {
       case NonFatal(ex) => failStage(ex)   // `open` remains true here
     }
   
   override def postStop(): Unit = {
     if (open) close(resource)   // second close when open was never cleared
   }
   ```
   
   ### Reproduction scenario
   
   1. Build a stream that consumes from `Source.unfoldResource(create, read, 
close)`.
   2. Arrange for `close(resource)` to throw a `NonFatal` exception (e.g. 
`IOException`).
   3. Terminate the stream either via:
      - downstream cancel (triggers `onDownstreamFinish` → `closeStage()`), or
      - `read` returning `None` (normal completion → `closeStage()`).
   4. Observe that `close` is invoked twice.
   
   ### Root cause
   
   In `closeStage()`, the `open = false` guard is assigned AFTER 
`close(resource)`. If `close` throws, the assignment is skipped, `open` remains 
`true`, and `postStop()` (which the interpreter always calls after `failStage`) 
invokes `close(resource)` again.
   
   This is inconsistent with two other code paths in the same stage that 
correctly clear the guard BEFORE calling `close`:
   
   - Supervision.Stop branch in `onPull` (around line 63): sets `open = false` 
then calls `close`.
   - `restartState()` (around line 79): sets `open = false` then calls `close`.
   
   The async counterpart (`UnfoldResourceSourceAsync`) does not have this 
problem — it clears `maybeResource = OptionVal.none` immediately after calling 
close, so `postStop` never double-closes.
   
   ### Impact
   
   - User-provided `close` has no idempotency contract; double-close can 
corrupt state, release external resources twice, or produce confusing error 
logs.
   - The second close's exception is swallowed silently by the interpreter's 
`finalizeStage` try/catch around `postStop`, masking the bug in production.
   
   ### Proposed fix
   
   Set `open = false` before calling `close(resource)` in `closeStage()`, 
matching the pattern already used in the Supervision.Stop branch and 
`restartState()`:
   
   ```scala
   private def closeStage(): Unit = {
     open = false
     try {
       close(resource)
       completeStage()
     } catch {
       case NonFatal(ex) => failStage(ex)
     }
   }
   ```
   
   A directional test should assert that `close` is invoked exactly once when 
`close` throws during stream completion, covering both the downstream-cancel 
path and the normal-completion path.
   
   ### Environment
   
   - Apache Pekko streams, current `main`
   - Affects `Source.unfoldResource` (sync variant only; async variant 
unaffected)


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to