Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Acquire a lock on the out dir in order to run tasks / commands #3599

Merged
merged 6 commits into from
Oct 8, 2024
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions integration/feature/output-directory/resources/build.mill
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,16 @@ import mill.scalalib._

object `package` extends RootModule with ScalaModule {
def scalaVersion = scala.util.Properties.versionNumberString

def hello = Task {
"Hello from hello task"
}

def blockWhileExists(path: os.Path) = Task.Command[String] {
if (!os.exists(path))
os.write(path, Array.emptyByteArray)
while (os.exists(path))
Thread.sleep(100L)
"Blocking command done"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package mill.integration

import mill.testkit.UtestIntegrationTestSuite
import utest._

import java.io.ByteArrayOutputStream
import java.util.concurrent.CountDownLatch

import scala.concurrent.Await
import scala.concurrent.duration.Duration

object OutputDirectoryLockTests extends UtestIntegrationTestSuite {

def tests: Tests = Tests {
test("basic") - integrationTest { tester =>
import tester._
val signalFile = workspacePath / "do-wait"
System.err.println("Spawning blocking task")
val blocksFuture = evalAsync(("show", "blockWhileExists", "--path", signalFile), check = true)
while (!os.exists(signalFile) && !blocksFuture.isCompleted)
Thread.sleep(100L)
if (os.exists(signalFile))
System.err.println("Blocking task is running")
else {
System.err.println("Failed to run blocking task")
Predef.assert(blocksFuture.isCompleted)
blocksFuture.value.get.get
}

val testCommand: os.Shellable = ("show", "hello")
val testMessage = "Hello from hello task"

System.err.println("Evaluating task without lock")
val noLockRes = eval(("--no-build-lock", testCommand), check = true)
assert(noLockRes.out.contains(testMessage))

System.err.println("Evaluating task without waiting for lock (should fail)")
val noWaitRes = eval(("--no-wait-for-build-lock", testCommand))
assert(noWaitRes.err.contains("Cannot proceed, another Mill process is running tasks"))

System.err.println("Evaluating task waiting for the lock")

val lock = new CountDownLatch(1)
val stderr = new ByteArrayOutputStream
var success = false
val futureWaitingRes = evalAsync(
testCommand,
stderr = os.ProcessOutput {
val expectedMessage =
"Another Mill process is running tasks, waiting for it to be done..."

(bytes, len) =>
stderr.write(bytes, 0, len)
val output = new String(stderr.toByteArray)
if (output.contains(expectedMessage))
lock.countDown()
},
check = true
)
try {
lock.await()
success = true
} finally {
if (!success) {
System.err.println("Waiting task output:")
System.err.write(stderr.toByteArray)
}
}

System.err.println("Task is waiting for the lock, unblocking it")
os.remove(signalFile)

System.err.println("Blocking task should exit")
val blockingRes = Await.result(blocksFuture, Duration.Inf)
assert(blockingRes.out.contains("Blocking command done"))

System.err.println("Waiting task should be free to proceed")
val waitingRes = Await.result(futureWaitingRes, Duration.Inf)
assert(waitingRes.out.contains(testMessage))
}
}
}
17 changes: 16 additions & 1 deletion main/api/src/mill/api/Logger.scala
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package mill.api

import java.io.{InputStream, PrintStream}

import mill.main.client.lock.{Lock, Locked}

/**
* The standard logging interface of the Mill build tool.
*
Expand All @@ -24,7 +26,7 @@ import java.io.{InputStream, PrintStream}
* but when `show` is used both are forwarded to stderr and stdout is only
* used to display the final `show` output for easy piping.
*/
trait Logger {
trait Logger extends AutoCloseable {
def colored: Boolean

def systemStreams: SystemStreams
Expand Down Expand Up @@ -79,4 +81,17 @@ trait Logger {
try t
finally removePromptLine()
}

def waitForLock(lock: Lock, waitingAllowed: Boolean): Locked = {
val tryLocked = lock.tryLock()
if (tryLocked.isLocked())
tryLocked
else if (waitingAllowed) {
info("Another Mill process is running tasks, waiting for it to be done...")
lock.lock()
} else {
error("Cannot proceed, another Mill process is running tasks")
throw new Exception("Cannot acquire lock on Mill output directory")
}
}
}
4 changes: 4 additions & 0 deletions main/client/src/mill/main/client/OutFiles.java
Original file line number Diff line number Diff line change
Expand Up @@ -57,5 +57,9 @@ public class OutFiles {
*/
final public static String millNoServer = "mill-no-server";

/**
* Lock file used for exclusive access to the Mill output directory
*/
final public static String millLock = "mill-lock";

}
22 changes: 22 additions & 0 deletions main/client/src/mill/main/client/lock/DummyLock.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package mill.main.client.lock;

import java.util.concurrent.locks.ReentrantLock;

class DummyLock extends Lock {

public boolean probe() {
return true;
}

public Locked lock() {
return new DummyTryLocked();
}

public TryLocked tryLock() {
return new DummyTryLocked();
}

@Override
public void close() throws Exception {
}
}
11 changes: 11 additions & 0 deletions main/client/src/mill/main/client/lock/DummyTryLocked.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package mill.main.client.lock;

class DummyTryLocked implements TryLocked {
public DummyTryLocked() {
}

public boolean isLocked(){ return true; }

public void release() throws Exception {
}
}
13 changes: 13 additions & 0 deletions main/client/src/mill/main/client/lock/Lock.java
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,17 @@ public void await() throws Exception {
*/
public abstract boolean probe() throws Exception;
public void delete() throws Exception {}

public static Lock file(String path) throws Exception {
return new FileLock(path);
}

public static Lock memory() {
return new MemoryLock();
}

public static Lock dummy() {
return new DummyLock();
}

}
14 changes: 13 additions & 1 deletion runner/src/mill/runner/MillCliConfig.scala
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,19 @@ case class MillCliConfig(
status at the command line and falls back to the legacy ticker
"""
)
disablePrompt: Flag = Flag()
disablePrompt: Flag = Flag(),
@arg(
hidden = true,
doc =
"""Evaluate tasks / commands without acquiring an exclusive lock on the Mill output directory"""
)
noBuildLock: Flag = Flag(),
@arg(
hidden = true,
doc =
"""Do not wait for an exclusive lock on the Mill output directory to evaluate tasks / commands. Fail if waiting for a lock is needed."""
)
noWaitForBuildLock: Flag = Flag()
)

import mainargs.ParserForClass
Expand Down
19 changes: 15 additions & 4 deletions runner/src/mill/runner/MillMain.scala
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@ import mill.api.{MillException, SystemStreams, WorkspaceRoot, internal}
import mill.bsp.{BspContext, BspServerResult}
import mill.main.BuildInfo
import mill.main.client.{OutFiles, ServerFiles}
import mill.main.client.lock.Lock
import mill.util.{PromptLogger, PrintLogger, Colors}

import java.lang.reflect.InvocationTargetException
import scala.util.control.NonFatal
import scala.util.Using

@internal
object MillMain {
Expand Down Expand Up @@ -209,6 +211,10 @@ object MillMain {
.map(_ => Seq(bspCmd))
.getOrElse(config.leftoverArgs.value.toList)

val out = os.Path(OutFiles.out, WorkspaceRoot.workspaceRoot)
val outLock =
if (config.noBuildLock.value || bspContext.isDefined) Lock.dummy()
else Lock.file((out / OutFiles.millLock).toString)
var repeatForBsp = true
var loopRes: (Boolean, RunnerState) = (false, RunnerState.empty)
while (repeatForBsp) {
Expand All @@ -235,9 +241,16 @@ object MillMain {
colored = colored,
colors = colors
)
try new MillBuildBootstrap(
Using.resources(
logger,
logger.waitForLock(
outLock,
waitingAllowed = !config.noWaitForBuildLock.value
)
) { (_, _) =>
new MillBuildBootstrap(
projectRoot = WorkspaceRoot.workspaceRoot,
output = os.Path(OutFiles.out, WorkspaceRoot.workspaceRoot),
output = out,
home = config.home,
keepGoing = config.keepGoing.value,
imports = config.imports,
Expand All @@ -252,8 +265,6 @@ object MillMain {
config.allowPositional.value,
systemExit = systemExit
).evaluate()
finally {
logger.close()
}
},
colors = colors
Expand Down
48 changes: 48 additions & 0 deletions testkit/src/mill/testkit/IntegrationTester.scala
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ import mill.eval.Evaluator
import mill.resolve.SelectMode
import ujson.Value

import java.util.concurrent.atomic.AtomicInteger

import scala.concurrent.{Future, Promise}
import scala.util.Try

/**
* Helper meant for executing Mill integration tests, which runs Mill in a subprocess
* against a folder with a `build.mill` and project files. Provides APIs such as [[eval]]
Expand Down Expand Up @@ -91,6 +96,49 @@ object IntegrationTester {
)
}

private val evalAsyncCounter = new AtomicInteger
def evalAsync(
cmd: os.Shellable,
env: Map[String, String] = millTestSuiteEnv,
cwd: os.Path = workspacePath,
stdin: os.ProcessInput = os.Pipe,
stdout: os.ProcessOutput = os.Pipe,
stderr: os.ProcessOutput = os.Pipe,
mergeErrIntoOut: Boolean = false,
timeout: Long = -1,
check: Boolean = false,
propagateEnv: Boolean = true,
timeoutGracePeriod: Long = 100
): Future[IntegrationTester.EvalResult] = {
alexarchambault marked this conversation as resolved.
Show resolved Hide resolved

val promise = Promise[IntegrationTester.EvalResult]()

val thread = new Thread(s"mill-test-background-eval-${evalAsyncCounter.incrementAndGet()}") {
setDaemon(true)
override def run(): Unit =
promise.complete {
Try {
eval(
cmd = cmd,
env = env,
cwd = cwd,
stdin = stdin,
stdout = stdout,
stderr = stderr,
mergeErrIntoOut = mergeErrIntoOut,
timeout = timeout,
check = check,
propagateEnv = propagateEnv,
timeoutGracePeriod = timeoutGracePeriod
)
}
}
}
thread.start()

promise.future
}

def millTestSuiteEnv: Map[String, String] = Map("MILL_TEST_SUITE" -> this.getClass().toString())

/**
Expand Down
Loading