From 5e480f478f7e99113f1ba85c813117f9c8b37996 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Tue, 28 Jul 2026 20:05:28 -0400 Subject: [PATCH] fix: probe artifact processes on macOS --- inc/Abilities/WorkspaceAbilities.php | 7 +- inc/Cli/Commands/WorkspaceCommand.php | 1 + inc/Support/ProcessPathProbe.php | 103 ++++++++++++++++++ inc/Workspace/WorkspaceArtifactCleanup.php | 52 +++++++-- .../artifact-cleanup-live-process-guards.php | 42 +++++-- 5 files changed, 183 insertions(+), 22 deletions(-) create mode 100644 inc/Support/ProcessPathProbe.php diff --git a/inc/Abilities/WorkspaceAbilities.php b/inc/Abilities/WorkspaceAbilities.php index fa8d23e7..26958580 100644 --- a/inc/Abilities/WorkspaceAbilities.php +++ b/inc/Abilities/WorkspaceAbilities.php @@ -2568,6 +2568,10 @@ private function registerAbilities(): void { 'type' => 'boolean', 'description' => 'Explicitly allow active artifact eviction after reviewing live/process/unavailable evidence. Separate from force and must be repeated at DB-backed apply/resume.', ), + 'allow_unavailable_process_probe' => array( + 'type' => 'boolean', + 'description' => 'Explicitly allow cleanup when process-path inspection is unavailable or uncertain. This does not waive authoritative live-owner or active-process evidence.', + ), 'apply_plan' => array( 'type' => 'object', 'description' => 'Decoded artifact cleanup dry-run report to apply after revalidating every worktree and artifact path.', @@ -4678,7 +4682,7 @@ public static function worktreeAbandonedCleanup( array $input ): array|\WP_Error * Remove profile-derived artifacts inside workspace worktrees. * * @param array $input Input parameters (dry_run, force, - * allow_active_artifact_cleanup, apply_plan, limit, + * allow_active_artifact_cleanup, allow_unavailable_process_probe, apply_plan, limit, * offset, exhaustive, safety_probes). * @return array */ @@ -4688,6 +4692,7 @@ public static function worktreeCleanupArtifacts( array $input ): array|\WP_Error 'dry_run' => ! empty($input['dry_run']), 'force' => ! empty($input['force']), 'allow_active_artifact_cleanup' => ! empty($input['allow_active_artifact_cleanup']), + 'allow_unavailable_process_probe' => ! empty($input['allow_unavailable_process_probe']), ); if ( isset($input['apply_plan']) && is_array($input['apply_plan']) ) { $opts['apply_plan'] = $input['apply_plan']; diff --git a/inc/Cli/Commands/WorkspaceCommand.php b/inc/Cli/Commands/WorkspaceCommand.php index e0d14297..b691937f 100644 --- a/inc/Cli/Commands/WorkspaceCommand.php +++ b/inc/Cli/Commands/WorkspaceCommand.php @@ -4328,6 +4328,7 @@ public function worktree( array $args, array $assoc_args ): void { $input['dry_run'] = ! empty($assoc_args['dry-run']); $input['force'] = ! empty($assoc_args['force']); $input['allow_active_artifact_cleanup'] = ! empty($assoc_args['allow-active-artifact-cleanup']); + $input['allow_unavailable_process_probe'] = ! empty($assoc_args['allow-unavailable-process-probe']); if ( isset($assoc_args['limit']) ) { $input['limit'] = (int) $assoc_args['limit']; } diff --git a/inc/Support/ProcessPathProbe.php b/inc/Support/ProcessPathProbe.php new file mode 100644 index 00000000..c46e70cc --- /dev/null +++ b/inc/Support/ProcessPathProbe.php @@ -0,0 +1,103 @@ +>,diagnostics:array} */ + public function snapshot(): array; +} + +final class ProcfsProcessPathProbe implements ProcessPathProbeInterface { + + /** @param callable $scanner Returns a procfs snapshot. */ + public function __construct(private $scanner) {} + + public function snapshot(): array { + return ($this->scanner)(); + } +} + +final class UnsupportedProcessPathProbe implements ProcessPathProbeInterface { + + public function __construct(private string $platform) {} + + public function snapshot(): array { + return array( + 'status' => 'unsupported', + 'records' => array(), + 'diagnostics' => array( 'reason' => 'process_path_probe_unsupported', 'platform' => $this->platform, 'remediation' => 'Use a supported host process-path provider before artifact cleanup.' ), + ); + } +} + +final class MacOSLsofProcessPathProbe implements ProcessPathProbeInterface { + + /** @param callable|null $runner Receives argv and returns a ProcessRunner envelope. */ + public function __construct(private $runner = null) {} + + public function snapshot(): array { + $argv = array( 'lsof', '-n', '-P', '-Fpcfn0' ); + if ( is_callable($this->runner) ) { + $result = ($this->runner)($argv); + } else { + $command = CommandSpec::from_argv($argv); + $result = $command instanceof \WP_Error ? $command : ProcessRunner::run($command, array( 'timeout_seconds' => 2, 'output_cap_bytes' => 1048576, 'error_as_result' => true )); + } + if ( $result instanceof \WP_Error || ! is_array($result) || empty($result['success']) ) { + $data = $result instanceof \WP_Error ? (array) $result->get_error_data() : (array) $result; + return array( + 'status' => 'uncertain', + 'records' => array(), + 'diagnostics' => array( 'reason' => ! empty($data['timeout']) ? 'process_path_probe_timeout' : 'process_path_probe_failed', 'provider' => 'lsof', 'details' => $data ), + ); + } + + $records = array(); + $pid = 0; + $command = ''; + $fd = ''; + foreach ( explode("\0", (string) ($result['output'] ?? '')) as $field ) { + $field = ltrim($field, "\n"); + if ( '' === $field ) { + continue; + } + $type = $field[0]; + $value = substr($field, 1); + if ( 'p' === $type ) { + $pid = ctype_digit($value) ? (int) $value : 0; + } elseif ( 'c' === $type ) { + $command = $value; + } elseif ( 'f' === $type ) { + $fd = $value; + } elseif ( 'n' === $type && $pid > 0 && str_starts_with($value, '/') ) { + $records[] = array( + 'pid' => $pid, + 'command' => $command, + 'match_type' => 'cwd' === $fd ? 'cwd' : 'open_file', + 'path' => preg_replace('/ \(deleted\)$/', '', $value), + ) + ( 'cwd' === $fd ? array() : array( 'fd' => $fd ) ); + } + } + + return array( + 'status' => 'available', + 'records' => $records, + 'diagnostics' => array( 'provider' => 'lsof', 'path_records' => count($records) ), + ); + } +} diff --git a/inc/Workspace/WorkspaceArtifactCleanup.php b/inc/Workspace/WorkspaceArtifactCleanup.php index 89bf7fb2..0a7ad47c 100644 --- a/inc/Workspace/WorkspaceArtifactCleanup.php +++ b/inc/Workspace/WorkspaceArtifactCleanup.php @@ -7,10 +7,16 @@ namespace DataMachineCode\Workspace; +use DataMachineCode\Support\MacOSLsofProcessPathProbe; +use DataMachineCode\Support\ProcfsProcessPathProbe; +use DataMachineCode\Support\ProcessPathProbeInterface; +use DataMachineCode\Support\UnsupportedProcessPathProbe; + defined('ABSPATH') || exit; require_once __DIR__ . '/WorktreeContextInjector.php'; require_once __DIR__ . '/WorktreeAgeFilter.php'; +require_once dirname(__DIR__) . '/Support/ProcessPathProbe.php'; trait WorkspaceArtifactCleanup { @@ -35,7 +41,7 @@ trait WorkspaceArtifactCleanup { * planned worktrees rather than the entire workspace. * * @param array $opts Cleanup options (dry_run, force, - * allow_active_artifact_cleanup, apply_plan, limit, + * allow_active_artifact_cleanup, allow_unavailable_process_probe, apply_plan, limit, * offset, exhaustive, safety_probes, older_than). * @return array|\WP_Error */ @@ -43,6 +49,7 @@ public function worktree_cleanup_artifacts( array $opts = array() ): array|\WP_E $dry_run = ! empty($opts['dry_run']); $force = ! empty($opts['force']); $allow_active = ! empty($opts['allow_active_artifact_cleanup']); + $allow_unavailable_process_probe = ! empty($opts['allow_unavailable_process_probe']); $apply_plan = isset($opts['apply_plan']) && is_array($opts['apply_plan']) ? $opts['apply_plan'] : null; $older_than = isset($opts['older_than']) ? trim( (string) $opts['older_than']) : ''; if ( '' === $older_than && null !== $apply_plan && is_array($apply_plan['age_filter'] ?? null) ) { @@ -66,7 +73,7 @@ public function worktree_cleanup_artifacts( array $opts = array() ): array|\WP_E $limit = 0; } $review_command = $this->build_artifact_cleanup_review_command($opts); - $apply_command = $this->build_artifact_cleanup_apply_command($force, $allow_active); + $apply_command = $this->build_artifact_cleanup_apply_command($force, $allow_active, $allow_unavailable_process_probe); $preview_command = $this->build_artifact_cleanup_preview_command($opts); // Apply paths default to safety probing (small subset). Dry-run defaults // to skipping the per-worktree git probes unless explicitly requested or @@ -107,6 +114,7 @@ public function worktree_cleanup_artifacts( array $opts = array() ): array|\WP_E $force, array( 'allow_active_artifact_cleanup' => $allow_active, + 'allow_unavailable_process_probe' => $allow_unavailable_process_probe, 'limit' => $plan_limit, 'offset' => $rank_by_size ? 0 : $offset, 'only_handles' => $only_handles, @@ -291,10 +299,11 @@ private function build_artifact_cleanup_review_command( array $opts = array() ): * * @return string */ - private function build_artifact_cleanup_apply_command( bool $force = false, bool $allow_active = false ): string { + private function build_artifact_cleanup_apply_command( bool $force = false, bool $allow_active = false, bool $allow_unavailable_process_probe = false ): string { return 'studio wp datamachine-code workspace cleanup apply ' . ( $force ? ' --force' : '' ) - . ( $allow_active ? ' --allow-active-artifact-cleanup' : '' ); + . ( $allow_active ? ' --allow-active-artifact-cleanup' : '' ) + . ( $allow_unavailable_process_probe ? ' --allow-unavailable-process-probe' : '' ); } /** @@ -311,6 +320,9 @@ private function build_artifact_cleanup_preview_command( array $opts ): string { if ( ! empty($opts['allow_active_artifact_cleanup']) ) { $parts[] = '--allow-active-artifact-cleanup'; } + if ( ! empty($opts['allow_unavailable_process_probe']) ) { + $parts[] = '--allow-unavailable-process-probe'; + } if ( isset($opts['limit']) ) { $parts[] = '--limit=' . (int) $opts['limit']; } @@ -361,6 +373,7 @@ private function build_worktree_artifact_cleanup_plan( bool $force, array $opts $limit = isset($opts['limit']) ? (int) $opts['limit'] : 0; $offset = isset($opts['offset']) ? max(0, (int) $opts['offset']) : 0; $allow_active = ! empty($opts['allow_active_artifact_cleanup']); + $allow_unavailable_process_probe = ! empty($opts['allow_unavailable_process_probe']); $only_handles = isset($opts['only_handles']) && is_array($opts['only_handles']) ? array_values(array_filter(array_map('strval', $opts['only_handles']), fn( $h ) => '' !== $h)) : null; @@ -512,7 +525,8 @@ private function build_worktree_artifact_cleanup_plan( bool $force, array $opts $process_protection = $this->active_artifact_process_protection($wt_path, $artifacts); if ( null !== $process_protection ) { - if ( ! $allow_active ) { + $is_probe_unavailable = str_starts_with((string) ($process_protection['reason_code'] ?? ''), 'active_process_probe_'); + if ( ( $is_probe_unavailable && ! $allow_unavailable_process_probe ) || ( ! $is_probe_unavailable && ! $allow_active ) ) { $skipped[] = array_merge($base_row, $process_protection, array( 'artifacts' => $artifacts )); continue; } @@ -726,7 +740,7 @@ private function active_artifact_process_protection( string $worktree_path, arra } /** - * Conservatively inspect Linux process cwd/open-file paths without naming runtimes or tools. + * Conservatively inspect process cwd/open-file paths through the host provider. * * @param string $worktree_path Worktree root. * @param array $artifacts Profile-derived artifact rows. @@ -785,6 +799,15 @@ protected function artifact_process_path_records( bool $fresh ): array { return $this->artifact_process_path_snapshot; } + $result = $this->artifact_process_path_probe()->snapshot(); + if ( ! $fresh ) { + $this->artifact_process_path_snapshot = $result; + } + return $result; + } + + /** @return array{status:string,records:array>,diagnostics:array} */ + private function artifact_procfs_process_path_records(): array { $proc_root = $this->artifact_process_root(); if ( ! is_dir($proc_root) || ! is_readable($proc_root) ) { return array( @@ -884,9 +907,6 @@ protected function artifact_process_path_records( bool $fresh ): array { 'unknown_mount_namespaces' => array_slice($unknown_namespaces, 0, 10), ), ); - if ( ! $fresh ) { - $this->artifact_process_path_snapshot = $result; - } return $result; } @@ -897,6 +917,20 @@ protected function artifact_process_root(): string { return '/proc'; } + /** Resolve the host process-path provider. Overridable for deterministic tests. */ + protected function artifact_process_path_probe(): ProcessPathProbeInterface { + if ( '/proc' !== $this->artifact_process_root() ) { + return new ProcfsProcessPathProbe(fn() => $this->artifact_procfs_process_path_records()); + } + if ( 'Darwin' === PHP_OS_FAMILY ) { + return new MacOSLsofProcessPathProbe(); + } + if ( 'Linux' === PHP_OS_FAMILY ) { + return new ProcfsProcessPathProbe(fn() => $this->artifact_procfs_process_path_records()); + } + return new UnsupportedProcessPathProbe(PHP_OS_FAMILY); + } + /** * Preserve measured mutation when a later artifact in the row becomes blocked. * diff --git a/tests/artifact-cleanup-live-process-guards.php b/tests/artifact-cleanup-live-process-guards.php index 130c37cb..f6a07c8c 100644 --- a/tests/artifact-cleanup-live-process-guards.php +++ b/tests/artifact-cleanup-live-process-guards.php @@ -163,6 +163,7 @@ protected function observe_artifact_reclamation_path( string $worktree_path, str use DataMachineCode\Workspace\ArtifactCleanupGuardHarness; use DataMachineCode\Workspace\ControlledArtifactCleanupGuardHarness; use DataMachineCode\Workspace\WorktreeContextInjector; + use DataMachineCode\Support\MacOSLsofProcessPathProbe; function artifact_guard_assert_same( mixed $expected, mixed $actual, string $message ): void { if ( $expected !== $actual ) { @@ -220,18 +221,35 @@ function artifact_guard_create_artifacts( string $path, bool $multiple = false ) $forced = $harness->worktree_cleanup_artifacts(array( 'dry_run' => true, 'safety_probes' => true, 'force' => true )); artifact_guard_assert_same(0, count($forced['candidates']), 'legacy force must not override unavailable active-process evidence'); $active_override = $harness->worktree_cleanup_artifacts(array( 'dry_run' => true, 'safety_probes' => true, 'allow_active_artifact_cleanup' => true )); - artifact_guard_assert_same(1, count($active_override['candidates']), 'distinct active artifact override must permit reviewed eviction'); - artifact_guard_assert_same('active_process_probe_unavailable', $active_override['candidates'][0]['safety_overrides'][0]['reason_code'] ?? null, 'active override must retain typed unavailable evidence'); - - $real_scanner = new ArtifactCleanupGuardHarness($root); - $descriptors = array( 0 => array( 'file', '/dev/null', 'r' ), 1 => array( 'file', '/dev/null', 'w' ), 2 => array( 'file', '/dev/null', 'w' ) ); - $process = proc_open(array( '/bin/sleep', '5' ), $descriptors, $pipes, $path); - if ( is_resource($process) ) { - usleep(100000); - $real_probe = $real_scanner->probe_processes($path, array( array( 'path' => 'vendor' ) )); - artifact_guard_assert_same(true, array() !== (array) ( $real_probe['evidence'] ?? array() ), 'real procfs scanner must detect a child process cwd in the worktree'); - proc_terminate($process); - proc_close($process); + artifact_guard_assert_same(0, count($active_override['candidates']), 'live-owner eviction override must not waive unavailable process evidence'); + $probe_override = $harness->worktree_cleanup_artifacts(array( 'dry_run' => true, 'safety_probes' => true, 'allow_unavailable_process_probe' => true )); + artifact_guard_assert_same(1, count($probe_override['candidates']), 'process-probe override must permit reviewed cleanup without waiving live-owner eviction'); + artifact_guard_assert_same('active_process_probe_unavailable', $probe_override['candidates'][0]['safety_overrides'][0]['reason_code'] ?? null, 'probe override must retain typed unavailable evidence'); + + $mac_no_match = new MacOSLsofProcessPathProbe(fn( array $argv ) => array( 'success' => true, 'output' => "p42\0ctest\0f3\0n/tmp/unrelated\0" )); + artifact_guard_assert_same('available', $mac_no_match->snapshot()['status'], 'macOS lsof no-match snapshot must be available'); + $mac_cwd = new MacOSLsofProcessPathProbe(fn( array $argv ) => array( 'success' => true, 'output' => "p42\0ctest\0fcwd\0n{$path}\0" )); + $mac_cwd_records = $mac_cwd->snapshot()['records']; + artifact_guard_assert_same('cwd', $mac_cwd_records[0]['match_type'] ?? null, 'macOS lsof cwd evidence must retain its match type'); + $mac_open_file = new MacOSLsofProcessPathProbe(fn( array $argv ) => array( 'success' => true, 'output' => "p42\0ctest\0f12\0n{$path}/vendor/generated.php\0" )); + $mac_open_file_records = $mac_open_file->snapshot()['records']; + artifact_guard_assert_same('open_file', $mac_open_file_records[0]['match_type'] ?? null, 'macOS lsof open-file evidence must retain its match type'); + $mac_timeout = new MacOSLsofProcessPathProbe(fn( array $argv ) => array( 'success' => false, 'timeout' => 2 )); + artifact_guard_assert_same('uncertain', $mac_timeout->snapshot()['status'], 'macOS lsof timeout must fail closed as uncertain'); + $mac_exit_race = new MacOSLsofProcessPathProbe(fn( array $argv ) => array( 'success' => true, 'output' => "p42\0ctest\0" )); + artifact_guard_assert_same(array(), $mac_exit_race->snapshot()['records'], 'macOS lsof process-exit races without path records must remain safe no-match evidence'); + + if ( in_array(PHP_OS_FAMILY, array( 'Linux', 'Darwin' ), true) ) { + $real_scanner = new ArtifactCleanupGuardHarness($root); + $descriptors = array( 0 => array( 'file', '/dev/null', 'r' ), 1 => array( 'file', '/dev/null', 'w' ), 2 => array( 'file', '/dev/null', 'w' ) ); + $process = proc_open(array( '/bin/sleep', '5' ), $descriptors, $pipes, $path); + if ( is_resource($process) ) { + usleep(100000); + $real_probe = $real_scanner->probe_processes($path, array( array( 'path' => 'vendor' ) )); + artifact_guard_assert_same(true, array() !== (array) ( $real_probe['evidence'] ?? array() ), 'host process scanner must detect a child process cwd in the worktree'); + proc_terminate($process); + proc_close($process); + } } $fake_proc = $root . '/proc-fixture';