diff --git a/.gitignore b/.gitignore index 328397e..bf3abdd 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,7 @@ .pid.lock build gradle-spawn-plugin.iml +/bin/ +/.classpath +/.project +/.settings/ diff --git a/build.gradle b/build.gradle index b273578..dd1935b 100644 --- a/build.gradle +++ b/build.gradle @@ -16,7 +16,7 @@ dependencies { group = 'com.wiredforcode' archivesBaseName = 'gradle-spawn-plugin' -version = '0.8.0' +version = '0.8.5' apply plugin: 'idea' apply plugin: 'groovy' @@ -34,7 +34,7 @@ distributions { ext { bintrayBaseUrl = 'https://api.bintray.com/maven' - bintrayUsername = 'vermeulen-mp' + bintrayUsername = 'gorky' bintrayRepository = 'gradle-plugins' bintrayPackage = 'gradle-spawn-plugin' } diff --git a/src/main/groovy/com/wiredforcode/gradle/spawn/DefaultSpawnTask.groovy b/src/main/groovy/com/wiredforcode/gradle/spawn/DefaultSpawnTask.groovy index 1779061..f8b1158 100644 --- a/src/main/groovy/com/wiredforcode/gradle/spawn/DefaultSpawnTask.groovy +++ b/src/main/groovy/com/wiredforcode/gradle/spawn/DefaultSpawnTask.groovy @@ -5,6 +5,11 @@ import org.gradle.api.DefaultTask class DefaultSpawnTask extends DefaultTask { String pidLockFileName = '.pid.lock' String directory = '.' + /** + * Time to wait for process to start/finish in seconds. + */ + int timeout + File getPidFile() { return new File(directory, pidLockFileName) diff --git a/src/main/groovy/com/wiredforcode/gradle/spawn/KillProcessTask.groovy b/src/main/groovy/com/wiredforcode/gradle/spawn/KillProcessTask.groovy index 15c60b1..5e02eac 100644 --- a/src/main/groovy/com/wiredforcode/gradle/spawn/KillProcessTask.groovy +++ b/src/main/groovy/com/wiredforcode/gradle/spawn/KillProcessTask.groovy @@ -1,5 +1,7 @@ package com.wiredforcode.gradle.spawn +import java.util.concurrent.TimeUnit + import org.gradle.api.tasks.TaskAction class KillProcessTask extends DefaultSpawnTask { @@ -15,9 +17,24 @@ class KillProcessTask extends DefaultSpawnTask { def process = "kill $pid".execute() try { - process.waitFor() + if (timeout <= 0){ + process.waitFor() + } else { + killWithTimeOut(process, pid) + } } finally { pidFile.delete() } } + + void killWithTimeOut(Process process, String pid){ + boolean success = process.waitFor(timeout, TimeUnit.SECONDS) + if (!success){ + logger.info "Soft stop timed out, executing 'kill -s 9 ${pid}" + def hardKillProcess = "kill -s 9 $pid".execute() + hardKillProcess.waitFor() + } + } + + } diff --git a/src/main/groovy/com/wiredforcode/gradle/spawn/SpawnProcessTask.groovy b/src/main/groovy/com/wiredforcode/gradle/spawn/SpawnProcessTask.groovy index effcb03..e9ca385 100644 --- a/src/main/groovy/com/wiredforcode/gradle/spawn/SpawnProcessTask.groovy +++ b/src/main/groovy/com/wiredforcode/gradle/spawn/SpawnProcessTask.groovy @@ -7,6 +7,12 @@ import org.gradle.api.tasks.TaskAction class SpawnProcessTask extends DefaultSpawnTask { String command String ready + /** + * If set, do NOT abort reading and close the pipe from the process stdout. There + * are applications that will pick this up as a "Shutdown" signal. Instead, continue + * "siphoning" or reading from the InputStream, but just "chuck" the data. + */ + boolean siphon List outputActions = new ArrayList() @Input @@ -26,6 +32,7 @@ class SpawnProcessTask extends DefaultSpawnTask { @TaskAction void spawn() { + logger.quiet "Spawning ${command}" if (!(command && ready)) { throw new GradleException("Ensure that mandatory fields command and ready are set.") } @@ -48,36 +55,52 @@ class SpawnProcessTask extends DefaultSpawnTask { private void checkForAbnormalExit(Process process) { try { - process.waitFor() def exitValue = process.exitValue() if (exitValue) { throw new GradleException("The process terminated unexpectedly - status code ${exitValue}") } } catch (IllegalThreadStateException ignored) { + logger.debug(ignored.getMessage(), ignored) + throw new GradleException("Process failed to finish starting before timeout ${timeout}sec") } } - private boolean waitUntilIsReadyOrEnd(Process process) { - def line - def reader = new BufferedReader(new InputStreamReader(process.getInputStream())) - boolean isReady = false - while (!isReady && (line = reader.readLine()) != null) { - logger.quiet line - runOutputActions(line) - if (line.contains(ready)) { - logger.quiet "$command is ready." - isReady = true - } - } - isReady - } - - def runOutputActions(String line) { - outputActions.each { Closure outputAction -> - outputAction.call(line) + private boolean waitUntilIsReadyOrEnd(final Process process) { + final def currentThread = Thread.currentThread() + final ReaderWorker reader = new ReaderWorker(); + reader.waiter = currentThread + Thread worker = new Thread(new Runnable(){ + public void run(){ + reader.waitUntilIsReadyOrEnd(process) + } + }); + worker.setDaemon(true) + worker.setName(name + "-worker") + worker.start(); + long startTime = System.currentTimeMillis() + def started = false + try { + Thread.sleep(timeout <= 0 ? Long.MAX_VALUE : timeout * 1000) + logger.warn "Timed out after: " + timeout + " seconds" + } catch (InterruptedException e) { + //Should just be the reader thread waking up because it found the success message. + logger.quiet "Finished after: " + (System.currentTimeMillis() - startTime) + "ms" + //Clear interrupt status + Thread.interrupted() + started = true } + if (reader.e != null){ + throw e; + }//else + //Signal worker that it is done. + worker.interrupt(); + //Clear listening. + reader.waiter = null; + logger.quiet "Spawn Post Processing. Ready = ${reader.isReady}" + started && reader.isReady } + private Process buildProcess(String directory, String command) { def builder = new ProcessBuilder(command.split(' ')) builder.redirectErrorStream(true) @@ -96,4 +119,116 @@ class SpawnProcessTask extends DefaultSpawnTask { return pidField.getInt(process) } + + class ReaderWorker { + boolean isReady = false + volatile Thread waiter + Exception e; + + void waitUntilIsReadyOrEnd(final Process process){ + //Read StdOut + final Thread outThread = new Thread(new Runnable(){ + public void run(){ + waitUntilIsReadyOrEnd(process, process.getInputStream(), "StdOut") + } + }) + outThread.start(); + //Read StdErr + final Thread errThread = new Thread(new Runnable(){ + public void run(){ + waitUntilIsReadyOrEnd(process, process.getErrorStream(), "StdErr") + } + }) + errThread.start(); + final Thread currentThread = Thread.currentThread() + logger.quiet "Waiting on startup Readers" + + if (!isRunnable(currentThread)){ + logger.quiet "Process finished fast finished startup" + outThread.interrupt(); + errThread.interrupt(); + } else { + waitOn(outThread, errThread, "StdOut") + waitOn(errThread, outThread, "StdErr") + logger.quiet "Joins complete" + } + } + + void waitOn(final Thread toWaitFor, final Thread other, String currentReader){ + try { + toWaitFor.join(); + } catch (InterruptedException e){ + logger.quiet "$currentReader Reader interrupted..." + other.interrupt() + final Thread waiter = this.waiter + if (waiter != null){ + waiter.interrupt() + } //else, stopped and deleted by another thread. + } + } + + void waitUntilIsReadyOrEnd(Process process, InputStream stream, String streamName){ + def line + BufferedReader reader = new BufferedReader(new InputStreamReader(stream)) + def currentThread = Thread.currentThread() + boolean interruptSent = false + try { + //Data to read, no EOF + while (((line = reader.readLine()) != null) + //Can run and not yet ready + && (isRunnable(currentThread) && !isReady + //If there is no data, nothing to siphon + || (line != null && siphon))) { + //provision siphoning for process that dumps when stdout is closed + //This applies to golang based code specifically + if (siphon && isReady){ + //done processing + continue; + } + logger.quiet line + runOutputActions(line) + if (line.contains(ready)) { + logger.quiet "$streamName: $command is ready." + isReady = true + logger.debug "$streamName: Wake listeners. Ready = ${isReady}" + if (waiter != null && !currentThread.isInterrupted() && !interruptSent) { + waiter.interrupt() + interruptSent = true + } //else, waiter has abandoned listening + } + } + } catch (Exception e){ + this.e = e; + logger.warn("$streamName: Exception starting process", e) + } finally { + try { + if (!siphon){ + //If siphoning, don't close. Otherwise the launched process will shutdown. + reader.close() + } + } catch (IOException e){ + logger.info("Exception closing process $streamName: ${e.message}", e) + }//end catch + //Interrupt If waiter is listening + if (waiter != null && !interruptSent + //Or Ready or not StdErr + && (this.isReady || !streamName.equals("StdErr"))) { + waiter.interrupt() + logger.quiet "$streamName sent Interrupt" + } //else, waiter has abandoned listening or StdErr with a closed stream. + }//end finally + logger.quiet "Finished reading $streamName" + }//end waitUntilIsReadyOrEnd + + boolean isRunnable(Thread currentThread){ + final Thread waiter = this.waiter; + return !currentThread.isInterrupted() && waiter != null && waiter.isAlive() + } + + def runOutputActions(String line) { + outputActions.each { Closure outputAction -> + outputAction.call(line) + } + } + } } diff --git a/src/test/groovy/com/wiredforcode/gradle/spawn/KillProcessTaskSpec.groovy b/src/test/groovy/com/wiredforcode/gradle/spawn/KillProcessTaskSpec.groovy index 5dee6b6..eb0d1c1 100644 --- a/src/test/groovy/com/wiredforcode/gradle/spawn/KillProcessTaskSpec.groovy +++ b/src/test/groovy/com/wiredforcode/gradle/spawn/KillProcessTaskSpec.groovy @@ -105,4 +105,76 @@ class KillProcessTaskSpec extends Specification { then: killTask.pidLockFileName == '.new.pid.lock' } + + void "should kill a process with timeout set to long"() { + println "should kill a process with timeout set to long" + given: + def directoryPath = directory.toString() + def processSource = new File("src/test/resources/process.sh") + def process = new File(directory, "process.sh") + process << processSource.text + process.setExecutable(true) + + and: + spawnTask.command = "./process.sh" + spawnTask.ready = "It is done..." + spawnTask.directory = directoryPath + + and: + killTask.directory = directoryPath + killTask.timeout = 30 + + when: + spawnTask.spawn() + def lockFile = spawnTask.pidFile + + then: + lockFile.exists() + + when: + killTask.kill() + + then: + !lockFile.exists() + + cleanup: + assert directory.deleteDir() + killTask.timeout = 0 + } + + void "should kill a process with timeout set to short"() { + println "should kill a process with timeout set to short" + given: + def directoryPath = directory.toString() + def processSource = new File("src/test/resources/process.sh") + def process = new File(directory, "process.sh") + process << processSource.text + process.setExecutable(true) + + and: + spawnTask.command = "./process.sh" + spawnTask.ready = "It is done..." + spawnTask.directory = directoryPath + + and: + killTask.directory = directoryPath + killTask.timeout = 1 + + when: + spawnTask.spawn() + def lockFile = spawnTask.pidFile + + then: + lockFile.exists() + + when: + killTask.kill() + + then: + !lockFile.exists() + + cleanup: + assert directory.deleteDir() + killTask.timeout = 0 + } } diff --git a/src/test/groovy/com/wiredforcode/gradle/spawn/SpawnProcessTaskSpec.groovy b/src/test/groovy/com/wiredforcode/gradle/spawn/SpawnProcessTaskSpec.groovy index 22e96b1..d6289fc 100644 --- a/src/test/groovy/com/wiredforcode/gradle/spawn/SpawnProcessTaskSpec.groovy +++ b/src/test/groovy/com/wiredforcode/gradle/spawn/SpawnProcessTaskSpec.groovy @@ -1,5 +1,7 @@ package com.wiredforcode.gradle.spawn +import java.nio.channels.InterruptedByTimeoutException + import org.gradle.api.GradleException import org.gradle.api.Project import org.gradle.testfixtures.ProjectBuilder @@ -143,6 +145,7 @@ class SpawnProcessTaskSpec extends Specification { task.directory == directory.toString() } + //KABOOM! void "should not write the pid lock file if the process exits abnormally"() { given: def command = './exitAbnormally.sh' @@ -191,6 +194,34 @@ class SpawnProcessTaskSpec extends Specification { outputBuilder.toString() == "Starting...\nIt is done...\n" task.getPidFile().name == pidLockFileName } + + void "will timeout on missing ready"(){ + given: + def command = './process.sh' + def ready = 'It is not done...' + + and: + setExecutableProcess("process.sh") + + and: + task.command = command + task.ready = ready + task.directory = directory.toString() + task.timeout = 7 + + when: + task.spawn() + + then: + def e = thrown(GradleException) + e.message == "Process failed to finish starting before timeout 7sec" + + and: + !task.getPidFile().exists() + + cleanup: + task.timeout = 0 + } private void setExecutableProcess(String processFile) { def processSource = new File("src/test/resources/$processFile")