Skip to content
Open
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
43 changes: 34 additions & 9 deletions usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsExperiment.kt
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,7 @@ internal class CallsExperimentRunner(
private val symbolicEngine: CallsSymbolicEngine,
private val targetReplayer: CallsTargetReplayer,
private val runtimeToolRevision: String = CallsBuildIdentity.toolRevision,
private val eventRecordWriter: ((Path, CallsRawRecord) -> Unit)? = null,
) {
fun run(
manifest: CallsExperimentManifest,
Expand Down Expand Up @@ -387,7 +388,10 @@ internal class CallsExperimentRunner(
target = target,
seed = seed,
profile = profile,
appendUnknownCall = { event -> append(rawOutput, event) },
appendUnknownCall = { event ->
val writer = eventRecordWriter
if (writer == null) append(rawOutput, event) else writer(rawOutput, event)
},
)

append(rawOutput, result)
Expand Down Expand Up @@ -417,18 +421,22 @@ internal class CallsExperimentRunner(
seed = seed,
budget = manifest.perTargetBudgetMillis.milliseconds,
)
val eventWrites = CallsEventWriteTracker()
val unknownCallSink = callsUnknownCallEventSink(
cell = request.cellIdentity(experimentId = manifest.experimentId),
appendAndFlush = appendUnknownCall,
)
val runtimeLimitationSink = callsRuntimeLimitationEventSink(
cell = request.cellIdentity(experimentId = manifest.experimentId),
appendAndFlush = appendUnknownCall,
)
val symbolic = symbolicEngine.search(
request.copy(
unknownCallEventSink = callsUnknownCallEventSink(
cell = request.cellIdentity(experimentId = manifest.experimentId),
appendAndFlush = appendUnknownCall,
),
runtimeLimitationEventSink = callsRuntimeLimitationEventSink(
cell = request.cellIdentity(experimentId = manifest.experimentId),
appendAndFlush = appendUnknownCall,
),
unknownCallEventSink = eventWrites.track(unknownCallSink),
runtimeLimitationEventSink = eventWrites.track(runtimeLimitationSink),
),
)
eventWrites.requireComplete()
val replay = symbolic.inputs?.let { inputs ->
targetReplayer.replay(
sourceRoots = listOf(sourceRoot),
Expand Down Expand Up @@ -477,6 +485,23 @@ internal class CallsExperimentRunner(
}
}

private class CallsEventWriteTracker {
private var firstFailure: Throwable? = null

fun <T> track(sink: (T) -> Unit): (T) -> Unit = { event ->
runCatching { sink(event) }.getOrElse { error ->
if (firstFailure == null) firstFailure = error
throw error
}
}

fun requireComplete() {
firstFailure?.let { error ->
throw IllegalStateException("Could not persist a Calls event", error)
}
}
}

internal data class CallsValidatedRawResults(
val metadata: CallsRunMetadata,
val results: List<CallsTargetResult>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ internal class OriginalTypeScriptTargetReplayer : CallsTargetReplayer {
marker = marker,
resultPath = resultPath,
targetMode = target.mode,
sourceArgumentCount = inputs.size,
),
)
val replayRoots = sourceRoots.mapIndexed { index, root ->
Expand Down Expand Up @@ -344,6 +345,7 @@ internal class OriginalTypeScriptTargetReplayer : CallsTargetReplayer {
marker: String,
resultPath: Path,
targetMode: CallsSourceTargetMode,
sourceArgumentCount: Int,
): String = """
import { writeFileSync } from 'node:fs';
import * as targetModule from ${jsString("./$sourcePath")};
Expand All @@ -361,7 +363,7 @@ internal class OriginalTypeScriptTargetReplayer : CallsTargetReplayer {
let invocation: 'returned' | 'threw' = 'returned';
let caught: unknown;
try {
const result = callable(...args);
const result = callable(...args.slice(0, $sourceArgumentCount));
if (result !== null && (typeof result === 'object' || typeof result === 'function')
&& typeof (result as { then?: unknown }).then === 'function') {
void Promise.resolve(result).catch(() => undefined);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,8 @@ internal class CurrentTsCallsSymbolicEngine(
) : CallsSymbolicEngine {
private val verifiedProjects = mutableMapOf<Path, String>()
private val preparedFunctions = mutableMapOf<CallsFunctionPreflightRequest, CallsFunctionPreparation>()
private val preparedTargets = mutableMapOf<CallsSymbolicPreflightRequest, CallsTargetPreparation>()
private val loadedSources = mutableMapOf<LoadedSourceKey, CallsSourceProject>()
private var preparedTarget: Pair<CallsSymbolicPreflightRequest, CallsTargetPreparation>? = null
private var verifiedNativeFrontendIdentity: String? = null

override fun search(request: CallsSymbolicSearchRequest): CallsSymbolicSearchResult {
Expand Down Expand Up @@ -372,9 +372,8 @@ internal class CurrentTsCallsSymbolicEngine(
}

private fun prepareSafely(request: CallsSymbolicPreflightRequest): CallsTargetPreparation {
preparedTargets[request]?.takeIf {
verifiedProjects[request.sourceRoot] == request.project.revision
}?.let { preparation -> return preparation }
preparedTarget?.takeIf { (cachedRequest, _) -> cachedRequest == request }
?.let { (_, preparation) -> return preparation }

val preparation = runCatching { prepare(request) }.getOrElse { error ->
CallsTargetPreparation.Rejected(
Expand All @@ -383,7 +382,9 @@ internal class CurrentTsCallsSymbolicEngine(
diagnostic = error.message ?: error::class.java.name,
)
}
preparedTargets[request] = preparation
if (preparation !is CallsTargetPreparation.Rejected || preparation.status != CallsSymbolicStatus.TOOL_ERROR) {
preparedTarget = request to preparation
}

return preparation
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,42 @@ class CallsExperimentTest {
assertEquals(4, CallsExperimentAggregator.summarize(rawOutput).resultRows)
}

@Test
fun `runner does not publish completion after an observer write failure`(@TempDir directory: Path) {
val source = Path.of(checkNotNull(javaClass.getResource("/calls/SourceTargetReplayFixture.ts")).toURI())
val file = loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND)
val method = file.allClasses.flatMap { cls -> cls.methods }
.single { candidate -> candidate.name == "completesReturnExpression" }
val callSite = method.cfg.stmts.first { statement -> statement.location.origin != null }
val event = TsUnknownCallEvent(
callSite = callSite,
callee = method.signature,
failureReason = TsUnknownCallFailureReason.METHOD_BODY_UNAVAILABLE,
decision = TsUnknownCallDecision.ResidualFallback(policy = TsResidualCallPolicy.STOP_PATH),
)
val engine = CallsSymbolicEngine { request ->
runCatching { checkNotNull(request.unknownCallEventSink).invoke(event) }
result(status = CallsSymbolicStatus.UNREACHED)
}
val rawOutput = directory.resolve("results.jsonl")

val failure = assertFailsWith<IllegalStateException> {
CallsExperimentRunner(
symbolicEngine = engine,
targetReplayer = CallsTargetReplayer { _, _, _, _, _ -> error("No witness to replay") },
runtimeToolRevision = FIXTURE_TOOL_REVISION,
eventRecordWriter = { _, _ -> error("Event write failed") },
).run(
manifest = manifest(sourceRoot = ".", seeds = listOf(17L)),
manifestDirectory = directory,
rawOutput = rawOutput,
)
}

assertTrue(failure.message.orEmpty().contains("Calls event"))
assertFalse(Files.exists(rawOutput))
}

@Test
fun `legacy source target defaults to entry mode`() {
val encoded = """
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,23 @@ class CallsSourceReplayTest {
assertEquals(CallsReplayStatus.REJECTED, replacement.status, replacement.toString())
}

@Test
fun `replays a zero-input export without the FastCheck placeholder`() {
val fixture = fixture()
val zeroArguments = fixture.target(functionName = "checksArgumentCount", statement = "return 0;")
val extraArgument = fixture.target(functionName = "checksArgumentCount", statement = "return 1;")

val reached = fixture.replay(exportName = "checksArgumentCount", inputs = emptyList(), target = zeroArguments)
val notReached = fixture.replay(
exportName = "checksArgumentCount",
inputs = emptyList(),
target = extraArgument,
)

assertEquals(CallsReplayStatus.CONFIRMED, reached.status, reached.toString())
assertEquals(CallsReplayStatus.REJECTED, notReached.status, notReached.toString())
}

@Test
fun `confirms only the exact source statement reached by original TypeScript`() {
val fixture = fixture()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,35 @@ class CurrentTsCallsSymbolicEngineTest {
}
}

@Test
fun `preflight retries a transient tool error for the same target`() {
val fixture = fixture(
source = "export function answer(): number { return 42; }",
exportName = "answer",
inputs = emptyList(),
targetStatement = "return 42;",
)
val environment = mutableMapOf("ETS_FRONTEND_DIR" to "/unused/frontend")
val engine = CurrentTsCallsSymbolicEngine(
environment = environment::get,
bundledNativeFrontendRevision = "bundled:test",
)
val request = CallsSymbolicPreflightRequest(
sourceRoot = fixture.sourceRoot,
project = fixture.project,
function = fixture.function,
target = fixture.target,
expectedNativeFrontendRevision = "bundled:test",
)

val first = engine.preflight(request)
environment.clear()
val second = engine.preflight(request)

assertEquals(CallsSymbolicPreflightStatus.TOOL_ERROR, first.status)
assertTrue(second.status != CallsSymbolicPreflightStatus.TOOL_ERROR, second.toString())
}

@Test
fun `extracts nonempty generic number array containing zero and replays source`() {
val fixture = fixture(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@ export function throwsAtTarget(): never {
throw new Error('expected');
}

export function checksArgumentCount(): number {
if (arguments.length !== 0) {
return 1;
}
return 0;
}

export function importOnlyTarget(): number {
return 7;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,18 @@ object TsBuiltInUnknownCallModels {
}
private val allModels by lazy { TsUnknownCallModelCatalog(models) }

internal fun hasPartialApproximationModel(
methodName: String,
enclosingClassName: String,
allowUnqualifiedTarget: Boolean = false,
): Boolean =
allModels.hasTarget(
methodName = methodName,
enclosingClassName = enclosingClassName,
failureReason = TsUnknownCallFailureReason.PARTIAL_APPROXIMATION,
allowUnqualifiedTarget = allowUnqualifiedTarget,
)

fun catalog(selection: TsUnknownCallModelSelection = TsUnknownCallModelSelection.All): TsUnknownCallModelCatalog =
if (selection == TsUnknownCallModelSelection.All) allModels else TsUnknownCallModelCatalog(models, selection)
}
50 changes: 1 addition & 49 deletions usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCall.kt
Original file line number Diff line number Diff line change
@@ -1,27 +1,20 @@
package org.usvm.machine.call

import io.ksmt.utils.asExpr
import org.jacodb.ets.model.EtsCallExpr
import org.jacodb.ets.model.EtsClassSignature
import org.jacodb.ets.model.EtsClassType
import org.jacodb.ets.model.EtsInstanceCallExpr
import org.jacodb.ets.model.EtsMethodSignature
import org.jacodb.ets.model.EtsPtrCallExpr
import org.jacodb.ets.model.EtsStmt
import org.jacodb.ets.model.EtsType
import org.jacodb.ets.model.EtsUnclearRefType
import org.jacodb.ets.model.EtsValue
import org.jacodb.ets.utils.CONSTRUCTOR_NAME
import org.usvm.UExpr
import org.usvm.api.mockMethodCall
import org.usvm.api.typeStreamOf
import org.usvm.machine.TsConcreteMethodCallStmt
import org.usvm.machine.TsVirtualMethodCallStmt
import org.usvm.machine.interpreter.TsStepScope
import org.usvm.machine.state.TsMethodResult
import org.usvm.machine.state.TsState
import org.usvm.machine.state.newStmt
import org.usvm.types.singleOrNull

/**
* A call that the regular TypeScript execution pipeline could not execute.
Expand Down Expand Up @@ -150,12 +143,8 @@ internal fun TsUnknownCallDispatcher.dispatch(
is EtsPtrCallExpr -> call.ptr
else -> null
}
val receiverIsDate = (call as? EtsInstanceCallExpr)?.let { instanceCall ->
scope.calcOnState { isDateReceiver(instanceCall, resolvedReceiver) }
} ?: false
val normalizedCallee = call.canonicalizeDateCallee(callee, receiverIsDate)
val unknownCall = TsUnknownCall(
callee = normalizedCallee,
callee = callee,
receiver = receiverSource?.let { TsUnknownCallValue(it, resolvedReceiver) },
arguments = call.args.zip(resolvedArguments) { source, resolved ->
TsUnknownCallValue(source, resolved)
Expand All @@ -168,43 +157,6 @@ internal fun TsUnknownCallDispatcher.dispatch(
return dispatch(scope, unknownCall)
}

internal fun EtsInstanceCallExpr.hasDateReceiver(): Boolean =
instance.name == "Date" || when (val type = instance.type) {
is EtsClassType -> type.signature.name == "Date"
is EtsUnclearRefType -> type.typeName == "Date"
else -> false
}

internal fun TsState.isDateReceiver(
call: EtsInstanceCallExpr,
receiver: UExpr<*>?,
): Boolean {
if (call.hasDateReceiver()) {
return true
}
if (receiver?.sort != ctx.addressSort) {
return false
}

val runtimeType = memory.typeStreamOf(receiver.asExpr(ctx.addressSort)).singleOrNull()
return (runtimeType as? EtsClassType)?.signature?.name == "Date"
}

private fun EtsCallExpr.canonicalizeDateCallee(
callee: EtsMethodSignature,
receiverIsDate: Boolean,
): EtsMethodSignature {
if (callee.enclosingClass.name == "Date") {
return callee
}

if (!receiverIsDate) {
return callee
}

return callee.copy(enclosingClass = EtsClassSignature.UNKNOWN.copy(name = "Date"))
}

internal fun TsUnknownCallDispatcher.dispatch(
scope: TsStepScope,
call: TsVirtualMethodCallStmt,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package org.usvm.machine.call

import org.jacodb.ets.model.EtsFile
import org.jacodb.ets.model.EtsFileSignature
import org.usvm.machine.call.intrinsic.TsDateEtsIrModelFamily
import org.usvm.machine.state.TsState
import java.util.Collections
import java.util.IdentityHashMap
Expand Down Expand Up @@ -74,13 +75,29 @@ class TsUnknownCallModelCatalog(
return candidates[call.callee.enclosingClass.name] ?: candidates[null]
}

internal fun hasTarget(
methodName: String,
enclosingClassName: String,
failureReason: TsUnknownCallFailureReason,
allowUnqualifiedTarget: Boolean,
): Boolean {
val candidates = index[methodName]?.get(failureReason) ?: return false
return enclosingClassName in candidates || (allowUnqualifiedTarget && null in candidates)
}

fun apply(state: TsState, call: TsUnknownCall): TsUnknownCallModelApplication {
val model = select(call) ?: return TsUnknownCallModelApplication.NotApplicable
val directModel = select(call)
val modelCall = if (directModel == null) {
TsDateEtsIrModelFamily.canonicalModelCall(state, call) ?: call
} else {
call
}
val model = directModel ?: select(modelCall) ?: return TsUnknownCallModelApplication.NotApplicable
if (state.isUnknownCallModelActive(model.id)) {
return TsUnknownCallModelApplication.NotApplicable
}

val execution = model.apply(state, call) ?: return TsUnknownCallModelApplication.NotApplicable
val execution = model.apply(state, modelCall) ?: return TsUnknownCallModelApplication.NotApplicable

return TsUnknownCallModelApplication.Applied(
modelId = model.id,
Expand Down
Loading