Skip to content
Draft
Show file tree
Hide file tree
Changes from 4 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
17 changes: 12 additions & 5 deletions src/halmos/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@
warn_code,
)
from halmos.mapper import BuildOut, DeployAddressMapper
from halmos.processes import ExecutorRegistry, ShutdownError
from halmos.processes import ExecutorRegistry, ShutdownError, get_global_executor
from halmos.sevm import (
EMPTY_BALANCE,
FOUNDRY_CALLER,
Expand Down Expand Up @@ -432,6 +432,7 @@ def setup(ctx: FunctionContext) -> Exec:
path_id=path_id,
query=query,
solving_ctx=ctx.solving_ctx,
tag=ctx.info.sig,
)
solver_output = solve_low_level(path_ctx)
if solver_output.result != unsat:
Expand Down Expand Up @@ -714,11 +715,14 @@ def _compute_frontier(ctx: ContractContext, depth: int) -> Iterator[Exec]:
msg = f"Assertion failure detected in {fun_info.contract_name}.{fun_info.sig}"

try:
# Use a unique tag for this specific probe
probe_tag = f"probe-{fun_info.contract_name}-{fun_info.name}"
handler.handle_assertion_violation(
path_id=path_id,
ex=post_ex,
panic_found=panic_found,
description=msg,
probe_tag=probe_tag,
)
except ShutdownError:
if args.debug:
Expand Down Expand Up @@ -817,6 +821,7 @@ def handle_assertion_violation(
ex: Exec,
panic_found: bool,
description: str = None,
probe_tag: str = None,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. the type hint should be probe_tag: str | None = None
  2. but also ideally it would just have a tag argument, not specifically a probe_tag just for probes. It should work the same for probes and regular tests, it's just that the tag is constructed differently

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated to use generic tag: str | None = None parameter instead of probe-specific naming. The API now works the same for probes and regular tests. (d0262bc)

) -> None:
"""
Handles a potential assertion violation by solving it in a separate process.
Expand Down Expand Up @@ -871,6 +876,7 @@ def handle_assertion_violation(
path_id=path_id,
query=query,
solving_ctx=ctx.solving_ctx,
tag=probe_tag if probe_tag else ctx.info.sig,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

similarly here, it should just pass down the tag is was initialized with and not be aware of the differences between probe tags and test tags

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Simplified to tag=tag if tag else ctx.info.sig - the method now just passes down the tag it was initialized with without being aware of probe vs test differences. (d0262bc)

)

# ShutdownError may be raised here and will be handled by the caller
Expand Down Expand Up @@ -918,7 +924,7 @@ def _solve_end_to_end_callback(
solver_output: SolverOutput = future.result()
result, model = solver_output.result, solver_output.model

if ctx.solving_ctx.executor.is_shutdown():
if get_global_executor().is_shutdown():
# if the thread pool is in the process of shutting down,
# we want to stop processing remaining models/timeouts/errors, etc.
return
Expand Down Expand Up @@ -968,8 +974,8 @@ def _solve_end_to_end_callback(

# we have a valid counterexample, so we are eligible for early exit
if args.early_exit:
debug(f"Shutting down {ctx.info.name}'s solver executor")
ctx.solving_ctx.executor.shutdown(wait=False)
debug(f"Interrupting {ctx.info.name}'s solver queries")
get_global_executor().interrupt(ctx.info.name)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this does not seem like the right tag

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed to use ctx.info.sig instead of ctx.info.name for consistent tagging. (d0262bc)

else:
warn_str = f"Counterexample (potentially invalid): {model}"
warn_code(COUNTEREXAMPLE_INVALID, warn_str)
Expand Down Expand Up @@ -1046,7 +1052,7 @@ def run_test(ctx: FunctionContext) -> TestResult:
path_id = 0 # default value in case we don't enter the loop body
for path_id, ex in enumerate(exs):
# check if early exit is triggered
if ctx.solving_ctx.executor.is_shutdown():
if get_global_executor().is_shutdown():
if args.debug:
print("aborting path exploration, executor has been shutdown")
break
Expand Down Expand Up @@ -1090,6 +1096,7 @@ def run_test(ctx: FunctionContext) -> TestResult:
path_id=path_id,
query=ex.path.to_smt2(args),
solving_ctx=ctx.solving_ctx,
tag=ctx.info.sig,
)
solver_output = solve_low_level(path_ctx)
if solver_output.result != unsat:
Expand Down
48 changes: 46 additions & 2 deletions src/halmos/processes.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ def shutdown_all(self):
class PopenFuture(concurrent.futures.Future):
cmd: list[str]
timeout: float | None # in seconds, None means no timeout
tag: str | None # optional tag for grouping and selective cancellation
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

don't make the tag optional

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Made tag required in PopenFuture constructor. Updated parameter order and added assertion for empty tags. (b421f6e)

process: subprocess.Popen | None
stdout: str | None
stderr: str | None
Expand All @@ -42,10 +43,13 @@ class PopenFuture(concurrent.futures.Future):
end_time: float | None
_exception: Exception | None

def __init__(self, cmd: list[str], timeout: float | None = None):
def __init__(
self, cmd: list[str], timeout: float | None = None, tag: str | None = None
):
super().__init__()
self.cmd = cmd
self.timeout = timeout
self.tag = tag
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we probably want to assert that the tag is not empty

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added assertion assert tag, "tag cannot be empty" in PopenFuture constructor to validate non-empty tags. (b421f6e)

self.process = None
self.stdout = None
self.stderr = None
Expand Down Expand Up @@ -193,6 +197,24 @@ def submit(self, future: PopenFuture) -> PopenFuture:
future.start()
return future

def interrupt(self, tag: str) -> None:
"""Interrupts all futures with the specified tag.

Args:
tag: The tag identifying futures to interrupt.
Futures without tags are not affected.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't want Futures without tags, change this to Futures with a different tag are not affected.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated docstring to "Futures with a different tag are not affected." (b421f6e)

"""
if not tag:
return
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

assert tag instead, it is not optional, and we should not allow empty tags

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed to assert tag, "tag cannot be empty" in interrupt() method to enforce non-empty tags instead of early return. (b421f6e)


with self._lock:
# Find all futures with the matching tag and cancel them
futures_to_cancel = [f for f in self._futures if f.tag == tag]

# Cancel outside the lock to avoid deadlocks
for future in futures_to_cancel:
future.cancel()

def is_shutdown(self) -> bool:
return self._shutdown.is_set()

Expand Down Expand Up @@ -228,6 +250,25 @@ def _join(self):
future.result()


# Global PopenExecutor instance for shared use across all tests and probes
_global_executor: PopenExecutor | None = None
_global_executor_lock = threading.Lock()


def get_global_executor() -> PopenExecutor:
"""Get the global PopenExecutor instance, creating it if necessary."""
global _global_executor

if _global_executor is None:
with _global_executor_lock:
if _global_executor is None:
_global_executor = PopenExecutor()
# Register with the ExecutorRegistry so it gets shut down properly
ExecutorRegistry().register(_global_executor)

return _global_executor
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the lock seems unnecessary.

Wouldn't this work?

_executor = PopenExecutor()
ExecutorRegistry().register(_executor)

def get_global_executor() -> PopenExecutor:
    return _executor

We don't need to create the executor lazily, and instead could rely on the module initialization system to get it initialized once

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replaced lazy initialization with simple module-level initialization as suggested. Removed the lock and used direct executor creation at module level. (b421f6e)



def main():
with PopenExecutor() as executor:
# example usage
Expand All @@ -251,7 +292,10 @@ def done_callback(future: PopenFuture):
"echo hello",
]

futures = [PopenFuture(command.split()) for command in commands]
futures = [
PopenFuture(command.split(), tag=f"test-{i}")
for i, command in enumerate(commands)
]

for future in futures:
future.add_done_callback(done_callback)
Expand Down
15 changes: 5 additions & 10 deletions src/halmos/solve.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,8 @@
warn,
)
from halmos.processes import (
ExecutorRegistry,
PopenExecutor,
PopenFuture,
get_global_executor,
)
from halmos.sevm import Address, Exec, SMTQuery
from halmos.utils import hexify
Expand Down Expand Up @@ -167,9 +166,6 @@ class SolvingContext:
# directory for dumping solver files
dump_dir: DumpDirectory

# shared solver executor for all paths in the same function
executor: PopenExecutor = field(default_factory=PopenExecutor)

# list of unsat cores
unsat_cores: list[list] = field(default_factory=list)

Expand Down Expand Up @@ -268,9 +264,6 @@ def __post_init__(self):
)
object.__setattr__(self, "thread_pool", thread_pool)

# register the solver executor to be shutdown on exit
ExecutorRegistry().register(solving_ctx.executor)

def append_unsat_core(self, unsat_core: list[str]) -> None:
self.solving_ctx.unsat_cores.append(unsat_core)

Expand All @@ -282,6 +275,7 @@ class PathContext:
solving_ctx: SolvingContext
query: SMTQuery
is_refined: bool = False
tag: str | None = None # optional tag for grouping solver queries
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not optional

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Made tag required in PathContext by removing | None type hint and adjusting parameter order. (b421f6e)


@property
def dump_file(self) -> Path:
Expand All @@ -297,6 +291,7 @@ def refine(self) -> "PathContext":
solving_ctx=self.solving_ctx,
query=refine(self.query),
is_refined=True,
tag=self.tag,
)


Expand Down Expand Up @@ -499,10 +494,10 @@ def solve_low_level(path_ctx: PathContext) -> SolverOutput:
else args.solver_command
)
cmd_with_file = cmd_list + [smt2_filename]
future = PopenFuture(cmd_with_file, timeout=timeout_seconds)
future = PopenFuture(cmd_with_file, timeout=timeout_seconds, tag=path_ctx.tag)

# starts the subprocess asynchronously
path_ctx.solving_ctx.executor.submit(future)
get_global_executor().submit(future)

# block until the external solver returns, times out, is interrupted, fails, etc.
try:
Expand Down
109 changes: 109 additions & 0 deletions tests/test_global_executor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# SPDX-License-Identifier: AGPL-3.0

import threading
from unittest.mock import Mock

from halmos.processes import PopenFuture, get_global_executor


class TestGlobalExecutor:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

rename this file test_executor.py

remove the class, the test functions should be at the top level

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed file to test_executor.py and removed the class wrapper - test functions are now at the top level. (d0262bc)

def test_global_executor_singleton(self):
"""Test that get_global_executor returns the same instance."""
executor1 = get_global_executor()
executor2 = get_global_executor()

assert (
executor1 is executor2
), "get_global_executor should return the same instance"

def test_global_executor_multithreaded(self):
"""Test that get_global_executor works correctly across threads."""
results = []

def get_executor():
executor = get_global_executor()
results.append(executor)

threads = [threading.Thread(target=get_executor) for _ in range(5)]
for thread in threads:
thread.start()
for thread in threads:
thread.join()

# All threads should get the same executor instance
assert len(set(id(executor) for executor in results)) == 1

def test_popen_future_with_tag(self):
"""Test that PopenFuture accepts and stores tag parameter."""
cmd = ["echo", "hello"]
tag = "test-tag"

future = PopenFuture(cmd, tag=tag)

assert future.cmd == cmd
assert future.tag == tag

def test_popen_future_without_tag(self):
"""Test that PopenFuture works without tag parameter."""
cmd = ["echo", "hello"]

future = PopenFuture(cmd)

assert future.cmd == cmd
assert future.tag is None
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

delete these tests

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deleted the requested tests from the file. (d0262bc)


def test_interrupt_by_tag(self):
"""Test that interrupt() cancels futures with matching tags."""
executor = get_global_executor()

# Create mock futures with different tags
future1 = Mock(spec=PopenFuture)
future1.tag = "tag1"
future2 = Mock(spec=PopenFuture)
future2.tag = "tag2"
future3 = Mock(spec=PopenFuture)
future3.tag = "tag1"
future4 = Mock(spec=PopenFuture)
future4.tag = None

# Add to executor's futures list
executor._futures = [future1, future2, future3, future4]

# Interrupt tag1
executor.interrupt("tag1")

# Check that only futures with tag1 were cancelled
future1.cancel.assert_called_once()
future2.cancel.assert_not_called()
future3.cancel.assert_called_once()
future4.cancel.assert_not_called()

def test_interrupt_with_empty_tag(self):
"""Test that interrupt() with empty tag does nothing."""
executor = get_global_executor()

# Create mock future
future = Mock(spec=PopenFuture)
future.tag = "some-tag"
executor._futures = [future]

# Interrupt with empty tag
executor.interrupt("")

# No futures should be cancelled
future.cancel.assert_not_called()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

delete, we don't want empty tags

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deleted the empty tag test as requested since we don't want empty tags. (d0262bc)


def test_interrupt_nonexistent_tag(self):
"""Test that interrupt() with non-existent tag does nothing."""
executor = get_global_executor()

# Create mock future
future = Mock(spec=PopenFuture)
future.tag = "existing-tag"
executor._futures = [future]

# Interrupt with non-existent tag
executor.interrupt("nonexistent-tag")

# No futures should be cancelled
future.cancel.assert_not_called()
Loading