ADR-003: Process Isolation Mechanism for Small Tests¶
Status¶
Implemented (v0.7.0)
Implementation Complete: All components are fully implemented and production-ready:
ProcessBlockerPortinterface with state machine
SubprocessPatchingBlockerproduction adapter
FakeProcessBlockertest adapter
SubprocessViolationErrorexception with remediation guidancePytest hook integration with enforcement modes
No Override Markers - By Design¶
This plugin intentionally provides no per-test override markers (e.g., @pytest.mark.allow_subprocess).
This is a deliberate architectural decision, not a missing feature.
Rationale:
Small tests must be hermetic and single-process. Period. No escape hatches.
If a test needs to spawn subprocesses, it should be
@pytest.mark.medium, not a small test with an exception.Override markers would undermine the entire philosophy and make enforcement meaningless.
The correct remediation is to mock subprocess calls or upgrade the test category.
If you need subprocess access in a test, change @pytest.mark.small to @pytest.mark.medium.
Context¶
Small tests, as defined by Google’s “Software Engineering at Google” best practices, must be hermetic and run in a single process. Subprocess spawning in small tests creates several problems:
Non-determinism: External processes have their own state and behavior
I/O overhead: Process creation involves system calls and resource allocation
Timing variability: Process startup times vary, causing flaky tests
Resource leakage: Spawned processes may outlive tests if not properly cleaned up
Environment coupling: Subprocesses inherit environment variables and may behave differently across systems
Currently, pytest-test-categories enforces timing constraints (v0.1.0), network isolation (v0.4.0), and filesystem isolation (v0.5.0). However, a test can still spawn subprocesses, violating the single-process constraint for small tests.
We need a mechanism to:
Detect subprocess spawn attempts during small test execution
Block spawns and provide clear error messages
Allow spawns for medium/large/xlarge tests where subprocess use is appropriate
Existing Architecture Context¶
The plugin follows hexagonal architecture (ports and adapters):
Ports: Abstract interfaces defining contracts (
TestTimer,NetworkBlockerPort,FilesystemBlockerPort)Production Adapters: Real implementations (
WallTimer,SocketPatchingNetworkBlocker,FilesystemPatchingBlocker)Test Adapters: Controllable test doubles (
FakeTimer,FakeNetworkBlocker,FakeFilesystemBlocker)
This pattern enables:
Unit tests to be fast and deterministic (using fake adapters)
Integration tests to validate real behavior (using production adapters)
Easy extensibility for new resource types
The network isolation (ADR-001) and filesystem isolation (ADR-002) established patterns we follow:
Port interface with state machine (INACTIVE -> ACTIVE -> INACTIVE)
Design-by-contract with icontract preconditions/postconditions
Pydantic models for configuration and data transfer
Clear exception hierarchy with actionable error messages
Research: Subprocess Entry Points in Python¶
Python provides multiple ways to spawn processes:
subprocess Module (Primary):
subprocess.Popen- Base class for all subprocess operationssubprocess.run- High-level convenience function (Python 3.5+)subprocess.call- Run command, return exit codesubprocess.check_call- Run command, raise on non-zero exitsubprocess.check_output- Run command, return stdout
os Module:
os.system- Run command in shell, return exit codeos.popen- Open pipe to/from command (deprecated but still used)os.spawn*family - Low-level process spawning (spawnl, spawnle, spawnlp, etc.)os.exec*family - Replace current process (execl, execle, execlp, etc.)
multiprocessing Module:
multiprocessing.Process- Spawn new Python interpreter process
Research: Interception Strategy¶
We can intercept subprocess spawning at multiple levels:
subprocess.Popen: Base class - all convenience functions use this
Convenience functions: Direct patching of run, call, check_call, check_output
os functions: Patch os.system, os.popen
multiprocessing: Patch Process.start()
Note: The os.spawn* and os.exec* families are rarely used in modern Python code and are not intercepted in the initial implementation. They can be added if needed based on user feedback.
Decision¶
We will implement process isolation using a subprocess patching approach following the hexagonal architecture pattern established in ADR-001 and ADR-002.
1. Port Interface: ProcessBlockerPort¶
Define an abstract interface for process blocking:
class ProcessBlockerPort(BaseModel, ABC):
"""Port defining process blocking behavior.
Implementations control whether subprocess spawning is permitted during
test execution. The port follows a state machine pattern:
INACTIVE -> ACTIVE -> INACTIVE
This mirrors the NetworkBlockerPort and FilesystemBlockerPort patterns.
Attributes:
state: Current blocker state (INACTIVE or ACTIVE).
"""
state: BlockerState = BlockerState.INACTIVE
@require(lambda self: self.state == BlockerState.INACTIVE, 'Blocker must be INACTIVE to activate')
@ensure(lambda self: self.state == BlockerState.ACTIVE, 'Blocker must be ACTIVE after activation')
def activate(self, test_size: TestSize, enforcement_mode: EnforcementMode) -> None:
"""Activate process blocking for a test."""
@require(lambda self: self.state == BlockerState.ACTIVE, 'Blocker must be ACTIVE to deactivate')
@ensure(lambda self: self.state == BlockerState.INACTIVE, 'Blocker must be INACTIVE after deactivation')
def deactivate(self) -> None:
"""Deactivate process blocking, restoring normal behavior."""
@require(lambda self: self.state == BlockerState.ACTIVE, 'Blocker must be ACTIVE to check spawns')
def check_spawn_allowed(self, command: str, args: tuple[str, ...]) -> bool:
"""Check if a process spawn is allowed."""
@require(lambda self: self.state == BlockerState.ACTIVE, 'Blocker must be ACTIVE to handle violations')
def on_violation(
self,
command: str,
args: tuple[str, ...],
test_nodeid: str,
method: str,
) -> None:
"""Handle a subprocess spawn violation."""
2. Spawn Attempt Record¶
class SpawnAttempt(BaseModel, frozen=True):
"""Immutable record of a subprocess spawn attempt.
Attributes:
command: The command or executable.
args: Arguments to the command.
test_nodeid: The pytest node ID of the test.
allowed: Whether the spawn was permitted.
method: The method used (e.g., 'subprocess.run').
"""
command: str
args: tuple[str, ...]
test_nodeid: str
allowed: bool
method: str
3. Production Adapter: SubprocessPatchingBlocker¶
Implements ProcessBlockerPort by:
Patching Strategy - Patch at commonly used entry points:
subprocess.Popen- Intercepts base classsubprocess.run,call,check_call,check_output- Convenience functionsos.system,os.popen- Legacy OS-level spawningmultiprocessing.Process- Python multiprocessing
Command Extraction - Handle various input formats:
List/tuple:
['python', 'script.py']-> command=’python’, args=(‘script.py’,)String:
'python script.py'-> command=’python script.py’, args=()
Violation Handling:
STRICT mode: Raise
SubprocessViolationErrorWARN mode: Log warning, allow spawn
OFF mode: No enforcement
class SubprocessPatchingBlocker(ProcessBlockerPort):
"""Production adapter that patches subprocess/os to block process spawning.
This adapter intercepts process spawning by patching:
- subprocess.Popen and convenience functions
- os.system, os.popen
- multiprocessing.Process
The patching is reversible - deactivate() restores originals.
"""
4. Test Adapter: FakeProcessBlocker¶
Provides controllable test double:
class FakeProcessBlocker(ProcessBlockerPort):
"""Test double for process blocking without actual patching.
Tracks all method calls and spawn attempts for verification.
Attributes:
spawn_attempts: List of recorded spawn attempts.
warnings: List of warning messages (WARN mode).
activate_count: Number of activate() calls.
deactivate_count: Number of deactivate() calls.
check_count: Number of check_spawn_allowed() calls.
"""
5. Exception Class¶
class SubprocessViolationError(HermeticityViolationError):
"""Raised when a test attempts to spawn a subprocess.
Attributes:
command: The command that was attempted.
command_args: The arguments passed to the command.
method: The spawn method used (e.g., 'subprocess.run').
"""
6. Integration Points¶
The process blocker integrates via pytest hooks:
pytest_configure: Create blocker instance, store in plugin statepytest_runtest_call(wrapper): Activate blocking before test, deactivate afterpytest_runtest_teardown: Ensure blocking is deactivatedpytest_terminal_summary: Report subprocess violation statistics
7. Error Message Format¶
Violation errors provide actionable guidance:
============================================================
HermeticityViolationError
============================================================
Test: test_run_external_command (tests/test_cli.py:42)
Category: SMALL
Violation: Subprocess spawn attempted
Details:
Attempted subprocess.run: python script.py --verbose
Small tests have restricted resource access. Options:
1. Mock subprocess.run using pytest-mock (mocker.patch)
2. Use dependency injection to provide a fake command executor
3. Test the logic that prepares subprocess arguments, not the spawn itself
4. Change test category to @pytest.mark.medium (if subprocess is required)
Documentation: See docs/architecture/adr-003-process-isolation.md
============================================================
Consequences¶
Benefits¶
Consistent Architecture: Follows established hexagonal architecture pattern from ADR-001/002
Testability: Fake adapter enables fast, deterministic unit tests
Gradual Adoption: Warn mode allows incremental enforcement
Clear Feedback: Actionable error messages guide developers
Comprehensive Coverage: Intercepts most common subprocess entry points
Single-Process Enforcement: Ensures small tests run in isolation
Trade-offs¶
Incomplete Coverage:
os.spawn*andos.exec*families not initially interceptedGlobal Patching: Subprocess patching affects entire process during test
Performance Overhead: Minor overhead from spawn interception (negligible)
Complexity: Adds to configuration surface area
Risks and Mitigations¶
Risk |
Mitigation |
|---|---|
Incomplete patching |
Start with common operations, add more based on feedback |
pytest-xdist compatibility |
Each worker has separate interpreter, patching is per-worker |
Complex command parsing |
Handle string and list inputs gracefully |
Multiprocessing edge cases |
Intercept at Process.start(), not init |
Alternatives Considered¶
Alternative 1: Import Hook Blocking¶
Approach: Use import hooks to prevent importing subprocess module.
Pros:
Catches subprocess usage at module level
No runtime overhead during test execution
Cons:
Too coarse-grained (blocks all use, not test-specific)
subprocess often imported before test runs
Breaks test fixtures that legitimately need subprocess
Verdict: Rejected - too inflexible for test-specific enforcement.
Alternative 2: Process Namespace Isolation (Linux)¶
Approach: Run tests in isolated PID namespaces.
Pros:
True kernel-level isolation
Cannot be bypassed
Cons:
Linux-only
Requires root or CAP_SYS_ADMIN
Significant performance overhead
Complex setup
Verdict: Rejected - platform-specific and heavy-weight.
Alternative 3: Seccomp-based Blocking (Linux)¶
Approach: Use seccomp to block fork/exec syscalls.
Pros:
Kernel-level enforcement
Cannot be bypassed from Python
Cons:
Linux-only
Requires privileged operations
Complex to configure correctly
May affect pytest internals
Verdict: Rejected - platform-specific and too low-level.
Implementation Notes¶
Intercepted Entry Points¶
The following entry points are intercepted:
subprocess.Popen(class replacement)subprocess.run(function wrapper)subprocess.call(function wrapper)subprocess.check_call(function wrapper)subprocess.check_output(function wrapper)os.system(function wrapper)os.popen(function wrapper)multiprocessing.Process(class replacement)
Not Intercepted (Future Work)¶
The following are not intercepted in the initial implementation:
os.spawn*family (spawnl, spawnle, spawnlp, spawnlpe, spawnv, spawnve, spawnvp, spawnvpe)os.exec*family (execl, execle, execlp, execlpe, execv, execve, execvp, execvpe)
These are rarely used in modern Python code. They can be added based on user feedback.
Thread Safety Considerations¶
For pytest-xdist parallel execution:
Each worker process has its own Python interpreter
Global patching affects only that worker’s process
Blocker state is stored in plugin state, which is per-worker
No special thread safety measures needed beyond standard pytest-xdist patterns.
Test Strategy¶
Unit Tests (Small, using FakeProcessBlocker)¶
Port state machine transitions
Command/args extraction
Spawn permission rules
Exception message formatting
Warning recording
Integration Tests (Medium, using SubprocessPatchingBlocker)¶
Actual subprocess interception
subprocess.run, call, check_call, check_output blocking
os.system, os.popen blocking
multiprocessing.Process blocking
Function restoration on deactivate
End-to-End Tests (Medium, using pytester)¶
Full test execution with enforcement
CLI option handling
Violation reporting in terminal
References¶
ADR-001: Network Isolation - Established patterns
ADR-002: Filesystem Isolation - Established patterns
Hexagonal Architecture - Ports and adapters pattern