diff --git a/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/LoadEtsFile.kt b/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/LoadEtsFile.kt index 5747c19ca..89c231027 100644 --- a/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/LoadEtsFile.kt +++ b/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/LoadEtsFile.kt @@ -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 @@ -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" @@ -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, @@ -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 } /** diff --git a/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsIrGenerationTest.kt b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsIrGenerationTest.kt index e006c8e37..2269c8823 100644 --- a/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsIrGenerationTest.kt +++ b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsIrGenerationTest.kt @@ -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 { - 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 { + 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 { + 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() + + 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("'"), + ) }