Mill Sandboxing

Task Sandboxing

In order to help manage your build, Mill performs some rudimentary filesystem sandboxing to keep different tasks and modules from interfering with each other. This tries to ensure your tasks only read and write from their designated .dest/ folders, which are unique to each task and thus guaranteed not to collide with the filesystem operations of other tasks that may be occurring in parallel.

Task.dest

The standard way of working with a task’s .dest/ folder is through the Task.dest property. This is available within any task, and gives you access to the out/<module-names>/<task-name>.dest/ folder to use. The .dest/ folder for each task is lazily initialized when Task.dest is referenced and used:

build.mill (download, browse)
package build
import mill._
import mill.define.BuildCtx

object foo extends Module {
  def tDestTask = Task { println(Task.dest.toString) }
}
> ./mill foo.tDestTask
.../out/foo/tDestTask.dest

Filesystem Read/Write Checks

Mill enforces limits on what you can read and write while a task is executing. In general, a task may only write to its own Task.dest folder, and may only read from the `PathRef`s provided to it from upstream tasks

def bannedWriteTask = Task {
  os.write(BuildCtx.workspaceRoot / "banned-path", "hello")
}
> ./mill bannedWriteTask
error: ...Writing to banned-path not allowed during execution of `bannedWriteTask`
def bannedReadTask = Task {
  os.read(BuildCtx.workspaceRoot / "build.mill")
}
> ./mill bannedReadTask
error: ...Reading from build.mill not allowed during execution of `bannedReadTask`
def bannedReadTask2 = Task {
  os.read(BuildCtx.workspaceRoot / "out/foo/tDestTask.json")
}
> ./mill bannedReadTask2
error: ...Reading from out/foo/tDestTask.json not allowed during execution of `bannedReadTask2`

Furthermore, code outside of a task that runs during module initialization can only read from disk if wrapped in a BuildCtx.watchValue block. This ensures that such reads are tracked by Mill’s --watch and cache-invalidation logic.

val listed = BuildCtx.watchValue(os.list(BuildCtx.workspaceRoot).map(_.last))

Disabling Task Sandboxing

You can disable Mill’s filesystem read/write limitations via BuildCtx.withFilesystemCheckerDisabled. Note that this bypasses the best-practices that Mill tries to enforce on your tasks and may result in strange bugs around caching, parallelism, and invalidation, so you shouldn’t do this unless you really know what you are doing:

def bannedWriteTaskOverridden = Task {
  BuildCtx.withFilesystemCheckerDisabled {
    os.write(BuildCtx.workspaceRoot / "banned-path", "hello")
    println(os.read(BuildCtx.workspaceRoot / "banned-path"))
  }
}
> ./mill bannedWriteTaskOverridden
hello

You can also disable it globally by passing in --no-filesystem-checker to Mill:

> ./mill --no-filesystem-checker bannedReadTask

os.pwd redirection

Task os.pwd redirection

Mill also redirects the os.pwd property from OS-Lib, such that that also points towards a running task’s own .dest/ folder

def osPwdTask = Task { println(os.pwd.toString) }
> ./mill osPwdTask
.../out/osPwdTask.dest

The redirection of os.pwd applies to os.proc, os.call, and os.spawn methods as well. In the example below, we can see the python3 subprocess we spawn prints its os.getcwd(), which is our osProcTask.dest/ sandbox folder:

def osProcTask = Task {
  println(os.call(("python3", "-c", "import os; print(os.getcwd())"), cwd = Task.dest).out.trim())
}
> ./mill osProcTask
.../out/osProcTask.dest

Non-task os.pwd redirection

Lastly, there is the possibily of calling os.pwd outside of a task. When outside of a task there is no .dest/ folder associated, so instead Mill will redirect os.pwd towards an empty sandbox/ folder in out/mill-daemon/…​:

val externalPwd = os.pwd
def externalPwdTask = Task { println(externalPwd.toString) }
> ./mill externalPwdTask
.../out/mill-daemon/sandbox

Test Sandboxing

Mill also creates sandbox folders for test suites to run in. Consider the following build with two modules foo and bar, and their test suites foo.test and bar.test:

build.mill (download, browse)
package build
import mill._, javalib._

trait MyModule extends JavaModule {
  object test extends JavaTests with TestModule.Junit4
}

object foo extends MyModule {
  def moduleDeps = Seq(bar)
}

object bar extends MyModule

For the sake of the example, both test modules contain tests that exercise the logic in their corresponding non-test module, but also do some basic filesystem operations at the same time, writing out a generated.html file and then reading it:

foo/src/foo/Foo.java (browse)
package foo;

public class Foo {
  public static String generateHtml(String text) {
    return "<h1>" + text + "</h1>";
  }
}
foo/test/src/foo/FooTests.java (browse)
package foo;

import static org.junit.Assert.assertEquals;

import java.nio.file.*;
import org.junit.Test;

public class FooTests {
  @Test
  public void simple() throws Exception {
    String result = Foo.generateHtml("hello");
    Path path = Paths.get("generated.html");
    Files.write(path, result.getBytes());
    assertEquals("<h1>hello</h1>", Files.readString(path));
  }
}
bar/src/bar/Bar.java (browse)
package bar;

public class Bar {
  public static String generateHtml(String text) {
    return "<p>" + text + "</p>";
  }
}
bar/test/src/bar/BarTests.java (browse)
package bar;

import static org.junit.Assert.assertEquals;

import java.nio.file.*;
import org.junit.Test;

public class BarTests {
  @Test
  public void simple() throws Exception {
    String result = Bar.generateHtml("world");
    Path path = Paths.get("generated.html");
    Files.write(path, result.getBytes());
    assertEquals("<p>world</p>", Files.readString(path));
  }
}

Both test suites can be run via

> ./mill __.test

Without sandboxing, due to the tests running in parallel, there is a race condition: it’s possible that FooTests may write the file, BarTests write over it, before FooTests reads the output from BarTests. That would cause non-deterministic flaky failures in your test suite that can be very difficult to debug and resolve.

With Mill’s test sandboxing, each test runs in a separate folder: the .dest folder of the respective task and module. For example:

  • foo.test runs in out/foo/test/testForked.dest/

  • bar.test runs in out/bar/test/testForked.dest/

As a result, each test’s generated.html file is written to its own dedicated working directory, without colliding with each other on disk:

> find . | grep generated.html
.../out/foo/test/testForked.dest/sandbox/generated.html
.../out/bar/test/testForked.dest/sandbox/generated.html

> cat out/foo/test/testForked.dest/sandbox/generated.html
<h1>hello</h1>

> cat out/bar/test/testForked.dest/sandbox/generated.html
<p>world</p>

As each test suite runs in a different working directory by default, naive usage reading and writing to the filesystem does not cause tests to interefere with one another, which helps keep tests stable and deterministic even when run in parallel

Escaping the Test Sandbox

Within a test, you can use the MILL_WORKSPACE_ROOT environment variable to access the workspace root directory:

object qux extends JavaModule {
  object test extends JavaTests with TestModule.Junit4
}
qux/src/qux/Qux.java (browse)
package qux;

public class Qux {
  public static String generateHtml(String text) {
    return "<h1>" + text + "</h1>";
  }
}
qux/test/src/qux/QuxTests.java (browse)
package qux;

import static org.junit.Assert.assertEquals;

import java.nio.file.*;
import java.util.stream.Collectors;
import org.junit.Test;

public class QuxTests {
  @Test
  public void simple() throws Exception {
    String workspaceRoot = System.getenv("MILL_WORKSPACE_ROOT");

    for (Path subpath : Files.list(Paths.get(workspaceRoot)).collect(Collectors.toList())) {
      String result = Qux.generateHtml(subpath.getFileName().toString());
      Path tmppath = Paths.get(subpath.getFileName() + ".html");
      Files.write(tmppath, result.getBytes());
      assertEquals("<h1>" + subpath.getFileName() + "</h1>", Files.readString(tmppath));
    }
  }
}
> ./mill qux.test


> find . | grep .html
...
.../out/qux/test/testForked.dest/sandbox/foo.html

> cat out/qux/test/testForked.dest/sandbox/foo.html
<h1>foo</h1>

Disabling Test Sandboxing

Test sandboxing can be disabled by setting def testSandboxWorkingDir = false on your test module, which makes your unit tests run in the root of your project workspace, with full access to the project files on disk.

Disabling the test sandbox can be useful when migrating existing projects, as other build tools like Maven, Gradle or SBT do not enforce such sandboxes by default, so many tests are written with the assumption they run in the project root. But it is advisable to use or adopt the default config of def testSandboxWorkingDir = true where possible as that will ensure that inputs to tests are properly tracked by the build tool, so features like cli/flags.adoc#_watch_w or Selective Test Execution are able to precisely determine when a test needs to be run and when it can be skipped.

Limitations

Mill’s approach to filesystem sandboxing is designed to avoid accidental interference between different Mill tasks and tests. It is not designed to block intentional misbehavior, and tasks are always able to traverse the filesystem and do whatever they want. Furthermore, Mill’s redirection of os.pwd does not apply to java.io or java.nio APIs, which are outside of Mill’s control.

However, by setting os.pwd to safe sandbox folders, and performing some basic checks on os.* read/write operations, we hope to minimize the cases where someone accidentally causes issues with their build by doing the wrong thing. The escape hatches of BuildCtx.workspaceRoot, MILL_WORKSPACE_ROOT, or BuildCtx.withFilesystemCheckerDisabled are provided to be used in cases you do need to do something unusual, but usage of them should be minimized if possible as they may result in --watch or selective.test not missing some implicit dependencies between your tasks and source files on disk.