diff --git a/inc/Workspace/WorkspaceWorktreeLifecycle.php b/inc/Workspace/WorkspaceWorktreeLifecycle.php index ec19e9fd..68bef6ca 100644 --- a/inc/Workspace/WorkspaceWorktreeLifecycle.php +++ b/inc/Workspace/WorkspaceWorktreeLifecycle.php @@ -1231,8 +1231,8 @@ public function worktree_add_request( WorktreeAllocationRequest $request ): arra // Fetch and demand planning only touch this primary. Keep them out of the // global capacity critical section so unrelated repositories can prepare in - // parallel; capacity-changing checkout remains globally fenced. Bootstrap - // demand is reserved durably before its child processes run without locks. + // parallel. Admission reserves bounded demand atomically, then releases the + // global lock so repository-scoped checkout can proceed independently. $this->worktree_add_progress($progress_callback, 'repo_preflight'); $preflight = WorkspaceMutationLock::with_repo( $this->workspace_path, @@ -1473,10 +1473,10 @@ private function worktree_capacity_dry_run( string $repo, string $branch, ?strin /** * Resolve the explicit global-capacity lock wait budget. * - * Lock order is always global capacity first, then the repository lock. The - * global lock remains held through checkout and durable bootstrap reservation. - * Later admissions include that reservation while dependency children run - * without inheriting this lock descriptor. + * Lock order is global capacity admission first, then the repository lock. + * The global lock serializes inspect-and-reserve only; checkout remains + * repository-scoped. Later admissions include durable reservations while + * dependency children run without inheriting this lock descriptor. */ public static function worktree_capacity_wait_timeout_seconds( bool $bootstrap = true ): int { $timeout = self::worktree_capacity_operation_timeout_seconds($bootstrap) + 60; @@ -1521,9 +1521,10 @@ public static function worktree_capacity_aggregate_timeout_seconds( bool $bootst } /** - * Inspect, create, and reserve bootstrap demand while holding the workspace- - * wide capacity lock. A later admission includes the durable reservation while - * the dependency process runs outside mutation lock boundaries. + * Inspect and reserve demand under the workspace capacity lock, then create + * the worktree under the repository lock. Later admissions include the durable + * reservation while checkout and dependency processes run without holding the + * global lock. */ private function worktree_add_with_capacity_lock( string $repo, @@ -1865,6 +1866,21 @@ static function ( $row ): string { ); } + $demand_plan['reservation_handle'] = $wt_handle; + $reserved = WorktreeContextInjector::reserve_capacity($this->workspace_path, $wt_handle, $demand_plan); + if ( is_wp_error($reserved) ) { + return $reserved; + } + if ( $capacity_lock instanceof WorkspaceMutationLock ) { + $released = $capacity_lock->release(); + if ( is_wp_error($released) ) { + WorktreeContextInjector::release_capacity_reservation($this->workspace_path, $wt_handle); + return $released; + } + $capacity_lock = null; + } + + try { $repo_timeout = $this->worktree_operation_remaining_seconds($operation_deadline); if ( $repo_timeout <= 0 ) { return $this->worktree_operation_timeout('repo_lock_wait', $operation_timeout, $operation_started); @@ -1955,6 +1971,7 @@ static function ( $row ): string { $measurement_plan = $post_rebase_demand; $post_rebase_demand = WorktreeBootstrapper::remaining_demand_after_materialization($post_rebase_demand); $post_rebase_demand['allow_percentage_byte_floor_exception'] = $allow_percentage_byte_floor_exception; + $post_rebase_demand['reservation_handle'] = $wt_handle; $this->worktree_add_progress($progress_callback, 'post_rebase_capacity_inspection'); $post_rebase_budget = $this->inspect_worktree_capacity($repo, $branch, $force, $post_rebase_demand); $this->worktree_add_progress($progress_callback, 'post_rebase_artifact_reclamation'); @@ -1987,7 +2004,7 @@ static function ( $row ): string { } if ( $bootstrap ) { - $bootstrap_before_capacity = $this->inspect_worktree_capacity($repo, $branch, false, array()); + $bootstrap_before_capacity = $this->inspect_worktree_capacity($repo, $branch, false, array( 'reservation_handle' => $wt_handle )); $remaining_seconds = $this->worktree_operation_remaining_seconds($operation_deadline); if ( $remaining_seconds <= 0 ) { $recorded = $this->record_bootstrap_outcome($wt_handle, 'failed', array(), 'operation_timeout'); @@ -2003,7 +2020,7 @@ static function ( $row ): string { if ( is_wp_error($response) ) { return $response; } - $after_capacity = $this->inspect_worktree_capacity($repo, $branch, false, array()); + $after_capacity = $this->inspect_worktree_capacity($repo, $branch, false, array( 'reservation_handle' => $wt_handle )); $response['capacity_evidence'] = WorktreeDemandCalibration::record_bootstrap($repo, $measurement_plan, $bootstrap_before_capacity, $after_capacity, true); $response['bootstrap_noop_completed'] = true; } else { @@ -2075,6 +2092,9 @@ static function ( $row ): string { $this->emit_workspace_changed('worktree_add', $repo, $wt_handle, $wt_path); return $response; + } finally { + WorktreeContextInjector::release_capacity_reservation($this->workspace_path, $wt_handle); + } } /** Whether target-tree planning proves bootstrap has no dependency work. */ @@ -5769,7 +5789,19 @@ public function worktree_prune( bool $dry_run = false, mixed $until_budget = nul * @return array */ protected function inspect_worktree_capacity( string $repo, string $branch, bool $force, array $demand_plan ): array { - $reservations = WorktreeContextInjector::bootstrap_capacity_reservations(); + $reservations = WorktreeContextInjector::capacity_reservations($this->workspace_path); + $exclude = (string) ( $demand_plan['reservation_handle'] ?? '' ); + if ( '' !== $exclude && isset($reservations['by_handle'][ $exclude ]) ) { + $reservations['bytes'] = max(0, (int) $reservations['bytes'] - (int) $reservations['by_handle'][ $exclude ]['bytes']); + $reservations['inodes'] = max(0, (int) $reservations['inodes'] - (int) $reservations['by_handle'][ $exclude ]['inodes']); + $reservations['handles'] = array_values( + array_filter( + (array) $reservations['handles'], + static fn( string $handle ): bool => $handle !== $exclude + ) + ); + unset($reservations['by_handle'][ $exclude ]); + } $demand_plan['bytes'] = max(0, (int) ( $demand_plan['bytes'] ?? 0 )) + (int) $reservations['bytes']; $demand_plan['inodes'] = max(0, (int) ( $demand_plan['inodes'] ?? 0 )) + (int) $reservations['inodes']; $demand_plan['capacity_reservations'] = $reservations; diff --git a/inc/Workspace/WorktreeContextInjector.php b/inc/Workspace/WorktreeContextInjector.php index bb4ae226..fa174704 100644 --- a/inc/Workspace/WorktreeContextInjector.php +++ b/inc/Workspace/WorktreeContextInjector.php @@ -271,6 +271,9 @@ private static function worktree_add_isolation_command( array $request, bool $te */ public const METADATA_OPTION = 'datamachine_worktree_metadata'; + /** Workspace-local demand reserved after admission and before checkout completes. */ + public const CAPACITY_RESERVATION_DIR = 'capacity-reservations'; + /** Journal-only record written before a Git worktree mutation. */ public const CREATION_INTENT_KEY = 'creation_intent'; @@ -1194,7 +1197,7 @@ public static function bootstrap_readiness( ?array $metadata ): array { /** Return dependency demand reserved by materialized worktrees still bootstrapping. */ public static function bootstrap_capacity_reservations(): array { if ( ! function_exists('get_option') ) { - return array( 'bytes' => 0, 'inodes' => 0, 'handles' => array() ); + return array( 'bytes' => 0, 'inodes' => 0, 'handles' => array(), 'by_handle' => array() ); } // Capacity admission must not reuse an earlier request's option snapshot // after another process has committed a reservation. @@ -1205,6 +1208,7 @@ public static function bootstrap_capacity_reservations(): array { $bytes = 0; $inodes = 0; $handles = array(); + $by_handle = array(); foreach ( is_array($all) ? $all : array() as $handle => $metadata ) { $bootstrap = (array) ($metadata['provisioning']['bootstrap'] ?? array()); $reservation = is_array($bootstrap['capacity_reservation'] ?? null) ? $bootstrap['capacity_reservation'] : null; @@ -1213,11 +1217,185 @@ public static function bootstrap_capacity_reservations(): array { if ( ! is_array($reservation) || 'running' !== ($bootstrap['outcome'] ?? null) || ( 'stale' === $coordinator['state'] && 'stale' === $child['state'] ) ) { continue; } - $bytes += max(0, (int) ($reservation['bytes'] ?? 0)); - $inodes += max(0, (int) ($reservation['inodes'] ?? 0)); + $demand = array( + 'bytes' => max(0, (int) ($reservation['bytes'] ?? 0)), + 'inodes' => max(0, (int) ($reservation['inodes'] ?? 0)), + ); + $bytes += $demand['bytes']; + $inodes += $demand['inodes']; $handles[] = (string) $handle; + $by_handle[ (string) $handle ] = $demand; + } + return array( 'bytes' => $bytes, 'inodes' => $inodes, 'handles' => $handles, 'by_handle' => $by_handle ); + } + + /** + * Return live bootstrap plus admitted-but-not-yet-materialized demand. + * + * @return array{bytes:int,inodes:int,handles:array,by_handle:array} + */ + public static function capacity_reservations( string $workspace_path = '' ): array { + return self::merge_capacity_reservations( + self::bootstrap_capacity_reservations(), + self::admission_capacity_reservations($workspace_path) + ); + } + + /** + * Atomically reserve this handle's bounded demand so later admissions include it + * after the global capacity lock is released. + * + * @param array $demand + */ + public static function reserve_capacity( string $workspace_path, string $handle, array $demand ): bool|\WP_Error { + $dir = self::capacity_reservation_dir($workspace_path); + if ( '' === $dir || '' === $handle ) { + return new \WP_Error( + 'workspace_capacity_reservation_invalid_target', + 'Capacity reservation requires a workspace path and worktree handle.', + array( 'status' => 400 ) + ); + } + if ( ! is_dir($dir) ) { + $created = function_exists('wp_mkdir_p') + ? wp_mkdir_p($dir) + : @mkdir($dir, 0755, true); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir,WordPress.PHP.NoSilencedErrors.Discouraged -- Atomic local reservation setup rechecks the directory. + if ( ! $created && ! is_dir($dir) ) { + return new \WP_Error( + 'workspace_capacity_reservation_create_failed', + sprintf('Failed to create capacity reservation directory: %s', $dir), + array( 'status' => 500 ) + ); + } + } + + $path = $dir . '/' . self::capacity_reservation_filename($handle) . '.json'; + $payload = array( + 'handle' => $handle, + 'bytes' => max(0, (int) ( $demand['bytes'] ?? 0 )), + 'inodes' => max(0, (int) ( $demand['inodes'] ?? 0 )), + 'coordinator' => self::bootstrap_owner(), + 'reserved_at' => gmdate('c'), + ); + $json = function_exists('wp_json_encode') ? wp_json_encode($payload) : json_encode($payload); // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- Reservation files also run outside WordPress bootstrap. + $temporary = $path . '.' . bin2hex(random_bytes(6)) . '.tmp'; + if ( false === file_put_contents($temporary, false === $json ? '{}' : (string) $json, LOCK_EX) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents + return new \WP_Error( + 'workspace_capacity_reservation_persist_failed', + sprintf('Failed to persist capacity reservation for "%s".', $handle), + array( 'status' => 500 ) + ); + } + if ( ! rename($temporary, $path) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename + unlink($temporary); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink + return new \WP_Error( + 'workspace_capacity_reservation_persist_failed', + sprintf('Failed to commit capacity reservation for "%s".', $handle), + array( 'status' => 500 ) + ); + } + + return true; + } + + /** Drop an admitted reservation after checkout, bootstrap handoff, or failure. */ + public static function release_capacity_reservation( string $workspace_path, string $handle ): void { + $path = self::capacity_reservation_path($workspace_path, $handle); + if ( '' !== $path && is_file($path) ) { + unlink($path); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink + } + } + + /** + * Return admitted demand that has not yet been converted to bootstrap reservation. + * + * @return array{bytes:int,inodes:int,handles:array,by_handle:array} + */ + public static function admission_capacity_reservations( string $workspace_path ): array { + $dir = self::capacity_reservation_dir($workspace_path); + if ( '' === $dir || ! is_dir($dir) ) { + return array( 'bytes' => 0, 'inodes' => 0, 'handles' => array(), 'by_handle' => array() ); + } + $files = glob($dir . '/*.json'); + $bytes = 0; + $inodes = 0; + $handles = array(); + $by_handle = array(); + foreach ( false === $files ? array() : $files as $file ) { + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,WordPress.PHP.NoSilencedErrors.Discouraged -- A vanished reservation file is an expected concurrent release race. + $data = json_decode((string) @file_get_contents($file), true); + if ( ! is_array($data) ) { + continue; + } + $coordinator = self::bootstrap_owner_state($data['coordinator'] ?? null); + if ( 'stale' === ( $coordinator['state'] ?? '' ) ) { + continue; + } + $handle = (string) ( $data['handle'] ?? '' ); + if ( '' === $handle ) { + continue; + } + $demand = array( + 'bytes' => max(0, (int) ( $data['bytes'] ?? 0 )), + 'inodes' => max(0, (int) ( $data['inodes'] ?? 0 )), + ); + $bytes += $demand['bytes']; + $inodes += $demand['inodes']; + $handles[] = $handle; + $by_handle[ $handle ] = $demand; + } + + return array( 'bytes' => $bytes, 'inodes' => $inodes, 'handles' => $handles, 'by_handle' => $by_handle ); + } + + /** + * @param array{bytes?:int,inodes?:int,handles?:array,by_handle?:array} $left + * @param array{bytes?:int,inodes?:int,handles?:array,by_handle?:array} $right + * @return array{bytes:int,inodes:int,handles:array,by_handle:array} + */ + private static function merge_capacity_reservations( array $left, array $right ): array { + $by_handle = array(); + foreach ( array( $left, $right ) as $set ) { + foreach ( (array) ( $set['by_handle'] ?? array() ) as $handle => $demand ) { + $handle = (string) $handle; + if ( '' === $handle || ! is_array($demand) ) { + continue; + } + $existing = $by_handle[ $handle ] ?? array( 'bytes' => 0, 'inodes' => 0 ); + $by_handle[ $handle ] = array( + 'bytes' => max( (int) $existing['bytes'], max(0, (int) ( $demand['bytes'] ?? 0 )) ), + 'inodes' => max( (int) $existing['inodes'], max(0, (int) ( $demand['inodes'] ?? 0 )) ), + ); + } + } + $bytes = 0; + $inodes = 0; + foreach ( $by_handle as $demand ) { + $bytes += (int) $demand['bytes']; + $inodes += (int) $demand['inodes']; } - return array( 'bytes' => $bytes, 'inodes' => $inodes, 'handles' => $handles ); + + return array( + 'bytes' => $bytes, + 'inodes' => $inodes, + 'handles' => array_keys($by_handle), + 'by_handle' => $by_handle, + ); + } + + private static function capacity_reservation_dir( string $workspace_path ): string { + $workspace_path = rtrim($workspace_path, '/'); + return '' === $workspace_path ? '' : $workspace_path . '/.locks/' . self::CAPACITY_RESERVATION_DIR; + } + + private static function capacity_reservation_path( string $workspace_path, string $handle ): string { + $dir = self::capacity_reservation_dir($workspace_path); + return '' === $dir || '' === $handle ? '' : $dir . '/' . self::capacity_reservation_filename($handle) . '.json'; + } + + private static function capacity_reservation_filename( string $handle ): string { + $handle = preg_replace('/[^a-zA-Z0-9._@-]/', '', $handle); + return trim( (string) $handle, '-.'); } /** Capture a PID and OS-issued process identity for bootstrap ownership. */ diff --git a/tests/workspace-capacity-lock-concurrency.php b/tests/workspace-capacity-lock-concurrency.php index c3680729..83684292 100644 --- a/tests/workspace-capacity-lock-concurrency.php +++ b/tests/workspace-capacity-lock-concurrency.php @@ -800,6 +800,11 @@ static function () use ( $state, $ready ): string { }; $lifecycle_source = (string) file_get_contents(dirname(__DIR__) . '/inc/Workspace/WorkspaceWorktreeLifecycle.php'); capacity_lock_assert(str_contains($lifecycle_source, "'workspace-capacity-admission', \$reuse"), 'Bootstrap resume must acquire global capacity admission before its repository lock.'); + $capacity_fn = strpos($lifecycle_source, 'function worktree_add_with_capacity_lock'); + $reserve_at = strpos($lifecycle_source, 'WorktreeContextInjector::reserve_capacity'); + $release_at = strpos($lifecycle_source, '$capacity_lock->release()'); + $create_at = strpos($lifecycle_source, '$this->worktree_add_locked('); + capacity_lock_assert(false !== $capacity_fn && false !== $reserve_at && false !== $release_at && false !== $create_at && $capacity_fn < $reserve_at && $reserve_at < $release_at && $release_at < $create_at, 'New worktree checkout must run only after demand is reserved and the global capacity lock is released.'); capacity_lock_assert(2400 === $policy::worktree_capacity_wait_timeout_seconds(true), 'Bootstrap admission wait must exceed the complete bounded operation lifecycle.'); $state = $workspace . '/capacity-state'; diff --git a/tests/worktree-parallel-multi-repo-admission.php b/tests/worktree-parallel-multi-repo-admission.php new file mode 100644 index 00000000..866f11fb --- /dev/null +++ b/tests/worktree-parallel-multi-repo-admission.php @@ -0,0 +1,307 @@ +code; } + public function get_error_message(): string { return $this->message; } + public function get_error_data(): mixed { return $this->data; } + } +} +if ( ! function_exists('is_wp_error') ) { + function is_wp_error( mixed $value ): bool { return $value instanceof WP_Error; } +} +if ( ! function_exists('wp_json_encode') ) { + function wp_json_encode( mixed $value, int $flags = 0, int $depth = 512 ): string|false { return json_encode($value, $flags, $depth); } +} +if ( ! function_exists('apply_filters') ) { + function apply_filters( string $hook, mixed $value, mixed ...$args ): mixed { + if ( 'datamachine_worktree_disk_budget_thresholds' === $hook ) { + return array_merge((array) $value, array( 'refuse_free_bytes' => 0, 'refuse_free_percent' => 0, 'refuse_free_inodes' => 0, 'refuse_free_inode_percent' => 0 )); + } + return $value; + } +} +if ( ! function_exists('get_option') ) { + function get_option( string $name, mixed $default = false ): mixed { + return $GLOBALS['dmc_parallel_options'][ $name ] ?? $default; + } +} +if ( ! function_exists('update_option') ) { + function update_option( string $name, mixed $value, mixed $autoload = null ): bool { + $GLOBALS['dmc_parallel_options'][ $name ] = $value; + return true; + } +} +$GLOBALS['dmc_parallel_options'] = $GLOBALS['dmc_parallel_options'] ?? array(); + +if ( ! function_exists('current_time') ) { + function current_time( string $type, bool $gmt = false ): string { return gmdate('Y-m-d H:i:s'); } +} +if ( ! function_exists('home_url') ) { + function home_url(): string { return 'https://example.test'; } +} +if ( ! function_exists('get_bloginfo') ) { + function get_bloginfo( string $show = '' ): string { return 'DMC Test'; } +} +if ( ! function_exists('wp_generate_password') ) { + function wp_generate_password( int $length = 12, bool $special_chars = true, bool $extra_special_chars = false ): string { return str_repeat('a', $length); } +} +if ( ! function_exists('dbDelta') ) { + function dbDelta( string $sql ): array { return array(); } +} + +const ARRAY_A = 'ARRAY_A'; + +final class Dmc_Parallel_Wpdb { + public string $prefix = 'wp_'; + public string $last_error = ''; + public int $insert_id = 0; + public int $rows_affected = 0; + /** @var array> */ + public array $rows = array(); + /** @var array> */ + public array $lock_rows = array(); + + public function get_charset_collate(): string { return ''; } + public function db_server_info(): string { return 'MySQL 8.4'; } + public function replace( string $table, array $data ): int|false { + $this->rows[ (string) $data['handle'] ] = $data; + $this->rows_affected = 1; + return 1; + } + public function insert( string $table, array $data, array $format = array() ): int|false { + ++$this->insert_id; + $data['id'] = $this->insert_id; + $this->lock_rows[ $this->insert_id ] = $data; + $this->rows_affected = 1; + return 1; + } + public function delete( string $table, array $where ): int|false { + unset($this->rows[ (string) ( $where['handle'] ?? '' ) ]); + return 1; + } + public function update( string $table, array $data, array $where ): int|false { + $handle = (string) ( $where['handle'] ?? '' ); + if ( isset($this->rows[ $handle ]) ) { + $this->rows[ $handle ] = array_merge($this->rows[ $handle ], $data); + } + if ( isset($where['id'], $this->lock_rows[ (int) $where['id'] ]) ) { + $this->lock_rows[ (int) $where['id'] ] = array_merge($this->lock_rows[ (int) $where['id'] ], $data); + } + $this->rows_affected = 1; + return 1; + } + public function get_results( string $sql, string $output = ARRAY_A ): array { return array_values($this->rows); } + public function get_row( string $sql, string $output = ARRAY_A ): ?array { + foreach ( $this->rows as $handle => $row ) { + if ( str_contains($sql, (string) $handle) ) { + return $row; + } + } + return null; + } + public function prepare( string $query, mixed ...$args ): string { + foreach ( $args as $arg ) { + $query = preg_replace('/%[is]/', addslashes((string) $arg), $query, 1) ?? $query; + } + return $query; + } + public function query( string $sql ): int|false { return 1; } + public function get_var( string $sql ): string|int|null { + return str_contains($sql, 'SHOW TABLES LIKE') ? $this->prefix . ( str_contains($sql, 'datamachine_code_locks') ? 'datamachine_code_locks' : 'datamachine_code_worktrees' ) : 0; + } + public function get_col( string $sql ): array { return array(); } +} + +function parallel_assert( bool $condition, string $message ): void { + if ( ! $condition ) { + throw new RuntimeException($message); + } +} + +function parallel_remove_tree( string $path ): void { + if ( ! is_dir($path) ) { + return; + } + $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST); + foreach ( $iterator as $item ) { + $item->isDir() && ! $item->isLink() ? rmdir($item->getPathname()) : unlink($item->getPathname()); + } + rmdir($path); +} + +function parallel_run( string $command, string $cwd ): void { + $output = array(); + $code = 0; + exec('cd ' . escapeshellarg($cwd) . ' && ' . $command . ' 2>&1', $output, $code); + parallel_assert(0 === $code, sprintf('Command failed (%d): %s\n%s', $code, $command, implode("\n", $output))); +} + +function parallel_create_repo( string $workspace, string $repo ): void { + $origin = $workspace . '/origin.git'; + $path = $workspace . '/' . $repo; + if ( ! is_dir($origin) ) { + parallel_run('git init --bare ' . escapeshellarg($origin), $workspace); + $source = $workspace . '/source'; + mkdir($source, 0777, true); + parallel_run('git init -b main', $source); + parallel_run('git config user.email test@example.test', $source); + parallel_run('git config user.name "DMC Test"', $source); + file_put_contents($source . '/README.md', "fixture\n"); + parallel_run('git add README.md && git commit -m initial && git remote add origin ' . escapeshellarg($origin) . ' && git push -u origin main', $source); + parallel_run('git symbolic-ref HEAD refs/heads/main', $origin); + } + parallel_run('git clone ' . escapeshellarg($origin) . ' ' . escapeshellarg($path), $workspace); + parallel_run('git config user.email test@example.test', $path); + parallel_run('git config user.name "DMC Test"', $path); + parallel_run('git symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/main', $path); +} + +require_once dirname(__DIR__) . '/vendor/autoload.php'; +require_once dirname(__DIR__) . '/inc/Workspace/WorktreeContextInjector.php'; +require_once dirname(__DIR__) . '/inc/Workspace/WorkspaceMutationLock.php'; +require_once __DIR__ . '/support/bootstrap.php'; + +use DataMachineCode\Workspace\WorktreeContextInjector; +use DataMachineCode\Workspace\WorkspaceMutationLock; + +$mode = $argv[1] ?? 'test'; +if ( 'add' === $mode ) { + $workspace = (string) $argv[2]; + $repo = (string) $argv[3]; + $branch = (string) $argv[4]; + $events = (string) $argv[5]; + if ( ! defined('DATAMACHINE_WORKSPACE_PATH') ) { + define('DATAMACHINE_WORKSPACE_PATH', $workspace); + } + $GLOBALS['wpdb'] = new Dmc_Parallel_Wpdb(); + require_once dirname(__DIR__) . '/inc/Workspace/Workspace.php'; + $started = microtime(true); + $result = ( new DataMachineCode\Workspace\Workspace() )->worktree_add_request( + dmc_test_allocation_request( + $repo, + $branch, + 'origin/main', + false, + false, + false, + false, + false, + array(), + true, + false, + array(), + 'reuse_compatible', + false, + false, + static function ( array $event ) use ( $events, $repo ): void { + $phase = (string) ( $event['phase'] ?? '' ); + if ( 'git_worktree_add' !== $phase ) { + return; + } + file_put_contents($events, wp_json_encode(array( 'repo' => $repo, 'mark' => 'start', 'at' => microtime(true) )) . "\n", FILE_APPEND | LOCK_EX); + usleep(750000); + file_put_contents($events, wp_json_encode(array( 'repo' => $repo, 'mark' => 'end', 'at' => microtime(true) )) . "\n", FILE_APPEND | LOCK_EX); + } + ) + ); + if ( is_wp_error($result) ) { + fwrite(STDOUT, 'error:' . $result->get_error_code() . ':' . $result->get_error_message()); + exit(2); + } + fwrite(STDOUT, wp_json_encode(array( 'ok' => true, 'handle' => $result['handle'] ?? null, 'elapsed' => microtime(true) - $started ))); + exit(0); +} + +$workspace = sys_get_temp_dir() . '/dmc-parallel-admission-' . bin2hex(random_bytes(6)); +mkdir($workspace, 0700, true); + +try { + $reserved = WorktreeContextInjector::reserve_capacity($workspace, 'repo-a@one', array( 'bytes' => 100, 'inodes' => 10 )); + parallel_assert(true === $reserved, 'Admission reservation did not persist.'); + $snapshot = WorktreeContextInjector::admission_capacity_reservations($workspace); + parallel_assert(100 === $snapshot['bytes'] && 10 === $snapshot['inodes'] && array( 'repo-a@one' ) === $snapshot['handles'], 'Live admission reservation was not visible to the next inspect.'); + WorktreeContextInjector::set_bootstrap_owner_probe_for_test(static fn( int $pid ): array => array( 'state' => 'stale', 'reason' => 'owner_process_missing' )); + $stale = WorktreeContextInjector::admission_capacity_reservations($workspace); + parallel_assert(0 === $stale['bytes'] && 0 === $stale['inodes'], 'Stale admission reservation remained capacity charged.'); + WorktreeContextInjector::set_bootstrap_owner_probe_for_test(null); + WorktreeContextInjector::release_capacity_reservation($workspace, 'repo-a@one'); + $released = WorktreeContextInjector::admission_capacity_reservations($workspace); + parallel_assert(0 === $released['bytes'] && array() === $released['handles'], 'Released admission reservation remained visible.'); + + if ( ! defined('DATAMACHINE_WORKSPACE_PATH') ) { + define('DATAMACHINE_WORKSPACE_PATH', $workspace); + } + $GLOBALS['wpdb'] = new Dmc_Parallel_Wpdb(); + require_once dirname(__DIR__) . '/inc/Workspace/Workspace.php'; + + $repos = array( 'repo-a', 'repo-b', 'repo-c' ); + foreach ( $repos as $repo ) { + parallel_create_repo($workspace, $repo); + } + + $events = $workspace . '/checkout-events.jsonl'; + file_put_contents($events, ''); + $workers = array(); + foreach ( $repos as $repo ) { + $process = proc_open( + array( PHP_BINARY, __FILE__, 'add', $workspace, $repo, 'parallel-' . $repo, $events ), + array( 0 => array( 'pipe', 'r' ), 1 => array( 'pipe', 'w' ), 2 => array( 'pipe', 'w' ) ), + $pipes + ); + parallel_assert(is_resource($process), 'Could not start parallel worktree add for ' . $repo); + fclose($pipes[0]); + $workers[ $repo ] = array( $process, $pipes ); + } + foreach ( $workers as $repo => [ $process, $pipes ] ) { + $output = stream_get_contents($pipes[1]); + $error = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + $status = proc_close($process); + parallel_assert(0 === $status, 'Parallel worktree add failed for ' . $repo . ': ' . $output . ' ' . $error); + $decoded = json_decode((string) $output, true); + parallel_assert(is_array($decoded) && true === ( $decoded['ok'] ?? false ), 'Parallel worktree add omitted success evidence for ' . $repo . ': ' . $output); + parallel_assert(is_dir($workspace . '/' . $repo . '@parallel-' . $repo), 'Parallel worktree add did not provision ' . $repo); + } + $event_log = (string) file_get_contents($events); + $windows = array(); + foreach ( array_filter(explode("\n", trim($event_log))) as $line ) { + $row = json_decode($line, true); + if ( ! is_array($row) ) { + continue; + } + $windows[ (string) $row['repo'] ][ (string) $row['mark'] ] = (float) $row['at']; + } + parallel_assert(3 === count($windows), 'Checkout overlap evidence missing for one or more repositories: ' . $event_log); + $checkout_starts = array_map(static fn( array $window ): float => (float) ( $window['start'] ?? 0.0 ), $windows); + parallel_assert(max($checkout_starts) - min($checkout_starts) < 1.2, 'Independent repositories waited too long to begin checkout after admission: ' . $event_log); + $overlapped = false; + $names = array_keys($windows); + foreach ( $names as $index => $left ) { + foreach ( array_slice($names, $index + 1) as $right ) { + $left_start = $windows[ $left ]['start'] ?? 0.0; + $left_end = $windows[ $left ]['end'] ?? 0.0; + $right_start = $windows[ $right ]['start'] ?? 0.0; + $right_end = $windows[ $right ]['end'] ?? 0.0; + if ( $left_start < $right_end && $right_start < $left_end ) { + $overlapped = true; + } + } + } + parallel_assert($overlapped, 'Independent repository checkouts did not overlap after capacity admission: ' . $event_log); + parallel_assert(array() === ( glob($workspace . '/.locks/capacity-reservations/*.json') ?: array() ), 'Successful allocations left admission reservations behind.'); + + echo "worktree-parallel-multi-repo-admission: ok\n"; +} finally { + parallel_remove_tree($workspace); +} diff --git a/tests/worktree-plan-capacity-hot-path.php b/tests/worktree-plan-capacity-hot-path.php index f8432067..e8b47abc 100644 --- a/tests/worktree-plan-capacity-hot-path.php +++ b/tests/worktree-plan-capacity-hot-path.php @@ -8,7 +8,11 @@ namespace DataMachineCode\Workspace { final class WorktreeContextInjector { public static function bootstrap_capacity_reservations(): array { - return array( 'bytes' => 0, 'inodes' => 0 ); + return array( 'bytes' => 0, 'inodes' => 0, 'handles' => array(), 'by_handle' => array() ); + } + + public static function capacity_reservations( string $workspace_path = '' ): array { + return self::bootstrap_capacity_reservations(); } }