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
47 changes: 42 additions & 5 deletions declib/cli/decompiler_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -367,24 +367,61 @@ def _wait_for_server(
_IDA_UNPACKED_SUFFIXES = (".id0", ".id1", ".id2", ".nam", ".til")


def _live_server_pid_for_project(project_dir: Path) -> Optional[int]:
"""PID of a running declib server whose --project-dir is `project_dir`.

Reads the process table rather than the registry, because the registry can
lose a live server and this decides whether we delete its database.
"""
try:
import psutil
except Exception:
# Without psutil we cannot prove the project is unowned, so assume it is.
return -1
target = str(project_dir.resolve())
for proc in psutil.process_iter(["pid", "cmdline"]):
try:
cmdline = proc.info.get("cmdline") or []
if "--server" not in cmdline or "--project-dir" not in cmdline:
continue
idx = cmdline.index("--project-dir")
if idx + 1 >= len(cmdline):
continue
if str(Path(cmdline[idx + 1]).resolve()) == target:
return int(proc.info["pid"])
except Exception:
continue
return None


def _clear_stale_ida_database(project_dir: Optional[Path], backend: str) -> list[str]:
"""Remove crash residue that would otherwise poison this project forever.

Only called when no live server is registered for this project, so anything
unpacked here is left over from a process that died without repacking. The
saved analysis lives in the .i64/.idb, which is deliberately preserved -- we
remove only the unpacked components, so a reload restores the saved work and
re-derives whatever was merely cached.
The saved analysis lives in the .i64/.idb, which is deliberately preserved
-- we remove only the unpacked components, so a reload restores the saved
work and re-derives whatever was merely cached.

Observed on a 40-binary benchmark: 64 of 80 DecLib failures were this, with
the same binary failing up to 7 times in a row because nothing ever cleared
the residue.

IMPORTANT: "no live server" is established by looking at *processes*, not
the registry. The registry is not a reliable witness -- a running server can
be missing from it (seen repeatedly: `decompiler list` returning zero while
`ps` showed four healthy servers), and trusting it here means deleting the
unpacked database of a server that is still using it. That turns a
registry glitch into real corruption, so the check has to be against
something that cannot silently lose a live process.
"""
if backend != IDA_DECOMPILER or project_dir is None:
return []
root = project_dir if project_dir.is_dir() else None
if root is None:
return []
owner = _live_server_pid_for_project(root)
if owner is not None:
_l.debug("Not clearing %s: server pid %s is still using it", root, owner)
return []
removed = []
for candidate in sorted(root.rglob("*")):
if candidate.is_file() and candidate.suffix in _IDA_UNPACKED_SUFFIXES:
Expand Down
66 changes: 66 additions & 0 deletions tests/test_decompiler_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import subprocess
import sys
import tempfile
import pathlib
import unittest
from contextlib import redirect_stdout
from io import StringIO
Expand Down Expand Up @@ -2106,6 +2107,71 @@ def test_safe_filename_is_filesystem_safe_and_unique(self):
self.assertNotEqual(_safe_filename("f", 1), _safe_filename("f", 2))


class TestStaleClearNeverTouchesALiveServer(unittest.TestCase):
"""Clearing residue must not delete a running server's database.

The registry is not a reliable witness that a project is unowned -- a live
server can be missing from it (observed: `decompiler list` returning zero
while `ps` showed four healthy servers). If the clear trusts the registry,
a registry glitch becomes real corruption of a database still in use.
"""

def _project_with_residue(self, tmp):
import pathlib as _pl
d = _pl.Path(tmp) / "ida"
d.mkdir(parents=True)
for suffix in (".id0", ".id1", ".id2", ".nam", ".til"):
(d / f"t{suffix}").write_bytes(b"in use")
(d / "t.i64").write_bytes(b"saved")
return _pl.Path(tmp)

def test_skips_when_a_process_still_owns_the_project(self):
import tempfile
from unittest import mock
from declib.cli import decompiler_cli as cli

with tempfile.TemporaryDirectory() as tmp:
proj = self._project_with_residue(tmp)
with mock.patch.object(cli, "_live_server_pid_for_project",
return_value=4242):
self.assertEqual(cli._clear_stale_ida_database(proj, "ida"), [])
left = {p.name for p in (proj / "ida").iterdir()}
self.assertEqual(len(left), 6, "a live server's files must survive")

def test_still_clears_when_nothing_owns_it(self):
"""The whole point of the fix must survive the new guard."""
import tempfile
from unittest import mock
from declib.cli import decompiler_cli as cli

with tempfile.TemporaryDirectory() as tmp:
proj = self._project_with_residue(tmp)
with mock.patch.object(cli, "_live_server_pid_for_project",
return_value=None):
removed = cli._clear_stale_ida_database(proj, "ida")
self.assertEqual(len(removed), 5)
self.assertTrue((proj / "ida" / "t.i64").exists())

def test_owner_lookup_matches_on_project_dir(self):
import tempfile
from unittest import mock
from declib.cli import decompiler_cli as cli

with tempfile.TemporaryDirectory() as tmp:
class _Proc:
def __init__(self, pid, cmdline):
self.info = {"pid": pid, "cmdline": cmdline}

mine = ["python3", "-m", "declib", "--server", "--project-dir", tmp]
other = ["python3", "-m", "declib", "--server", "--project-dir", "/somewhere/else"]
with mock.patch("psutil.process_iter", return_value=[_Proc(7, other), _Proc(9, mine)]):
self.assertEqual(
cli._live_server_pid_for_project(pathlib.Path(tmp)), 9)
with mock.patch("psutil.process_iter", return_value=[_Proc(7, other)]):
self.assertIsNone(
cli._live_server_pid_for_project(pathlib.Path(tmp)))


class TestStaleIdaDatabaseRecovery(unittest.TestCase):
"""A crashed IDA server must not poison its project forever.

Expand Down