Skip to content

Commit dac5018

Browse files
authored
feat: save timeout queries for offline solving (#552)
1 parent 9b4f241 commit dac5018

2 files changed

Lines changed: 75 additions & 23 deletions

File tree

src/halmos/__main__.py

Lines changed: 57 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
ZeroExt,
3636
eq,
3737
set_option,
38+
unknown,
3839
unsat,
3940
)
4041

@@ -107,6 +108,7 @@
107108
InvariantTestingContext,
108109
PathContext,
109110
SolverOutput,
111+
dirname,
110112
solve_end_to_end,
111113
solve_low_level,
112114
)
@@ -126,21 +128,20 @@
126128
Word,
127129
address,
128130
color_error,
131+
color_good,
132+
color_info,
133+
color_warn,
129134
con,
130135
con_addr,
131136
create_solver,
132-
cyan,
133137
extract_bytes,
134-
green,
135138
hexify,
136139
indent_text,
137140
int_of,
138-
red,
139141
smt_and,
140142
smt_or,
141143
uid,
142144
unbox_int,
143-
yellow,
144145
)
145146

146147
BAD_FILE_DESCRIPTOR = 9
@@ -646,11 +647,11 @@ def _compute_frontier(ctx: ContractContext, depth: int) -> Iterator[Exec]:
646647
for idx, pre_ex in enumerate(curr_exs):
647648
ui.update_status(
648649
f"{contract_name}: "
649-
f"depth: {cyan(depth)} | "
650-
f"starting states: {cyan(len(curr_exs))} | "
651-
f"unique states: {cyan(len(visited))} | "
652-
f"frontier states: {cyan(len(next_exs))} | "
653-
f"completed paths: {cyan(idx)} "
650+
f"depth: {color_info(depth)} | "
651+
f"starting states: {color_info(len(curr_exs))} | "
652+
f"unique states: {color_info(len(visited))} | "
653+
f"frontier states: {color_info(len(next_exs))} | "
654+
f"completed paths: {color_info(idx)} "
654655
)
655656

656657
for addr in resolve_target_contracts(ctx.inv_ctx, pre_ex):
@@ -908,6 +909,7 @@ def _solve_end_to_end_callback(
908909
raise
909910

910911
result, model = solver_output.result, solver_output.model
912+
path_id = solver_output.path_id
911913

912914
# keep track of the solver outputs, so that we can display PASS/FAIL/TIMEOUT/ERROR later
913915
ctx.solver_outputs.append(solver_output)
@@ -918,11 +920,17 @@ def _solve_end_to_end_callback(
918920
return
919921

920922
if result == "err":
923+
self._save_failed_query(path_id, solver_output, "error")
921924
error(
922925
f"solver error: {solver_output.error} (returncode={solver_output.returncode})"
923926
)
924927
return
925928

929+
# handle "unknown" (timeout) result
930+
if result == unknown:
931+
self._save_failed_query(path_id, solver_output, "timeout")
932+
return
933+
926934
# model could be an empty dict here, so compare to None explicitly
927935
if model is None:
928936
return
@@ -936,14 +944,13 @@ def _solve_end_to_end_callback(
936944
if description:
937945
print(description)
938946

939-
path_id = solver_output.path_id
940947
if args.verbose >= VERBOSITY_TRACE_COUNTEREXAMPLE:
941948
pid_str = f" #{path_id}" if args.verbose >= VERBOSITY_TRACE_PATHS else ""
942949
print(f"Trace{pid_str}:")
943950
print(ctx.traces[path_id], end="")
944951

945952
if model.is_valid:
946-
print(red(f"Counterexample: {model}"))
953+
print(color_error(f"Counterexample: {model}"))
947954
ctx.valid_counterexamples.append(model)
948955

949956
# add the stacks from the temporary flamegraph to the global one
@@ -966,6 +973,33 @@ def _solve_end_to_end_callback(
966973
if sequence := ctx.call_sequences[path_id]:
967974
print(f"Sequence:\n{sequence}")
968975

976+
def _save_failed_query(
977+
self, path_id: int, solver_output: SolverOutput, failure_type: str
978+
) -> None:
979+
"""Save query and call sequence to a separate directory for debugging."""
980+
ctx = self.ctx
981+
args = ctx.args
982+
983+
debug_dir = f"{dirname(ctx.solving_ctx.dump_dir)}-{failure_type}"
984+
os.makedirs(debug_dir, exist_ok=True)
985+
query_file = solver_output.query_file
986+
debug_query_file = os.path.join(debug_dir, os.path.basename(query_file))
987+
try:
988+
shutil.copy2(query_file, debug_query_file)
989+
except Exception as e:
990+
error(f"Could not copy failed query to {failure_type} directory: {e}")
991+
992+
# save call sequence to a separate file for debugging context
993+
if call_sequence := ctx.call_sequences.get(path_id):
994+
call_seq_file = f"{debug_query_file}.callseq"
995+
try:
996+
with open(call_seq_file, "w") as f:
997+
f.write(call_sequence)
998+
if args.verbose >= VERBOSITY_TRACE_COUNTEREXAMPLE:
999+
f.write(f"\nTrace:\n{ctx.traces[path_id]}")
1000+
except Exception as e:
1001+
error(f"Could not save call sequence: {e}")
1002+
9691003

9701004
def run_test(ctx: FunctionContext) -> TestResult:
9711005
args = ctx.args
@@ -1141,26 +1175,26 @@ def run_test(ctx: FunctionContext) -> TestResult:
11411175

11421176
counter = Counter(str(m.result) for m in ctx.solver_outputs)
11431177
if counter["sat"] > 0:
1144-
passfail = red("[FAIL]")
1178+
passfail = color_error("[FAIL]")
11451179
exitcode = Exitcode.COUNTEREXAMPLE.value
11461180
elif counter["err"] > 0:
1147-
passfail = red("[ERROR]")
1181+
passfail = color_error("[ERROR]")
11481182
exitcode = Exitcode.EXCEPTION.value
11491183
elif counter["unknown"] > 0:
1150-
passfail = yellow("[TIMEOUT]")
1184+
passfail = color_warn("[TIMEOUT]")
11511185
exitcode = Exitcode.TIMEOUT.value
11521186
elif len(stuck) > 0:
1153-
passfail = red("[ERROR]")
1187+
passfail = color_error("[ERROR]")
11541188
exitcode = Exitcode.STUCK.value
11551189
elif normal == 0:
1156-
passfail = red("[ERROR]")
1190+
passfail = color_error("[ERROR]")
11571191
exitcode = Exitcode.REVERT_ALL.value
11581192
warn_code(
11591193
REVERT_ALL,
11601194
f"{funsig}: all paths have been reverted; the setup state or inputs may have been too restrictive.",
11611195
)
11621196
else:
1163-
passfail = green("[PASS]")
1197+
passfail = color_good("[PASS]")
11641198
exitcode = Exitcode.PASS.value
11651199

11661200
timer.stop()
@@ -1172,6 +1206,12 @@ def run_test(ctx: FunctionContext) -> TestResult:
11721206
f"bounds: [{', '.join([str(x) for x in dyn_params])}])"
11731207
)
11741208

1209+
if exitcode in (Exitcode.TIMEOUT.value, Exitcode.EXCEPTION.value):
1210+
# print query directory information
1211+
failure_type = "timeout" if exitcode == Exitcode.TIMEOUT.value else "error"
1212+
query_dir = f"{dirname(ctx.solving_ctx.dump_dir)}-{failure_type}"
1213+
print(color_info(f"{failure_type.capitalize()} queries saved in: {query_dir}"))
1214+
11751215
for path_id, _, err in stuck:
11761216
warn_code(INTERNAL_ERROR, f"Encountered {type(err).__name__}: {err}")
11771217
if args.print_blocked_states:

src/halmos/solve.py

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,9 @@ class SolverOutput:
313313
# references to Exec objects past the lifetime of the path
314314
path_id: int
315315

316+
# input query file path
317+
query_file: str
318+
316319
# solver model
317320
model: PotentialModel | None = None
318321

@@ -331,21 +334,27 @@ def from_result(
331334
first_line = stdout[:newline_idx] if newline_idx != -1 else stdout
332335

333336
args, path_id = path_ctx.args, path_ctx.path_id
337+
query_file = str(path_ctx.dump_file)
338+
334339
if args.verbose >= 1:
335340
debug(f" {first_line}")
336341

337342
match first_line:
338343
case "unsat":
339344
unsat_core = parse_unsat_core(stdout) if args.cache_solver else None
340-
return SolverOutput(unsat, returncode, path_id, unsat_core=unsat_core)
345+
return SolverOutput(
346+
unsat, returncode, path_id, query_file, unsat_core=unsat_core
347+
)
341348
case "sat":
342349
is_valid = is_model_valid(stdout)
343350
model = PotentialModel(model=parse_model_str(stdout), is_valid=is_valid)
344-
return SolverOutput(sat, returncode, path_id, model=model)
351+
return SolverOutput(sat, returncode, path_id, query_file, model=model)
345352
case "unknown":
346-
return SolverOutput(unknown, returncode, path_id)
353+
return SolverOutput(unknown, returncode, path_id, query_file)
347354
case _:
348-
return SolverOutput("err", returncode, path_id, error=stderr)
355+
return SolverOutput(
356+
"err", returncode, path_id, query_file, error=stderr
357+
)
349358

350359

351360
def parse_const_value(value: str) -> int:
@@ -504,7 +513,10 @@ def solve_low_level(path_ctx: PathContext) -> SolverOutput:
504513
stdout, stderr, returncode = future.result()
505514
except subprocess.TimeoutExpired:
506515
return SolverOutput(
507-
result=unknown, returncode=EXIT_TIMEDOUT, path_id=path_ctx.path_id
516+
result=unknown,
517+
returncode=EXIT_TIMEDOUT,
518+
path_id=path_ctx.path_id,
519+
query_file=smt2_filename,
508520
)
509521

510522
# save solver stdout to file
@@ -536,7 +548,7 @@ def solve_end_to_end(ctx: PathContext) -> SolverOutput:
536548
# if the query contains an unsat-core, it is unsat; no need to run the solver
537549
if check_unsat_cores(query, ctx.solving_ctx.unsat_cores):
538550
verbose(" Already proven unsat")
539-
return SolverOutput(unsat, 0, path_id)
551+
return SolverOutput(unsat, 0, path_id, str(ctx.dump_file))
540552

541553
solver_output = solve_low_level(ctx)
542554
result, model = solver_output.result, solver_output.model

0 commit comments

Comments
 (0)