From fdf86560fb265cf992a856ffddfc8b76cbce7936 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 1 Oct 2026 03:59:14 +0300 Subject: [PATCH 1/4] [ETS] Type frontend process timeouts --- .../org/jacodb/ets/utils/LoadEtsFile.kt | 16 ++++++---- .../jacodb/ets/test/EtsIrGenerationTest.kt | 29 ++++++++++++++++++- 2 files changed, 38 insertions(+), 7 deletions(-) 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..884817210 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 @@ -165,7 +165,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" @@ -233,11 +235,13 @@ fun generateEtsIR( 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'" + - "\nSTDOUT:\n${res.stdout}" + - "\nSTDERR:\n${res.stderr}" - ) + val message = "$failure\nOutput: '$output'" + + "\nSTDOUT:\n${res.stdout}" + + "\nSTDERR:\n${res.stderr}" + if (res.isTimeout) { + throw EtsIrGenerationTimeoutException(message) + } + throw EtsIrGenerationException(message) } 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..1c9fe8f5b 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,6 +17,7 @@ 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 @@ -24,7 +25,9 @@ import kotlin.io.path.createDirectories import kotlin.io.path.createTempDirectory import kotlin.io.path.writeText import kotlin.test.assertFailsWith +import kotlin.test.assertFalse import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.milliseconds class EtsIrGenerationTest { @Test @@ -37,12 +40,36 @@ class EtsIrGenerationTest { 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")) } 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("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, + ) + } + + assertTrue(error.message.orEmpty().contains("timed out")) + } finally { + System.clearProperty("ets.frontend.dir") + } + } } From 214b207f22a2f319240460b67b3fab2ae674ca20 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 1 Oct 2026 04:46:43 +0300 Subject: [PATCH 2/4] [ETS] Allow callers to clean failed frontend output --- .../org/jacodb/ets/utils/LoadEtsFile.kt | 77 +++++++++--- .../jacodb/ets/test/EtsIrGenerationTest.kt | 116 +++++++++++++++++- 2 files changed, 171 insertions(+), 22 deletions(-) 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 884817210..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 @@ -184,6 +185,7 @@ 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, @@ -191,6 +193,25 @@ fun generateEtsIR( 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, + useArkAnalyzerTypeInference: Int? = null, + timeout: Duration? = defaultEtsIrGenerationTimeout(isProject), + provider: EtsIrProvider = defaultProviderFor(projectPath, isProject), ): Path { val script = etsIrSerializerScript(provider) val node = System.getenv(ENV_VAR_NODE_EXECUTABLE) ?: DEFAULT_NODE_EXECUTABLE @@ -222,28 +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'" } - val message = "$failure\nOutput: '$output'" + - "\nSTDOUT:\n${res.stdout}" + - "\nSTDERR:\n${res.stderr}" - if (res.isTimeout) { - throw EtsIrGenerationTimeoutException(message) + 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 EtsIrGenerationException(message) + 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 1c9fe8f5b..882d9276d 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 @@ -21,20 +21,50 @@ 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.util.concurrent.atomic.AtomicReference import kotlin.io.path.createDirectories import kotlin.io.path.createTempDirectory 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(-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("{}", Files.readString(output)) + 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(-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()) @@ -45,6 +75,9 @@ class EtsIrGenerationTest { 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", Files.readString(output)) + Files.delete(output) } finally { System.clearProperty("ets.frontend.dir") } @@ -54,7 +87,9 @@ class EtsIrGenerationTest { fun `process deadline has a typed generation failure`() { val frontend = createTempDirectory("slow-ets-frontend") frontend.resolve("dist").createDirectories() - frontend.resolve("dist/index.js").writeText("setTimeout(() => {}, 5000);") + frontend.resolve("dist/index.js").writeText( + "require('fs').writeFileSync(process.argv.at(-1), 'partial'); setTimeout(() => {}, 5000);" + ) val source = frontend.resolve("input.ts").also { it.writeText("const value = 1;") } System.setProperty("ets.frontend.dir", frontend.toString()) @@ -64,12 +99,89 @@ class EtsIrGenerationTest { 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(-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(-1));" + + "require('fs').writeFileSync(process.argv.at(-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(Path.of(Files.readString(outputMarker)))) + } finally { + System.clearProperty("ets.frontend.dir") + } + } + + private fun outputPath(error: Throwable): Path = Path.of( + error.message.orEmpty().substringAfter("Output: '").substringBefore("'"), + ) } From e9b18faedf2f33ae2a8cf374d68aff80757972de Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 1 Oct 2026 05:24:51 +0300 Subject: [PATCH 3/4] [ETS] Select frontend test output before verbose flag --- .../org/jacodb/ets/test/EtsIrGenerationTest.kt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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 882d9276d..65edc500a 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 @@ -39,7 +39,7 @@ class EtsIrGenerationTest { val frontend = createTempDirectory("successful-ets-frontend") frontend.resolve("dist").createDirectories() frontend.resolve("dist/index.js").writeText( - "require('fs').writeFileSync(process.argv.at(-1), '{}');" + "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;") } @@ -63,7 +63,7 @@ class EtsIrGenerationTest { val frontend = createTempDirectory("failing-ets-frontend") frontend.resolve("dist").createDirectories() frontend.resolve("dist/index.js").writeText( - "require('fs').writeFileSync(process.argv.at(-1), 'partial'); console.error('frontend failed'); process.exit(7);" + "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;") } @@ -88,7 +88,7 @@ class EtsIrGenerationTest { val frontend = createTempDirectory("slow-ets-frontend") frontend.resolve("dist").createDirectories() frontend.resolve("dist/index.js").writeText( - "require('fs').writeFileSync(process.argv.at(-1), 'partial'); setTimeout(() => {}, 5000);" + "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;") } @@ -115,7 +115,7 @@ class EtsIrGenerationTest { val frontend = createTempDirectory("cleaned-ets-frontend") frontend.resolve("dist").createDirectories() frontend.resolve("dist/index.js").writeText( - "require('fs').writeFileSync(process.argv.at(-1), 'partial'); process.exit(7);" + "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;") } @@ -141,8 +141,8 @@ class EtsIrGenerationTest { 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(-1));" + - "require('fs').writeFileSync(process.argv.at(-1), 'partial'); setTimeout(() => {}, 5000);" + "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") From bec946fd783201174d73401dad1d755ca067fef1 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 1 Oct 2026 14:18:00 +0300 Subject: [PATCH 4/4] Keep frontend lifecycle tests compatible with Java 8 --- .../kotlin/org/jacodb/ets/test/EtsIrGenerationTest.kt | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) 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 65edc500a..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 @@ -23,9 +23,11 @@ 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 @@ -51,7 +53,7 @@ class EtsIrGenerationTest { keepPartialOutputOnFailure = false, ) - assertEquals("{}", Files.readString(output)) + assertEquals("{}", output.readText()) Files.delete(output) } finally { System.clearProperty("ets.frontend.dir") @@ -76,7 +78,7 @@ class EtsIrGenerationTest { assertTrue(error.message.orEmpty().contains("exit code 7")) assertTrue(error.message.orEmpty().contains("frontend failed")) val output = outputPath(error) - assertEquals("partial", Files.readString(output)) + assertEquals("partial", output.readText()) Files.delete(output) } finally { System.clearProperty("ets.frontend.dir") @@ -175,13 +177,13 @@ class EtsIrGenerationTest { assertFalse(worker.isAlive) assertTrue(failure.get() is InterruptedException) - assertFalse(Files.exists(Path.of(Files.readString(outputMarker)))) + assertFalse(Files.exists(Paths.get(outputMarker.readText()))) } finally { System.clearProperty("ets.frontend.dir") } } - private fun outputPath(error: Throwable): Path = Path.of( + private fun outputPath(error: Throwable): Path = Paths.get( error.message.orEmpty().substringAfter("Output: '").substringBefore("'"), ) }