Skip to content

Commit 383964d

Browse files
authored
Add flag to register shutdown hooks for os.call and os.spawn APIs, overhaul destroy APIs (#324)
* Backports the functionality from Mill to allow us to register shutdown hooks such that when the parent process terminates the subprocesses are shut down as well. This allows the same logic to be used consistently across all `.call` and `.spawn` invocations * Consolidate `SubProcess#destroy` and `SubProcess#destroyForcibly` into a single `SubProcess#destroy` method which takes some default parameters, allowing the user to choose `async = true` or configure the `shutdownGracePeriod: Long`. * The default behavior of `SubProcess#destroy` has changed to block on the subprocess actually exiting, which I think is more intuitive. The old behavior is available under `destroy(async = true)` Best reviewed with `Hide whitespace`. Added some simple unit tests to assert the new functionality, existing unit tests should assert on existing workflows
1 parent 9e7efc3 commit 383964d

File tree

8 files changed

+587
-284
lines changed

8 files changed

+587
-284
lines changed

Readme.adoc

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1725,7 +1725,9 @@ os.call(cmd: os.Shellable,
17251725
mergeErrIntoOut: Boolean = false,
17261726
timeout: Long = Long.MaxValue,
17271727
check: Boolean = true,
1728-
propagateEnv: Boolean = true): os.CommandResult
1728+
propagateEnv: Boolean = true,
1729+
shutdownGracePeriod: Long = 100,
1730+
destroyOnExit: Boolean = true): os.CommandResult
17291731
----
17301732

17311733
_Also callable via `os.proc(cmd).call(...)`_
@@ -1853,7 +1855,9 @@ os.spawn(cmd: os.Shellable,
18531855
stdout: os.ProcessOutput = os.Pipe,
18541856
stderr: os.ProcessOutput = os.Pipe,
18551857
mergeErrIntoOut: Boolean = false,
1856-
propagateEnv: Boolean = true): os.SubProcess
1858+
propagateEnv: Boolean = true,
1859+
shutdownGracePeriod: Long = 100,
1860+
destroyOnExit: Boolean = true): os.SubProcess
18571861
----
18581862

18591863
_Also callable via `os.proc(cmd).spawn(...)`_

build.sc

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,13 +171,21 @@ object os extends Module {
171171
def forkEnv = super.forkEnv() ++ Map(
172172
"TEST_JAR_WRITER_ASSEMBLY" -> testJarWriter.assembly().path.toString,
173173
"TEST_JAR_READER_ASSEMBLY" -> testJarReader.assembly().path.toString,
174-
"TEST_JAR_EXIT_ASSEMBLY" -> testJarExit.assembly().path.toString
174+
"TEST_JAR_EXIT_ASSEMBLY" -> testJarExit.assembly().path.toString,
175+
"TEST_SPAWN_EXIT_HOOK_ASSEMBLY" -> testSpawnExitHook.assembly().path.toString,
176+
"TEST_SPAWN_EXIT_HOOK_ASSEMBLY2" -> testSpawnExitHook2.assembly().path.toString
175177
)
176178

177179
object testJarWriter extends JavaModule
178180
object testJarReader extends JavaModule
179181
object testJarExit extends JavaModule
182+
object testSpawnExitHook extends ScalaModule{
183+
def scalaVersion = OsJvmModule.this.scalaVersion()
184+
def moduleDeps = Seq(OsJvmModule.this)
185+
}
186+
object testSpawnExitHook2 extends JavaModule
180187
}
188+
181189
object nohometest extends ScalaTests with OsLibTestModule
182190
}
183191

os/src/ProcessOps.scala

Lines changed: 151 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,11 @@
11
package os
22

3-
import java.util.concurrent.{ArrayBlockingQueue, Semaphore, TimeUnit}
43
import collection.JavaConverters._
5-
import scala.annotation.tailrec
64
import java.lang.ProcessBuilder.Redirect
75
import os.SubProcess.InputStream
86
import java.io.IOException
97
import java.util.concurrent.LinkedBlockingQueue
108
import ProcessOps._
11-
import scala.util.Try
129

1310
object call {
1411

@@ -28,7 +25,8 @@ object call {
2825
timeout: Long = -1,
2926
check: Boolean = true,
3027
propagateEnv: Boolean = true,
31-
timeoutGracePeriod: Long = 100
28+
shutdownGracePeriod: Long = 100,
29+
destroyOnExit: Boolean = true
3230
): CommandResult = {
3331
os.proc(cmd).call(
3432
cwd = cwd,
@@ -40,7 +38,40 @@ object call {
4038
timeout = timeout,
4139
check = check,
4240
propagateEnv = propagateEnv,
43-
timeoutGracePeriod = timeoutGracePeriod
41+
shutdownGracePeriod = shutdownGracePeriod,
42+
destroyOnExit = destroyOnExit
43+
)
44+
}
45+
46+
// Bincompat Forwarder
47+
def apply(
48+
cmd: Shellable,
49+
env: Map[String, String],
50+
// Make sure `cwd` only comes after `env`, so `os.call("foo", path)` is a compile error
51+
// since the correct syntax is `os.call(("foo", path))`
52+
cwd: Path,
53+
stdin: ProcessInput,
54+
stdout: ProcessOutput,
55+
stderr: ProcessOutput,
56+
mergeErrIntoOut: Boolean,
57+
timeout: Long,
58+
check: Boolean,
59+
propagateEnv: Boolean,
60+
timeoutGracePeriod: Long
61+
): CommandResult = {
62+
call(
63+
cmd = cmd,
64+
cwd = cwd,
65+
env = env,
66+
stdin = stdin,
67+
stdout = stdout,
68+
stderr = stderr,
69+
mergeErrIntoOut = mergeErrIntoOut,
70+
timeout = timeout,
71+
check = check,
72+
propagateEnv = propagateEnv,
73+
shutdownGracePeriod = timeoutGracePeriod,
74+
destroyOnExit = false
4475
)
4576
}
4677
}
@@ -59,7 +90,9 @@ object spawn {
5990
stdout: ProcessOutput = Pipe,
6091
stderr: ProcessOutput = os.Inherit,
6192
mergeErrIntoOut: Boolean = false,
62-
propagateEnv: Boolean = true
93+
propagateEnv: Boolean = true,
94+
shutdownGracePeriod: Long = 100,
95+
destroyOnExit: Boolean = true
6396
): SubProcess = {
6497
os.proc(cmd).spawn(
6598
cwd = cwd,
@@ -68,7 +101,36 @@ object spawn {
68101
stdout = stdout,
69102
stderr = stderr,
70103
mergeErrIntoOut = mergeErrIntoOut,
71-
propagateEnv = propagateEnv
104+
propagateEnv = propagateEnv,
105+
shutdownGracePeriod = shutdownGracePeriod,
106+
destroyOnExit = destroyOnExit
107+
)
108+
}
109+
110+
// Bincompat Forwarder
111+
def apply(
112+
cmd: Shellable,
113+
// Make sure `cwd` only comes after `env`, so `os.spawn("foo", path)` is a compile error
114+
// since the correct syntax is `os.spawn(("foo", path))`
115+
env: Map[String, String],
116+
cwd: Path,
117+
stdin: ProcessInput,
118+
stdout: ProcessOutput,
119+
stderr: ProcessOutput,
120+
mergeErrIntoOut: Boolean,
121+
propagateEnv: Boolean
122+
): SubProcess = {
123+
spawn(
124+
cmd = cmd,
125+
cwd = cwd,
126+
env = env,
127+
stdin = stdin,
128+
stdout = stdout,
129+
stderr = stderr,
130+
mergeErrIntoOut = mergeErrIntoOut,
131+
propagateEnv = propagateEnv,
132+
shutdownGracePeriod = 100,
133+
destroyOnExit = false
72134
)
73135
}
74136
}
@@ -119,7 +181,7 @@ case class proc(command: Shellable*) {
119181
* fails with a non-zero exit code
120182
* @param propagateEnv disable this to avoid passing in this parent process's
121183
* environment variables to the subprocess
122-
* @param timeoutGracePeriod if the timeout is enabled, how long in milliseconds for the
184+
* @param shutdownGracePeriod if the timeout is enabled, how long in milliseconds for the
123185
* subprocess to gracefully terminate before attempting to
124186
* forcibly kill it
125187
* (-1 for no kill, 0 for always kill immediately)
@@ -138,7 +200,8 @@ case class proc(command: Shellable*) {
138200
check: Boolean = true,
139201
propagateEnv: Boolean = true,
140202
// this cannot be next to `timeout` as this will introduce a bin-compat break (default arguments are numbered in the bytecode)
141-
timeoutGracePeriod: Long = 100
203+
shutdownGracePeriod: Long = 100,
204+
destroyOnExit: Boolean = true
142205
): CommandResult = {
143206

144207
val chunks = new java.util.concurrent.ConcurrentLinkedQueue[Either[geny.Bytes, geny.Bytes]]
@@ -159,7 +222,7 @@ case class proc(command: Shellable*) {
159222
propagateEnv
160223
)
161224

162-
sub.join(timeout, timeoutGracePeriod)
225+
sub.join(timeout, shutdownGracePeriod)
163226

164227
val chunksSeq = chunks.iterator.asScala.toIndexedSeq
165228
val res = CommandResult(commandChunks, sub.exitCode(), chunksSeq)
@@ -188,7 +251,33 @@ case class proc(command: Shellable*) {
188251
timeout,
189252
check,
190253
propagateEnv,
191-
timeoutGracePeriod = 100
254+
shutdownGracePeriod = 100
255+
)
256+
257+
// Bincompat Forwarder
258+
private[os] def call(
259+
cwd: Path,
260+
env: Map[String, String],
261+
stdin: ProcessInput,
262+
stdout: ProcessOutput,
263+
stderr: ProcessOutput,
264+
mergeErrIntoOut: Boolean,
265+
timeout: Long,
266+
check: Boolean,
267+
propagateEnv: Boolean,
268+
timeoutGracePeriod: Long
269+
): CommandResult = call(
270+
cwd,
271+
env,
272+
stdin,
273+
stdout,
274+
stderr,
275+
mergeErrIntoOut,
276+
timeout,
277+
check,
278+
propagateEnv,
279+
timeoutGracePeriod,
280+
destroyOnExit = false
192281
)
193282

194283
/**
@@ -208,7 +297,9 @@ case class proc(command: Shellable*) {
208297
stdout: ProcessOutput = Pipe,
209298
stderr: ProcessOutput = os.Inherit,
210299
mergeErrIntoOut: Boolean = false,
211-
propagateEnv: Boolean = true
300+
propagateEnv: Boolean = true,
301+
shutdownGracePeriod: Long = 100,
302+
destroyOnExit: Boolean = true
212303
): SubProcess = {
213304

214305
val cmdChunks = commandChunks
@@ -230,19 +321,62 @@ case class proc(command: Shellable*) {
230321
propagateEnv
231322
)
232323

324+
lazy val shutdownHookThread =
325+
if (!destroyOnExit) None
326+
else Some(new Thread("subprocess-shutdown-hook") {
327+
override def run(): Unit = proc.destroy(shutdownGracePeriod)
328+
})
329+
330+
lazy val shutdownHookMonitorThread = shutdownHookThread.map(t =>
331+
new Thread("subprocess-shutdown-hook-monitor") {
332+
override def run(): Unit = {
333+
while (proc.wrapped.isAlive) Thread.sleep(1)
334+
try Runtime.getRuntime().removeShutdownHook(t)
335+
catch { case e: Throwable => /*do nothing*/ }
336+
}
337+
}
338+
)
339+
340+
shutdownHookThread.foreach(Runtime.getRuntime().addShutdownHook)
341+
233342
lazy val proc: SubProcess = new SubProcess(
234343
builder.start(),
235344
resolvedStdin.processInput(proc.stdin).map(new Thread(_, commandStr + " stdin thread")),
236345
resolvedStdout.processOutput(proc.stdout).map(new Thread(_, commandStr + " stdout thread")),
237-
resolvedStderr.processOutput(proc.stderr).map(new Thread(_, commandStr + " stderr thread"))
346+
resolvedStderr.processOutput(proc.stderr).map(new Thread(_, commandStr + " stderr thread")),
347+
shutdownGracePeriod = shutdownGracePeriod,
348+
shutdownHookMonitorThread = shutdownHookMonitorThread
238349
)
239350

351+
shutdownHookMonitorThread.foreach(_.start())
352+
240353
proc.inputPumperThread.foreach(_.start())
241354
proc.outputPumperThread.foreach(_.start())
242355
proc.errorPumperThread.foreach(_.start())
243356
proc
244357
}
245358

359+
// Bincompat Forwarder
360+
def spawn(
361+
cwd: Path,
362+
env: Map[String, String],
363+
stdin: ProcessInput,
364+
stdout: ProcessOutput,
365+
stderr: ProcessOutput,
366+
mergeErrIntoOut: Boolean,
367+
propagateEnv: Boolean
368+
): SubProcess = spawn(
369+
cwd = cwd,
370+
env = env,
371+
stdin = stdin,
372+
stdout = stdout,
373+
stderr = stderr,
374+
mergeErrIntoOut = mergeErrIntoOut,
375+
propagateEnv = propagateEnv,
376+
shutdownGracePeriod = 100,
377+
destroyOnExit = false
378+
)
379+
246380
/**
247381
* Pipes the output of this process into the input of the [[next]] process. Returns a
248382
* [[ProcGroup]] containing both processes, which you can then either execute or
@@ -295,7 +429,7 @@ case class ProcGroup private[os] (commands: Seq[proc]) {
295429
* will be caught and handled by killing the writing process. This behaviour
296430
* is consistent with handlers of SIGPIPE signals in most programs
297431
* supporting interruptable piping. Disabled by default on Windows.
298-
* @param timeoutGracePeriod if the timeout is enabled, how long in milliseconds for the
432+
* @param shutdownGracePeriod if the timeout is enabled, how long in milliseconds for the
299433
* subprocess to gracefully terminate before attempting to
300434
* forcibly kill it
301435
* (-1 for no kill, 0 for always kill immediately)
@@ -316,7 +450,7 @@ case class ProcGroup private[os] (commands: Seq[proc]) {
316450
pipefail: Boolean = true,
317451
handleBrokenPipe: Boolean = !isWindows,
318452
// this cannot be next to `timeout` as this will introduce a bin-compat break (default arguments are numbered in the bytecode)
319-
timeoutGracePeriod: Long = 100
453+
shutdownGracePeriod: Long = 100
320454
): CommandResult = {
321455
val chunks = new java.util.concurrent.ConcurrentLinkedQueue[Either[geny.Bytes, geny.Bytes]]
322456

@@ -337,7 +471,7 @@ case class ProcGroup private[os] (commands: Seq[proc]) {
337471
pipefail
338472
)
339473

340-
sub.join(timeout, timeoutGracePeriod)
474+
sub.join(timeout, shutdownGracePeriod)
341475

342476
val chunksSeq = chunks.iterator.asScala.toIndexedSeq
343477
val res =
@@ -370,7 +504,7 @@ case class ProcGroup private[os] (commands: Seq[proc]) {
370504
propagateEnv,
371505
pipefail,
372506
handleBrokenPipe,
373-
timeoutGracePeriod = 100
507+
shutdownGracePeriod = 100
374508
)
375509

376510
/**

0 commit comments

Comments
 (0)