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
48 changes: 48 additions & 0 deletions .github/workflows/python-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,54 @@ jobs:
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false

# macOS gets a single cell instead of a row in the matrix above. Its only
# purpose is to measure the `macos` backend so the merged Codecov report
# covers all three platforms; the Python-version sweep stays on Linux and
# Windows, where it costs a fraction of the wall clock.
#
# Unlike the matrix, this job installs `.[dev,speed]` (NumPy). That is not a
# preference — it is what makes a macOS cell viable at all. A macOS process
# exposes ~390 GiB of sparse address space across ~145 regions, two orders of
# magnitude more than a Linux one, and the scan tests walk all of it. Measured
# on an M-series Mac, this exact command takes ~82s with NumPy and 681s
# without: an 8x gap that a 3-core runner only widens, which is what made
# macOS look like it hung here before (it was removed in 041bfc0).
# The pure-Python scan path loses nothing — all eight cells of the matrix
# above still exercise it.
build-macos:
needs: lint
runs-on: macos-latest
# Hard ceiling so a pathological scan can never sit at the 6h default.
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
# No Qt system-library step here: the PySide6 macOS wheel bundles its own
# Qt frameworks, unlike the Linux one.
- name: Install dependencies (with NumPy speed extra)
run: |
python -m pip install --upgrade pip
pip install -e ".[dev,speed]"
- name: Test with pytest
env:
QT_QPA_PLATFORM: offscreen
# Same conservative coverage gate as the main matrix (see that step's note).
run: |
pytest tests -v -s -x --cov=PyMemoryEditor --cov-report=term --cov-report=xml --cov-fail-under=60
- name: Upload coverage to Codecov
# Flag mirrors the matrix format (`runner.os` is "macOS" here) so Codecov
# groups this cell alongside the Linux and Windows uploads.
if: always()
uses: codecov/codecov-action@v5
with:
files: ./coverage.xml
flags: macOS-py3.12
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false

# Isolated job for the optional NumPy-accelerated scan fast path (the
# `[speed]` extra). The matrix above deliberately runs WITHOUT NumPy so the
# pure-Python scan path stays covered; this single Linux job installs
Expand Down
37 changes: 35 additions & 2 deletions PyMemoryEditor/macos/functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,31 @@ def get_memory_regions(task: int, pid: int = 0) -> Generator[MemoryRegion, None,
KERN_MEMORY_ERROR,
)

# KERN_PROTECTION_FAILURE is tolerated by a full-address-space *sweep* only,
# never by a read of caller-supplied addresses — hence a second tuple rather
# than another entry above.
#
# The sweeps walk every region the filter accepted and have no opinion about
# any single one: a range the kernel declines to hand over is a range with no
# matches in it, and aborting the whole scan over it loses every region that
# came after. `search_values_by_addresses` is the opposite case — the caller
# named those addresses, and its documented `raise_error=True` must still be
# able to report that one of them could not be read. Anything in the tuple
# above is swallowed even when `raise_error` is set (see
# process.scanning.iter_values_for_addresses), so putting kr=2 there would
# silently turn "permission denied" into the same `(address, None)` the caller
# gets for an address in a gap.
#
# Honest limits of what is known here. This was added because a plain
# search_by_value over the current process died with kr=2 on GitHub's
# virtualized macOS runners, on a 22 MB region that reported VM_PROT_READ and
# Shared=False. The mechanism was not established. It does *not* reproduce
# locally: probing all 135 scannable regions of a live process yields only kr=1
# and kr=10, and a page deliberately re-protected to PROT_NONE (or never
# mapped) answers KERN_INVALID_ADDRESS, not kr=2 — so the usual
# "protection changed under us" story does not explain it.
_SWEEP_SKIPPABLE_KRS = _PAGE_GONE_KRS + (KERN_PROTECTION_FAILURE,)


class MachReadError(OSError):
"""OSError subclass that carries the underlying kern_return_t."""
Expand Down Expand Up @@ -494,6 +519,14 @@ def _is_transient(exc: BaseException) -> bool:
return isinstance(exc, MachReadError) and exc.kr in _PAGE_GONE_KRS


def _is_skippable_by_sweep(exc: BaseException) -> bool:
"""
Same as :func:`_is_transient`, plus the codes only a full-address-space
sweep may skip. See :data:`_SWEEP_SKIPPABLE_KRS`.
"""
return isinstance(exc, MachReadError) and exc.kr in _SWEEP_SKIPPABLE_KRS


def _query_region(task: int, address: int):
"""Return the region containing `address`, or None when the query fails."""
addr = mach_vm_address_t(address)
Expand Down Expand Up @@ -683,7 +716,7 @@ def search_addresses_by_value(
scan_type,
_make_read_chunk(task),
progress_information=progress_information,
transient_error_check=_is_transient,
transient_error_check=_is_skippable_by_sweep,
)


Expand Down Expand Up @@ -1044,7 +1077,7 @@ def search_addresses_by_pattern(
length,
_make_read_chunk(task),
progress_information=progress_information,
transient_error_check=_is_transient,
transient_error_check=_is_skippable_by_sweep,
)


Expand Down
39 changes: 39 additions & 0 deletions tests/platforms/test_macos_transient_reads.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from PyMemoryEditor.macos.functions import ( # noqa: E402
MachPartialReadError,
MachReadError,
_is_skippable_by_sweep,
_is_transient,
)
from PyMemoryEditor.macos.types import ( # noqa: E402
Expand Down Expand Up @@ -59,6 +60,12 @@ def test_a_vanished_page_lets_the_scan_continue(kr):
# KERN_MEMORY_FAILURE directly above KERN_MEMORY_ERROR and documents it as
# permanent, which is exactly the distinction being drawn here; KERN_FAILURE is
# what task_for_pid returns without the debugger entitlement.
#
# KERN_PROTECTION_FAILURE stays here even though a *sweep* skips it (see
# test_a_sweep_may_skip_a_protected_range below): this classifier also drives
# search_values_by_addresses, whose caller named the addresses and whose
# documented raise_error=True must still be able to report one it could not
# read.
@pytest.mark.parametrize(
"kr", (KERN_FAILURE, KERN_PROTECTION_FAILURE, KERN_MEMORY_FAILURE)
)
Expand All @@ -79,3 +86,35 @@ def test_a_short_read_lets_the_scan_continue():
def test_a_non_mach_error_is_never_transient():
assert not _is_transient(OSError("some unrelated failure"))
assert not _is_transient(ValueError("not an OSError at all"))


# A full-address-space sweep is the one caller allowed to skip a range the
# kernel refuses to hand over. It has no opinion about any single region, and
# aborting on one loses every region that came after it — which is how a plain
# search_by_value died on a virtualized macOS CI runner.
@pytest.mark.parametrize(
"kr",
(
KERN_INVALID_ADDRESS,
KERN_INVALID_ARGUMENT,
KERN_NO_ACCESS,
KERN_MEMORY_ERROR,
KERN_PROTECTION_FAILURE,
),
)
def test_a_sweep_may_skip_a_protected_range(kr):
assert _is_skippable_by_sweep(MachReadError(kr, "read failed (kr=%d)" % kr))


# The sweep is more tolerant, not unconditionally tolerant: a task-level
# failure still has to stop it.
@pytest.mark.parametrize("kr", (KERN_FAILURE, KERN_MEMORY_FAILURE))
def test_a_sweep_still_stops_on_a_task_level_failure(kr):
assert not _is_skippable_by_sweep(MachReadError(kr, "read failed (kr=%d)" % kr))


def test_only_the_sweep_skips_a_protected_range():
"""The two classifiers must disagree on exactly one code, and this is it."""
exc = MachReadError(KERN_PROTECTION_FAILURE, "read failed")
assert _is_skippable_by_sweep(exc)
assert not _is_transient(exc)
Loading