This is an automated email from the ASF dual-hosted git repository.

asf-gitbox-commits pushed a commit to branch branch-4.x
in repository https://gitbox.apache.org/repos/asf/spark.git


The following commit(s) were added to refs/heads/branch-4.x by this push:
     new b097c99baf39 [SPARK-57318][SQL] Refactor WorkerSession into a 
state-machine interface
b097c99baf39 is described below

commit b097c99baf39de4b1165bc55a23ef393813d1865
Author: Haiyang Sun <[email protected]>
AuthorDate: Wed Jun 10 08:46:18 2026 -0400

    [SPARK-57318][SQL] Refactor WorkerSession into a state-machine interface
    
    ### What changes were proposed in this pull request?
    
    This PR refactors the experimental UDF worker **session interface** in the 
`udf-worker-core` module into an explicit state machine with well-defined 
terminal outcomes, so that a concrete transport backend can plug in cleanly. 
(The first such backend -- a gRPC-over-UDS dispatcher -- lands in a follow-up 
PR.)
    
    Core interface changes:
    
    - **`WorkerSession`** becomes a single `AtomicReference`-driven state 
machine (`Created -> Initializing -> Initialized -> Streaming -> Finishing`, 
with `Cancelling`/`Finished`/`Cancelled`/`Failed`/`TransportFailed` terminals). 
`init` now returns an `InitResponse`, `process` takes a `finish` thunk, and 
`close` returns a `Termination` instead of throwing; the previous separate 
`cancel()` is folded into `close()`. Subclasses implement `doInit` / 
`doProcess` / `doClose` hooks and drive tr [...]
    - **`Termination`** (new) is a sealed trait modeling the four terminal 
outcomes: `Finished(FinishResponse)`, `Cancelled(CancelResponse)`, 
`Failed(ExecutionError)`, and `TransportFailed(Throwable)`.
    - **`WorkerHandle`** (new) is a small trait that decouples a session from 
the concrete worker-provisioning model. `DirectWorkerProcess` implements it and 
gains a cleanup-hook registry, so a dispatcher can register transport-specific 
cleanup (e.g. deleting a socket file) without the core hard-coding it.
    - **`DirectWorkerDispatcher`** is generalized from a Unix-socket-specific 
class into abstract transport hooks (`newEndpointAddress` / `waitForReady` / 
`cleanupEndpointAddress` / `closeTransport` / `validateTransportSupport` / 
`newConnection` / `newSession` / `initialize`). The concrete Unix-socket 
dispatcher and session (`DirectUnixSocketWorkerDispatcher`, 
`DirectWorkerSession`) are removed -- the first concrete backend is provided by 
the follow-up gRPC change.
    - **`WorkerConnection`** becomes a trait.
    
    Caller/test changes:
    
    - `ExternalUDFExec` (sql/core) is updated to the new `close()` / 
`Termination` finalizer: a single task-completion listener now handles both the 
success path (a `FinishResponse` has already arrived) and the 
failure/early-stop path (send `Cancel`, await `CancelResponse`), replacing the 
previous separate cancel-on-failure listener.
    - The in-core test scaffolding (`TestDirectWorkerHelpers`, 
`DirectWorkerDispatcherSuite`) is adapted to the new API as a small UDS-backed 
test dispatcher, so `udf-worker-core` and `sql/core` stay green. This 
scaffolding moves to the `udf-worker-grpc` module alongside the concrete gRPC 
dispatcher in the follow-up PR.
    
    Class hierarchy after this change:
    
    ```
    WorkerSession (abstract state machine; close(): Termination)
    WorkerHandle  (trait)  <- DirectWorkerProcess
    DirectWorkerDispatcher (abstract transport hooks)
    WorkerConnection (trait)
    Termination = Finished | Cancelled | Failed | TransportFailed
    ```
    
    ### Why are the changes needed?
    
    The previous `WorkerSession` tracked its lifecycle with a pair of 
`AtomicBoolean`s and signaled completion/cancellation by throwing, which does 
not cleanly express the several terminal outcomes a worker session can reach 
(clean finish, cancellation, user/worker error, transport failure) nor make 
worker reuse decisions explicit. It was also tied to a single Unix-socket 
transport. Reshaping it into an explicit state machine with a `Termination` 
result and abstract transport hooks gives  [...]
    
    ### Does this PR introduce _any_ user-facing change?
    
    No. The UDF worker framework is experimental and currently unused by any 
released code path; this PR only reshapes its internal interfaces.
    
    ### How was this patch tested?
    
    Existing and adapted unit tests, run on top of `master`:
    
    - `build/sbt udf-worker-core/Test/compile` and `build/sbt sql/Test/compile` 
succeed.
    - `build/sbt "udf-worker-core/testOnly *DirectWorkerDispatcherSuite"` -- 
the dispatcher process-lifecycle suite (spawn / wait-for-ready / cleanup / 
error / timeout / concurrency) passes (31 tests) against the adapted in-core 
UDS test dispatcher.
    - `PythonUDFWorkerSpecificationSuite` (sql/core) is updated to the new 
`close()` signature and continues to spawn a real Python worker and verify 
reachability.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Yes
    
    Closes #56397 from haiyangsun-db/pr2-udf-core-session-refactor.
    
    Authored-by: Haiyang Sun <[email protected]>
    Signed-off-by: Herman van Hövell <[email protected]>
    (cherry picked from commit bd046363da14d2235e20817c40dc949e99298c75)
    Signed-off-by: Herman van Hövell <[email protected]>
---
 .../execution/externalUDF/ExternalUDFExec.scala    |  41 +-
 .../PythonUDFWorkerSpecificationSuite.scala        |  84 ++--
 .../apache/spark/udf/worker/core/Termination.scala |  66 +++
 .../spark/udf/worker/core/WorkerConnection.scala   |   4 +-
 .../spark/udf/worker/core/WorkerHandle.scala       |  66 +++
 .../spark/udf/worker/core/WorkerSession.scala      | 481 +++++++++++++++++----
 .../direct/DirectUnixSocketWorkerDispatcher.scala  | 145 -------
 .../core/direct/DirectWorkerDispatcher.scala       | 132 ++++--
 .../worker/core/direct/DirectWorkerProcess.scala   | 129 +++++-
 .../worker/core/direct/DirectWorkerSession.scala   |  56 ---
 .../worker/core/DirectWorkerDispatcherSuite.scala  | 181 ++++----
 .../udf/worker/core/TestDirectWorkerHelpers.scala  | 252 +++++++++--
 .../spark/udf/worker/core/WorkerSessionSuite.scala | 270 ++++++++++++
 13 files changed, 1375 insertions(+), 532 deletions(-)

diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/externalUDF/ExternalUDFExec.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/externalUDF/ExternalUDFExec.scala
index 883e7dce88b1..57630ce1cba9 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/externalUDF/ExternalUDFExec.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/externalUDF/ExternalUDFExec.scala
@@ -49,7 +49,7 @@ trait ExternalUDFExec extends UnaryExecNode {
   // 
---------------------------------------------------------------------------
 
   protected def externalUdfMetrics: Map[String, SQLMetric] = Map(
-    // TODO [SPARK-55278]: Emit the correct metrics here
+    // TODO [SPARK-57324]: Emit the correct metrics here
   )
 
   override lazy val metrics: Map[String, SQLMetric] = externalUdfMetrics
@@ -60,10 +60,12 @@ trait ExternalUDFExec extends UnaryExecNode {
 
   /**
    * Creates a [[WorkerSession]] via [[SparkEnv#getExternalUDFDispatcher]].
-   * Registers session cancellation on task failure and session termination
-   * on task completion. The provided function receives the session
-   * and must return the result iterator. The function may use the
-   * session but MUST NOT cancel or close it.
+   * Finalizes the session on task completion (which fires on both success and
+   * failure). [[WorkerSession#close]] is the single finalizer: it fetches the
+   * `FinishResponse` if processing completed, or cancels anything still in
+   * flight and waits for the `CancelResponse`. The provided function receives
+   * the session and must return the result iterator. It may use the session
+   * but MUST NOT close it.
    */
   protected def withUDFWorkerSession(
       taskContext: TaskContext,
@@ -74,12 +76,29 @@ trait ExternalUDFExec extends UnaryExecNode {
       workerSpec)
     val session = dispatcher.createSession(securityScope)
 
-    // Make sure to cancel the session, if the task fails
-    taskContext.addTaskFailureListener { (_, _) =>
-      session.cancel()
-    }
-
-    // Make sure to close the session once we are done
+    // Finalize the session when the task ends. The completion listener fires 
on
+    // both success and failure, and close() is the single finalizer that
+    // resolves to whichever terminator the stream reached:
+    //
+    //  - Task completed and the result iterator was fully consumed: process()
+    //    sent Finish (input exhausted) and the data drained. close() then
+    //    typically returns the Finished termination -- but a failure during 
the
+    //    finish/cleanup phase (raised after the data drained, so it reached 
no one
+    //    through the iterator) is surfaced here too, as Failed / 
TransportFailed.
+    //  - Task failed, was killed, or stopped before draining (e.g. a 
downstream
+    //    LIMIT or exception): the stream has not finished, so close() sends a
+    //    Cancel, the worker runs its cleanup, and its CancelResponse is 
returned
+    //    as a Cancelled termination. An empty Cancel is enough here -- there 
is
+    //    no extra information to convey to the worker on cancellation -- so we
+    //    rely on close()'s default and pass no cancel thunk.
+    //  - The stream died without a terminator (transport failure / timeout):
+    //    close() returns a best-effort TransportFailed termination rather than
+    //    raising it (a thread interrupt may still propagate); the underlying
+    //    failure has already surfaced through the result iterator.
+    //
+    // The returned Termination (per-execution metrics, finish/cancel callback
+    // result) is not consumed yet -- TODO [SPARK-57324] surface it once 
metrics
+    // wiring lands.
     taskContext.addTaskCompletionListener[Unit] { _ =>
       session.close()
     }
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/externalUDF/PythonUDFWorkerSpecificationSuite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/externalUDF/PythonUDFWorkerSpecificationSuite.scala
index f44d3adf7b7d..bbd47741aba1 100644
--- 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/externalUDF/PythonUDFWorkerSpecificationSuite.scala
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/externalUDF/PythonUDFWorkerSpecificationSuite.scala
@@ -17,6 +17,8 @@
 package org.apache.spark.sql.execution.externalUDF
 
 import java.io.File
+import java.net.{StandardProtocolFamily, UnixDomainSocketAddress}
+import java.nio.channels.SocketChannel
 import java.nio.charset.StandardCharsets
 import java.nio.file.Files
 
@@ -26,72 +28,57 @@ import scala.jdk.CollectionConverters._
 import org.apache.spark.api.python.SimplePythonFunction
 import org.apache.spark.sql.IntegratedUDFTestUtils
 import org.apache.spark.sql.test.SharedSparkSession
-import org.apache.spark.udf.worker.UDFWorkerSpecification
-import org.apache.spark.udf.worker.core.{TestDirectWorkerDispatcher,
-  UnixSocketWorkerConnection}
+import org.apache.spark.udf.worker.{Cancel, UDFWorkerSpecification}
+import org.apache.spark.udf.worker.core.{TestDirectWorkerDispatcher, 
WorkerConnection}
 
 /**
- * A test [[UnixSocketWorkerConnection]] that opens a real Unix
- * domain socket channel to the worker.
+ * A [[WorkerConnection]] that opens a real Unix-domain-socket channel to the
+ * worker at construction and considers itself active while the channel is
+ * open. Verifies the worker's socket is actually connectable, not merely
+ * that the file exists.
  */
-private class RealSocketConnection(
-    socketPath: String,
-    private val channel: java.nio.channels.SocketChannel)
-    extends UnixSocketWorkerConnection(socketPath) {
-
+private class ConnectingSocketConnection(
+    private val channel: SocketChannel) extends WorkerConnection {
   override def isActive: Boolean = channel.isOpen
-
-  override def close(): Unit = {
-    channel.close()
-    super.close()
-  }
+  override def close(): Unit = channel.close()
 }
 
-private object RealSocketConnection {
-  def connect(socketPath: String): RealSocketConnection = {
-    val address =
-      java.net.UnixDomainSocketAddress.of(socketPath)
-    val channel = java.nio.channels.SocketChannel.open(
-      java.net.StandardProtocolFamily.UNIX)
-    channel.connect(address)
-    new RealSocketConnection(socketPath, channel)
+private object ConnectingSocketConnection {
+  def connect(socketPath: String): ConnectingSocketConnection = {
+    val channel = SocketChannel.open(StandardProtocolFamily.UNIX)
+    channel.connect(UnixDomainSocketAddress.of(socketPath))
+    new ConnectingSocketConnection(channel)
   }
 }
 
 /**
- * Extends [[TestDirectWorkerDispatcher]] to use a real Unix
- * domain socket connection instead of just checking file
- * existence.
+ * A [[TestDirectWorkerDispatcher]] whose connection actually connects to the
+ * worker socket (rather than just checking file existence), so the test
+ * verifies the spawned Python worker is reachable.
  */
-private class RealSocketTestDispatcher(
-    spec: UDFWorkerSpecification)
+private class ConnectingTestDispatcher(spec: UDFWorkerSpecification)
     extends TestDirectWorkerDispatcher(spec) {
-
-  // Use /tmp to avoid UDS path length limits in deep
-  // worktree paths.
-  override protected def newEndpointAddress(
-      workerId: String): String = {
-    val socketDir = java.nio.file.Files.createTempDirectory(
-      java.nio.file.Paths.get("/tmp"), "udf-test-")
-    socketDir.toFile.deleteOnExit()
-    socketDir.resolve(s"w-$workerId.sock").toString
-  }
-
-  override protected def createConnection(
-      socketPath: String): UnixSocketWorkerConnection =
-    RealSocketConnection.connect(socketPath)
+  override protected def newConnection(address: String): WorkerConnection =
+    ConnectingSocketConnection.connect(address)
 }
 
 /**
  * Tests that [[PythonUDFWorkerSpecification#fromPythonFunction]]
- * produces a valid [[UDFWorkerSpecification]] that can be used by
- * a [[org.apache.spark.udf.worker.core.WorkerDispatcher]]
+ * produces a valid [[org.apache.spark.udf.worker.UDFWorkerSpecification]]
+ * that can be used by a
+ * [[org.apache.spark.udf.worker.core.WorkerDispatcher]]
  * to spawn a real Python worker process.
  *
  * The test overrides `spark.python.worker.module` to point to a
  * test-only Python module that creates the UDS socket and waits
  * for SIGTERM, verifying that pythonExec, PYTHONPATH, env vars,
  * and command construction all work end-to-end.
+ *
+ * Reuses [[TestDirectWorkerDispatcher]]'s spawn / wait-for-ready machinery
+ * (shared from `udf-worker-core` test sources) but overrides the connection
+ * to open a real UDS channel -- the Python test worker only binds a raw
+ * socket, it does not host a gRPC server, so a full gRPC session is not
+ * exercised here.
  */
 class PythonUDFWorkerSpecificationSuite
     extends SharedSparkSession {
@@ -185,15 +172,16 @@ class PythonUDFWorkerSpecificationSuite
     val workerSpec =
       PythonUDFWorkerSpecification.fromPythonFunction(func, conf)
 
-    // Verify the spec works end-to-end with a real
-    // socket connection
-    val dispatcher = new RealSocketTestDispatcher(workerSpec)
+    // Verify the spec works end-to-end: the dispatcher spawns the Python
+    // worker, waits for the socket, and opens a real UDS connection to it
+    // (proving the worker is reachable, not just that the file exists).
+    val dispatcher = new ConnectingTestDispatcher(workerSpec)
     try {
       val session = dispatcher.createSession(
         securityScope = None)
       assert(session != null,
         "Expected a non-null session from the dispatcher")
-      session.close()
+      session.close(() => Cancel.getDefaultInstance)
     } finally {
       dispatcher.close()
     }
diff --git 
a/udf/worker/core/src/main/scala/org/apache/spark/udf/worker/core/Termination.scala
 
b/udf/worker/core/src/main/scala/org/apache/spark/udf/worker/core/Termination.scala
new file mode 100644
index 000000000000..4278313fdf39
--- /dev/null
+++ 
b/udf/worker/core/src/main/scala/org/apache/spark/udf/worker/core/Termination.scala
@@ -0,0 +1,66 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.spark.udf.worker.core
+
+import org.apache.spark.annotation.Experimental
+import org.apache.spark.udf.worker.{CancelResponse, ExecutionError, 
FinishResponse}
+
+/**
+ * :: Experimental ::
+ * The terminal outcome a [[WorkerSession]] settles on, returned by
+ * [[WorkerSession#close]]. Mirrors the four terminal 
`WorkerSession.SessionState`s,
+ * so close() reports the outcome faithfully rather than collapsing failures 
into
+ * a clean cancel.
+ *
+ * '''Clean outcomes''' ([[Finished]] / [[Cancelled]]) wrap the worker's
+ * `FinishResponse` / `CancelResponse` -- per-execution metrics, an optional
+ * inline `data` payload, and an optional finish/cancel '''callback error''' --
+ * which the engine surfaces to the UDF's client-side finish/cancel callbacks.
+ * The proto allows `Cancel` to follow `Finish` on the same stream, so close()
+ * may observe either depending on arrival timing (see `udf_message.proto`): if
+ * `FinishResponse` was already produced when a `Cancel` arrives the engine 
still
+ * receives [[Finished]], otherwise [[Cancelled]].
+ *
+ * '''Failure outcomes''' ([[Failed]] / [[TransportFailed]]) carry the cause
+ * instead of a proto terminator (none arrived, so they have no metrics). They
+ * exist so a failure is not reported as a benign cancel -- in particular an
+ * error raised during finish/close, '''after all data has been drained''',
+ * reaches the caller only through this value, never through the result 
iterator.
+ */
+@Experimental
+sealed trait Termination
+
+object Termination {
+  /** The stream ended with a `FinishResponse` (normal completion). */
+  final case class Finished(response: FinishResponse) extends Termination
+
+  /** The stream ended with a `CancelResponse` (cooperative cancellation). */
+  final case class Cancelled(response: CancelResponse) extends Termination
+
+  /**
+   * An execution error settled the session without a proto terminator (e.g. an
+   * `ErrorResponse`, or an error before init completed). Carries the 
structured
+   * [[ExecutionError]].
+   */
+  final case class Failed(error: ExecutionError) extends Termination
+
+  /**
+   * A transport failure, timeout, or interrupt tore the stream down before any
+   * terminator arrived. Carries the underlying cause.
+   */
+  final case class TransportFailed(cause: Throwable) extends Termination
+}
diff --git 
a/udf/worker/core/src/main/scala/org/apache/spark/udf/worker/core/WorkerConnection.scala
 
b/udf/worker/core/src/main/scala/org/apache/spark/udf/worker/core/WorkerConnection.scala
index 82b2fff8df58..921dc910fd34 100644
--- 
a/udf/worker/core/src/main/scala/org/apache/spark/udf/worker/core/WorkerConnection.scala
+++ 
b/udf/worker/core/src/main/scala/org/apache/spark/udf/worker/core/WorkerConnection.scala
@@ -33,7 +33,7 @@ import org.apache.spark.annotation.Experimental
  * multiplexed streams over this channel.
  *
  * Implementations expose only lifecycle. Data transmission happens at
- * the [[WorkerSession]] level -- this class is solely about whether the
+ * the [[WorkerSession]] level -- this trait is solely about whether the
  * channel is open.
  *
  * '''Relationship to other classes (direct creation mode):'''
@@ -43,7 +43,7 @@ import org.apache.spark.annotation.Experimental
  * }}}
  */
 @Experimental
-abstract class WorkerConnection extends AutoCloseable {
+trait WorkerConnection extends AutoCloseable {
   /** Returns true if the underlying transport channel is still usable. */
   def isActive: Boolean
 }
diff --git 
a/udf/worker/core/src/main/scala/org/apache/spark/udf/worker/core/WorkerHandle.scala
 
b/udf/worker/core/src/main/scala/org/apache/spark/udf/worker/core/WorkerHandle.scala
new file mode 100644
index 000000000000..c66cb5689b87
--- /dev/null
+++ 
b/udf/worker/core/src/main/scala/org/apache/spark/udf/worker/core/WorkerHandle.scala
@@ -0,0 +1,66 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.spark.udf.worker.core
+
+import org.apache.spark.annotation.Experimental
+
+/**
+ * :: Experimental ::
+ * The lifecycle-side handle the engine uses to release a worker back to
+ * its dispatcher.
+ *
+ * Sessions hold a [[WorkerHandle]] -- not the concrete worker type --
+ * so the engine-facing layer (the `core` package) is decoupled from any
+ * specific worker provisioning model (direct local spawn, indirect
+ * daemon-provided, etc.). Each [[WorkerDispatcher]] supplies its own
+ * implementation; the session calls back into it on close.
+ *
+ * Contract:
+ *  - [[releaseSession]] decrements the dispatcher-side reference count.
+ *    The caller ([[WorkerSession.close]]) invokes it exactly once per
+ *    session, so implementations need not be idempotent and MAY fail fast
+ *    on an unbalanced (negative) count. Sessions sharing the same worker
+ *    call it independently (each exactly once).
+ *  - [[markInvalid]] is sticky: once set, the dispatcher MUST NOT return
+ *    this worker to any reuse pool. Sessions set this when the worker
+ *    is observed in an unsafe state (transport error, hung worker,
+ *    protocol violation).
+ *  - [[id]] is a stable string for diagnostics. The session uses it in
+ *    error messages; the dispatcher uses it to key the worker in its
+ *    own tracking map. Implementations must keep it constant for the
+ *    lifetime of the handle.
+ */
+@Experimental
+trait WorkerHandle {
+
+  /** Stable identifier for logs and diagnostic messages. */
+  def id: String
+
+  /**
+   * Marks the worker as unsafe to recycle. Idempotent and sticky.
+   * Sessions call this in their close path when the protocol layer
+   * reports an unsalvageable termination.
+   */
+  def markInvalid(): Unit
+
+  /**
+   * Decrements the dispatcher-side session ref count. The caller
+   * ([[WorkerSession.close]]) invokes this exactly once per session, so
+   * implementations need not be idempotent.
+   */
+  def releaseSession(): Unit
+}
diff --git 
a/udf/worker/core/src/main/scala/org/apache/spark/udf/worker/core/WorkerSession.scala
 
b/udf/worker/core/src/main/scala/org/apache/spark/udf/worker/core/WorkerSession.scala
index ef18ce8d7085..6ee9b129ac94 100644
--- 
a/udf/worker/core/src/main/scala/org/apache/spark/udf/worker/core/WorkerSession.scala
+++ 
b/udf/worker/core/src/main/scala/org/apache/spark/udf/worker/core/WorkerSession.scala
@@ -16,10 +16,13 @@
  */
 package org.apache.spark.udf.worker.core
 
-import java.util.concurrent.atomic.AtomicBoolean
+import java.util.concurrent.atomic.{AtomicBoolean, AtomicReference}
+
+import scala.util.control.NonFatal
 
 import org.apache.spark.annotation.Experimental
-import org.apache.spark.udf.worker.Init
+import org.apache.spark.udf.worker.{Cancel, DataRequest, DataResponse, Finish, 
Init,
+  InitResponse}
 
 /**
  * :: Experimental ::
@@ -28,145 +31,441 @@ import org.apache.spark.udf.worker.Init
  * A [[WorkerSession]] is the '''per-UDF-invocation''' handle that Spark
  * obtains from [[WorkerDispatcher#createSession]]. It carries the full
  * init / data-stream / finish lifecycle for a single UDF evaluation.
- *
- * A [[WorkerSession]] does ''not'' own the underlying worker or its
- * transport channel -- those are managed by the [[WorkerDispatcher]].
- * Multiple sessions may share the same worker when the worker supports
- * concurrency.
+ * Concrete protocol implementations subclass this and provide the
+ * protocol-specific request/response I/O; the base class owns the
+ * [[SessionState]] machine and handles dispatcher-side cleanup via
+ * [[WorkerHandle]].
  *
  * '''Usage:'''
  * {{{
  *   val session = dispatcher.createSession(securityScope = None)
+ *   val initResponse = session.init(Init.newBuilder()...build())
+ *   val results = session.process(inputBatches, () => 
Finish.newBuilder()...build())
  *   try {
- *     session.init(Init.newBuilder()
- *       .setProtocolVersion(1)
- *       
.setUdf(UdfPayload.newBuilder().setPayload(callable).setFormat(fmt).build())
- *       .setDataFormat(UDFWorkerDataFormat.ARROW)
- *       .build())
- *     val results = session.process(inputBatches)
- *     results.foreach(handleBatch)
+ *     results.foreach(handleBatch)        // may throw on a UDF / data-phase 
error
  *   } finally {
- *     session.close()
+ *     val termination = session.close(() => Cancel.newBuilder()...build())
  *   }
  * }}}
  *
  * '''Lifecycle:'''
- *  - [[init]] must be called exactly once before [[process]].
- *  - [[process]] must be called at most once per session.
- *  - [[close]] must always be called (use try-finally).
- *  - [[cancel]] may be called at any time from any execution context.
- *    See [[cancel]] for the full contract.
+ *  - [[init]] must be called exactly once before [[process]]; it returns the
+ *    worker's [[InitResponse]] (init-callback result + metrics).
+ *  - [[process]] must be called at most once per session. It returns the
+ *    result iterator; consuming that iterator drives the data exchange and
+ *    surfaces any UDF / data-phase error (as an exception from `next()` /
+ *    `hasNext`). `Finish` is sent (via the supplied `finish` thunk) when the
+ *    input iterator is exhausted, so the result iterator yields the trailing
+ *    output before ending.
+ *  - [[close]] must always be called (use try-finally). It finalizes the
+ *    session: if the stream already finished it returns the [[Finish]]
+ *    terminator, otherwise it cancels anything still in flight (sending the
+ *    [[Cancel]] from the supplied thunk so the cancel callback is carried) and
+ *    waits for the terminator. It returns the [[Termination]] either way.
+ *
+ * '''State machine.''' A single [[SessionState]] reference tracks the whole
+ * session -- the call-ordering contract, the protocol phase, and the terminal
+ * outcome -- so there is one source of truth rather than separate lifecycle 
and
+ * wire machines:
+ * {{{
+ *   Created
+ *     | init()
+ *     v
+ *   Initializing --(InitResponse ok)--> Initialized
+ *     |                                     | process()
+ *     | init failure / transport            v
+ *     |                                   Streaming
+ *     |                                     | input exhausted (Finish sent)
+ *     |                                     v
+ *     |                                   Finishing
+ *     |                                     | terminator
+ *     |                                     v
+ *     +---------------------------------> (terminal)
+ *                                           ^
+ *                                           | terminator
+ *                                         Cancelling
+ *                                           ^
+ *                                           | close() / cancel / ErrorResponse
+ *                                           |   (from any non-terminal state)
+ * }}}
+ * The clean path (via Finishing) and the cancel path (via Cancelling) settle 
the
+ * same `(terminal)`. The four terminals are:
+ * {{{
+ *   Finished(FinishResponse) | Cancelled(CancelResponse)
+ *   Failed(ExecutionError)   | TransportFailed(Throwable)
+ * }}}
+ * The two clean terminals carry the worker's `FinishResponse` / 
`CancelResponse`
+ * (metrics + finish/cancel callback `data`/`error`); the failure terminals 
carry
+ * the cause. Reaching any terminal means the session is over -- no further
+ * protocol writes are valid and [[close]] derives its [[Termination]] from it.
+ * The terminal states subsume what would otherwise be a separate "closed" 
state:
+ * [[close]] settles a terminal (cancelling first if needed) rather than 
moving to
+ * a distinct closed marker.
+ *
+ * '''Who drives transitions.''' Transitions are split between this base and 
the
+ * subclass, but all act on the one [[SessionState]]:
+ *  - the base drives the API-call edges -- `Created -> Initializing` in 
[[init]]
+ *    and `Initialized -> Streaming` in [[process]] -- with a single atomic
+ *    transition before delegating to [[doInit]] / [[doProcess]]; an 
out-of-order
+ *    or repeat call fails the transition and throws `IllegalStateException`;
+ *  - the subclass drives the protocol-event edges as it exchanges messages
+ *    (`Initializing -> Initialized` on `InitResponse`, `Streaming -> 
Finishing`
+ *    on `Finish`, any non-terminal `-> Cancelling` on `Cancel`, and every
+ *    terminal) through [[compareAndSetState]] and [[completeTerminal]].
+ *
+ * [[close]] is idempotent (it finalizes once and releases the worker exactly
+ * once); a failed init leaves a terminal state, so a subsequent [[process]] is
+ * rejected just like a call after [[close]].
  *
- * The lifecycle is enforced here: [[init]] and [[process]] are `final`
- * and delegate to [[doInit]] / [[doProcess]] after AtomicBoolean guards.
- * Subclasses implement the protocol-specific work and do not re-check
- * the contract.
+ * @param workerHandle dispatcher-side handle used to release the worker
+ *                     on [[close]]. Package-private to the udf-worker
+ *                     modules (`private[worker]`): the handle is a dispatcher
+ *                     implementation detail.
+ * @param logger       logger for lifecycle diagnostics.
  */
 @Experimental
-abstract class WorkerSession extends AutoCloseable {
+abstract class WorkerSession(
+    private[worker] val workerHandle: WorkerHandle,
+    protected val logger: WorkerLogger) {
+
+  import WorkerSession.SessionState
+
+  require(workerHandle != null, "workerHandle is required")
+
+  // Single source of truth for the session: call-ordering contract, protocol
+  // phase, and terminal outcome. CAS transitions make the contract independent
+  // of caller threading, serialise the base's API-call edges against the
+  // subclass's protocol-event edges, and let the first terminal win. See the
+  // class-level "State machine" note.
+  private val state = new AtomicReference[SessionState](SessionState.Created)
 
-  private val initialized = new AtomicBoolean(false)
-  private val processed = new AtomicBoolean(false)
+  // close() is the finalizer; this records whether it has run so the worker
+  // handle is released exactly once. Kept separate from `state` because the
+  // terminal that close() settles describes the protocol outcome, not "close 
was
+  // called" -- a session can reach a terminal (finished / failed) before 
close().
+  private val closeInvoked = new AtomicBoolean(false)
 
   /**
    * Initializes the UDF execution. Must be called exactly once before
-   * [[process]].
-   *
-   * Throws `IllegalStateException` if called more than once.
+   * [[process]]. Returns the worker's [[InitResponse]] (carrying the
+   * init-callback result and metrics) on success; throws if the worker
+   * reports an init error or the stream fails before init completes.
    *
    * @param message the [[Init]] message carrying the UDF body, data
    *                format, optional schemas, and any session context
    *                the worker needs to start processing.
    */
-  final def init(message: Init): Unit = {
-    if (!initialized.compareAndSet(false, true)) {
-      throw new IllegalStateException("init has already been called on this 
session")
+  final def init(message: Init): InitResponse = {
+    if (!state.compareAndSet(SessionState.Created, SessionState.Initializing)) 
{
+      throw new IllegalStateException(
+        s"init must be called exactly once before process (current state: 
${state.get()})")
     }
-    doInit(message)
+    val response = doInit(message)
+    // doInit returned normally, so the worker accepted the session: ensure the
+    // state reflects that. A subclass MAY advance `Initializing -> 
Initialized`
+    // itself (e.g. to mark init resolved the moment `InitResponse` arrives), 
in
+    // which case this is a no-op; and a terminal that raced in keeps the CAS
+    // from clobbering it. On failure doInit throws and we never get here.
+    state.compareAndSet(SessionState.Initializing, SessionState.Initialized)
+    response
   }
 
   /**
-   * Processes input data through the worker and returns results.
+   * Processes input data through the worker and returns the result iterator.
    *
    * Follows Spark's Iterator-to-Iterator pattern: input batches are streamed
    * to the worker, and result batches are lazily pulled from the returned
-   * iterator. The session sends a finish signal to the worker when the input
-   * iterator is exhausted.
+   * iterator. When the input iterator is exhausted the session sends `Finish`
+   * -- built lazily from `finish` so the caller can carry a finish callback --
+   * and the result iterator yields any trailing output before ending. A UDF or
+   * data-phase error surfaces as an exception while the result iterator is
+   * consumed.
    *
-   * Must be called after [[init]] and at most once per session.
-   * Throws `IllegalStateException` if called before [[init]] or more than 
once.
+   * Must be called after [[init]] has succeeded and at most once per session.
+   * Throws `IllegalStateException` if called before [[init]] completes, after
+   * the session has terminated (including a failed init), or more than once.
    *
-   * @param input iterator of raw input data batches (e.g., Arrow IPC)
-   * @return iterator of raw result data batches
+   * @param input  iterator of input data batches ([[DataRequest]], each
+   *               wrapping e.g. an Arrow IPC payload).
+   * @param finish supplies the [[Finish]] message sent once input is
+   *               exhausted; invoked at most once. Defaults to an empty
+   *               [[Finish]], which is sufficient when the caller has no extra
+   *               information to convey to the worker on finish.
+   * @return iterator of result data batches ([[DataResponse]]).
    */
-  final def process(input: Iterator[Array[Byte]]): Iterator[Array[Byte]] = {
-    if (!initialized.get()) {
-      throw new IllegalStateException("process called before init")
+  final def process(
+      input: Iterator[DataRequest],
+      finish: () => Finish = () => Finish.getDefaultInstance): 
Iterator[DataResponse] = {
+    if (!state.compareAndSet(SessionState.Initialized, 
SessionState.Streaming)) {
+      state.get() match {
+        case SessionState.Created | SessionState.Initializing =>
+          throw new IllegalStateException("process called before init 
completed")
+        case s if s.isTerminal =>
+          throw new IllegalStateException(
+            s"process called after the session terminated (state: $s)")
+        case _ =>
+          throw new IllegalStateException("process has already been called on 
this session")
+      }
     }
-    if (!processed.compareAndSet(false, true)) {
-      throw new IllegalStateException("process has already been called on this 
session")
+    doProcess(input, finish)
+  }
+
+  /**
+   * Finalizes the session and releases resources. Idempotent; safe to call
+   * from a `finally` block regardless of whether [[init]] or [[process]] ran.
+   *
+   * If the stream already finished (the result iterator was fully drained, so
+   * `Finish` has been sent and `FinishResponse` received), this returns the
+   * [[Termination.Finished]] terminator. Otherwise it cancels anything still 
in
+   * flight -- sending the [[Cancel]] produced by `cancel` so the cancel
+   * callback is carried -- waits for the terminator, and returns it. The
+   * `cancel` thunk is invoked only when a cancel actually needs to be sent.
+   *
+   * Does not raise application or transport failures -- they are reported 
through
+   * the returned [[Termination]], not thrown (though a thread interruption, or
+   * another fatal error, may still propagate). An execution error yields
+   * [[Termination.Failed]] and a transport failure / timeout yields
+   * [[Termination.TransportFailed]], each carrying the cause. A data-phase
+   * failure also surfaces while the [[process]] result iterator is consumed, 
but
+   * a failure that occurs during finish/close -- after
+   * the data is drained -- is reported only here, so the caller must inspect 
the
+   * [[Termination]]. After the terminator is settled the worker handle's ref
+   * count is decremented; if [[isWorkerSalvageable]] is false the handle is
+   * marked invalid so the dispatcher will not recycle the worker.
+   *
+   * @param cancel supplies the [[Cancel]] message used to abort in-flight 
work;
+   *               invoked at most once, and only if the stream had not already
+   *               finished. Defaults to an empty [[Cancel]], which is 
sufficient
+   *               when the caller has no extra information to convey to the
+   *               worker on cancellation (e.g. a reason).
+   * @return the [[Termination]] the stream settled on.
+   */
+  final def close(cancel: () => Cancel = () => Cancel.getDefaultInstance): 
Termination = {
+    val firstClose = closeInvoked.compareAndSet(false, true)
+    try {
+      try {
+        doClose(cancel)
+      } catch {
+        case NonFatal(e) =>
+          // doClose is contracted not to throw -- it reports its outcome via 
the
+          // settled terminal. A NonFatal escape is a subclass bug; uphold the
+          // finalizer's must-not-throw contract by settling a failure terminal
+          // here rather than letting it propagate out of close(). (Fatal 
errors
+          // and interrupts still propagate, per the contract.)
+          completeTerminal(Termination.TransportFailed(e))
+          logger.warn("doClose threw unexpectedly; treating the worker as 
unsalvageable", e)
+      }
+      // Enforce the doClose post-condition: it must leave the session 
terminal,
+      // because isWorkerSalvageable (read below) classifies the worker off the
+      // settled SessionState. A non-terminal state here is a subclass contract
+      // violation. close() is the finalizer and must not throw, so rather than
+      // raise (or silently misclassify a healthy-looking worker as 
salvageable)
+      // we settle a failure terminal -- which makes the worker unsalvageable 
--
+      // and log loudly. Terminals are sticky, so this is a no-op on the normal
+      // path where doClose already settled one.
+      if (!currentState.isTerminal) {
+        completeTerminal(Termination.TransportFailed(new IllegalStateException(
+          s"doClose returned without settling a terminal (state: 
${currentState})")))
+        logger.warn(
+          "doClose did not settle a terminal; treating the worker as 
unsalvageable")
+      }
+      // Derive the result from the settled terminal rather than trusting
+      // doClose's return value, so the Termination always agrees with the 
state
+      // close() classified the worker on -- in particular when the guard above
+      // had to override a misbehaving doClose. On the normal path this equals
+      // what doClose returned.
+      settledTermination
+    } finally {
+      if (firstClose) {
+        // close() is a finalizer (commonly invoked from a task-completion
+        // listener), so it avoids throwing: a misbehaving handle is logged via
+        // NonFatal, not propagated (a thread interrupt may still escape).
+        // isWorkerSalvageable reads the settled terminal (doClose, or the 
guard
+        // above, leaves the session terminal). releaseSession is still 
attempted
+        // even if markInvalid fails, so the ref count is not leaked; 
markInvalid
+        // runs first so a released worker is never recycled.
+        try {
+          if (!isWorkerSalvageable) workerHandle.markInvalid()
+        } catch {
+          case NonFatal(e) =>
+            logger.warn(s"markInvalid for worker ${workerHandle.id} on close() 
failed", e)
+        }
+        try {
+          workerHandle.releaseSession()
+        } catch {
+          case NonFatal(e) =>
+            logger.warn(s"releaseSession for worker ${workerHandle.id} on 
close() failed", e)
+        }
+      }
     }
-    doProcess(input)
   }
 
   /**
-   * Subclass hook for [[init]]. Called once, after the guard.
-   * Implementations MUST establish the worker connection here, not
-   * earlier. This ensures [[cancel]] before [[init]] is a no-op.
+   * Subclass hook for [[init]]. Called exactly once, after the state
+   * transition to `Initializing`. Implementations MUST establish the worker
+   * conversation here, not earlier, and advance `Initializing -> Initialized`
+   * (via [[compareAndSetState]]) once the worker accepts the session; on
+   * failure they settle a terminal (via [[completeTerminal]]) and throw.
    */
-  protected def doInit(message: Init): Unit
+  protected def doInit(message: Init): InitResponse
 
-  /** Subclass hook for [[process]]. Called at most once, after the guard. */
-  protected def doProcess(input: Iterator[Array[Byte]]): Iterator[Array[Byte]]
+  /**
+   * Subclass hook for [[process]]. Called at most once, after the state
+   * transition to `Streaming`.
+   */
+  protected def doProcess(
+      input: Iterator[DataRequest],
+      finish: () => Finish): Iterator[DataResponse]
 
   /**
-   * Requests cancellation of the current UDF execution.
+   * Subclass hook for [[close]]. Settles the stream terminator (cancelling
+   * in-flight work via `cancel` if it has not already finished), releases any
+   * protocol resources, and returns the [[Termination]] (typically via
+   * [[settledTermination]]).
    *
-   * '''Thread-safety:''' [[cancel]] may be called concurrently with
-   * [[process]] from any execution context.
+   * '''Must settle a terminal before returning''' (via [[completeTerminal]] /
+   * [[settledTermination]]): [[close]] reads [[isWorkerSalvageable]] -- which
+   * inspects the settled [[SessionState]] -- the instant this returns, so a
+   * non-terminal state here would misclassify the worker. The base asserts 
this
+   * post-condition.
    *
-   * '''Lifecycle:''' [[cancel]] is idempotent and safe at any point in
-   * the session's life:
-   *  - before [[init]] -- a no-op; the session may still be closed
-   *    normally via [[close]].
-   *  - between [[init]] and [[process]] -- signals that the session
-   *    should be terminated; the caller should not invoke [[process]]
-   *    and should call [[close]] to release resources.
-   *    Implementations SHOULD surface this as an error if [[process]]
-   *    is subsequently invoked despite the cancellation.
-   *  - during [[process]] (data flowing or awaiting completion)
-   *    -- requests the worker to abort on a best-effort basis.
-   *  - after [[process]] has returned (session already terminated)
-   *    -- a no-op.
+   * '''Must be thread-safe and idempotent.''' [[close]] does not serialise 
calls
+   * to this hook: it can be invoked '''concurrently''' (e.g. a task-completion
+   * listener on one thread while the result iterator is consumed on another) 
and
+   * '''repeatedly''' (a second [[close]]). Implementations must tolerate both 
--
+   * e.g. settle the terminal through the first-wins [[completeTerminal]] and
+   * guard any wire write under the same lock the data path uses. The `cancel`
+   * thunk must be evaluated at most once across all callers.
    *
-   * Implementations are responsible for the lifecycle-aware behavior
-   * described above so that the caller does not need to coordinate
-   * with the execution context driving [[process]].
+   * As the cleanup path it does not re-throw a failed terminal -- it reports a
+   * best-effort [[Termination]] (the underlying failure surfaces through the
+   * result iterator). Should it nonetheless throw an unexpected (non-fatal)
+   * error, [[close]] converts that into a [[Termination.TransportFailed]] 
rather
+   * than propagating, to honour its must-not-throw contract.
    */
-  def cancel(): Unit
+  protected def doClose(cancel: () => Cancel): Termination
 
   /**
-   * Closes this session and releases resources. Idempotent; safe to
-   * call from a `finally` block regardless of whether [[init]],
-   * [[process]], or [[cancel]] have been invoked.
-   *
-   * If [[init]] was called but [[process]] was not (e.g. an exception
-   * was thrown between the two), [[close]] signals cancellation to the
-   * worker before releasing resources so it can clean up
-   * deterministically. Subclasses implement [[doClose]] for resource
-   * teardown; the base class handles the cancel-before-close guarantee
-   * automatically.
+   * Whether the underlying worker is in a state safe to reuse after this
+   * session ends. The default treats only a dead or unknown transport as
+   * unsafe: a [[Termination.TransportFailed]] outcome (transport failure,
+   * timeout, or interrupt) -- or a session that never settled -- leaves the
+   * worker in an unknown state and is not salvageable. Every other terminal is
+   * salvageable: a clean [[Termination.Finished]], a cooperative
+   * [[Termination.Cancelled]], and also an execution [[Termination.Failed]],
+   * which is typically a user-code (UDF) error reported by a still-healthy
+   * worker rather than a worker fault. A `false` result tells [[close]] to 
mark
+   * `workerHandle` invalid so no reuse pool recycles the worker. Subclasses 
may
+   * override for protocol-specific nuances.
+   */
+  protected def isWorkerSalvageable: Boolean = state.get() match {
+    case SessionState.Terminal(_: Termination.TransportFailed) => false
+    case t if t.isTerminal => true
+    case _ => false
+  }
+
+  // ---- State-machine primitives for subclasses 
------------------------------
+
+  /** The current [[SessionState]]. */
+  protected final def currentState: SessionState = state.get()
+
+  /**
+   * Atomically advances the state from `expect` to `update`, returning whether
+   * the transition was applied. Used by subclasses to drive the protocol-event
+   * edges (e.g. `Initializing -> Initialized`, `Streaming -> Finishing`). A
+   * terminal that arrived first makes the CAS fail, so the terminal wins.
+   * Terminals must be reached via [[completeTerminal]], not this method.
+   */
+  protected final def compareAndSetState(
+      expect: SessionState, update: SessionState): Boolean =
+    state.compareAndSet(expect, update)
+
+  /**
+   * Settles the session on its terminal [[Termination]] outcome. The first
+   * caller wins (an already-terminal state is left untouched); only the winner
+   * runs [[onTerminalSettled]] and this returns `true`. All later callers
+   * observe the existing terminal and return `false`.
    */
-  final override def close(): Unit = {
-    if (initialized.get() && !processed.get()) {
-      cancel()
+  protected final def completeTerminal(termination: Termination): Boolean = {
+    val terminal = SessionState.Terminal(termination)
+    var cur = state.get()
+    while (!cur.isTerminal) {
+      if (state.compareAndSet(cur, terminal)) {
+        onTerminalSettled(termination)
+        return true
+      }
+      cur = state.get()
     }
-    doClose()
+    false
+  }
+
+  /**
+   * Hook invoked exactly once, by the caller that settles the terminal in
+   * [[completeTerminal]], with the settled [[Termination]]. Subclasses 
override
+   * it to wake whatever is blocked on the terminator (output queues, latches).
+   * The default does nothing.
+   */
+  protected def onTerminalSettled(termination: Termination): Unit = ()
+
+  /**
+   * The [[Termination]] the session settled on, for [[doClose]] to return. The
+   * clean terminators carry the worker's response proto (metrics + 
finish/cancel
+   * callback data/error); the failure terminations carry their cause and have 
no
+   * proto terminator, so no metrics. A failure also surfaces through the 
result
+   * iterator when it is consumed, but is carried here too because an error 
raised
+   * during finish/close -- after the data has been drained -- reaches no one
+   * through the iterator. Throws only if called before a terminal is settled,
+   * which [[doClose]] must not do.
+   */
+  protected final def settledTermination: Termination = state.get() match {
+    case SessionState.Terminal(termination) => termination
+    case other =>
+      throw new IllegalStateException(s"settledTermination called in 
non-terminal state: $other")
   }
+}
+
+object WorkerSession {
 
-  /** Subclass hook for [[close]]. The base class guarantees that
-   *  [[cancel]] has already been called if [[init]] was invoked but
-   *  [[process]] was not.
+  /**
+   * The single state machine for a [[WorkerSession]]: the call-ordering
+   * contract, the protocol phase, and the terminal outcome. The normal
+   * progression is `Created -> Initializing -> Initialized -> Streaming ->
+   * Finishing -> Terminal`; `close`/cancel and errors fork to `Cancelling`
+   * before the `Terminal`. The single terminal state carries the public
+   * [[Termination]] outcome. See [[WorkerSession]] for the full diagram and
+   * which party drives each edge.
+   *
+   * Visible to the udf-worker modules (`private[worker]`) so concrete protocol
+   * subclasses can drive and inspect the state; it is an implementation 
detail,
+   * not part of the public API.
    */
-  protected def doClose(): Unit
+  private[worker] sealed trait SessionState {
+    /** Whether this is a terminal state (the session is over). */
+    def isTerminal: Boolean = false
+  }
+
+  private[worker] object SessionState {
+    /** Constructed; [[WorkerSession.init]] not yet called. */
+    case object Created extends SessionState
+    /** [[WorkerSession.init]] is in flight; awaiting the worker's 
`InitResponse`. */
+    case object Initializing extends SessionState
+    /** `InitResponse` OK; [[WorkerSession.process]] not yet called. */
+    case object Initialized extends SessionState
+    /** [[WorkerSession.process]] is active; input may still be sent. */
+    case object Streaming extends SessionState
+    /** Input exhausted, `Finish` sent; awaiting trailing output + terminator. 
*/
+    case object Finishing extends SessionState
+    /** `Cancel` sent (engine cancel or post-error cleanup); awaiting 
terminator. */
+    case object Cancelling extends SessionState
+
+    /**
+     * The session is over; no further writes are valid. The single terminal
+     * state carries the public [[Termination]] outcome (`Finished` /
+     * `Cancelled` / `Failed` / `TransportFailed`), so those four outcome cases
+     * live once on [[Termination]] rather than being mirrored on the state.
+     */
+    final case class Terminal(termination: Termination) extends SessionState {
+      override def isTerminal: Boolean = true
+    }
+  }
 }
diff --git 
a/udf/worker/core/src/main/scala/org/apache/spark/udf/worker/core/direct/DirectUnixSocketWorkerDispatcher.scala
 
b/udf/worker/core/src/main/scala/org/apache/spark/udf/worker/core/direct/DirectUnixSocketWorkerDispatcher.scala
deleted file mode 100644
index 8da0354187e4..000000000000
--- 
a/udf/worker/core/src/main/scala/org/apache/spark/udf/worker/core/direct/DirectUnixSocketWorkerDispatcher.scala
+++ /dev/null
@@ -1,145 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You under the Apache License, Version 2.0
- * (the "License"); you may not use this file except in compliance with
- * the License.  You may obtain a copy of the License at
- *
- *    http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package org.apache.spark.udf.worker.core.direct
-
-import java.io.File
-import java.nio.file.{Files, Path}
-import java.nio.file.attribute.PosixFilePermissions
-
-import org.apache.spark.annotation.Experimental
-import org.apache.spark.udf.worker.UDFWorkerSpecification
-import org.apache.spark.udf.worker.core.{UnixSocketWorkerConnection, 
WorkerLogger}
-import 
org.apache.spark.udf.worker.core.direct.DirectWorkerDispatcher.SOCKET_POLL_INTERVAL_MS
-
-/**
- * :: Experimental ::
- * A [[DirectWorkerDispatcher]] using Unix domain sockets as the worker
- * transport. Allocates a private 0700 socket directory at construction;
- * each worker is given a UDS path inside it.
- *
- * Concrete subclasses implement [[createConnection]] (with a UDS protocol
- * of choice) and [[createSessionForWorker]].
- */
-@Experimental
-abstract class DirectUnixSocketWorkerDispatcher(
-    workerSpec: UDFWorkerSpecification,
-    logger: WorkerLogger = WorkerLogger.NoOp)
-  extends DirectWorkerDispatcher(workerSpec, logger) {
-
-  // Removed explicitly in closeTransport(). deleteOnExit is avoided because
-  // the JDK retains the path for the JVM lifetime, which leaks in
-  // long-lived drivers.
-  private val socketDir: Path = createPrivateTempDirectory()
-
-  override protected def newEndpointAddress(workerId: String): String =
-    socketDir.resolve(s"worker-$workerId.sock").toString
-
-  override protected def waitForReady(
-      address: String,
-      process: Process,
-      outputFile: File): Unit = {
-    val file = new File(address)
-    // At least one poll so very small initTimeouts don't trip a premature
-    // timeout before the worker has any chance to create the socket.
-    val maxAttempts = math.max(1, (initTimeoutMs / 
SOCKET_POLL_INTERVAL_MS).toInt)
-    var attempts = 0
-    while (!file.exists() && attempts < maxAttempts) {
-      if (!process.isAlive) throwWorkerExitedBeforeSocket(process, address, 
outputFile)
-      Thread.sleep(SOCKET_POLL_INTERVAL_MS)
-      attempts += 1
-    }
-    if (!file.exists()) {
-      if (process.isAlive) {
-        DirectWorkerDispatcher.destroyForciblyAndReap(
-          process, logger, s"init timeout $address")
-        val tail = readOutputTail(outputFile)
-        throw new DirectWorkerTimeoutException(
-          s"Worker did not create socket at $address within 
${initTimeoutMs}ms\n$tail")
-      } else {
-        // Worker exited after the last poll without creating the socket;
-        // prefer the exit-code message over the ambiguous "did not create".
-        throwWorkerExitedBeforeSocket(process, address, outputFile)
-      }
-    }
-  }
-
-  override protected def cleanupEndpointAddress(address: String): Unit = {
-    Files.deleteIfExists(new File(address).toPath)
-  }
-
-  override protected def closeTransport(): Unit = {
-    val dir = socketDir.toFile
-    if (dir.exists()) {
-      val remaining = dir.listFiles()
-      if (remaining != null) remaining.foreach(_.delete())
-      dir.delete()
-    }
-  }
-
-  override protected def validateTransportSupport(): Unit = {
-    val props = workerSpec.getDirect.getProperties
-    require(props.hasConnection,
-      "DirectWorker.properties.connection must be set")
-    val conn = props.getConnection
-    require(conn.hasUnixDomainSocket,
-      "DirectUnixSocketWorkerDispatcher requires UNIX domain socket transport, 
" +
-        s"got ${conn.getTransportCase}")
-  }
-
-  override protected def createConnection(address: String): 
UnixSocketWorkerConnection
-
-  private def throwWorkerExitedBeforeSocket(
-      process: Process,
-      address: String,
-      outputFile: File): Nothing = {
-    val tail = readOutputTail(outputFile)
-    throw new DirectWorkerException(
-      s"Worker exited with code ${process.exitValue()} " +
-        s"before creating socket at $address\n$tail")
-  }
-
-  /**
-   * Creates a temp directory with owner-only permissions (0700 on POSIX).
-   * On non-POSIX filesystems falls back to best-effort `File.setXxx`,
-   * which is TOCTOU-racy and weaker; a WARN surfaces if the platform
-   * refuses the setters.
-   */
-  private def createPrivateTempDirectory(): Path = {
-    val attr = PosixFilePermissions.asFileAttribute(
-      PosixFilePermissions.fromString("rwx------"))
-    try {
-      Files.createTempDirectory("spark-udf-worker", attr)
-    } catch {
-      case _: UnsupportedOperationException =>
-        val dir = Files.createTempDirectory("spark-udf-worker")
-        val f = dir.toFile
-        // `&` (non-short-circuiting) so every setter is attempted even if
-        // an earlier one refused.
-        val applied =
-          f.setReadable(false, false) & f.setWritable(false, false) &
-            f.setExecutable(false, false) & f.setReadable(true, true) &
-            f.setWritable(true, true) & f.setExecutable(true, true)
-        if (!applied) {
-          logger.warn(
-            s"Could not fully restrict permissions on $dir; socket " +
-              s"directory may be accessible to other local users on this " +
-              s"filesystem")
-        }
-        dir
-    }
-  }
-}
diff --git 
a/udf/worker/core/src/main/scala/org/apache/spark/udf/worker/core/direct/DirectWorkerDispatcher.scala
 
b/udf/worker/core/src/main/scala/org/apache/spark/udf/worker/core/direct/DirectWorkerDispatcher.scala
index 2438546ddafb..db81b5265b79 100644
--- 
a/udf/worker/core/src/main/scala/org/apache/spark/udf/worker/core/direct/DirectWorkerDispatcher.scala
+++ 
b/udf/worker/core/src/main/scala/org/apache/spark/udf/worker/core/direct/DirectWorkerDispatcher.scala
@@ -29,7 +29,7 @@ import scala.util.control.NonFatal
 
 import org.apache.spark.annotation.Experimental
 import org.apache.spark.udf.worker.{ProcessCallable, UDFWorkerSpecification}
-import org.apache.spark.udf.worker.core.{WorkerConnection, WorkerDispatcher,
+import org.apache.spark.udf.worker.core.{WorkerConnection, WorkerDispatcher, 
WorkerHandle,
   WorkerLogger, WorkerSecurityScope, WorkerSession}
 import 
org.apache.spark.udf.worker.core.direct.DirectWorkerDispatcher.{CallableResult,
   DEFAULT_CALLABLE_TIMEOUT_MS, DEFAULT_GRACEFUL_TIMEOUT_MS, 
DEFAULT_INIT_TIMEOUT_MS,
@@ -46,18 +46,17 @@ import 
org.apache.spark.udf.worker.core.direct.DirectWorkerDispatcher.{CallableR
  * currently gets a fresh worker that is terminated when the session closes
  * (the single-reference case of the future pooling policy).
  *
- * Subclasses implement [[createConnection]] and [[createSessionForWorker]]
- * to provide protocol-specific behavior (e.g., gRPC, raw sockets).
+ * Subclasses pick the transport and protocol: they allocate the endpoint
+ * address, wait for the worker to be reachable, build the
+ * [[WorkerConnection]] and the per-session [[WorkerSession]]. See
+ * [[newEndpointAddress]], [[newConnection]], [[newSession]].
  *
  * For workers obtained through a provisioning service or daemon (indirect
  * creation), see the `indirect` package (TODO).
  *
- * @param workerSpec worker specification (proto)
- * @param logger [[WorkerLogger]] used for dispatcher-internal messages.
- *               The framework does not depend on any concrete logging
- *               backend; callers should pass an adapter that forwards
- *               to their preferred logger (Spark's `Logging` trait,
- *               SLF4J, etc.). Defaults to [[WorkerLogger.NoOp]].
+ * @param workerSpec worker specification (proto).
+ * @param logger     [[WorkerLogger]] for dispatcher-internal messages.
+ *                   Defaults to [[WorkerLogger.NoOp]].
  */
 @Experimental
 abstract class DirectWorkerDispatcher(
@@ -68,8 +67,22 @@ abstract class DirectWorkerDispatcher(
   // TODO: Connection pooling -- reuse idle workers across sessions.
   // TODO: Security scope isolation -- partition pool by WorkerSecurityScope.
 
-  validateTransportSupport()
+  // Pre-flight spec validation. Per convention in this dispatcher, all
+  // spec-shape validation throws `IllegalArgumentException` (via `require`
+  // in the validators below); runtime failures during environment
+  // preparation throw `DirectWorkerException`. Callers can rely on this
+  // split to decide between programmer-error and operational-error paths.
+  // `workerSpec` is passed explicitly to `validateTransportSupport` so
+  // subclass implementations cannot read partially-initialized subclass
+  // fields: at this point in construction, the parent constructor body is
+  // still running and subclass `val`s have not yet been assigned.
+  validateTransportSupport(workerSpec)
   validateEnvironmentCallables()
+  // Transport-specific setup runs only after validation passes, so an
+  // invalid spec allocates no resources. Subclasses override [[initialize]]
+  // rather than relying on field-initialiser ordering relative to this
+  // constructor.
+  initialize()
 
   /**
    * Maximum time to wait for a setup/verify/cleanup callable to finish.
@@ -82,7 +95,7 @@ abstract class DirectWorkerDispatcher(
   // dispatcher-internal callableTimeoutMs above is subclass-controlled and
   // not subject to the cap.
   // Package-private for test access.
-  private[core] val initTimeoutMs: Long = {
+  private[worker] val initTimeoutMs: Long = {
     val props = workerSpec.getDirect.getProperties
     val raw = if (props.hasInitializationTimeoutMs && 
props.getInitializationTimeoutMs > 0) {
       props.getInitializationTimeoutMs.toLong
@@ -117,6 +130,10 @@ abstract class DirectWorkerDispatcher(
   private[this] val workers = new ConcurrentHashMap[String, 
DirectWorkerProcess]()
   private[this] val closed = new AtomicBoolean(false)
 
+  // TODO [SPARK-55278]: extract the env state machine + JVM shutdown hook
+  //   into a standalone EnvironmentManager once the gRPC dispatcher work has
+  //   landed; the env machinery is unrelated to the gRPC protocol and
+  //   only lives here for historical reasons.
   @volatile private var environmentState: EnvironmentState = 
EnvironmentState.Pending
   private val environmentLock = new Object
   private[this] var cleanupHook: Option[Thread] = None
@@ -150,17 +167,74 @@ abstract class DirectWorkerDispatcher(
   protected def closeTransport(): Unit
 
   /**
-   * Validates the worker spec's transport choice. Subclasses declare
-   * which transports they support. Called from the base constructor;
-   * implementations must only read base-class state (`workerSpec`).
+   * Validates the worker spec from the dispatcher's point of view -- both
+   * the transport choice (which transports can this dispatcher provision?)
+   * and the protocol's spec-level requirements (e.g. capabilities flags).
+   *
+   * Invoked from the base-class constructor BEFORE subclass `val`s have
+   * been initialised. Implementations MUST validate against `spec` only;
+   * reading subclass fields will see uninitialised values.
+   *
+   * Convention: throw `IllegalArgumentException` (via `require`) for spec
+   * problems. Operational/install failures are reported separately via
+   * `DirectWorkerException` from `ensureEnvironmentReady`.
+   *
+   * @param spec the worker specification to validate. Identical to the
+   *             `workerSpec` field but passed explicitly to make the
+   *             "constructor-time validation" contract obvious at call
+   *             sites and prevent subclass-state reads.
    */
-  protected def validateTransportSupport(): Unit
+  protected def validateTransportSupport(spec: UDFWorkerSpecification): Unit
 
-  /** Creates a protocol-specific connection to a worker at the given address. 
*/
-  protected def createConnection(address: String): WorkerConnection
+  /**
+   * Transport-specific one-time setup, invoked from the base-class
+   * constructor exactly once, AFTER spec validation has passed and BEFORE
+   * any session is created. Subclasses override this to allocate
+   * transport resources (e.g. a private socket directory) instead of
+   * relying on the ordering of subclass field initialisers relative to
+   * the base constructor. Overrides MUST call `super.initialize()`.
+   *
+   * The base implementation does nothing.
+   */
+  protected def initialize(): Unit = ()
+
+  /**
+   * Opens the transport-level connection to a worker reachable at
+   * `address` (typically the value returned by [[newEndpointAddress]]).
+   * Called once per worker; the resulting connection is shared by every
+   * session opened against that worker.
+   */
+  protected def newConnection(address: String): WorkerConnection
 
-  /** Creates a protocol-specific session for the given worker. */
-  protected def createSessionForWorker(worker: DirectWorkerProcess): 
WorkerSession
+  /**
+   * Constructs the per-invocation [[WorkerSession]] for a worker.
+   * Subclasses build the concrete session implementation (e.g.
+   * `GrpcWorkerSession` for gRPC over UDS) using `workerHandle` for
+   * dispatcher-side cleanup and `connection` for the wire transport.
+   */
+  protected def newSession(
+      workerHandle: WorkerHandle,
+      connection: WorkerConnection): WorkerSession
+
+  /**
+   * Test-only hook: invoked once per [[createSession]] after the worker
+   * has been spawned and registered in the dispatcher's `workers` map,
+   * but before [[newSession]] has been called. The default is a no-op.
+   *
+   * Tests override to capture the worker reference or to inject a
+   * deliberate block to drive race conditions between `createSession`
+   * and `close`. Production code should not override this -- the
+   * production path provides no useful extension point here and the
+   * race-window control it offers is only meaningful for unit tests.
+   *
+   * Implementations MAY block. They run on the createSession caller's
+   * thread.
+   *
+   * Visible for testing only: `protected` so tests in this package can
+   * override it; production code should not. (The Guava annotation for this
+   * is banned by scalastyle per SPARK-11615, so the intent is noted here.)
+   */
+  protected def afterWorkerRegistered(worker: DirectWorkerProcess): Unit = ()
 
   override def createSession(
       securityScope: Option[WorkerSecurityScope]): WorkerSession = {
@@ -180,7 +254,8 @@ abstract class DirectWorkerDispatcher(
       throwClosed()
     }
     try {
-      createSessionForWorker(worker)
+      afterWorkerRegistered(worker)
+      newSession(worker, worker.connection)
     } catch {
       case e: InterruptedException =>
         Thread.currentThread().interrupt()
@@ -343,7 +418,7 @@ abstract class DirectWorkerDispatcher(
    * Runs a [[ProcessCallable]] synchronously and returns the result.
    * Always throws on timeout; callers check `exitCode` for non-timeout 
failures.
    */
-  private[core] def runCallable(callable: ProcessCallable): CallableResult = {
+  private[worker] def runCallable(callable: ProcessCallable): CallableResult = 
{
     val cmd = (callable.getCommandList.asScala ++ 
callable.getArgumentsList.asScala).toSeq
     require(cmd.nonEmpty,
       "ProcessCallable must have at least one entry in command or arguments")
@@ -383,8 +458,11 @@ abstract class DirectWorkerDispatcher(
 
     try {
       waitForReady(address, process, outputFile.toFile)
-      val connection = createConnection(address)
+      val connection = newConnection(address)
       val artifacts = new WorkerArtifacts(process, connection, outputFile, 
logger)
+      // Remove the worker's endpoint artifact (its UDS socket file) on close;
+      // the worker creates it, the dispatcher owns its deletion.
+      artifacts.registerCleanup(() => cleanupEndpointAddress(address))
       new DirectWorkerProcess(
         workerId, artifacts, gracefulTimeoutMs, logger,
         onLastSessionReleased = releaseWorker)
@@ -471,8 +549,10 @@ abstract class DirectWorkerDispatcher(
   }
 }
 
-private[direct] object DirectWorkerDispatcher {
-  private[direct] val SOCKET_POLL_INTERVAL_MS = 100L
+// Visible to `core` (and the sibling `grpc` module) so concrete dispatchers in
+// the `grpc` module can call shared helpers like `destroyForciblyAndReap`.
+private[worker] object DirectWorkerDispatcher {
+  private[worker] val SOCKET_POLL_INTERVAL_MS = 100L
   private[direct] val DEFAULT_INIT_TIMEOUT_MS = 10000L
   private[direct] val DEFAULT_CALLABLE_TIMEOUT_MS = 120000L
   private[direct] val DEFAULT_GRACEFUL_TIMEOUT_MS = 5000L
@@ -499,7 +579,7 @@ private[direct] object DirectWorkerDispatcher {
    * @param context short tag included in the timeout warning so operators
    *                can correlate a stuck child with its source.
    */
-  private[direct] def destroyForciblyAndReap(
+  private[worker] def destroyForciblyAndReap(
       process: Process,
       logger: WorkerLogger,
       context: String = ""): Unit = {
@@ -522,7 +602,7 @@ private[direct] object DirectWorkerDispatcher {
   }
 
   /** Result of running a [[ProcessCallable]]. */
-  private[core] case class CallableResult(exitCode: Int, outputTail: String)
+  private[worker] case class CallableResult(exitCode: Int, outputTail: String)
 
   private[direct] sealed trait EnvironmentState
   private[direct] object EnvironmentState {
diff --git 
a/udf/worker/core/src/main/scala/org/apache/spark/udf/worker/core/direct/DirectWorkerProcess.scala
 
b/udf/worker/core/src/main/scala/org/apache/spark/udf/worker/core/direct/DirectWorkerProcess.scala
index f4b5c1df6319..52e7d5123d81 100644
--- 
a/udf/worker/core/src/main/scala/org/apache/spark/udf/worker/core/direct/DirectWorkerProcess.scala
+++ 
b/udf/worker/core/src/main/scala/org/apache/spark/udf/worker/core/direct/DirectWorkerProcess.scala
@@ -17,13 +17,13 @@
 package org.apache.spark.udf.worker.core.direct
 
 import java.nio.file.{Files, Path}
-import java.util.concurrent.TimeUnit
+import java.util.concurrent.{CopyOnWriteArrayList, TimeUnit}
 import java.util.concurrent.atomic.{AtomicBoolean, AtomicInteger}
 
 import scala.util.control.NonFatal
 
 import org.apache.spark.annotation.Experimental
-import org.apache.spark.udf.worker.core.{WorkerConnection, WorkerLogger}
+import org.apache.spark.udf.worker.core.{WorkerConnection, WorkerHandle, 
WorkerLogger}
 
 /**
  * :: Experimental ::
@@ -33,7 +33,7 @@ import org.apache.spark.udf.worker.core.{WorkerConnection, 
WorkerLogger}
  * future pooling -- today one process per session.
  *
  * Closing sends SIGTERM, waits up to [[gracefulTimeoutMs]], then
- * delegates connection close + forced kill + file cleanup to
+ * delegates connection close + forced kill + socket/log cleanup to
  * [[WorkerArtifacts.close]].
  *
  * @param id stable worker identifier (UUID passed to the binary as `--id`).
@@ -48,18 +48,27 @@ import org.apache.spark.udf.worker.core.{WorkerConnection, 
WorkerLogger}
  */
 @Experimental
 class DirectWorkerProcess(
-    val id: String,
+    override val id: String,
     private[direct] val artifacts: WorkerArtifacts,
     val gracefulTimeoutMs: Long,
     protected val logger: WorkerLogger = WorkerLogger.NoOp,
     private[direct] val onLastSessionReleased: DirectWorkerProcess => Unit = _ 
=> ())
-  extends AutoCloseable {
+  extends AutoCloseable with WorkerHandle {
 
   // TODO: idle-timeout tracking and concurrent session capacity.
 
   private val activeSessionCount = new AtomicInteger(0)
   private val closed = new AtomicBoolean(false)
 
+  // Reuse-readiness flag. Sessions set this to `true` when they observe the
+  // worker in a state that is unsafe to recycle (transport error, hung
+  // worker, observed protocol violation). Pool-aware dispatchers consult
+  // [[isInvalid]] in [[onLastSessionReleased]] to decide between
+  // terminate-and-discard vs. return-to-pool. Today the non-pooling path
+  // always terminates, so this is forward-looking; setting it now is
+  // harmless and locks in the reuse contract.
+  private val invalid = new AtomicBoolean(false)
+
   /** The OS process handle for this worker. */
   def process: Process = artifacts.process
 
@@ -72,20 +81,59 @@ class DirectWorkerProcess(
   /** Number of sessions currently using this worker. */
   def activeSessions: Int = activeSessionCount.get()
 
-  /** Increments the active session count. */
-  def acquireSession(): Unit = activeSessionCount.incrementAndGet()
+  /**
+   * Increments the active session count.
+   *
+   * Preconditions: must not be called after [[close]]. The dispatcher
+   * arbitrates acquire-vs-close ordering; calling this on a closed worker
+   * indicates a dispatcher-side bug.
+   *
+   * @throws IllegalStateException if the worker has already been closed.
+   */
+  def acquireSession(): Unit = {
+    if (closed.get()) {
+      throw new IllegalStateException(
+        s"cannot acquire session: worker $id is already closed")
+    }
+    activeSessionCount.incrementAndGet()
+  }
+
+  /**
+   * Returns true if a session has marked this worker as unsafe to reuse.
+   * Pool implementations MUST treat [[isInvalid]] as a hard rejection in
+   * [[onLastSessionReleased]] -- the worker must be torn down, not
+   * recycled. Once set, the flag is sticky for the worker's lifetime.
+   */
+  def isInvalid: Boolean = invalid.get()
+
+  /**
+   * Marks this worker as unsafe to return to a reuse pool. Idempotent;
+   * sticky once set. Sessions call this when they observe a transport
+   * failure, hung worker, or any termination path that leaves the worker
+   * in an unknown state.
+   */
+  override def markInvalid(): Unit = invalid.set(true)
 
   /**
    * Decrements the active session count. Fires [[onLastSessionReleased]]
-   * on the 0-transition. A negative count indicates an unbalanced
-   * acquire/release; we log and reset to 0 rather than silently mask it.
+   * on the 0-transition.
+   *
+   * A negative count means this was called without a matching
+   * [[acquireSession]] -- an unbalanced acquire/release, which is a
+   * dispatcher-side bug -- and throws `IllegalStateException`.
+   *
+   * @throws IllegalStateException if the active session count goes negative.
    */
-  def releaseSession(): Unit = {
+  override def releaseSession(): Unit = {
     val c = activeSessionCount.decrementAndGet()
     if (c < 0) {
-      logger.warn(
+      // A negative count means releaseSession was called without a matching
+      // acquireSession -- an unbalanced acquire/release, which is a
+      // dispatcher-side bug. Fail fast: a corrupt count could later let a
+      // release prematurely fire onLastSessionReleased and tear the worker
+      // down under a live session.
+      throw new IllegalStateException(
         s"releaseSession called without a matching acquireSession (count=$c)")
-      activeSessionCount.set(0)
     } else if (c == 0) {
       // Swallow callback errors so session.close cannot throw.
       try onLastSessionReleased(this) catch {
@@ -125,10 +173,15 @@ class DirectWorkerProcess(
 /**
  * Closeable bundle of per-worker OS resources: the child [[Process]], its
  * transport [[WorkerConnection]], and its merged stdout/stderr log.
- * [[close]] runs connection close (which for UDS removes the socket
- * file), then SIGKILL-reaps the process, then deletes the output log.
- * Graceful SIGTERM is the higher layer's responsibility (see
- * [[DirectWorkerProcess#close]]).
+ * [[close]] tears down the connection, SIGKILL-reaps the process, runs any
+ * cleanup hooks registered via [[registerCleanup]] (e.g. removing the worker's
+ * UDS socket file), then deletes the output log. Graceful SIGTERM is the 
higher
+ * layer's responsibility (see [[DirectWorkerProcess#close]]).
+ *
+ * Transport-specific teardown is attached via [[registerCleanup]] rather than
+ * baked into this bundle, so `WorkerArtifacts` stays transport-agnostic: a
+ * dispatcher registers whatever per-worker resources it created without this
+ * class knowing what they are.
  */
 private[direct] final class WorkerArtifacts(
     val process: Process,
@@ -138,11 +191,37 @@ private[direct] final class WorkerArtifacts(
 
   private[this] val closed = new AtomicBoolean(false)
 
+  // Resource-cleanup callbacks run during close(), in registration order, 
after
+  // the process is reaped. A CopyOnWriteArrayList gives thread-safe 
registration
+  // and lock-free iteration at close without an explicit lock; in practice all
+  // hooks are registered at spawn time, before the bundle is exposed to any
+  // close path.
+  private[this] val cleanupHooks = new CopyOnWriteArrayList[() => Unit]()
+
   /**
-   * Idempotently closes the connection (transport teardown + any
-   * transport-specific cleanup such as deleting a UDS socket file),
-   * SIGKILL-reaps the process, and deletes the output log. Each step
-   * is guarded so a failure in one does not skip the next.
+   * Registers a resource-cleanup callback to run during [[close]], in
+   * registration order, after the process is reaped. Lets a dispatcher attach
+   * the per-worker resources it created -- e.g. `() => 
cleanupEndpointAddress(address)`
+   * to delete the UDS socket file -- without `WorkerArtifacts` knowing what 
they
+   * are. Each hook is invoked at most once and guarded independently during
+   * close, so one failing hook does not skip the rest.
+   *
+   * Must be called before [[close]]; registering on an already-closed bundle 
is
+   * a dispatcher-side bug and throws `IllegalStateException`.
+   */
+  def registerCleanup(hook: () => Unit): Unit = {
+    if (closed.get()) {
+      throw new IllegalStateException(
+        "cannot register a cleanup hook on an already-closed WorkerArtifacts")
+    }
+    cleanupHooks.add(hook)
+  }
+
+  /**
+   * Idempotently closes the connection (transport teardown),
+   * SIGKILL-reaps the process, runs the dispatcher cleanup hooks, and deletes
+   * the output log. Each step (and each hook) is guarded so a failure in one
+   * does not skip the next.
    */
   override def close(): Unit = {
     if (!closed.compareAndSet(false, true)) return
@@ -154,6 +233,16 @@ private[direct] final class WorkerArtifacts(
 
     DirectWorkerDispatcher.destroyForciblyAndReap(process, logger, "worker 
artifacts")
 
+    // `closed` is already set, so registerCleanup rejects any further hook;
+    // every hook registered before close is iterated here 
(CopyOnWriteArrayList
+    // iteration needs no lock and no snapshot copy).
+    cleanupHooks.forEach { hook =>
+      try hook() catch {
+        case NonFatal(e) =>
+          logger.warn("Error running worker resource-cleanup hook", e)
+      }
+    }
+
     try Files.deleteIfExists(outputFile) catch {
       case NonFatal(e) =>
         logger.warn(s"Error cleaning up worker output file $outputFile", e)
diff --git 
a/udf/worker/core/src/main/scala/org/apache/spark/udf/worker/core/direct/DirectWorkerSession.scala
 
b/udf/worker/core/src/main/scala/org/apache/spark/udf/worker/core/direct/DirectWorkerSession.scala
deleted file mode 100644
index de1dc45b5a8d..000000000000
--- 
a/udf/worker/core/src/main/scala/org/apache/spark/udf/worker/core/direct/DirectWorkerSession.scala
+++ /dev/null
@@ -1,56 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You under the Apache License, Version 2.0
- * (the "License"); you may not use this file except in compliance with
- * the License.  You may obtain a copy of the License at
- *
- *    http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package org.apache.spark.udf.worker.core.direct
-
-import java.util.concurrent.atomic.AtomicBoolean
-
-import org.apache.spark.annotation.Experimental
-import org.apache.spark.udf.worker.core.{WorkerConnection, WorkerSession}
-
-/**
- * :: Experimental ::
- * A [[WorkerSession]] backed by a locally-spawned [[DirectWorkerProcess]].
- *
- * This is the session type returned by [[DirectWorkerDispatcher]]. It ties
- * the session lifecycle to the worker's ref-count: the dispatcher increments
- * the count before construction, and [[doClose]] decrements it, so the
- * dispatcher knows when a worker process is idle and can be terminated or
- * reused.
- *
- * Subclasses implement the protocol-specific data transmission
- * ([[init]], [[process]], [[cancel]]).
- *
- * @param workerProcess the direct worker process backing this session.
- *                      Internal to the `core` package and test code -- the
- *                      worker handle is a dispatcher implementation detail,
- *                      not part of the public WorkerSession API.
- */
-@Experimental
-abstract class DirectWorkerSession(
-    private[core] val workerProcess: DirectWorkerProcess) extends 
WorkerSession {
-
-  private val released = new AtomicBoolean(false)
-
-  /** The connection to the worker for this session. */
-  def connection: WorkerConnection = workerProcess.connection
-
-  override protected def doClose(): Unit = {
-    if (released.compareAndSet(false, true)) {
-      workerProcess.releaseSession()
-    }
-  }
-}
diff --git 
a/udf/worker/core/src/test/scala/org/apache/spark/udf/worker/core/DirectWorkerDispatcherSuite.scala
 
b/udf/worker/core/src/test/scala/org/apache/spark/udf/worker/core/DirectWorkerDispatcherSuite.scala
index 06574fdf1013..28ecc1179f6a 100644
--- 
a/udf/worker/core/src/test/scala/org/apache/spark/udf/worker/core/DirectWorkerDispatcherSuite.scala
+++ 
b/udf/worker/core/src/test/scala/org/apache/spark/udf/worker/core/DirectWorkerDispatcherSuite.scala
@@ -27,11 +27,12 @@ import org.scalatest.BeforeAndAfterEach
 import org.scalatest.funsuite.AnyFunSuite
 
 import org.apache.spark.udf.worker.{
-  DirectWorker, LocalTcpConnection, ProcessCallable, UDFWorkerProperties,
-  UDFWorkerSpecification, UnixDomainSocket, WorkerConnectionSpec,
-  WorkerEnvironment}
-import 
org.apache.spark.udf.worker.core.direct.{DirectUnixSocketWorkerDispatcher,
-  DirectWorkerException, DirectWorkerProcess,
+  Cancel, DirectWorker, LocalTcpConnection, ProcessCallable, 
UDFProtoCommunicationPattern,
+  UDFWorkerProperties, UDFWorkerSpecification, UnixDomainSocket, 
WorkerCapabilities,
+  WorkerConnectionSpec, WorkerEnvironment}
+import org.apache.spark.udf.worker.core.{WorkerConnection, WorkerHandle, 
WorkerSecurityScope,
+  WorkerSession}
+import org.apache.spark.udf.worker.core.direct.{DirectWorkerException, 
DirectWorkerProcess,
   DirectWorkerTimeoutException}
 
 /**
@@ -71,8 +72,14 @@ class DirectWorkerDispatcherSuite
   private def directWorker(runner: ProcessCallable): DirectWorker =
     
DirectWorker.newBuilder().setRunner(runner).setProperties(udsProperties).build()
 
+  // TestDirectWorkerDispatcher requires the spec to advertise 
BIDIRECTIONAL_STREAMING.
+  private def bidiCapabilities: WorkerCapabilities = 
WorkerCapabilities.newBuilder()
+    
.addSupportedCommunicationPatterns(UDFProtoCommunicationPattern.BIDIRECTIONAL_STREAMING)
+    .build()
+
   private def specWithRunner(runner: ProcessCallable): UDFWorkerSpecification =
     UDFWorkerSpecification.newBuilder()
+      .setCapabilities(bidiCapabilities)
       .setDirect(directWorker(runner))
       .build()
 
@@ -81,11 +88,16 @@ class DirectWorkerDispatcherSuite
       env: WorkerEnvironment): UDFWorkerSpecification =
     UDFWorkerSpecification.newBuilder()
       .setEnvironment(env)
+      .setCapabilities(bidiCapabilities)
       .setDirect(directWorker(runner))
       .build()
 
   private var dispatcher: TestDirectWorkerDispatcher = _
 
+  // Finalization message for session.close(); these lifecycle-only tests never
+  // drive the protocol, so an empty Cancel suffices.
+  private val emptyCancel: () => Cancel = () => Cancel.getDefaultInstance
+
   override def afterEach(): Unit = {
     if (dispatcher != null) {
       dispatcher.close()
@@ -94,36 +106,46 @@ class DirectWorkerDispatcherSuite
     super.afterEach()
   }
 
-  // Narrow the publicly-typed WorkerSession returned by `createSession` back
-  // down to StubWorkerSession in one place, with a descriptive failure if
-  // the cast is ever wrong, so individual tests don't scatter `asInstanceOf`
-  // (which would throw ClassCastException rather than a useful message).
-  private def createStubSession(): StubWorkerSession =
-    dispatcher.createSession(None) match {
-      case stub: StubWorkerSession => stub
+  // The dispatcher returns a NoOpWorkerSession over the dispatcher's
+  // workerProcess; tests reach through to workerHandle (a
+  // DirectWorkerProcess in this configuration) via the session's
+  // package-private field directly.
+  private def createStubSession(): WorkerSession =
+    dispatcher.createSession(None)
+
+  // Centralised cast: every test in this suite uses 
TestDirectWorkerDispatcher,
+  // which always returns a DirectWorkerProcess. Wrapping in a helper with a
+  // clear failure message (a) deduplicates the boilerplate cast at every
+  // assertion site, and (b) means a future dispatcher that returns a
+  // different WorkerHandle implementation fails with a recognisable test
+  // assertion rather than a bare ClassCastException.
+  private def workerProcess(s: WorkerSession): DirectWorkerProcess =
+    s.workerHandle match {
+      case dp: DirectWorkerProcess => dp
       case other => fail(
-        s"Expected StubWorkerSession, got ${other.getClass.getSimpleName}")
+        s"Expected DirectWorkerProcess, got ${other.getClass.getSimpleName}")
     }
 
-  // The whole suite uses UDS as the only transport, so reaching past the
-  // generic WorkerConnection abstraction to read the socket path is fine.
+  // The whole suite uses the SocketFileConnection test fixture, so reaching
+  // past the generic WorkerConnection abstraction to read the socket path
+  // is fine.
   private def udsPath(w: DirectWorkerProcess): String = w.connection match {
-    case uds: UnixSocketWorkerConnection => uds.socketPath
+    case sfc: SocketFileConnection => sfc.socketPath
     case other => fail(
-      s"Expected UnixSocketWorkerConnection, got 
${other.getClass.getSimpleName}")
+      s"Expected SocketFileConnection, got ${other.getClass.getSimpleName}")
   }
 
   test("creates a worker and session") {
     dispatcher = new TestDirectWorkerDispatcher(specWithRunner(defaultRunner))
 
     val session = createStubSession()
-    val worker = session.workerProcess
+    val worker = workerProcess(session)
 
     assert(worker.isAlive, "worker should be alive after creation")
     assert(worker.activeSessions == 1, "should have 1 active session")
     assert(new File(udsPath(worker)).exists(), "socket file should exist")
 
-    session.close()
+    session.close(emptyCancel)
     assert(worker.activeSessions == 0, "should have 0 sessions after close")
   }
 
@@ -131,7 +153,7 @@ class DirectWorkerDispatcherSuite
     dispatcher = new TestDirectWorkerDispatcher(specWithRunner(defaultRunner))
 
     val threads = 8
-    val sessions = new 
java.util.concurrent.ConcurrentLinkedQueue[StubWorkerSession]()
+    val sessions = new 
java.util.concurrent.ConcurrentLinkedQueue[WorkerSession]()
     val startGate = new java.util.concurrent.CountDownLatch(1)
     val doneGate = new java.util.concurrent.CountDownLatch(threads)
     val errors = new java.util.concurrent.ConcurrentLinkedQueue[Throwable]()
@@ -157,7 +179,7 @@ class DirectWorkerDispatcherSuite
     assert(sessions.size == threads, "expected one session per thread")
 
     val sessionList = sessions.asScala.toList
-    val workerObjects = sessionList.map(_.workerProcess)
+    val workerObjects = sessionList.map(workerProcess)
     assert(workerObjects.distinct.length == threads,
       "each session should have its own DirectWorkerProcess")
     // Object-identity is not sufficient on its own: a future regression
@@ -168,7 +190,7 @@ class DirectWorkerDispatcherSuite
     assert(socketPaths.distinct.length == threads,
       s"each worker should have its own socket path, got $socketPaths")
 
-    sessionList.foreach(_.close())
+    sessionList.foreach(_.close(emptyCancel))
   }
 
   test("close shuts down all workers via SIGTERM") {
@@ -177,11 +199,11 @@ class DirectWorkerDispatcherSuite
     val session1 = createStubSession()
     val session2 = createStubSession()
 
-    val worker1 = session1.workerProcess
-    val worker2 = session2.workerProcess
+    val worker1 = workerProcess(session1)
+    val worker2 = workerProcess(session2)
 
-    session1.close()
-    session2.close()
+    session1.close(emptyCancel)
+    session2.close(emptyCancel)
     dispatcher.close()
     dispatcher = null
 
@@ -217,17 +239,18 @@ class DirectWorkerDispatcherSuite
       .setGracefulTerminationTimeoutMs(500)
       .build()
     val spec = UDFWorkerSpecification.newBuilder()
+      .setCapabilities(bidiCapabilities)
       .setDirect(DirectWorker.newBuilder()
         .setRunner(runner).setProperties(shortGracefulProps).build())
       .build()
     dispatcher = new TestDirectWorkerDispatcher(spec)
 
     val session = createStubSession()
-    val worker = session.workerProcess
+    val worker = workerProcess(session)
     assert(worker.process.isAlive, "worker should be alive before close")
 
     val closeStart = System.nanoTime()
-    session.close()
+    session.close(emptyCancel)
     val closeElapsedMs = (System.nanoTime() - closeStart) / 1000000L
 
     assert(!worker.process.isAlive,
@@ -241,13 +264,13 @@ class DirectWorkerDispatcherSuite
     dispatcher = new TestDirectWorkerDispatcher(specWithRunner(defaultRunner))
 
     val session = createStubSession()
-    val worker = session.workerProcess
+    val worker = workerProcess(session)
     val socketFile = new File(udsPath(worker))
 
     assert(worker.process.isAlive, "worker should be alive before session 
close")
     assert(socketFile.exists(), "socket file should exist before session 
close")
 
-    session.close()
+    session.close(emptyCancel)
 
     // The session-close path is synchronous: SIGTERM is sent and the process
     // is reaped before `close` returns.
@@ -261,7 +284,7 @@ class DirectWorkerDispatcherSuite
     dispatcher = new TestDirectWorkerDispatcher(specWithRunner(defaultRunner))
 
     val sessions = (1 to 4).map(_ => createStubSession())
-    val workers = sessions.map(_.workerProcess)
+    val workers = sessions.map(workerProcess)
 
     val barrier = new java.util.concurrent.CyclicBarrier(sessions.size + 1)
     val errors = new java.util.concurrent.ConcurrentLinkedQueue[Throwable]()
@@ -270,7 +293,7 @@ class DirectWorkerDispatcherSuite
       val t = new Thread(() => {
         try {
           barrier.await()
-          s.close()
+          s.close(emptyCancel)
         } catch {
           case t: Throwable => errors.add(t)
         }
@@ -312,12 +335,8 @@ class DirectWorkerDispatcherSuite
     val releaseLatch = new java.util.concurrent.CountDownLatch(1)
     val capturedWorkers =
       new java.util.concurrent.ConcurrentLinkedQueue[DirectWorkerProcess]()
-    val racing = new 
DirectUnixSocketWorkerDispatcher(specWithRunner(defaultRunner)) {
-      override protected def createConnection(
-          socketPath: String): UnixSocketWorkerConnection =
-        new SocketFileConnection(socketPath)
-      override protected def createSessionForWorker(
-          worker: DirectWorkerProcess): WorkerSession = {
+    val racing = new TestDirectWorkerDispatcher(specWithRunner(defaultRunner)) 
{
+      override protected def afterWorkerRegistered(worker: 
DirectWorkerProcess): Unit = {
         capturedWorkers.add(worker)
         readyLatch.countDown()
         // Block here so dispatcher.close() runs while createSession is in
@@ -325,7 +344,6 @@ class DirectWorkerDispatcherSuite
         if (!releaseLatch.await(30, java.util.concurrent.TimeUnit.SECONDS)) {
           fail("releaseLatch never fired -- test orchestration broken")
         }
-        new StubWorkerSession(worker)
       }
     }
     try {
@@ -344,7 +362,7 @@ class DirectWorkerDispatcherSuite
       // Wait for thread A to have published the worker and entered the
       // blocking override.
       assert(readyLatch.await(10, java.util.concurrent.TimeUnit.SECONDS),
-        "createSession thread never reached createSessionForWorker")
+        "createSession thread never reached afterWorkerRegistered")
 
       val closeThread = new Thread(() => racing.close(), "close-racer")
       closeThread.start()
@@ -406,16 +424,17 @@ class DirectWorkerDispatcherSuite
       .setGracefulTerminationTimeoutMs(60000)
       .build()
     val spec = UDFWorkerSpecification.newBuilder()
+      .setCapabilities(bidiCapabilities)
       .setDirect(DirectWorker.newBuilder()
         .setRunner(defaultRunner).setProperties(oversizedProps).build())
       .build()
     dispatcher = new TestDirectWorkerDispatcher(spec)
 
     val session = createStubSession()
-    assert(session.workerProcess.gracefulTimeoutMs == 30000L,
-      s"graceful timeout should be capped at 30000ms, " +
-        s"got ${session.workerProcess.gracefulTimeoutMs}")
-    session.close()
+    val worker = workerProcess(session)
+    assert(worker.gracefulTimeoutMs == 30000L,
+      s"graceful timeout should be capped at 30000ms, got 
${worker.gracefulTimeoutMs}")
+    session.close(emptyCancel)
   }
 
   test("worker-provided init timeout is capped at the engine-side maximum") {
@@ -425,6 +444,7 @@ class DirectWorkerDispatcherSuite
       .setInitializationTimeoutMs(60000)
       .build()
     val spec = UDFWorkerSpecification.newBuilder()
+      .setCapabilities(bidiCapabilities)
       .setDirect(DirectWorker.newBuilder()
         .setRunner(defaultRunner).setProperties(oversizedProps).build())
       .build()
@@ -451,8 +471,8 @@ class DirectWorkerDispatcherSuite
     // Drive one createSession so a worker (and therefore the socket dir) is
     // observable via the UDS connection's path.
     val session = createStubSession()
-    val socketDir: Path = new 
File(udsPath(session.workerProcess)).toPath.getParent
-    session.close()
+    val socketDir: Path = new 
File(udsPath(workerProcess(session))).toPath.getParent
+    session.close(emptyCancel)
 
     val view = Files.getFileAttributeView(socketDir, 
classOf[PosixFileAttributeView])
     // Skip explicitly on non-POSIX filesystems rather than silently pass,
@@ -469,10 +489,10 @@ class DirectWorkerDispatcherSuite
   test("socket directory is removed after dispatcher.close") {
     dispatcher = new TestDirectWorkerDispatcher(specWithRunner(defaultRunner))
     val session = createStubSession()
-    val socketDir = new 
File(udsPath(session.workerProcess)).toPath.getParent.toFile
+    val socketDir = new 
File(udsPath(workerProcess(session))).toPath.getParent.toFile
     assert(socketDir.exists(),
       s"socket directory $socketDir should exist while a session is open")
-    session.close()
+    session.close(emptyCancel)
 
     dispatcher.close()
     dispatcher = null
@@ -483,19 +503,18 @@ class DirectWorkerDispatcherSuite
 
   // -- Error-path tests 
-------------------------------------------------------
 
-  test("worker is cleaned up when createSessionForWorker throws") {
-    // A dispatcher whose createSessionForWorker always throws. The spawned
-    // worker must be terminated rather than leaked until dispatcher.close().
+  test("worker is cleaned up when newSession throws") {
+    // A dispatcher whose newSession always throws. The spawned worker
+    // must be terminated rather than leaked until dispatcher.close().
     var capturedWorker: DirectWorkerProcess = null
     val failingDispatcher =
-      new DirectUnixSocketWorkerDispatcher(specWithRunner(defaultRunner)) {
-        override protected def createConnection(
-            socketPath: String): UnixSocketWorkerConnection =
-          new SocketFileConnection(socketPath)
-        override protected def createSessionForWorker(
-            worker: DirectWorkerProcess): WorkerSession = {
-          capturedWorker = worker
+      new TestDirectWorkerDispatcher(specWithRunner(defaultRunner)) {
+        override protected def newSession(
+            workerHandle: WorkerHandle,
+            connection: WorkerConnection): WorkerSession =
           throw new RuntimeException("session creation failed")
+        override protected def afterWorkerRegistered(w: DirectWorkerProcess): 
Unit = {
+          capturedWorker = w
         }
       }
 
@@ -541,25 +560,22 @@ class DirectWorkerDispatcherSuite
       s"expected UDS-only error, got: ${ex.getMessage}")
   }
 
-  test("socket file is cleaned up when createConnection throws") {
+  test("socket file is cleaned up when newConnection throws") {
     val capturedSocketPaths = new 
java.util.concurrent.ConcurrentLinkedQueue[String]()
     val failingDispatcher =
-      new DirectUnixSocketWorkerDispatcher(specWithRunner(defaultRunner)) {
-        override protected def createConnection(
-            socketPath: String): UnixSocketWorkerConnection = {
-          capturedSocketPaths.add(socketPath)
+      new TestDirectWorkerDispatcher(specWithRunner(defaultRunner)) {
+        override protected def newConnection(address: String): 
WorkerConnection = {
+          capturedSocketPaths.add(address)
           throw new RuntimeException("connection creation failed")
         }
-        override protected def createSessionForWorker(
-            worker: DirectWorkerProcess): WorkerSession =
-          new StubWorkerSession(worker)
       }
     try {
       val ex = intercept[RuntimeException] {
         failingDispatcher.createSession(None)
       }
       assert(ex.getMessage.contains("connection creation failed"))
-      assert(capturedSocketPaths.size == 1, "createConnection should have been 
called once")
+      assert(capturedSocketPaths.size == 1,
+        "newConnection should have been called once")
       val socketPath = capturedSocketPaths.peek()
       assert(!new File(socketPath).exists(),
         s"socket file $socketPath should have been cleaned up")
@@ -608,6 +624,7 @@ class DirectWorkerDispatcherSuite
       .setInitializationTimeoutMs(500)
       .build()
     val spec = UDFWorkerSpecification.newBuilder()
+      .setCapabilities(bidiCapabilities)
       .setDirect(DirectWorker.newBuilder()
         .setRunner(hangingRunner).setProperties(shortInitProps).build())
       .build()
@@ -638,7 +655,7 @@ class DirectWorkerDispatcherSuite
     dispatcher = new TestDirectWorkerDispatcher(specWithEnv(env = env))
 
     val session = dispatcher.createSession(None)
-    session.close()
+    session.close(emptyCancel)
 
     assert(!markerFile.exists(),
       "installation should not run when verification succeeds")
@@ -658,7 +675,7 @@ class DirectWorkerDispatcherSuite
     dispatcher = new TestDirectWorkerDispatcher(specWithEnv(env = env))
 
     val session = dispatcher.createSession(None)
-    session.close()
+    session.close(emptyCancel)
 
     assert(markerFile.exists(),
       "installation should run when verification fails")
@@ -677,7 +694,7 @@ class DirectWorkerDispatcherSuite
     dispatcher = new TestDirectWorkerDispatcher(specWithEnv(env = env))
 
     val session = dispatcher.createSession(None)
-    session.close()
+    session.close(emptyCancel)
 
     assert(markerFile.exists(),
       "installation should run when no verification is defined")
@@ -710,14 +727,8 @@ class DirectWorkerDispatcherSuite
       .addCommand("sleep 30").build()
     val env = 
WorkerEnvironment.newBuilder().setInstallation(slowInstall).build()
     val shortTimeoutDispatcher =
-      new DirectUnixSocketWorkerDispatcher(specWithEnv(env = env)) {
+      new TestDirectWorkerDispatcher(specWithEnv(env = env)) {
         override protected def callableTimeoutMs: Long = 500L
-        override protected def createConnection(
-            socketPath: String): UnixSocketWorkerConnection =
-          new SocketFileConnection(socketPath)
-        override protected def createSessionForWorker(
-            worker: DirectWorkerProcess): WorkerSession =
-          new StubWorkerSession(worker)
       }
     try {
       val ex = intercept[DirectWorkerTimeoutException] {
@@ -743,8 +754,8 @@ class DirectWorkerDispatcherSuite
       .build()
     dispatcher = new TestDirectWorkerDispatcher(specWithEnv(env = env))
 
-    val s1 = dispatcher.createSession(None); s1.close()
-    val s2 = dispatcher.createSession(None); s2.close()
+    val s1 = dispatcher.createSession(None); s1.close(emptyCancel)
+    val s2 = dispatcher.createSession(None); s2.close(emptyCancel)
 
     val src = scala.io.Source.fromFile(counterFile)
     val lines = try src.getLines().toList finally src.close()
@@ -799,7 +810,7 @@ class DirectWorkerDispatcherSuite
       s"installation should run exactly once under concurrent createSession, " 
+
         s"but ran ${lines.size} time(s)")
 
-    sessions.asScala.foreach(_.close())
+    sessions.asScala.foreach(_.close(emptyCancel))
     counterFile.delete()
   }
 
@@ -847,14 +858,8 @@ class DirectWorkerDispatcherSuite
           s"echo invoked >> ${counterFile.getAbsolutePath}; sleep 30").build())
       .build()
     val timeoutDispatcher =
-      new DirectUnixSocketWorkerDispatcher(specWithEnv(env = env)) {
+      new TestDirectWorkerDispatcher(specWithEnv(env = env)) {
         override protected def callableTimeoutMs: Long = 500L
-        override protected def createConnection(
-            socketPath: String): UnixSocketWorkerConnection =
-          new SocketFileConnection(socketPath)
-        override protected def createSessionForWorker(
-            worker: DirectWorkerProcess): WorkerSession =
-          new StubWorkerSession(worker)
       }
     try {
       val first = intercept[DirectWorkerTimeoutException] {
@@ -916,7 +921,7 @@ class DirectWorkerDispatcherSuite
     dispatcher = new TestDirectWorkerDispatcher(specWithEnv(env = env))
 
     val session = dispatcher.createSession(None)
-    session.close()
+    session.close(emptyCancel)
 
     assert(!cleanupMarker.exists(),
       "cleanup should not run until dispatcher is closed")
diff --git 
a/udf/worker/core/src/test/scala/org/apache/spark/udf/worker/core/TestDirectWorkerHelpers.scala
 
b/udf/worker/core/src/test/scala/org/apache/spark/udf/worker/core/TestDirectWorkerHelpers.scala
index 1843024e9fb8..ca7c86a82a26 100644
--- 
a/udf/worker/core/src/test/scala/org/apache/spark/udf/worker/core/TestDirectWorkerHelpers.scala
+++ 
b/udf/worker/core/src/test/scala/org/apache/spark/udf/worker/core/TestDirectWorkerHelpers.scala
@@ -16,64 +16,226 @@
  */
 package org.apache.spark.udf.worker.core
 
-import java.io.File
+import java.io.{File, IOException}
+import java.nio.file.{Files, FileVisitResult, Path, SimpleFileVisitor}
+import java.nio.file.attribute.{BasicFileAttributes, PosixFilePermissions}
 
-import org.apache.spark.udf.worker.{Init, UDFWorkerSpecification}
-import org.apache.spark.udf.worker.core.direct.{
-  DirectUnixSocketWorkerDispatcher, DirectWorkerProcess,
-  DirectWorkerSession}
+import org.apache.spark.udf.worker.{Cancel, DataRequest, DataResponse, Finish, 
FinishResponse,
+  Init, InitResponse, UDFProtoCommunicationPattern, UDFWorkerSpecification, 
WorkerConnectionSpec}
+import org.apache.spark.udf.worker.core.direct.{DirectWorkerDispatcher, 
DirectWorkerException,
+  DirectWorkerTimeoutException}
+import 
org.apache.spark.udf.worker.core.direct.DirectWorkerDispatcher.SOCKET_POLL_INTERVAL_MS
 
 /**
- * A [[WorkerConnection]] test implementation that considers the
- * connection active as long as the socket file exists on disk.
- * Inherits socket-file deletion from
- * [[UnixSocketWorkerConnection.close]].
+ * A [[WorkerConnection]] test implementation that treats the connection as
+ * active as long as the worker's UDS file exists on disk. The socket file is
+ * removed on close.
+ *
+ * Suitable for dispatcher-lifecycle tests that don't need to drive a wire
+ * protocol -- e.g. verifying that a worker spec spawns a real worker process
+ * that creates the expected socket.
  */
-class SocketFileConnection(socketPath: String)
-    extends UnixSocketWorkerConnection(socketPath) {
+class SocketFileConnection(val socketPath: String) extends WorkerConnection {
   override def isActive: Boolean = new File(socketPath).exists()
+  override def close(): Unit = {
+    val f = new File(socketPath)
+    if (f.exists()) f.delete()
+  }
 }
 
 /**
- * A stub [[DirectWorkerSession]] for process-lifecycle tests
- * that don't need actual data transmission.
- *
- * TODO: [[cancel]] is a no-op here. Once a concrete
- * [[DirectWorkerSession]] with real data-plane wiring lands, add
- * tests exercising cancel() in particular: cancel from a
- * different thread than process(), cancel after process() has
- * returned, and cancel before init (should be a no-op). See the
- * thread-safety contract in the docstring on
- * [[org.apache.spark.udf.worker.core.WorkerSession.cancel]].
+ * No-op [[WorkerSession]] for lifecycle-only tests. All protocol methods are
+ * inert (init/finish report empty responses); tests that exercise the actual
+ * wire protocol use a concrete transport-backed session.
  */
-class StubWorkerSession(workerProcess: DirectWorkerProcess)
-    extends DirectWorkerSession(workerProcess) {
-
-  override protected def doInit(message: Init): Unit = {}
+class NoOpWorkerSession(
+    workerHandle: WorkerHandle,
+    logger: WorkerLogger = WorkerLogger.NoOp)
+  extends WorkerSession(workerHandle, logger) {
 
+  override protected def doInit(message: Init): InitResponse = 
InitResponse.getDefaultInstance
   override protected def doProcess(
-      input: Iterator[Array[Byte]]
-  ): Iterator[Array[Byte]] =
-    Iterator.empty
-
-  override def cancel(): Unit = {}
+      input: Iterator[DataRequest],
+      finish: () => Finish): Iterator[DataResponse] =
+    Iterator.empty[DataResponse]
+  override protected def doClose(cancel: () => Cancel): Termination = {
+    // Settle the clean terminal so close() does not fall through to its
+    // contract-violation recovery path. A no-op session has no in-flight work,
+    // so the cancel thunk is never needed.
+    completeTerminal(Termination.Finished(FinishResponse.getDefaultInstance))
+    settledTermination
+  }
 }
 
 /**
- * A [[DirectUnixSocketWorkerDispatcher]] subclass for testing
- * that uses a socket-file connection and stub sessions instead
- * of a real protocol implementation.
+ * A concrete [[DirectWorkerDispatcher]] for tests that spawns workers over a
+ * Unix domain socket and yields [[SocketFileConnection]]s / 
[[NoOpWorkerSession]]s,
+ * so lifecycle tests exercise the dispatcher's spawn / wait-for-ready / 
cleanup
+ * machinery without driving a wire protocol. Allocates a private 0700 socket
+ * directory at construction; each worker is given a UDS path inside it.
+ *
+ * Reusable across modules: callers in `sql/core` (or anywhere with a test-jar
+ * dependency on `udf-worker-core`) can drop this in for tests that only need 
to
+ * verify a worker spec produces a spawnable worker.
  */
-class TestDirectWorkerDispatcher(spec: UDFWorkerSpecification)
-    extends DirectUnixSocketWorkerDispatcher(spec) {
-
-  override protected def createConnection(
-      socketPath: String
-  ): UnixSocketWorkerConnection =
-    new SocketFileConnection(socketPath)
-
-  override protected def createSessionForWorker(
-      worker: DirectWorkerProcess
-  ): WorkerSession =
-    new StubWorkerSession(worker)
+class TestDirectWorkerDispatcher(
+    workerSpec: UDFWorkerSpecification,
+    logger: WorkerLogger = WorkerLogger.NoOp)
+  extends DirectWorkerDispatcher(workerSpec, logger) {
+
+  // Upper bound on the rename-on-collision retry loop in newEndpointAddress.
+  private val MAX_SOCKET_LEAF_RETRIES = 16
+
+  // The private 0700 socket directory, created in [[initialize]] (after the
+  // base class has validated the spec) and removed in [[closeTransport]].
+  private lazy val socketDir: Path = createPrivateTempDirectory()
+
+  override protected def initialize(): Unit = {
+    super.initialize()
+    // Force the lazy val now so the directory is created (and any failure
+    // surfaces) at construction time, after spec validation has passed.
+    socketDir
+  }
+
+  /**
+   * Returns the UDS path the worker should bind. Uses a short 16-hex-char leaf
+   * (the worker's full UUID still travels via `--id`) to stay within the
+   * 108-byte UDS `sun_path` limit.
+   */
+  override protected def newEndpointAddress(workerId: String): String = {
+    val short = workerId.replace("-", "").take(16)
+    var candidate = socketDir.resolve(s"w-$short.sock")
+    var suffix = 0
+    while (Files.exists(candidate) && suffix < MAX_SOCKET_LEAF_RETRIES) {
+      suffix += 1
+      candidate = socketDir.resolve(s"w-$short-$suffix.sock")
+    }
+    if (Files.exists(candidate)) {
+      throw new IllegalStateException(
+        s"could not allocate a free UDS path under $socketDir after " +
+          s"$MAX_SOCKET_LEAF_RETRIES retries (truncated id=$short)")
+    }
+    candidate.toString
+  }
+
+  override protected def waitForReady(
+      address: String,
+      process: Process,
+      outputFile: File): Unit = {
+    val file = new File(address)
+    // At least one poll so very small initTimeouts don't trip a premature
+    // timeout before the worker has any chance to create the socket.
+    val maxAttempts = math.max(1, (initTimeoutMs / 
SOCKET_POLL_INTERVAL_MS).toInt)
+    var attempts = 0
+    while (!file.exists() && attempts < maxAttempts) {
+      if (!process.isAlive) throwWorkerExitedBeforeSocket(process, address, 
outputFile)
+      Thread.sleep(SOCKET_POLL_INTERVAL_MS)
+      attempts += 1
+    }
+    if (!file.exists()) {
+      if (process.isAlive) {
+        DirectWorkerDispatcher.destroyForciblyAndReap(
+          process, logger, s"init timeout $address")
+        val tail = readOutputTail(outputFile)
+        throw new DirectWorkerTimeoutException(
+          s"Worker did not create socket at $address within 
${initTimeoutMs}ms\n$tail")
+      } else {
+        // Worker exited after the last poll without creating the socket;
+        // prefer the exit-code message over the ambiguous "did not create".
+        throwWorkerExitedBeforeSocket(process, address, outputFile)
+      }
+    }
+  }
+
+  override protected def cleanupEndpointAddress(address: String): Unit = {
+    Files.deleteIfExists(new File(address).toPath)
+  }
+
+  override protected def closeTransport(): Unit = {
+    if (!Files.exists(socketDir)) return
+    // Recursive post-order delete: today socketDir contains only socket files
+    // at the top level, but a future change that namespaces workers into
+    // subdirectories should not silently leak them.
+    Files.walkFileTree(socketDir, new SimpleFileVisitor[Path] {
+      override def visitFile(file: Path, attrs: BasicFileAttributes): 
FileVisitResult = {
+        try Files.deleteIfExists(file) catch { case _: IOException => () }
+        FileVisitResult.CONTINUE
+      }
+      override def postVisitDirectory(dir: Path, exc: IOException): 
FileVisitResult = {
+        try Files.deleteIfExists(dir) catch { case _: IOException => () }
+        FileVisitResult.CONTINUE
+      }
+    })
+  }
+
+  // `spec` is the same object as the `workerSpec` field but passed explicitly:
+  // at the point this runs (parent constructor body), `this` is only partially
+  // constructed and reading subclass fields is unsafe.
+  override protected def validateTransportSupport(spec: 
UDFWorkerSpecification): Unit = {
+    val props = spec.getDirect.getProperties
+    require(props.hasConnection,
+      "DirectWorker.properties.connection must be set")
+    val conn = props.getConnection
+    require(conn.getTransportCase == 
WorkerConnectionSpec.TransportCase.UNIX_DOMAIN_SOCKET,
+      "TestDirectWorkerDispatcher requires UNIX domain socket transport, " +
+        s"got ${conn.getTransportCase}")
+    require(spec.hasCapabilities,
+      "TestDirectWorkerDispatcher requires WorkerCapabilities declaring " +
+        "BIDIRECTIONAL_STREAMING in supported_communication_patterns")
+    val patterns = spec.getCapabilities.getSupportedCommunicationPatternsList
+    val supportsBidi = (0 until patterns.size()).exists { i =>
+      patterns.get(i) == UDFProtoCommunicationPattern.BIDIRECTIONAL_STREAMING
+    }
+    require(supportsBidi,
+      "TestDirectWorkerDispatcher requires BIDIRECTIONAL_STREAMING " +
+        "in WorkerCapabilities.supported_communication_patterns")
+  }
+
+  override protected def newConnection(address: String): WorkerConnection =
+    new SocketFileConnection(address)
+
+  override protected def newSession(
+      workerHandle: WorkerHandle,
+      connection: WorkerConnection): WorkerSession =
+    new NoOpWorkerSession(workerHandle, logger)
+
+  private def throwWorkerExitedBeforeSocket(
+      process: Process,
+      address: String,
+      outputFile: File): Nothing = {
+    val tail = readOutputTail(outputFile)
+    throw new DirectWorkerException(
+      s"Worker exited with code ${process.exitValue()} " +
+        s"before creating socket at $address\n$tail")
+  }
+
+  /**
+   * Creates a private (owner-only, 0700) temp directory for worker sockets.
+   * On POSIX filesystems the permissions are applied atomically at creation;
+   * the non-POSIX branch tightens best-effort after creation.
+   */
+  private def createPrivateTempDirectory(): Path = {
+    val attr = PosixFilePermissions.asFileAttribute(
+      PosixFilePermissions.fromString("rwx------"))
+    try {
+      Files.createTempDirectory("spark-udf-worker", attr)
+    } catch {
+      case _: UnsupportedOperationException =>
+        val dir = Files.createTempDirectory("spark-udf-worker")
+        val f = dir.toFile
+        // Bit-wise AND (NOT &&): all six setters must run even if an earlier
+        // one returns false, so the final permission state matches owner-only.
+        val applied =
+          f.setReadable(false, false) & f.setWritable(false, false) &
+            f.setExecutable(false, false) & f.setReadable(true, true) &
+            f.setWritable(true, true) & f.setExecutable(true, true)
+        if (!applied) {
+          logger.warn(
+            s"Could not fully restrict permissions on $dir; socket " +
+              s"directory may be accessible to other local users on this " +
+              s"filesystem")
+        }
+        dir
+    }
+  }
 }
diff --git 
a/udf/worker/core/src/test/scala/org/apache/spark/udf/worker/core/WorkerSessionSuite.scala
 
b/udf/worker/core/src/test/scala/org/apache/spark/udf/worker/core/WorkerSessionSuite.scala
new file mode 100644
index 000000000000..37380d0d7765
--- /dev/null
+++ 
b/udf/worker/core/src/test/scala/org/apache/spark/udf/worker/core/WorkerSessionSuite.scala
@@ -0,0 +1,270 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.spark.udf.worker.core
+
+// scalastyle:off funsuite
+import org.scalatest.funsuite.AnyFunSuite
+
+import org.apache.spark.udf.worker.{Cancel, CancelResponse, DataRequest, 
DataResponse,
+  ExecutionError, Finish, FinishResponse, Init, InitResponse}
+import org.apache.spark.udf.worker.core.WorkerSession.SessionState
+
+/**
+ * Unit tests for the [[WorkerSession]] state machine, exercised through a fake
+ * subclass that drives the protocol-event edges directly with no transport.
+ *
+ * The concrete transport-backed sessions test this same machine end-to-end 
over
+ * the wire; these tests pin the transport-agnostic base-class contract on its
+ * own: call ordering, terminal settling (first-wins), the [[Termination]]
+ * mapping, and the `close()` finalizer (release-once, invalidation, and the
+ * doClose post-condition guard).
+ */
+class WorkerSessionSuite extends AnyFunSuite {
+// scalastyle:on funsuite
+
+  /** A [[WorkerHandle]] that counts the lifecycle callbacks made against it. 
*/
+  private final class RecordingHandle extends WorkerHandle {
+    var released = 0
+    var invalidated = 0
+    override def id: String = "test-worker"
+    override def markInvalid(): Unit = invalidated += 1
+    override def releaseSession(): Unit = released += 1
+  }
+
+  /**
+   * A [[WorkerSession]] whose protocol hooks are supplied by the test and 
whose
+   * protected state-machine primitives are re-exposed, so a test can drive the
+   * machine (settle terminals, CAS edges) without a real worker.
+   */
+  private final class FakeWorkerSession(
+      handle: WorkerHandle = new RecordingHandle,
+      onInit: FakeWorkerSession => InitResponse = _ => 
InitResponse.getDefaultInstance,
+      onProcess: (FakeWorkerSession, Iterator[DataRequest], () => Finish) =>
+        Iterator[DataResponse] = (_, _, _) => Iterator.empty[DataResponse],
+      onCloseHook: (FakeWorkerSession, () => Cancel) => Termination =
+        (self, _) => {
+          self.settle(Termination.Finished(FinishResponse.getDefaultInstance))
+          self.term
+        })
+    extends WorkerSession(handle, WorkerLogger.NoOp) {
+
+    var terminalSettledCount = 0
+
+    override protected def doInit(message: Init): InitResponse = onInit(this)
+    override protected def doProcess(
+        input: Iterator[DataRequest], finish: () => Finish): 
Iterator[DataResponse] =
+      onProcess(this, input, finish)
+    override protected def doClose(cancel: () => Cancel): Termination = 
onCloseHook(this, cancel)
+    override protected def onTerminalSettled(termination: Termination): Unit =
+      terminalSettledCount += 1
+
+    // Re-expose the protected primitives so the test can drive the machine.
+    def state: SessionState = currentState
+    def cas(expect: SessionState, update: SessionState): Boolean =
+      compareAndSetState(expect, update)
+    def settle(t: Termination): Boolean = completeTerminal(t)
+    def term: Termination = settledTermination
+  }
+
+  test("init returns the worker InitResponse and advances Created -> 
Initialized") {
+    val resp = InitResponse.getDefaultInstance
+    val s = new FakeWorkerSession(onInit = _ => resp)
+    assert(s.state == SessionState.Created)
+    assert(s.init(Init.getDefaultInstance) eq resp)
+    assert(s.state == SessionState.Initialized)
+  }
+
+  test("init must be called exactly once") {
+    val s = new FakeWorkerSession()
+    s.init(Init.getDefaultInstance)
+    val ex = intercept[IllegalStateException](s.init(Init.getDefaultInstance))
+    assert(ex.getMessage.contains("exactly once"))
+  }
+
+  test("process before init is rejected") {
+    val s = new FakeWorkerSession()
+    val ex = intercept[IllegalStateException](s.process(Iterator.empty))
+    assert(ex.getMessage.contains("before init"))
+  }
+
+  test("process advances to Streaming and may only be called once") {
+    val s = new FakeWorkerSession()
+    s.init(Init.getDefaultInstance)
+    s.process(Iterator.empty)
+    assert(s.state == SessionState.Streaming)
+    val ex = intercept[IllegalStateException](s.process(Iterator.empty))
+    assert(ex.getMessage.contains("already been called"))
+  }
+
+  test("a failed doInit settles a terminal and rejects a later process") {
+    val err = ExecutionError.getDefaultInstance
+    val s = new FakeWorkerSession(onInit = self => {
+      self.settle(Termination.Failed(err))
+      throw new RuntimeException("init boom")
+    })
+    val initEx = intercept[RuntimeException](s.init(Init.getDefaultInstance))
+    assert(initEx.getMessage.contains("init boom"))
+    assert(s.term == Termination.Failed(err))
+    val ex = intercept[IllegalStateException](s.process(Iterator.empty))
+    assert(ex.getMessage.contains("terminated"))
+  }
+
+  test("close returns the Termination produced by doClose") {
+    val resp = FinishResponse.getDefaultInstance
+    val s = new FakeWorkerSession(onCloseHook = (self, _) => {
+      self.settle(Termination.Finished(resp))
+      self.term
+    })
+    assert(s.close() == Termination.Finished(resp))
+  }
+
+  test("close releases the worker exactly once and leaves a salvageable worker 
valid") {
+    val h = new RecordingHandle
+    val s = new FakeWorkerSession(handle = h)  // default doClose settles 
Finished
+    s.close()
+    s.close()
+    assert(h.released == 1)
+    assert(h.invalidated == 0)
+  }
+
+  test("close marks a transport-failed worker invalid") {
+    val h = new RecordingHandle
+    val s = new FakeWorkerSession(handle = h, onCloseHook = (self, _) => {
+      self.settle(Termination.TransportFailed(new RuntimeException("transport 
down")))
+      self.term
+    })
+    s.close()
+    assert(h.invalidated == 1)
+    assert(h.released == 1)
+  }
+
+  test("close leaves a worker salvageable after an execution Failed") {
+    val h = new RecordingHandle
+    val err = ExecutionError.getDefaultInstance
+    // A Failed terminal is typically a user-code (UDF) error, not a worker 
fault,
+    // so the worker stays reusable: markInvalid must NOT be called.
+    val s = new FakeWorkerSession(handle = h, onCloseHook = (self, _) => {
+      self.settle(Termination.Failed(err))
+      self.term
+    })
+    assert(s.close() == Termination.Failed(err))
+    assert(h.invalidated == 0)
+    assert(h.released == 1)
+  }
+
+  test("close enforces the doClose terminal post-condition") {
+    val h = new RecordingHandle
+    // doClose returns a Termination without settling any terminal -- a 
subclass
+    // contract violation. close() must settle a TransportFailed terminal, 
treat
+    // the worker as unsalvageable, and return a Termination consistent with 
that
+    // settled state rather than the (untrustworthy) value doClose produced.
+    val s = new FakeWorkerSession(handle = h,
+      onCloseHook = (_, _) => 
Termination.Finished(FinishResponse.getDefaultInstance))
+    val termination = s.close()
+    assert(s.term.isInstanceOf[Termination.TransportFailed])
+    assert(termination.isInstanceOf[Termination.TransportFailed])
+    assert(h.invalidated == 1)
+  }
+
+  test("close converts an unexpected internal doClose failure into 
TransportFailed") {
+    val h = new RecordingHandle
+    val boom = new RuntimeException("internal doClose failure")
+    // doClose throws an unexpected internal / transport-level error (e.g. a
+    // failed wire write). close() must not propagate it: it settles
+    // TransportFailed, returns it, and marks the worker unsalvageable.
+    val s = new FakeWorkerSession(handle = h, onCloseHook = (_, _) => throw 
boom)
+    assert(s.close() == Termination.TransportFailed(boom))
+    assert(h.invalidated == 1)
+    assert(h.released == 1)
+  }
+
+  test("close swallows a non-fatal worker-handle failure") {
+    val throwingHandle = new WorkerHandle {
+      override def id: String = "boom"
+      override def markInvalid(): Unit = throw new 
RuntimeException("markInvalid boom")
+      override def releaseSession(): Unit = throw new 
RuntimeException("releaseSession boom")
+    }
+    val cause = new RuntimeException("transport down")
+    // A TransportFailed terminal makes the worker unsalvageable, so close()
+    // attempts both markInvalid() and releaseSession() -- here both throw.
+    // close() must swallow them and still return the settled termination.
+    val s = new FakeWorkerSession(handle = throwingHandle, onCloseHook = 
(self, _) => {
+      self.settle(Termination.TransportFailed(cause))
+      self.term
+    })
+    assert(s.close() == Termination.TransportFailed(cause))
+  }
+
+  test("close still releases the worker when markInvalid throws") {
+    // markInvalid throws but releaseSession succeeds: close() must swallow the
+    // markInvalid failure and still attempt releaseSession, so the ref count 
is
+    // not leaked. A TransportFailed terminal makes the worker unsalvageable, 
so
+    // markInvalid is reached on the way to releaseSession.
+    var released = 0
+    val handle = new WorkerHandle {
+      override def id: String = "throws-on-invalidate"
+      override def markInvalid(): Unit = throw new 
RuntimeException("markInvalid boom")
+      override def releaseSession(): Unit = released += 1
+    }
+    val cause = new RuntimeException("transport down")
+    val s = new FakeWorkerSession(handle = handle, onCloseHook = (self, _) => {
+      self.settle(Termination.TransportFailed(cause))
+      self.term
+    })
+    assert(s.close() == Termination.TransportFailed(cause))
+    assert(released == 1, "releaseSession must be attempted even when 
markInvalid throws")
+  }
+
+  test("completeTerminal is first-wins and runs onTerminalSettled once") {
+    val first = Termination.Finished(FinishResponse.getDefaultInstance)
+    val s = new FakeWorkerSession()
+    assert(s.settle(first))
+    assert(!s.settle(Termination.Cancelled(CancelResponse.getDefaultInstance)))
+    assert(s.term == first)
+    assert(s.terminalSettledCount == 1)
+  }
+
+  test("compareAndSetState drives non-terminal edges and loses to a settled 
terminal") {
+    val s = new FakeWorkerSession()
+    s.init(Init.getDefaultInstance)
+    assert(s.cas(SessionState.Initialized, SessionState.Streaming))
+    assert(s.state == SessionState.Streaming)
+    assert(s.settle(Termination.Failed(ExecutionError.getDefaultInstance)))
+    assert(!s.cas(SessionState.Streaming, SessionState.Finishing))
+  }
+
+  test("settledTermination maps each terminal to its Termination") {
+    val fin = FinishResponse.getDefaultInstance
+    val can = CancelResponse.getDefaultInstance
+    val err = ExecutionError.getDefaultInstance
+    val cause = new RuntimeException("transport down")
+    def termFor(t: Termination): Termination = {
+      val s = new FakeWorkerSession()
+      s.settle(t)
+      s.term
+    }
+    assert(termFor(Termination.Finished(fin)) == Termination.Finished(fin))
+    assert(termFor(Termination.Cancelled(can)) == Termination.Cancelled(can))
+    assert(termFor(Termination.Failed(err)) == Termination.Failed(err))
+    assert(termFor(Termination.TransportFailed(cause)) == 
Termination.TransportFailed(cause))
+  }
+
+  test("settledTermination throws before a terminal is settled") {
+    val s = new FakeWorkerSession()
+    intercept[IllegalStateException](s.term)
+  }
+}


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

Reply via email to