Skip to content

Commit 7a75392

Browse files
committed
fix(apple): properly detect output timeout across stdout and stderr
1 parent fee8c92 commit 7a75392

File tree

5 files changed

+61
-29
lines changed

5 files changed

+61
-29
lines changed

vendor/vendor-apple/base/src/main/kotlin/com/malinskiy/marathon/apple/cmd/BaseCommand.kt

+32-14
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,17 @@ import kotlinx.coroutines.CompletableJob
44
import kotlinx.coroutines.Deferred
55
import kotlinx.coroutines.Dispatchers
66
import kotlinx.coroutines.async
7+
import kotlinx.coroutines.awaitAll
8+
import kotlinx.coroutines.cancel
79
import kotlinx.coroutines.cancelAndJoin
810
import kotlinx.coroutines.channels.ReceiveChannel
11+
import kotlinx.coroutines.channels.onClosed
12+
import kotlinx.coroutines.channels.onFailure
13+
import kotlinx.coroutines.channels.onSuccess
914
import kotlinx.coroutines.runBlocking
1015
import kotlinx.coroutines.supervisorScope
1116
import kotlinx.coroutines.withContext
17+
import kotlin.coroutines.cancellation.CancellationException
1218

1319
abstract class BaseCommand(
1420
override val stdout: ReceiveChannel<String>,
@@ -19,38 +25,50 @@ abstract class BaseCommand(
1925
override suspend fun await(): CommandResult = withContext(Dispatchers.IO) {
2026
val deferredStdout = supervisorScope {
2127
async(job) {
22-
val stdoutBuffer = mutableListOf<String>()
23-
for (line in stdout) {
24-
stdoutBuffer.add(line)
28+
val buffer = mutableListOf<String>()
29+
while (true) {
30+
val channelResult = stdout.receiveCatching()
31+
channelResult.onSuccess { buffer.add(it) }
32+
channelResult.onClosed { if (it != null) cancel(CancellationException("Channel closed", it)) }
33+
channelResult.onFailure { if (it != null) cancel(CancellationException("Channel failed", it)) }
34+
35+
if (!channelResult.isSuccess) break
2536
}
26-
stdoutBuffer
37+
buffer
2738
}
2839
}
2940

3041
val deferredStderr = supervisorScope {
3142
async(job) {
32-
val stderrBuffer = mutableListOf<String>()
33-
for (line in stderr) {
34-
stderrBuffer.add(line)
43+
val buffer = mutableListOf<String>()
44+
while (true) {
45+
val channelResult = stderr.receiveCatching()
46+
channelResult.onSuccess { buffer.add(it) }
47+
channelResult.onClosed { if (it != null) cancel(CancellationException("Channel closed", it)) }
48+
channelResult.onFailure { if (it != null) cancel(CancellationException("Channel failed", it)) }
49+
50+
if (!channelResult.isSuccess) break
3551
}
36-
stderrBuffer
52+
buffer
3753
}
3854
}
3955

40-
val out = deferredStdout.await()
41-
val err = deferredStderr.await()
42-
val exitCode = exitCode.await()
4356

44-
CommandResult(out, err, exitCode)
57+
58+
val (out, err, exitCode) = awaitAll(deferredStdout, deferredStderr, exitCode)
59+
60+
CommandResult(out as List<String>, err as List<String>, exitCode as Int)
4561
}
4662

4763
override suspend fun drain() {
4864
return supervisorScope {
4965
async(job) {
50-
for (line in stdout) {}
66+
for (line in stdout) {
67+
}
5168
}
5269
async(job) {
53-
for (line in stderr) {}
70+
for (line in stderr) {
71+
}
5472
}
5573
}
5674
}

vendor/vendor-apple/base/src/main/kotlin/com/malinskiy/marathon/apple/cmd/local/KotlinProcessCommandExecutor.kt

+5-4
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import kotlinx.coroutines.delay
1616
import java.io.File
1717
import java.nio.charset.Charset
1818
import java.time.Duration
19+
import java.util.concurrent.atomic.AtomicLong
1920

2021
/**
2122
* Note: doesn't support idle timeout currently
@@ -64,11 +65,11 @@ class KotlinProcessCommandExecutor(
6465
process.suspendFor()
6566
}
6667
}
67-
68-
val stdout = produceLinesManually(job, process.inputStream, idleTimeout, charset, channelCapacity) { process.isAlive && !exitCode.isCompleted }
69-
val stderr = produceLinesManually(job, process.errorStream, idleTimeout, charset, channelCapacity) { process.isAlive && !exitCode.isCompleted }
7068

71-
69+
val lastOutputTimeMillis = AtomicLong(System.currentTimeMillis())
70+
val stdout = produceLinesManually(job, process.inputStream, lastOutputTimeMillis, idleTimeout, charset, channelCapacity) { process.isAlive && !exitCode.isCompleted }
71+
val stderr = produceLinesManually(job, process.errorStream, lastOutputTimeMillis, idleTimeout, charset, channelCapacity) { process.isAlive && !exitCode.isCompleted }
72+
7273
return KotlinProcessCommand(
7374
process, job, stdout, stderr, exitCode, destroyForcibly
7475
)

vendor/vendor-apple/base/src/main/kotlin/com/malinskiy/marathon/apple/cmd/remote/ssh/sshj/SshjCommandExecutor.kt

+6-3
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package com.malinskiy.marathon.apple.cmd.remote.ssh.sshj
22

33
import com.malinskiy.marathon.apple.cmd.CommandExecutor
44
import com.malinskiy.marathon.apple.cmd.CommandSession
5+
import com.malinskiy.marathon.apple.extensions.Durations
56
import com.malinskiy.marathon.apple.extensions.produceLinesManually
67
import com.malinskiy.marathon.extension.withTimeoutOrNull
78
import com.malinskiy.marathon.log.MarathonLogging
@@ -20,6 +21,7 @@ import java.io.IOException
2021
import java.nio.charset.Charset
2122
import java.time.Duration
2223
import java.util.concurrent.TimeUnit
24+
import java.util.concurrent.atomic.AtomicLong
2325
import kotlin.coroutines.coroutineContext
2426

2527
class SshjCommandExecutor(
@@ -53,9 +55,10 @@ class SshjCommandExecutor(
5355
}
5456
}
5557
val cmd = session.exec(escapedCmd)
56-
57-
val stdout = produceLinesManually(job, cmd.inputStream, idleTimeout, charset, channelCapacity) { cmd.isOpen && !cmd.isEOF }
58-
val stderr = produceLinesManually(job, cmd.errorStream, idleTimeout, charset, channelCapacity) { cmd.isOpen && !cmd.isEOF }
58+
59+
val lastOutputTimeMillis = AtomicLong(System.currentTimeMillis())
60+
val stdout = produceLinesManually(job, cmd.inputStream, lastOutputTimeMillis, idleTimeout, charset, channelCapacity) { cmd.isOpen && !cmd.isEOF }
61+
val stderr = produceLinesManually(job, cmd.errorStream, lastOutputTimeMillis, idleTimeout, charset, channelCapacity) { cmd.isOpen && !cmd.isEOF }
5962
val exitCode: Deferred<Int?> = async(job) {
6063
val result = withTimeoutOrNull(timeout) {
6164
cmd.suspendFor()

vendor/vendor-apple/base/src/main/kotlin/com/malinskiy/marathon/apple/cmd/remote/ssh/sshj/SshjCommandSession.kt

+5
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@ class SshjCommandSession(
3030

3131
override fun close() {
3232
if (!closed.getAndSet(true)) {
33+
if (command.isOpen) {
34+
terminate()
35+
}
36+
command.close()
37+
3338
command.join()
3439
super.close()
3540
}

vendor/vendor-apple/base/src/main/kotlin/com/malinskiy/marathon/apple/extensions/Reader.kt

+13-8
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import java.io.InputStream
1212
import java.nio.charset.Charset
1313
import java.time.Duration
1414
import java.util.concurrent.TimeoutException
15+
import java.util.concurrent.atomic.AtomicLong
1516
import kotlin.math.min
1617

1718
fun CoroutineScope.produceLines(
@@ -38,15 +39,14 @@ fun CoroutineScope.produceLines(
3839
fun CoroutineScope.produceLinesManually(
3940
job: Job,
4041
inputStream: InputStream,
42+
lastOutputTimeMillis: AtomicLong,
4143
idleTimeout: Duration,
4244
charset: Charset,
4345
channelCapacity: Int,
4446
canRead: () -> Boolean,
4547
): ReceiveChannel<String> {
4648
return produce(capacity = channelCapacity, context = job) {
4749
inputStream.buffered().use { inputStream ->
48-
49-
var lastOutputTimeMillis = System.currentTimeMillis()
5050
LineBuffer(charset, onLine = { send(it) }).use { lineBuffer ->
5151
val byteArray = ByteArray(16384)
5252
while (coroutineContext.isActive && !channel.isClosedForSend && !job.isCancelled) {
@@ -66,6 +66,15 @@ fun CoroutineScope.produceLinesManually(
6666
available > 0 -> inputStream.read(byteArray, 0, min(available, byteArray.size))
6767
else -> 0
6868
}
69+
70+
//Check we didn't go over idle timeout
71+
val lastOutput = lastOutputTimeMillis.get()
72+
val timeSinceLastOutputMillis = System.currentTimeMillis() - lastOutput
73+
if (timeSinceLastOutputMillis > idleTimeout.toMillis()) {
74+
close(TimeoutException("idle timeout $idleTimeout reached"))
75+
break
76+
}
77+
6978
// if there was nothing to read
7079
if (count == 0) {
7180
// if session received EOF or has been closed, reading stops
@@ -77,13 +86,9 @@ fun CoroutineScope.produceLinesManually(
7786
} else if (count == -1) {
7887
break
7988
} else {
80-
val timeSinceLastOutputMillis = System.currentTimeMillis() - lastOutputTimeMillis
81-
if (timeSinceLastOutputMillis > idleTimeout.toMillis()) {
82-
close(TimeoutException("idle timeout $idleTimeout reached"))
83-
break
84-
}
8589
lineBuffer.append(byteArray, count)
86-
lastOutputTimeMillis = System.currentTimeMillis()
90+
//Check we didn't go over idle timeout
91+
lastOutputTimeMillis.set(System.currentTimeMillis())
8792
}
8893
// immediately send any full lines for parsing
8994
lineBuffer.flush()

0 commit comments

Comments
 (0)