commonMain.io.matthewnelson.kmp.process.Process.kt Maven / Gradle / Ivy
Go to download
Show more of this group Show more artifacts with this name
Show all versions of process-jvm Show documentation
Show all versions of process-jvm Show documentation
Process for Kotlin Multiplatform
The newest version!
/*
* Copyright (c) 2024 Matthew Nelson
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
**/
package io.matthewnelson.kmp.process
import io.matthewnelson.immutable.collections.toImmutableList
import io.matthewnelson.immutable.collections.toImmutableMap
import io.matthewnelson.kmp.file.*
import io.matthewnelson.kmp.process.ProcessException.Companion.CTX_DESTROY
import io.matthewnelson.kmp.process.internal.PlatformBuilder
import io.matthewnelson.kmp.process.internal.SyntheticAccess
import io.matthewnelson.kmp.process.internal.appendProcessInfo
import io.matthewnelson.kmp.process.internal.commonWaitFor
import kotlinx.coroutines.delay
import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.InvocationKind
import kotlin.contracts.contract
import kotlin.jvm.JvmField
import kotlin.jvm.JvmName
import kotlin.jvm.JvmStatic
import kotlin.jvm.JvmSynthetic
import kotlin.time.ComparableTimeMark
import kotlin.time.Duration
import kotlin.time.TimeSource
/**
* A Process.
*
* @see [Builder]
* @see [Current]
* @see [OutputFeed.Handler]
* @see [Blocking]
* */
public abstract class Process internal constructor(
/**
* The command being executed.
* */
@JvmField
public val command: String,
/**
* Optional arguments being executed.
* */
@JvmField
public val args: List,
/**
* The current working directory, or `null` if
* one was not set via [Builder.chdir].
* */
@JvmField
public val cwd: File?,
/**
* The environment that was passed.
* */
@JvmField
public val environment: Map,
/**
* The I/O configuration of the process.
* */
@JvmField
public val stdio: Stdio.Config,
/**
* A stream to write data to the process's standard
* input, or `null` if [Stdio.Config.stdin] is not
* [Stdio.Pipe].
* */
@JvmField
public val input: AsyncWriteStream?,
/**
* The [Signal] utilized to stop the process (if
* not already stopped) when [destroy] is called.
* */
@JvmField
public val destroySignal: Signal,
private val handler: ProcessException.Handler,
init: SyntheticAccess,
): OutputFeed.Handler(stdio) {
/**
* The "rough" start time mark of the [Process]. This is **actually**
* a time mark for when [Process] was instantiated, which for all
* platforms is immediately after the underlying platform's process
* implementation was created.
* */
@JvmField
public val startTime: ComparableTimeMark = TimeSource.Monotonic.markNow()
/**
* Information about the current process (i.e. the "parent" process).
* */
public object Current {
@JvmStatic
public fun environment(): Map = PlatformBuilder.get().env.toImmutableMap()
@JvmStatic
public fun pid(): Int = PlatformBuilder.myPid()
}
/**
* Destroys the [Process] by:
* - Sending it [destroySignal] (if it has not completed yet)
* - Closes all I/O streams
* - Stops all [OutputFeed] production (See [OutputFeed.Waiter])
* - **NOTE:** This may not be immediate if there is buffered
* data on `stdout` or `stderr`. The contents of what are
* left on the stream(s) will be drained which may stay live
* briefly **after** destruction.
*
* This **MUST** be called after you are done with the [Process]
* to ensure resource closure occurs.
*
* **NOTE:** Depending on your [ProcessException.Handler], if an
* error is produced (e.g. a file descriptor closure failure) and
* you choose to throw it from [ProcessException.Handler.onException],
* the caller of this function will receive the exception. You can
* choose to ignore it (e.g. log only) by not throwing when the
* [ProcessException.context] is equal to [CTX_DESTROY].
*
* @see [Signal]
* @see [Builder.spawn]
* @see [OutputFeed.Waiter]
* @return this [Process] instance
* */
public fun destroy(): Process {
try {
destroyProtected(immediate = true)
} catch (t: Throwable) {
onError(t, lazyContext = { CTX_DESTROY })
}
return this
}
/**
* Returns the exit code for which the process
* completed with.
*
* @throws [IllegalStateException] if the [Process] has
* not exited yet
* */
@Throws(IllegalStateException::class)
public fun exitCode(): Int = exitCodeOrNull()
?: throw IllegalStateException("Process hasn't exited")
/**
* Returns the exit code for which the process
* completed with, or `null` if it has not
* exited yet.
* */
public abstract fun exitCodeOrNull(): Int?
/**
* Checks if the [Process] is still running
* */
@get:JvmName("isAlive")
public val isAlive: Boolean get() = exitCodeOrNull() == null
/**
* Returns the [Process] identifier (PID).
*
* **NOTE:** On Jvm this can return -1 (Unknown) if:
* - Unable to retrieve via `java.lang.Process.toString` output
* - `java.lang.Process.pid` method unavailable (Java 8 or Android Runtime)
*
* **NOTE:** On Js this can return -1 (Unknown) if called
* immediately after [Process] creation and the underlying
* child process has not spawned yet.
* */
public abstract fun pid(): Int
/**
* Delays the current coroutine until [Process] completion.
*
* **NOTE:** For Jvm & Android the `kotlinx.coroutines.core`
* dependency is needed.
*
* **NOTE:** Care must be had when using Async APIs such that,
* upon cancellation, [Process.destroy] is still called.
*
* @see [io.matthewnelson.kmp.process.Blocking.waitFor]
* @return The [Process.exitCode]
* */
public suspend fun waitForAsync(): Int {
var exitCode: Int? = null
while (exitCode == null) {
exitCode = waitForAsync(Duration.INFINITE)
}
return exitCode
}
/**
* Delays the current coroutine for the specified [duration],
* or until [Process.exitCode] is available (i.e. the
* [Process] completed).
*
* **NOTE:** For Jvm & Android the `kotlinx.coroutines.core`
* dependency is needed.
*
* **NOTE:** Care must be had when using Async APIs such that,
* upon cancellation, [Process.destroy] is still called.
*
* @param [duration] the [Duration] to wait
* @see [io.matthewnelson.kmp.process.Blocking.waitFor]
* @return The [Process.exitCode], or null if [duration] is
* exceeded without [Process] completion.
* */
public suspend fun waitForAsync(
duration: Duration,
): Int? = commonWaitFor(duration) { delay(it) }
/**
* Creates a new [Process].
*
* e.g. (Shell commands on a Unix system)
*
* val out = Process.Builder("sh")
* .args("-c")
* .args("sleep 1; exit 5")
* .destroySignal(Signal.SIGKILL)
* .stdin(Stdio.Null)
* .output { timeoutMillis = 1_500 }
*
* assertEquals(5, out.processInfo.exitCode)
*
* e.g. (Executable file)
*
* val p = Process.Builder(myExecutableFile)
* .args("--some-flag")
* .args("someValue")
* .args("--another-flag", "anotherValue")
* .environment {
* remove("HOME")
* // ...
* }
* .stdin(Stdio.Null)
* .stdout(Stdio.File.of("logs/myExecutable.log", append = true))
* .stderr(Stdio.File.of("logs/myExecutable.err"))
* .spawn()
*
* @param [command] The command to run. On Native `Linux`, `macOS` and
* `iOS`, if [command] is a relative file path or program name (e.g.
* `ping`) then `posix_spawnp` is utilized. If it is an absolute file
* path (e.g. `/usr/bin/ping`), then `posix_spawn` is utilized.
* */
public class Builder(
@JvmField
public val command: String
) {
/**
* Alternate constructor for an executable [File]. Will take the
* absolute + normalized path to use for [command].
* */
public constructor(executable: File): this(executable.absoluteFile.normalize().path)
private val args = mutableListOf()
private var chdir: File? = null
private var destroy: Signal = Signal.SIGTERM
private val platform = PlatformBuilder.get()
private var handler: ProcessException.Handler? = null
private val stdio = Stdio.Config.Builder.get()
/**
* Add a single argument
* */
public fun args(
arg: String,
): Builder = apply { args.add(arg) }
/**
* Add multiple arguments
* */
public fun args(
vararg args: String,
): Builder = apply { args.forEach { this.args.add(it) } }
/**
* Add multiple arguments
* */
public fun args(
args: List,
): Builder = apply { args.forEach { this.args.add(it) } }
/**
* Set the [Signal] to use when [Process.destroy] is called.
* */
public fun destroySignal(
signal: Signal,
): Builder = apply { destroy = signal }
/**
* Changes the working directory of the spawned process.
*
* [directory] must exist, otherwise the process will fail
* to be spawned.
*
* **WARNING:** `iOS` does not support changing directories!
* Specifying this option will result in a failure to
* spawn a process.
* */
public fun chdir(
directory: File?,
): Builder = apply { chdir = directory }
/**
* Set/overwrite an environment variable
*
* By default, the new [Process] will inherit all environment
* variables from the current one.
* */
public fun environment(
key: String,
value: String,
): Builder = apply { platform.env[key] = value }
/**
* Modify the environment via lambda
*
* By default, the new [Process] will inherit all environment
* variables from the current one.
* */
public fun environment(
block: MutableMap.() -> Unit,
): Builder = apply { block(platform.env) }
/**
* Set a [ProcessException.Handler] to manage internal
* [Process] errors for spawned processes.
*
* By default, [ProcessException.Handler.IGNORE] is used
* if one is not set.
*
* **NOTE:** [output] utilizes its own [ProcessException.Handler]
* and does **not** use whatever may be set by [onError].
*
* @see [ProcessException]
* */
public fun onError(
handler: ProcessException.Handler?,
): Builder = apply { this.handler = handler }
/**
* Modify the standard input source
*
* @see [Stdio]
* */
public fun stdin(
source: Stdio,
): Builder = apply { stdio.stdin = source }
/**
* Modify the standard output destination
*
* @see [Stdio]
* */
public fun stdout(
destination: Stdio,
): Builder = apply { stdio.stdout = destination }
/**
* Modify the standard error output destination
*
* @see [Stdio]
* */
public fun stderr(
destination: Stdio,
): Builder = apply { stdio.stderr = destination }
/**
* Blocks the current thread until [Process] completion,
* [Output.Options.Builder.timeoutMillis] is exceeded,
* or [Output.Options.Builder.maxBuffer] is exceeded.
*
* Utilizes the default [Output.Options]
*
* For a long-running [Process], [spawn] should be utilized.
*
* @return [Output]
* @throws [IOException] if [Process] creation failed
* */
@Throws(IOException::class)
public fun output(): Output = output { /* defaults */ }
/**
* Blocks the current thread until [Process] completion,
* [Output.Options.Builder.timeoutMillis] is exceeded,
* or [Output.Options.Builder.maxBuffer] is exceeded.
*
* For a long-running [Process], [spawn] should be utilized.
*
* @param [block] lambda to configure [Output.Options]
* @return [Output]
* @see [Output.Options.Builder]
* @throws [IOException] if [Process] creation failed
* */
@Throws(IOException::class)
public fun output(
block: Output.Options.Builder.() -> Unit,
): Output {
if (command.isBlank()) throw IOException("command cannot be blank")
val options = Output.Options.Builder.build(block)
val stdio = stdio.build(outputOptions = options)
val args = args.toImmutableList()
val env = platform.env.toImmutableMap()
return platform.output(command, args, chdir, env, stdio, options, destroy)
}
/**
* Spawns the [Process]
*
* **NOTE:** [Process.destroy] **MUST** be called before de-referencing
* the [Process] instance in order to close resources. This is best done
* via try/finally, or utilizing the other [spawn] function which handles
* it automatically for you.
*
* @throws [IOException] if [Process] creation failed
* */
@Throws(IOException::class)
public fun spawn(): Process {
if (command.isBlank()) throw IOException("command cannot be blank")
val stdio = stdio.build(outputOptions = null)
val args = args.toImmutableList()
val env = platform.env.toImmutableMap()
val handler = handler ?: ProcessException.Handler.IGNORE
return platform.spawn(command, args, chdir, env, stdio, destroy, handler)
}
/**
* Spawns the [Process] and calls [destroy] upon [block] closure.
*
* @throws [IOException] if [Process] creation failed
* */
@Throws(IOException::class)
@OptIn(ExperimentalContracts::class)
public inline fun spawn(
block: (process: Process) -> T,
): T {
contract {
callsInPlace(block, InvocationKind.AT_MOST_ONCE)
}
val p = spawn()
val result = try {
block(p)
} catch (t: Throwable) {
p.destroy()
throw t
}
p.destroy()
return result
}
}
public final override fun toString(): String = buildString {
val exitCode = exitCodeOrNull()?.toString() ?: "not exited"
appendProcessInfo(
"Process",
pid(),
exitCode,
command,
args,
cwd,
stdio,
destroySignal
)
}
@Throws(Throwable::class)
protected abstract fun destroyProtected(immediate: Boolean)
@Throws(Throwable::class)
protected final override fun onError(t: Throwable, lazyContext: () -> String) {
if (handler == ProcessException.Handler.IGNORE) return
val context = lazyContext()
val threw = try {
handler.onException(ProcessException.of(context, t))
null
} catch (t: Throwable) {
// Handler threw on us. Clean up shop.
t
}
if (threw == null) return
// Error was coming from somewhere other than destroy call.
// Ensure we are destroyed to close everything down before
// re-throwing.
if (context != CTX_DESTROY) {
try {
// Because this error did not come from destroy,
// Native must terminate Workers lazily because the
// current thread may be the Worker (i.e. OutputFeed
// threw exception). So, `immediate = false`.
destroyProtected(immediate = false)
} catch (t: Throwable) {
threw.addSuppressed(t)
}
}
throw threw
}
protected companion object {
@JvmSynthetic
internal val INIT = SyntheticAccess.new()
}
init {
check(init == INIT) { "Process cannot be extended. Use Process.Builder" }
}
}