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
11 changes: 10 additions & 1 deletion usvm-ts-fast-check/fast-check-adapter/src/js-value.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,16 @@ export function decodeJsNumber(taggedNumber: unknown, path = 'number'): number {
);
}

return bitsToDouble(taggedNumber.bits);
const finiteValue = bitsToDouble(taggedNumber.bits);
if (!Number.isFinite(finiteValue)) {
throw protocolError(
adapterDiagnostic.jsNumberEncodingInvalid,
'Finite JavaScript numbers require finite IEEE-754 bits',
path,
);
}

return finiteValue;

case 'nan':
requireNoBits(taggedNumber, path);
Expand Down
16 changes: 16 additions & 0 deletions usvm-ts-fast-check/fast-check-adapter/test/js-value.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,22 @@ test('tagged finite numbers require exactly sixteen lowercase hexadecimal digits
}
});

test('finite tags reject nonfinite bit patterns at the tagged input boundary', () => {
for (const bits of ['7ff0000000000000', 'fff0000000000000', '7ff8000000000000']) {
assert.throws(
() => decodeJsValue({ kind: 'number', value: 'finite', bits }),
/js-number\.encoding\.invalid/,
);
assert.throws(
() => decodeJsValue({
kind: 'array',
elements: [{ kind: 'array', elements: [{ kind: 'number', value: 'finite', bits }] }],
}),
/js-number\.encoding\.invalid/,
);
}
});

test('unknown tagged value kinds are rejected explicitly', () => {
assert.throws(() => decodeJsValue({ kind: 'symbol' }), /js-value\.kind\.unknown/);
});
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import org.usvm.ts.pbt.backend.PropertyRunConfiguration
import org.usvm.ts.pbt.backend.PropertyRunResult
import org.usvm.ts.pbt.manifest.toManifest
import org.usvm.ts.pbt.model.JsConcreteValue
import org.usvm.ts.pbt.model.JsNumberKind
import org.usvm.ts.pbt.model.PropertyDefinition
import org.usvm.ts.pbt.model.accepts
import org.usvm.ts.pbt.model.contains
Expand Down Expand Up @@ -159,7 +158,7 @@ class FastCheckBackend(
value: JsConcreteValue,
path: String,
) {
if (value is JsConcreteValue.Number && !hasValidEncoding(value)) {
if (value is JsConcreteValue.Number && !value.number.hasValidEncoding()) {
throw invalidRequest(
code = FastCheckDiagnosticCode.BACKEND_EXAMPLES_VALUE_INVALID,
message = "Explicit example contains an invalid tagged JavaScript number",
Expand All @@ -179,11 +178,6 @@ class FastCheckBackend(
}
}

private fun hasValidEncoding(value: JsConcreteValue.Number): Boolean = when (value.number.value) {
JsNumberKind.FINITE -> value.number.bits?.matches(FINITE_NUMBER_BITS_REGEX) == true
else -> value.number.bits == null
}

private fun invalidRequest(
code: String,
message: String,
Expand All @@ -200,7 +194,6 @@ class FastCheckBackend(
companion object {
const val FAST_CHECK_BACKEND_ID = "fast-check"

private val FINITE_NUMBER_BITS_REGEX = Regex("[0-9a-f]{16}")
private val SHA256_REGEX = Regex("[0-9a-f]{64}")
private const val MAX_OBSERVED_INVOCATIONS = 64
private const val MAX_OBSERVED_POINTS = 8
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import org.usvm.ts.pbt.coverage.mergeCoverageDiagnostics
import java.io.IOException
import java.nio.file.Files
import java.nio.file.Path
import java.util.concurrent.TimeUnit

/** Prepared c8 invocation and the temporary artifacts produced by one property run. */
internal class FastCheckCoverageSession private constructor(
Expand Down Expand Up @@ -172,59 +171,46 @@ internal class FastCheckCoverageSession private constructor(
nodeExecutable: String,
request: FastCheckExecutionRequest,
): String {
val process = try {
ProcessBuilder(nodeExecutable, "--version").start()
} catch (error: IOException) {
failPreparation(
request = request,
code = FastCheckDiagnosticCode.COVERAGE_RUNTIME_VERSION_UNAVAILABLE,
message = "Cannot query the Node.js runtime version: ${error.message}",
cause = error,
val transport = FastCheckProcessTransport(
nodeExecutable = nodeExecutable,
maxRequestBytes = 1,
maxStdoutBytes = MAX_VERSION_OUTPUT_BYTES,
maxStderrBytes = MAX_VERSION_OUTPUT_BYTES,
shutdownGraceMillis = VERSION_SHUTDOWN_GRACE_MILLIS,
useProcessSupervisor = false,
)
val output = try {
transport.invoke(
command = listOf(nodeExecutable, "--version"),
request = "",
timeoutMillis = NODE_VERSION_TIMEOUT_MILLIS,
reportedTimeoutMillis = NODE_VERSION_TIMEOUT_MILLIS,
description = "Node.js version probe",
)
}

try {
awaitNodeVersion(process, request)
val version = process.inputStream.bufferedReader(Charsets.UTF_8).use { input ->
input.readLine().orEmpty().trim()
}
if (process.exitValue() != 0 || version.isBlank()) {
failPreparation(
request = request,
code = FastCheckDiagnosticCode.COVERAGE_RUNTIME_VERSION_UNAVAILABLE,
message = "Cannot query the Node.js runtime version",
)
}

return requireSupportedNodeVersion(version, request)
} finally {
runCatching { process.outputStream.close() }
runCatching { process.inputStream.close() }
runCatching { process.errorStream.close() }
}
}

private fun awaitNodeVersion(process: Process, request: FastCheckExecutionRequest) {
val completed = try {
process.waitFor(NODE_VERSION_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS)
} catch (error: InterruptedException) {
Thread.currentThread().interrupt()
} catch (error: FastCheckTransportException) {
val interrupted = error.code == FastCheckDiagnosticCode.BACKEND_PROCESS_INTERRUPTED
failPreparation(
request = request,
code = FastCheckDiagnosticCode.BACKEND_PROCESS_INTERRUPTED,
message = "Interrupted while querying the Node.js runtime version",
kind = BackendErrorKind.PROCESS_FAILURE,
code = if (interrupted) {
error.code
} else {
FastCheckDiagnosticCode.COVERAGE_RUNTIME_VERSION_UNAVAILABLE
},
message = "Cannot query the Node.js runtime version: ${error.message}",
kind = if (interrupted) BackendErrorKind.PROCESS_FAILURE else BackendErrorKind.COVERAGE,
cause = error,
)
}
if (!completed) {
process.destroyForcibly()
val version = output.stdout.trim()
if (output.exitCode != 0 || version.isBlank()) {
failPreparation(
request = request,
code = FastCheckDiagnosticCode.COVERAGE_RUNTIME_VERSION_UNAVAILABLE,
message = "Timed out while querying the Node.js runtime version",
message = "Cannot query the Node.js runtime version",
)
}

return requireSupportedNodeVersion(version, request)
}

private fun requireSupportedNodeVersion(version: String, request: FastCheckExecutionRequest): String {
Expand Down Expand Up @@ -270,6 +256,8 @@ internal class FastCheckCoverageSession private constructor(
)

private const val NODE_VERSION_TIMEOUT_MILLIS = 5_000L
private const val VERSION_SHUTDOWN_GRACE_MILLIS = 100L
private const val MAX_VERSION_OUTPUT_BYTES = 1_000
private const val MINIMUM_NODE_MAJOR_VERSION = 18
private const val MINIMUM_NODE_MINOR_VERSION = 18
private val NODE_VERSION_PATTERN = Regex("""^v(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$""")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ internal class FastCheckProcessTransport(
private val maxStdoutBytes: Int,
private val maxStderrBytes: Int,
private val shutdownGraceMillis: Long,
private val useProcessSupervisor: Boolean = true,
) {
init {
require(maxRequestBytes > 0) { "Maximum request size must be positive" }
Expand Down Expand Up @@ -91,7 +92,7 @@ internal class FastCheckProcessTransport(
tasks.all.forEach { task -> task.cancel() }
terminate(managedProcess, deadlineNanos)
closeStreams(managedProcess.process)
runCatching { Files.deleteIfExists(managedProcess.processGroupFile) }
managedProcess.processGroupFile?.let { file -> runCatching { Files.deleteIfExists(file) } }
executor.shutdownNow()
}
}
Expand Down Expand Up @@ -193,6 +194,16 @@ internal class FastCheckProcessTransport(
supervisedCommand: List<String>,
description: String,
): SupervisedProcessHandle {
if (!useProcessSupervisor) {
val process = try {
ProcessBuilder(supervisedCommand).start()
} catch (error: IOException) {
processStartFailure(description, error)
}

return SupervisedProcessHandle(process = process, processGroupFile = null)
}

val processGroupFile = try {
Files.createTempFile(PROCESS_GROUP_FILE_PREFIX, ".pid")
} catch (error: IOException) {
Expand Down Expand Up @@ -228,6 +239,13 @@ internal class FastCheckProcessTransport(

private fun terminate(managedProcess: SupervisedProcessHandle, deadlineNanos: Long) {
val process = managedProcess.process
if (managedProcess.processGroupFile == null) {
process.toHandle().descendants().forEach { child -> child.destroyForcibly() }
if (process.isAlive) process.destroyForcibly()
awaitProcessExit(process, deadlineNanos)
return
}

if (!process.isAlive) {
forceTerminateOwnedProcessGroup(managedProcess.processGroupFile, deadlineNanos)
return
Expand Down Expand Up @@ -274,6 +292,15 @@ internal class FastCheckProcessTransport(
}

private fun awaitProcessExit(process: Process, deadlineNanos: Long): Boolean {
val wasInterrupted = Thread.interrupted()
try {
return pollProcessExit(process, deadlineNanos)
} finally {
if (wasInterrupted) Thread.currentThread().interrupt()
}
}

private fun pollProcessExit(process: Process, deadlineNanos: Long): Boolean {
while (process.isAlive) {
val waitMillis = minOf(remainingMillis(deadlineNanos), PROCESS_POLL_MILLIS)
if (waitMillis == 0L) return false
Expand Down Expand Up @@ -318,7 +345,7 @@ internal class FastCheckProcessTransport(

private data class SupervisedProcessHandle(
val process: Process,
val processGroupFile: Path,
val processGroupFile: Path?,
)

private data class ProcessIoTasks(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -193,19 +193,21 @@ class FastCheckBackendTest {
assertEquals(BackendErrorKind.INVALID_REQUEST, error.kind)
assertEquals("backend.examples.arity", error.code)

val invalidNumber = JsConcreteValue.Number(
JsNumber(value = JsNumberKind.FINITE, bits = "invalid"),
)
val encodingError = assertFailsWith<PbtBackendException> {
missingNodeBackend.run(
property = property(predicate = "alwaysTrue"),
configuration = configuration.copy(examples = listOf(listOf(invalidNumber))),
for (bits in listOf("invalid", "7ff0000000000000", "fff0000000000000", "7ff8000000000000")) {
val invalidNumber = JsConcreteValue.Number(
JsNumber(value = JsNumberKind.FINITE, bits = bits),
)
val encodingError = assertFailsWith<PbtBackendException> {
missingNodeBackend.run(
property = property(predicate = "alwaysTrue"),
configuration = configuration.copy(examples = listOf(listOf(invalidNumber))),
)
}

assertEquals(BackendErrorKind.INVALID_REQUEST, encodingError.kind)
assertEquals("backend.examples.value.invalid", encodingError.code)
assertEquals("examples[0][0]", encodingError.path)
}

assertEquals(BackendErrorKind.INVALID_REQUEST, encodingError.kind)
assertEquals("backend.examples.value.invalid", encodingError.code)
assertEquals("examples[0][0]", encodingError.path)
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ import org.usvm.ts.pbt.testResourcesRoot
import java.nio.file.Files
import java.nio.file.Path
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
import kotlin.concurrent.thread
import kotlin.io.path.createDirectories
import kotlin.io.path.createFile
import kotlin.io.path.createTempDirectory
Expand All @@ -26,6 +29,49 @@ import kotlin.test.assertFalse
import kotlin.test.assertTrue

class FastCheckProcessClientTest {
@Test
fun `interrupted coverage version probe terminates its process and preserves interruption`() {
val runtime = createTempDirectory(prefix = "interrupted-version-probe-")
val node = runtime.resolve("fake-node")
val pidFile = runtime.resolve("pid")
val failure = AtomicReference<Throwable>()
val interrupted = AtomicBoolean()
node.writeText("#!/bin/sh\necho ${'$'}${'$'} > '$pidFile'\nexec sleep 30\n")
check(node.toFile().setExecutable(true))
val worker = thread(name = "interrupted-version-probe") {
try {
FastCheckProcessClient(
nodeExecutable = node.toString(),
adapterEntryPoint = runtime.resolve("unused.js"),
).check(validRequest.copy(coverageRequest = PropertyCoverageRequest()))
} catch (error: Exception) {
failure.set(error)
interrupted.set(Thread.currentThread().isInterrupted)
}
}

try {
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5)
while (!processIsAlive(pidFile) && worker.isAlive && System.nanoTime() < deadline) {
Thread.sleep(10)
}
assertTrue(processIsAlive(pidFile), "Version probe did not start")

worker.interrupt()
worker.join(2_000)

assertFalse(worker.isAlive, "Interrupted version probe did not finish")
assertEquals("backend.process.interrupted", (failure.get() as PbtBackendException).code)
assertTrue(interrupted.get())
assertFalse(processIsAlive(pidFile), "Version probe process survived interruption")
} finally {
worker.interrupt()
terminateProcess(pidFile)
worker.join(2_000)
runtime.toFile().deleteRecursively()
}
}

@Test
fun `process client rejects a shutdown grace period outside the Node timer range`() {
assertFailsWith<IllegalArgumentException> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import java.nio.file.Path
/** Selects relevant report entries and assembles the coverage artifact for one property run. */
internal class IstanbulCoverageDecoder(
private val context: IstanbulCoverageContext,
private val checkBudget: () -> Unit = {},
) {
private val sourceRoots = context.sourceRoots.map(::normalizeCoveragePath)
private val propertyEntryPoints = context.propertyEntryPointPaths
Expand All @@ -36,6 +37,7 @@ internal class IstanbulCoverageDecoder(
): List<SourceFileCoverage> {
val decodedFiles = buildList {
for ((reportKey, reportEntry) in report) {
checkBudget()
val decodedFile = decodeFile(reportKey, reportEntry, diagnostics)
if (decodedFile != null) {
add(decodedFile)
Expand Down Expand Up @@ -71,6 +73,7 @@ internal class IstanbulCoverageDecoder(
file = fileObject,
path = path,
reportKey = reportKey,
checkBudget = checkBudget,
)

return decoder.decode()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,18 @@ class CoverageArtifactException(
}
}

/** Decodes and filters one source-mapped Istanbul JSON report produced by an isolated c8 run. */
/**
* Decodes and filters one source-mapped Istanbul JSON report produced by an isolated c8 run.
* [checkBudget] can cancel between I/O, parsing and entry decoding; the bounded JSON parse itself is not preemptible.
*/
fun decodeIstanbulCoverageReport(
reportPath: Path,
context: IstanbulCoverageContext,
checkBudget: () -> Unit = {},
maxReportBytes: Long = MAX_COVERAGE_REPORT_BYTES,
): PropertyCoverageArtifact {
val report = IstanbulCoverageReportReader.read(reportPath)
val decoder = IstanbulCoverageDecoder(context)
val report = IstanbulCoverageReportReader.read(reportPath, checkBudget, maxReportBytes)
val decoder = IstanbulCoverageDecoder(context, checkBudget)

return decoder.decode(report)
}
Loading
Loading