Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 59 additions & 18 deletions jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/LoadEtsFile.kt
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import org.jacodb.ets.dto.toEtsFile
import org.jacodb.ets.model.EtsFile
import org.jacodb.ets.model.EtsScene
import java.io.FileNotFoundException
import java.nio.file.Files
import java.nio.file.Path
import kotlin.io.path.Path
import kotlin.io.path.PathWalkOption
Expand Down Expand Up @@ -165,7 +166,9 @@ private fun resolveFrontendScript(frontendDir: Path, scriptPath: String): Path {
return script
}

class EtsIrGenerationException(message: String) : IllegalStateException(message)
open class EtsIrGenerationException(message: String) : IllegalStateException(message)

class EtsIrGenerationTimeoutException(message: String) : EtsIrGenerationException(message)

private const val ENV_VAR_ETS_IR_GENERATION_TIMEOUT_SEC = "ETS_IR_GENERATION_TIMEOUT_SEC"

Expand All @@ -182,7 +185,27 @@ fun defaultEtsIrGenerationTimeout(isProject: Boolean): Duration {
return if (isProject) 10.minutes else 60.seconds
}

/** Generates EtsIR and retains partial output on failure for diagnostics. */
fun generateEtsIR(
projectPath: Path,
isProject: Boolean = false,
loadEntrypoints: Boolean = true,
useArkAnalyzerTypeInference: Int? = null,
timeout: Duration? = defaultEtsIrGenerationTimeout(isProject),
provider: EtsIrProvider = defaultProviderFor(projectPath, isProject),
): Path = generateEtsIR(
keepPartialOutputOnFailure = true,
projectPath = projectPath,
isProject = isProject,
loadEntrypoints = loadEntrypoints,
useArkAnalyzerTypeInference = useArkAnalyzerTypeInference,
timeout = timeout,
provider = provider,
)

/** Generates EtsIR with caller-selected cleanup of partial output on failure. */
fun generateEtsIR(
keepPartialOutputOnFailure: Boolean,
projectPath: Path,
isProject: Boolean = false,
loadEntrypoints: Boolean = true,
Expand Down Expand Up @@ -220,26 +243,44 @@ fun generateEtsIR(
if (logger.isDebugEnabled) add("-v")
}
logger.debug { "Running EtsIR generation ($provider): ${cmd.joinToString(" ")}" }
val res = ProcessUtil.run(cmd, timeout = timeout)
val failure = when {
res.isTimeout -> "EtsIR generation ($provider) timed out after $timeout"
res.exitCode != 0 -> "EtsIR generation ($provider) failed with exit code ${res.exitCode}"
else -> null
}
if (failure != null) {
// Keep whatever has already been generated: on a partial failure (or a timeout
// on a large project) the produced files are still useful for diagnostics.
logger.error { "$failure\nCommand: ${cmd.joinToString(" ")}" }
logger.error { "STDOUT:\n${res.stdout}" }
logger.error { "STDERR:\n${res.stderr}" }
logger.error { "Partial output is kept at '$output'" }
throw EtsIrGenerationException(
"$failure\nOutput: '$output'" +
try {
val res = ProcessUtil.run(cmd, timeout = timeout)
val failure = when {
res.isTimeout -> "EtsIR generation ($provider) timed out after $timeout"
res.exitCode != 0 -> "EtsIR generation ($provider) failed with exit code ${res.exitCode}"
else -> null
}
if (failure != null) {
// Partial output may be useful for diagnostics when the caller keeps it.
logger.error { "$failure\nCommand: ${cmd.joinToString(" ")}" }
logger.error { "STDOUT:\n${res.stdout}" }
logger.error { "STDERR:\n${res.stderr}" }
if (keepPartialOutputOnFailure) {
logger.error { "Partial output is kept at '$output'" }
}
val message = "$failure\nOutput: '$output'" +
"\nSTDOUT:\n${res.stdout}" +
"\nSTDERR:\n${res.stderr}"
)
if (res.isTimeout) {
throw EtsIrGenerationTimeoutException(message)
}
throw EtsIrGenerationException(message)
}
return output
} catch (failure: Throwable) {
if (!keepPartialOutputOnFailure) {
try {
if (isProject) {
check(output.toFile().deleteRecursively()) { "Could not remove partial EtsIR output '$output'" }
} else {
Files.deleteIfExists(output)
}
} catch (cleanupFailure: Throwable) {
failure.addSuppressed(cleanupFailure)
}
}
throw failure
}
return output
}

/**
Expand Down
145 changes: 143 additions & 2 deletions jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsIrGenerationTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -17,32 +17,173 @@
package org.jacodb.ets.test

import org.jacodb.ets.utils.EtsIrGenerationException
import org.jacodb.ets.utils.EtsIrGenerationTimeoutException
import org.jacodb.ets.utils.EtsIrProvider
import org.jacodb.ets.utils.generateEtsIR
import org.junit.jupiter.api.Test
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
import java.util.concurrent.atomic.AtomicReference
import kotlin.io.path.createDirectories
import kotlin.io.path.createTempDirectory
import kotlin.io.path.readText
import kotlin.io.path.writeText
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertTrue
import kotlin.time.Duration.Companion.milliseconds

class EtsIrGenerationTest {
@Test
fun `successful generation returns output to caller`() {
val frontend = createTempDirectory("successful-ets-frontend")
frontend.resolve("dist").createDirectories()
frontend.resolve("dist/index.js").writeText(
"require('fs').writeFileSync(process.argv.at(process.argv.at(-1) === '-v' ? -2 : -1), '{}');"
)
val source = frontend.resolve("input.ts").also { it.writeText("const value = 1;") }

System.setProperty("ets.frontend.dir", frontend.toString())
try {
val output = generateEtsIR(
projectPath = source,
provider = EtsIrProvider.TS_FRONTEND,
keepPartialOutputOnFailure = false,
)

assertEquals("{}", output.readText())
Files.delete(output)
} finally {
System.clearProperty("ets.frontend.dir")
}
}

@Test
fun `generation fails instead of returning an invalid output path`() {
val frontend = createTempDirectory("failing-ets-frontend")
frontend.resolve("dist").createDirectories()
frontend.resolve("dist/index.js").writeText("console.error('frontend failed'); process.exit(7);")
frontend.resolve("dist/index.js").writeText(
"require('fs').writeFileSync(process.argv.at(process.argv.at(-1) === '-v' ? -2 : -1), 'partial'); console.error('frontend failed'); process.exit(7);"
)
val source = frontend.resolve("input.ts").also { it.writeText("const value = 1;") }

System.setProperty("ets.frontend.dir", frontend.toString())
try {
val error = assertFailsWith<EtsIrGenerationException> {
generateEtsIR(source, provider = EtsIrProvider.TS_FRONTEND)
generateEtsIR(projectPath = source, provider = EtsIrProvider.TS_FRONTEND)
}
assertFalse(error is EtsIrGenerationTimeoutException)
assertTrue(error.message.orEmpty().contains("exit code 7"))
assertTrue(error.message.orEmpty().contains("frontend failed"))
val output = outputPath(error)
assertEquals("partial", output.readText())
Files.delete(output)
} finally {
System.clearProperty("ets.frontend.dir")
}
}

@Test
fun `process deadline has a typed generation failure`() {
val frontend = createTempDirectory("slow-ets-frontend")
frontend.resolve("dist").createDirectories()
frontend.resolve("dist/index.js").writeText(
"require('fs').writeFileSync(process.argv.at(process.argv.at(-1) === '-v' ? -2 : -1), 'partial'); setTimeout(() => {}, 5000);"
)
val source = frontend.resolve("input.ts").also { it.writeText("const value = 1;") }

System.setProperty("ets.frontend.dir", frontend.toString())
try {
val error = assertFailsWith<EtsIrGenerationTimeoutException> {
generateEtsIR(
projectPath = source,
provider = EtsIrProvider.TS_FRONTEND,
timeout = 200.milliseconds,
keepPartialOutputOnFailure = false,
)
}

assertTrue(error.message.orEmpty().contains("timed out"))
assertFalse(Files.exists(outputPath(error)))
} finally {
System.clearProperty("ets.frontend.dir")
}
}

@Test
fun `failed generation removes partial output when requested`() {
val frontend = createTempDirectory("cleaned-ets-frontend")
frontend.resolve("dist").createDirectories()
frontend.resolve("dist/index.js").writeText(
"require('fs').writeFileSync(process.argv.at(process.argv.at(-1) === '-v' ? -2 : -1), 'partial'); process.exit(7);"
)
val source = frontend.resolve("input.ts").also { it.writeText("const value = 1;") }

System.setProperty("ets.frontend.dir", frontend.toString())
try {
val error = assertFailsWith<EtsIrGenerationException> {
generateEtsIR(
projectPath = source,
provider = EtsIrProvider.TS_FRONTEND,
keepPartialOutputOnFailure = false,
)
}

assertTrue(error.message.orEmpty().contains("exit code 7"))
assertFalse(Files.exists(outputPath(error)))
} finally {
System.clearProperty("ets.frontend.dir")
}
}

@Test
fun `interrupted generation removes partial output when requested`() {
val frontend = createTempDirectory("interrupted-ets-frontend")
frontend.resolve("dist").createDirectories()
frontend.resolve("dist/index.js").writeText(
"require('fs').writeFileSync(__dirname + '/../output-path', process.argv.at(process.argv.at(-1) === '-v' ? -2 : -1));" +
"require('fs').writeFileSync(process.argv.at(process.argv.at(-1) === '-v' ? -2 : -1), 'partial'); setTimeout(() => {}, 5000);"
)
val source = frontend.resolve("input.ts").also { it.writeText("const value = 1;") }
val outputMarker = frontend.resolve("output-path")
val failure = AtomicReference<Throwable?>()

System.setProperty("ets.frontend.dir", frontend.toString())
try {
val worker = Thread {
try {
generateEtsIR(
projectPath = source,
provider = EtsIrProvider.TS_FRONTEND,
keepPartialOutputOnFailure = false,
)
} catch (error: Throwable) {
failure.set(error)
}
}
worker.start()
try {
val deadline = System.nanoTime() + 5_000_000_000L
while (!Files.exists(outputMarker) && System.nanoTime() < deadline) {
Thread.sleep(10)
}
assertTrue(Files.exists(outputMarker))
} finally {
worker.interrupt()
worker.join(5_000)
}

assertFalse(worker.isAlive)
assertTrue(failure.get() is InterruptedException)
assertFalse(Files.exists(Paths.get(outputMarker.readText())))
} finally {
System.clearProperty("ets.frontend.dir")
}
}

private fun outputPath(error: Throwable): Path = Paths.get(
error.message.orEmpty().substringAfter("Output: '").substringBefore("'"),
)
}
Loading