mirror of
https://github.com/sbt/sbt.git
synced 2026-09-06 01:26:55 +02:00
[2.x] fix: Don't freeze the server when a terminal-properties response is malformed or slow (#9526)
One attached client answering the sbt/terminalpropertiesquery request badly, or slower than 5 seconds, could freeze the whole server for every client: - The response handler dropped malformed responses (response.foreach(buffer.put)) instead of falling back to a default like every sibling handler, so the updater thread waiting on the queue timed out with the properties reference still null. - getProperties(block = true) waits while properties is null, but nothing completes it after the updater's one-shot 5-second poll times out: waiters woke from the notify, saw null, and waited again with no updater outstanding. The 1-second lastUpdate throttle also let a caller start waiting with no query in flight at all, and the wait condition was checked outside the pending monitor, losing wakeups that fired between the check and the wait. - A response arriving after the poll timeout was delivered into a queue that was never deregistered, so it neither set properties nor woke anyone. The threads that block here include the command loop iterating channels and the fast-track thread handling attach and cancel, so one bad or briefly-stalled client wedged prompts, Ctrl-C, and command dispatch server-wide until that client disconnected. The properties response handler now falls back to a default like its siblings; the updater expires its query on timeout, deregistering it (rescuing a response that raced in) and completing properties with the empty default so waiters always make progress; and both wait sites hold the pending monitor and gate on the query in flight. waitForPending gets the same treatment, since it seeds lazy vals whose initialization otherwise parks every thread touching them. Regression test: a raw-protocol session that answers every server request with a result of the wrong shape attaches interactively; a well-behaved batch client must then still be served twice. Fails on develop with a three-minute timeout (the server is frozen), passes with this change. A unit spec pins the expiry semantics, including the late-response rescue. Co-authored-by: Claude Fable 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Fable 5
parent
6698589c59
commit
1bde3d23a9
@@ -754,21 +754,31 @@ final class NetworkChannel(
|
||||
pending.set(true)
|
||||
val queue = VirtualTerminal.sendTerminalPropertiesQuery(term.name, jsonRpcRequest)
|
||||
val update: Runnable = () => {
|
||||
queue.poll(5, java.util.concurrent.TimeUnit.SECONDS) match {
|
||||
case null =>
|
||||
case t => properties.set(t)
|
||||
}
|
||||
pending.synchronized {
|
||||
lastUpdate.set(Deadline.now)
|
||||
pending.set(false)
|
||||
pending.notifyAll()
|
||||
try {
|
||||
queue.poll(5, java.util.concurrent.TimeUnit.SECONDS) match {
|
||||
case null =>
|
||||
VirtualTerminal.expireTerminalPropertiesQuery(term.name, queue) match {
|
||||
case Some(late) => properties.set(late)
|
||||
case None => Util.ignoreResult(properties.compareAndSet(null, empty))
|
||||
}
|
||||
case t => properties.set(t)
|
||||
}
|
||||
} finally {
|
||||
pending.synchronized {
|
||||
lastUpdate.set(Deadline.now)
|
||||
pending.set(false)
|
||||
pending.notifyAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
new Thread(update, s"network-terminal-${term.name}-update") {
|
||||
setDaemon(true)
|
||||
}.start()
|
||||
}
|
||||
while (block && properties.get == null) pending.synchronized(pending.wait())
|
||||
// The updater clears pending inside this monitor before notifying.
|
||||
pending.synchronized {
|
||||
while (block && properties.get == null && pending.get) pending.wait()
|
||||
}
|
||||
()
|
||||
} else throw new InterruptedException
|
||||
}
|
||||
@@ -798,7 +808,7 @@ final class NetworkChannel(
|
||||
else
|
||||
withThread(
|
||||
{
|
||||
if (pending.get) pending.synchronized(pending.wait())
|
||||
pending.synchronized { while (pending.get) pending.wait() }
|
||||
Option(properties.get).map(f).getOrElse(false)
|
||||
},
|
||||
false
|
||||
|
||||
@@ -84,6 +84,19 @@ object VirtualTerminal {
|
||||
jsonRpcRequest(id, terminalCapabilities, query)
|
||||
queue
|
||||
}
|
||||
private[sbt] def expireTerminalPropertiesQuery(
|
||||
channelName: String,
|
||||
queue: ArrayBlockingQueue[TerminalPropertiesResponse],
|
||||
): Option[TerminalPropertiesResponse] = {
|
||||
import scala.jdk.CollectionConverters.*
|
||||
pendingTerminalProperties.asScala.collectFirst {
|
||||
case (k @ (`channelName`, _), q) if q eq queue => k
|
||||
} match {
|
||||
case Some(k) if pendingTerminalProperties.remove(k) != null => Option(queue.poll())
|
||||
// The response handler won the removal: its put is imminent, wait it out briefly.
|
||||
case _ => Option(queue.poll(100, java.util.concurrent.TimeUnit.MILLISECONDS))
|
||||
}
|
||||
}
|
||||
private[sbt] def cancelRequests(name: String): Unit = {
|
||||
import scala.jdk.CollectionConverters.*
|
||||
pendingTerminalCapabilities.asScala.foreach {
|
||||
@@ -191,7 +204,10 @@ object VirtualTerminal {
|
||||
r.result.flatMap(Converter.fromJson[TerminalPropertiesResponse](_).toOption)
|
||||
pendingTerminalProperties.remove((callback.name, r.id)) match {
|
||||
case null =>
|
||||
case buffer => response.foreach(buffer.put)
|
||||
case buffer =>
|
||||
buffer.put(
|
||||
response.getOrElse(TerminalPropertiesResponse(0, 0, false, false, false, false))
|
||||
)
|
||||
}
|
||||
case r if pendingTerminalCapabilities.get((callback.name, r.id)) != null =>
|
||||
val response =
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* sbt
|
||||
* Copyright 2023, Scala center
|
||||
* Copyright 2011 - 2022, Lightbend, Inc.
|
||||
* Copyright 2008 - 2010, Mark Harrah
|
||||
* Licensed under Apache License 2.0 (see LICENSE)
|
||||
*/
|
||||
|
||||
package sbt.internal.server
|
||||
|
||||
import sbt.protocol.TerminalPropertiesResponse
|
||||
import verify.BasicTestSuite
|
||||
|
||||
object VirtualTerminalSpec extends BasicTestSuite:
|
||||
test("expiring an unanswered properties query deregisters it"):
|
||||
val queue = VirtualTerminal.sendTerminalPropertiesQuery("expire-test", (_, _, _) => ())
|
||||
assert(VirtualTerminal.expireTerminalPropertiesQuery("expire-test", queue).isEmpty)
|
||||
// Once expired, the registration is gone: a channel-wide cancel must not touch the queue.
|
||||
VirtualTerminal.cancelRequests("expire-test")
|
||||
assert(queue.poll() == null)
|
||||
|
||||
test("expiring rescues a response that raced in before deregistration"):
|
||||
val queue = VirtualTerminal.sendTerminalPropertiesQuery("expire-race", (_, _, _) => ())
|
||||
val r = TerminalPropertiesResponse(80, 24, true, true, true, true)
|
||||
queue.put(r)
|
||||
assert(VirtualTerminal.expireTerminalPropertiesQuery("expire-race", queue) == Some(r))
|
||||
end VirtualTerminalSpec
|
||||
Reference in New Issue
Block a user