Validate that process is actually a patching operation before terminating - #367
Validate that process is actually a patching operation before terminating#367michellemcdaniel wants to merge 8 commits into
Conversation
…ting There are times when the previous patching operation will not be running (reboot, etc), where its PID will be reused before the next patching operation starts. When ProcessHandler checks for running previous patching operations, it will identify this new process which is not the patching extension as a process that needs to be terminated. If this process is a required process for the service, it causes that service to effectively crash. This change checks the process's cmdline by reading the pid's cmdline file in the proc filesystem. If it contains the patching extension Core filename, then we can be more confident that the process we are about to end is the correct one. If it does not find the core filename in the cmdline, we can guarantee that the process is not a patching operation, and we will not end that process.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #367 +/- ##
==========================================
+ Coverage 94.10% 94.11% +0.01%
==========================================
Files 109 109
Lines 20642 20689 +47
==========================================
+ Hits 19425 19472 +47
Misses 1217 1217
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Pull request overview
This PR improves safety when terminating leftover “previous patch” processes by verifying the target PID actually belongs to the LinuxPatchExtension core process (via /proc/<pid>/cmdline) before sending SIGTERM, reducing the risk of killing an unrelated process whose PID was reused after reboot.
Changes:
- Added
is_process_patching_operation()to validate a PID’s command line contains the core filename before termination. - Updated
kill_process()to terminate only when the process is both running and identified as a patching operation. - Updated unit tests to account for the new termination gating logic.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| src/extension/src/ProcessHandler.py | Adds process cmdline validation and gates termination on “running && patching operation”. |
| src/extension/tests/Test_ProcessHandler.py | Adjusts kill_process test setup for the new patching-operation check. |
Comments suppressed due to low confidence (1)
src/extension/tests/Test_ProcessHandler.py:152
test_kill_processonly covers the case whereis_process_patching_operationreturns True; the PR’s key behavior change is that non-patching processes must NOT be terminated. Add a negative test that ensuresos.killis not called whenis_process_patching_operationreturns False.
def test_kill_process(self):
# setting mocks
is_process_running_backup = ProcessHandler.is_process_running
ProcessHandler.is_process_running = self.mock_is_process_running_to_return_true
is_process_patching_operation_backup = ProcessHandler.is_process_patching_operation
ProcessHandler.is_process_patching_operation = self.mock_is_process_patching_operation_to_return_true
os_kill_backup = os.kill
os.kill = self.mock_os_kill_to_raise_exception
# error in terminating process
pid = 123
process_handler = ProcessHandler(self.logger, self.env_layer, self.ext_output_status_handler)
self.assertRaises(OSError, process_handler.kill_process, pid)
# reseting mocks
ProcessHandler.is_process_running = is_process_running_backup
ProcessHandler.is_process_patching_operation = is_process_patching_operation_backup
os.kill = os_kill_backup
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (2)
src/extension/src/ProcessHandler.py:206
/proc/<pid>/cmdlineis a null-separated byte stream; opening it in text mode can introduce decoding differences between Python 2/3 and locales. Reading it as bytes and comparing against a UTF-8 encoded core filename keeps the check deterministic.
with self.env_layer.file_system.open("/proc/{0}/cmdline".format(str(pid)), mode="r") as cmdline_file:
cmdline = cmdline_file.read()
if Constants.CORE_CODE_FILE_NAME in cmdline:
src/extension/src/ProcessHandler.py:214
- Returning True on exceptions can still terminate an unrelated process in the exact scenarios this PR is trying to avoid (e.g., cmdline read fails due to transient /proc races or permissions). If the cmdline cannot be read, treat it as "not a patching operation" so kill_process will skip termination.
except Exception as error:
self.logger.log_debug("Error checking if process is a patching operation. [PID={0}] [Error={1}]".format(str(pid), repr(error)))
return True # If we cannot determine, assume it is a patching operation to be safe
|
This UT class is a good reference for your new UT (mocks) : https://github.com/Azure/LinuxPatchExtension/blob/master/src/core/tests/Test_LifecycleManagerAzure.py |
| return True | ||
| else: | ||
| self.logger.log_debug("Process is not a patching operation. [PID={0}]".format(str(pid))) | ||
| return False |
There was a problem hiding this comment.
Similar comment to use log_verbose
There was a problem hiding this comment.
Please leave as log_debug and include what it actually shows up as instead of our operation. This demonstrates if this actually fixed or changed any outcome at scale.
There was a problem hiding this comment.
@kjohn-msft
+1 to log what it actually shows up as.
But I think this should be log_verbose than log_debug. log_debug doesn't show up in telemetry so we will have to depend on getting log files from the machine. Having telemetry will be easier to validate in case we see this again
There was a problem hiding this comment.
@kjohn-msft @michellemcdaniel re-opening this comment to check on whether this should be log_debug or log_verbose. log_debug won't show up in telemetry
| return True | ||
| else: | ||
| self.logger.log_debug("Process is not a patching operation. [PID={0}]".format(str(pid))) | ||
| return False |
There was a problem hiding this comment.
@kjohn-msft
+1 to log what it actually shows up as.
But I think this should be log_verbose than log_debug. log_debug doesn't show up in telemetry so we will have to depend on getting log files from the machine. Having telemetry will be easier to validate in case we see this again
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (1)
src/extension/src/ProcessHandler.py:210
is_process_patching_operation()decodes/proc/<pid>/cmdlineas UTF-8 and logs the entire cmdline when it’s not a patching operation. Two problems: (1) cmdline may contain non-UTF8 bytes; a decode error is caught and currently returnsTrue, which can still terminate an unrelated process and defeats the purpose of this PR. (2) logging full cmdlines can leak sensitive args/paths and is also emitted to telemetry vialog_debug.
Consider checking for CORE_CODE_FILE_NAME in the raw bytes (or null-split argv) without decoding, avoid logging the full cmdline, and if cmdline cannot be read, treat it as "not a patching operation" (skip termination).
with self.env_layer.file_system.open("/proc/{0}/cmdline".format(str(pid)), mode="rb") as cmdline_file:
cmdline = cmdline_file.read()
cmdline = cmdline.replace(b'\x00', b' ').decode("utf-8") # cmdline is null-separated, so replacing nulls with spaces for easier parsing and logging
if Constants.CORE_CODE_FILE_NAME in cmdline:
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (3)
src/extension/src/ProcessHandler.py:210
is_process_patching_operationcurrently decodes/proc/<pid>/cmdlineas UTF-8 (which can raise) and returnsTrueon any exception. ReturningTrueon read/decode errors can still allow terminating unrelated processes (the scenario this PR is trying to avoid). Consider checking for the core filename in the raw cmdline bytes (no decoding needed) and returnFalsewhen cmdline can't be confidently verified.
with self.env_layer.file_system.open("/proc/{0}/cmdline".format(str(pid)), mode="rb") as cmdline_file:
cmdline = cmdline_file.read()
cmdline = cmdline.replace(b'\x00', b' ').decode("utf-8") # cmdline is null-separated, so replacing nulls with spaces for easier parsing and logging
if Constants.CORE_CODE_FILE_NAME in cmdline:
src/extension/tests/Test_ProcessHandler.py:50
tempfile.mkdtemp()creates a directory that isn't cleaned up intearDown, which can leak temp dirs across test runs. PreferTemporaryDirectory()(or explicit cleanup) so resources are always released.
self.test_dir = tempfile.mkdtemp()
self.proc_cmdline_path = os.path.join(self.test_dir, "proc_cmdline")
self.ext_output_status_handler = ExtOutputStatusHandler(self.logger, self.utility, self.json_file_handler, dir_path)
self.process = subprocess.Popen(["echo", "Hello World!"], shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
def tearDown(self):
VirtualTerminal().print_lowlight("\n----------------- tear down test runner -----------------")
self.process.kill()
src/extension/tests/Test_ProcessHandler.py:149
- This test monkey-patches
ProcessHandlerandos.killbut restores them only at the end of the method. If an assertion fails or an unexpected exception is raised, the patches can leak into other tests. Wrap the patched section intry/finallyso restoration is guaranteed.
# setting mocks
is_process_running_backup = ProcessHandler.is_process_running
ProcessHandler.is_process_running = self.mock_is_process_running_to_return_true
is_process_patching_operation_backup = ProcessHandler.is_process_patching_operation
ProcessHandler.is_process_patching_operation = self.mock_is_process_patching_operation_to_return_true
os_kill_backup = os.kill
os.kill = self.mock_os_kill_to_raise_exception
| # setting mocks | ||
| backup_file_system_open = self.env_layer.file_system.open | ||
| self.env_layer.file_system.open = self.mock_file_system_open_to_return_proc_cmdline | ||
|
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (5)
src/extension/src/ProcessHandler.py:209
cmdlineis decoded with strict UTF-8. If/proc/<pid>/cmdlinecontains non-UTF8 bytes, this will raise and fall into the exception path, bypassing the normal decision logic. Decode witherrors="replace"(or avoid decoding for the core-file check).
with self.env_layer.file_system.open("/proc/{0}/cmdline".format(str(pid)), mode="rb") as cmdline_file:
cmdline = cmdline_file.read()
cmdline = cmdline.replace(b'\x00', b' ').decode("utf-8") # cmdline is null-separated, so replacing nulls with spaces for easier parsing and logging
src/extension/src/ProcessHandler.py:218
- On any exception reading
/proc/<pid>/cmdline, this returnsTrue, which can still terminate an unrelated process (e.g., cmdline file missing due to races/permissions). For the PR’s safety goal, failure to verify should default to not treating the PID as a patching operation.
except Exception as error:
self.logger.log_debug("Error checking if process is a patching operation. [PID={0}] [Error={1}]".format(str(pid), repr(error)))
return True # If we cannot determine, assume it is a patching operation to be safe
src/extension/tests/Test_ProcessHandler.py:151
- This test monkey-patches globals/classes without guaranteed restoration if an assertion fails, which can cascade failures into later tests. Also, the updated
kill_processgating should be covered for the non-patching path (ensureos.killis not invoked whenis_process_patching_operationis False). UsingaddCleanupplus a non-patching sub-case makes the test robust and covers the new behavior.
# setting mocks
is_process_running_backup = ProcessHandler.is_process_running
ProcessHandler.is_process_running = self.mock_is_process_running_to_return_true
is_process_patching_operation_backup = ProcessHandler.is_process_patching_operation
ProcessHandler.is_process_patching_operation = self.mock_is_process_patching_operation_to_return_true
os_kill_backup = os.kill
os.kill = self.mock_os_kill_to_raise_exception
src/extension/tests/Test_ProcessHandler.py:228
- This test overrides
self.env_layer.file_system.openbut only restores it at the end of the test; if an earlier assertion fails, the mock can leak into later tests. Register the restoration withaddCleanupimmediately after patching.
# setting mocks
backup_file_system_open = self.env_layer.file_system.open
self.env_layer.file_system.open = self.mock_file_system_open_to_return_proc_cmdline
src/extension/tests/Test_ProcessHandler.py:255
- If
is_process_patching_operationcannot read/proc/<pid>/cmdline, the safest behavior for this PR is to treat it as not a patching operation (to avoid terminating an unrelated reused PID). The test currently assertsTruefor the exception case, which would preserve the risky behavior.
# process cmdline exception
self.env_layer.file_system.open = self.mock_file_system_open_raises_exception
process_handler = ProcessHandler(self.logger, self.env_layer, self.ext_output_status_handler)
pid = 1234
self.assertTrue(process_handler.is_process_patching_operation(pid))
| self.test_dir = tempfile.mkdtemp() | ||
| self.proc_cmdline_path = os.path.join(self.test_dir, "proc_cmdline") |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
Comments suppressed due to low confidence (3)
src/extension/src/ProcessHandler.py:218
is_process_patching_operationreturns True on any exception (including/proc/<pid>/cmdlineraces/permissions or UTF-8 decode errors), which can still allow terminating an unrelated process—the scenario this PR is trying to prevent. Prefer decoding defensively and returning False when the cmdline cannot be confidently verified.
except Exception as error:
self.logger.log_debug("Error checking if process is a patching operation. [PID={0}] [Error={1}]".format(str(pid), repr(error)))
return True # If we cannot determine, assume it is a patching operation to be safe
src/extension/tests/Test_ProcessHandler.py:153
- These monkey-patches are restored only at the end of the test; if an assertion fails (or an unexpected exception is raised), the patches can leak into later tests and cause cascading failures. Register cleanup (or use try/finally) so patches are always restored.
def test_kill_process(self):
# setting mocks
is_process_running_backup = ProcessHandler.is_process_running
ProcessHandler.is_process_running = self.mock_is_process_running_to_return_true
is_process_patching_operation_backup = ProcessHandler.is_process_patching_operation
ProcessHandler.is_process_patching_operation = self.mock_is_process_patching_operation_to_return_true
os_kill_backup = os.kill
os.kill = self.mock_os_kill_to_raise_exception
src/extension/tests/Test_ProcessHandler.py:230
env_layer.file_system.openis monkey-patched here, but restoration relies on the final lines of the test executing. If an assertion fails earlier, the patch can leak. Register a cleanup callback immediately after taking the backup.
# setting mocks
backup_file_system_open = self.env_layer.file_system.open
self.env_layer.file_system.open = self.mock_file_system_open_to_return_proc_cmdline
| def mock_file_system_open_raises_exception(self, path, mode): | ||
| raise FileNotFoundError | ||
|
|
| process_handler = ProcessHandler(self.logger, self.env_layer, self.ext_output_status_handler) | ||
| pid = 1234 | ||
|
|
||
| self.assertTrue(process_handler.is_process_patching_operation(pid)) |
rane-rajasi
left a comment
There was a problem hiding this comment.
Comments inline + one earlier comment re-opened
| f.write(is_not_patching_operation_content) | ||
|
|
||
| process_handler = ProcessHandler(self.logger, self.env_layer, self.ext_output_status_handler) | ||
| pid = 1234 |
There was a problem hiding this comment.
Do you need to initialize ProcessHandler again or would simply calling is_process_patching_operation() work
There was a problem hiding this comment.
pid is also the same as before, could be reused between both cases
| self.env_layer.file_system.open = self.mock_file_system_open_raises_exception | ||
|
|
||
| process_handler = ProcessHandler(self.logger, self.env_layer, self.ext_output_status_handler) | ||
| pid = 1234 |
| self.assertTrue(process_handler.is_process_patching_operation(pid)) | ||
|
|
||
| # resetting mocks | ||
| self.env_layer.file_system.open = backup_file_system_open |
There was a problem hiding this comment.
For one test with multiple scenarios, LPE generally uses a pattern to reduce repetitive lines of code. Refer: test_get_package_manager() in core/tests/Test_EnvLayer.py or test_is_cert_update_supported__with_various_use_cases() in core/src/Test_AptitudePackageManager.py
There's more such examples, sharing these 2.
There are times when the previous patching operation will not be running (reboot, etc), where its PID will be reused before the next patching operation starts. When ProcessHandler checks for running previous patching operations, it will identify this new process which is not the patching extension as a process that needs to be terminated. If this process is a required process for the service, it causes that service to crash.
This change checks the process's cmdline by reading the pid's cmdline file in the proc filesystem. If it contains the patching extension Core filename, then we can be more confident that the process we are about to end is the correct one. If it does not find the core filename in the cmdline, we can guarantee that the process is not a patching operation, and we will not end that process.
Logging: