You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@texera.apache.org by gi...@apache.org on 2026/08/23 04:36:09 UTC
(texera) 01/01: fix(amber): report a malformed PVE websocket handshake (#7852)
This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a commit to branch gh-readonly-queue/main/pr-7852-f8e3e9ef082f69b6409371502706b0af9b4d62ff
in repository https://gitbox.apache.org/repos/asf/texera.git
commit c9a0ddf7ae18f9fd9ecc75732c0b5e93f0203794
Author: Xinyuan Lin <xi...@uci.edu>
AuthorDate: Sun Aug 23 04:35:46 2026 +0000
fix(amber): report a malformed PVE websocket handshake (#7852)
### What changes were proposed in this PR?
`PveWebsocketResource.onOpen` read the handshake parameters in its
prologue, outside the `Future` that owns both the `catch` arm that turns
a failure into an `[ERR]` line and the pump that writes to the socket. A
handshake missing `cuid`, carrying an empty value list for it, or
carrying a non-numeric one threw out of `onOpen` itself, ahead of both —
so the endpoint's own error contract could not see it:
```
Before: ?pveName=env&action=install -> NumberFormatException out of onOpen -> socket closes, nothing sent
After: ?pveName=env&action=install -> [ERR] Missing required parameter: cuid -> __DONE__
```
The client side makes that silent close worse than a lost message:
`computing-unit-selection.component.ts` clears `isInstalling`/`isLocked`
only when `onmessage` sees `__DONE__`, and there is no `onclose`
handler, so the pip modal stayed locked on "installing" with an empty
log until the user navigated away.
The three reads move inside the `try`, so a malformed handshake now
travels the same path as any other failure — an `[ERR]` line followed by
the sentinel. A `requiredParam` helper rejects an absent key, an empty
value list, and a blank value alike; the last one is reachable from the
wire, since `?pveName=` arrives as `[""]` and used to resolve to a venv
directory named `" "` rather than failing.
| Handshake | Before | After |
| --- | --- | --- |
| no `cuid` | NPE out of `onOpen`, socket closes empty | `[ERR] Missing
required parameter: cuid` then `__DONE__` |
| `cuid` present, value list empty | IndexOutOfBoundsException, same |
same as above |
| `cuid=abc` | NumberFormatException, same | `[ERR] Invalid cuid: abc`
then `__DONE__` |
| `pveName=` (blank) | resolved a venv path with a whitespace name |
`[ERR] Missing required parameter: pveName` then `__DONE__` |
Well-formed handshakes are untouched: the parsed `cuid`/`pveName` reach
`PveManager` exactly as before.
### Any related issues, documentation, discussions?
Follows #7847, which added `PveWebsocketResourceSpec` and deliberately
left this path unpinned so that fixing it would not have to fight a test
that had cemented it.
### How was this PR tested?
Four cases added to `PveWebsocketResourceSpec` — `cuid` absent, `cuid`
with an empty value list, a non-numeric `cuid`, and a blank `pveName` —
each asserting the client receives both the `[ERR]` line and the
sentinel, and that the pump stops rather than parking in `queue.take()`.
```bash
sbt "WorkflowExecutionService/testOnly org.apache.texera.web.resource.pythonvirtualenvironment.PveWebsocketResourceSpec"
```
`Tests: succeeded 7, failed 0`. Written before the fix: all four failed
against the old source with the escaping exception itself
(`NullPointerException`, `IndexOutOfBoundsException`,
`NumberFormatException`) rather than an assertion mismatch, which is the
defect stated as a test. Hoisting the reads back out of the `Future`
fails all four again.
Mutation testing on the new code — 4 mutants, 4 killed: dropping the
`values == null` arm, dropping the `isEmpty` arm, hardcoding `cuid` into
the message instead of interpolating the parameter name, and swallowing
a bad `cuid` as `0`.
`PveResourceSpec`, the other suite in the package, is unaffected —
locally it reports 6 pre-existing failures that all come from the
Windows interpreter path (`Scripts\python.exe`), unrelated to the
endpoint.
```bash
sbt scalafmtCheckAll "scalafixAll --check"
```
Clean.
### Was this PR authored or co-authored using generative AI tooling?
Generated-by: Claude Code (Opus 5)
---
.../PveWebsocketResource.scala | 32 +++++++--
.../PveWebsocketResourceSpec.scala | 82 +++++++++++++++++++++-
2 files changed, 109 insertions(+), 5 deletions(-)
diff --git a/amber/src/main/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveWebsocketResource.scala b/amber/src/main/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveWebsocketResource.scala
index 34654c7604..81bf1a1085 100644
--- a/amber/src/main/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveWebsocketResource.scala
+++ b/amber/src/main/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveWebsocketResource.scala
@@ -34,19 +34,43 @@ import scala.concurrent.ExecutionContext.Implicits.global
@ServerEndpoint("/wsapi/pve")
class PveWebsocketResource {
+ /**
+ * Reads a single-valued handshake parameter, rejecting an absent key, an empty value list
+ * and a blank value alike: all three are malformed handshakes, and reading one straight
+ * through would either throw from the reader itself or hand `PveManager` an empty name to
+ * resolve into a directory path.
+ */
+ private def requiredParam(
+ params: java.util.Map[String, java.util.List[String]],
+ name: String
+ ): String = {
+ val values = params.get(name)
+ if (values == null || values.isEmpty || values.get(0).isBlank) {
+ throw new IllegalArgumentException(s"Missing required parameter: $name")
+ }
+ values.get(0)
+ }
+
@OnOpen
def onOpen(session: Session): Unit = {
val params = session.getRequestParameterMap
- val cuid = params.get("cuid").get(0).toInt
- val pveName = params.get("pveName").get(0)
- val action = params.getOrDefault("action", java.util.List.of("create")).get(0)
-
val queue = new LinkedBlockingQueue[String]()
Future {
try {
+ // These reads belong inside the try, not in the prologue of `onOpen`: up there a
+ // malformed handshake throws before either this catch arm or the pump below exists,
+ // and the client is left with a socket that closes carrying neither an `[ERR]` line
+ // nor the `__DONE__` sentinel it waits for.
+ val cuidParam = requiredParam(params, "cuid")
+ val cuid = cuidParam.toIntOption.getOrElse(
+ throw new IllegalArgumentException(s"Invalid cuid: $cuidParam")
+ )
+ val pveName = requiredParam(params, "pveName")
+ val action = params.getOrDefault("action", java.util.List.of("create")).get(0)
+
action match {
case "create" =>
PveManager.createNewPve(cuid, queue, pveName)
diff --git a/amber/src/test/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveWebsocketResourceSpec.scala b/amber/src/test/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveWebsocketResourceSpec.scala
index ad1f48fa03..7ae0bbf2e3 100644
--- a/amber/src/test/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveWebsocketResourceSpec.scala
+++ b/amber/src/test/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveWebsocketResourceSpec.scala
@@ -45,7 +45,12 @@ import scala.jdk.CollectionConverters._
* it closes the socket and then leaves the loop rather than parking in `queue.take()`
* on a queue nothing will ever fill again,
* - the failure path is symmetric with the happy one — the catch arm reports the action's
- * own exception message and the `finally` still emits the sentinel, and
+ * own exception message and the `finally` still emits the sentinel,
+ * - a malformed handshake is a failure of that same shape and not a special case: an
+ * absent, empty or blank `cuid` or `pveName`, or a `cuid` that is not an Int, produces
+ * an `[ERR]` line and then the sentinel. The reads have to stay inside the Future for
+ * that — hoisted back out of it they throw from `onOpen` itself, ahead of both the catch
+ * arm and the pump, and the client sees a socket that closes with nothing on it, and
* - `action` selects the branch, with the cuid and pveName parsed off the handshake
* handed to `PveManager` unswapped.
*
@@ -240,4 +245,79 @@ class PveWebsocketResourceSpec extends AnyFlatSpec with Matchers with MockFactor
f.sentLines.last shouldBe "__DONE__"
f.assertPumpStopped()
}
+
+ it should "report a handshake with no cuid rather than dying on the socket" in {
+ // The frontend always sends `cuid`, so this is a hand-rolled or stale handshake. With the
+ // read hoisted out of the Future the absent key threw a NullPointerException from `onOpen`
+ // -- ahead of both the catch arm and the pump -- so the client got no error line, no
+ // sentinel, and nothing to tell this apart from a socket that simply went away.
+ val f = fixture(
+ "pveName" -> "ws-no-cuid",
+ "action" -> "install"
+ )
+
+ new PveWebsocketResource().onOpen(f.session)
+ f.awaitClose()
+
+ f.sentLines shouldBe List("[ERR] Missing required parameter: cuid", "__DONE__")
+ f.assertPumpStopped()
+ }
+
+ it should "report a cuid whose value list is empty" in {
+ // A present-but-empty value list is the other half of the presence guard: `.get(0)` on it
+ // raises IndexOutOfBoundsException rather than the NullPointerException the absent key
+ // above raises, so dropping either half of `values == null || values.isEmpty` has to fail
+ // one of these two tests.
+ val f = fixtureOf(
+ java.util.Map.of(
+ "cuid",
+ java.util.List.of[String](),
+ "pveName",
+ java.util.List.of("ws-empty-cuid"),
+ "action",
+ java.util.List.of("install")
+ )
+ )
+
+ new PveWebsocketResource().onOpen(f.session)
+ f.awaitClose()
+
+ f.sentLines shouldBe List("[ERR] Missing required parameter: cuid", "__DONE__")
+ f.assertPumpStopped()
+ }
+
+ it should "report a non-numeric cuid" in {
+ // Present and non-blank but not an Int, so it fails past the presence guard, in the parse.
+ // The message has to name the offending value: `NumberFormatException`'s own wording says
+ // "For input string" and never mentions which parameter it came from.
+ val f = fixture(
+ "cuid" -> "not-a-number",
+ "pveName" -> "ws-bad-cuid",
+ "action" -> "install"
+ )
+
+ new PveWebsocketResource().onOpen(f.session)
+ f.awaitClose()
+
+ f.sentLines shouldBe List("[ERR] Invalid cuid: not-a-number", "__DONE__")
+ f.assertPumpStopped()
+ }
+
+ it should "report a blank pveName, naming the parameter that is missing" in {
+ // Blank is what a servlet container hands back for `?pveName=`, and it is as malformed as
+ // an absent key: there is no PVE whose name is whitespace, and `PveManager` would resolve
+ // it to a venv directory named "" under the user's. Asserting on pveName rather than cuid
+ // also pins that the message interpolates the parameter's name instead of hardcoding one.
+ val f = fixture(
+ "cuid" -> "424244",
+ "pveName" -> " ",
+ "action" -> "install"
+ )
+
+ new PveWebsocketResource().onOpen(f.session)
+ f.awaitClose()
+
+ f.sentLines shouldBe List("[ERR] Missing required parameter: pveName", "__DONE__")
+ f.assertPumpStopped()
+ }
}