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
27 changes: 25 additions & 2 deletions data-machine-code.php
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,28 @@ function datamachine_code_finish_minimal_runtime_cli_request(): void {
remove_all_actions('shutdown');
}
}

/** Release request-owned resources after a synchronous workspace result. */
function datamachine_code_finish_bounded_workspace_cli_request(): void {
if ( ! datamachine_code_is_bounded_workspace_cli_request() ) {
return;
}

if ( defined('STDOUT') ) {
@fflush(STDOUT);
}
if ( defined('STDERR') ) {
@fflush(STDERR);
}
if ( function_exists('remove_all_actions') ) {
remove_all_actions('shutdown');
}

global $wpdb;
if ( is_object($wpdb) && is_callable(array( $wpdb, 'close' )) ) {
$wpdb->close();
}
}
if ( datamachine_code_is_minimal_runtime_cli_request() ) {
add_action('shutdown', 'datamachine_code_finish_minimal_runtime_cli_request', PHP_INT_MIN);
}
Expand Down Expand Up @@ -426,15 +448,16 @@ function datamachine_code_register_cli_commands() {

\WP_CLI::add_command('datamachine-code github', \DataMachineCode\Cli\Commands\GitHubCommand::class);
\WP_CLI::add_command('datamachine-code runtime', \DataMachineCode\Cli\Commands\RuntimeCommand::class);
\WP_CLI::add_command('datamachine-code workspace', \DataMachineCode\Cli\Commands\WorkspaceCommand::class);
$bounded_workspace_command = array( 'after_invoke' => 'datamachine_code_finish_bounded_workspace_cli_request' );
\WP_CLI::add_command('datamachine-code workspace', \DataMachineCode\Cli\Commands\WorkspaceCommand::class, $bounded_workspace_command);
\WP_CLI::add_command('datamachine-code workspace worktree', \WP_CLI\Dispatcher\CommandNamespace::class);
foreach ( \DataMachineCode\Cli\Commands\WorkspaceCommand::worktree_command_definitions() as $operation => $definition ) {
\WP_CLI::add_command(
'datamachine-code workspace worktree ' . $operation,
static function ( array $args, array $assoc_args ) use ( $operation ): void {
( new \DataMachineCode\Cli\Commands\WorkspaceCommand() )->__worktree_operation($operation, $args, $assoc_args);
},
$definition
array_merge($definition, $bounded_workspace_command)
);
}
\WP_CLI::add_command('datamachine-code code-task', \DataMachineCode\Cli\Commands\CodeTaskCommand::class);
Expand Down
89 changes: 61 additions & 28 deletions tests/workspace-cli-bounded-exit.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,16 +32,29 @@ public static function format_trigger_reasons( array $capacity ): array { return
final class WP_CLI {
/** @var array<string,mixed> */
public static array $commands = array();
/** @var array<string,array<string,mixed>> */
public static array $command_args = array();
/** @var list<string> */
public static array $output = array();
/** @param array<string,mixed> $args */
public static function add_command( string $name, mixed $command, array $args = array() ): void { self::$commands[ $name ] = $command; }
public static function line( string $message ): void { self::$output[] = $message; }
public static function log( string $message ): void { self::$output[] = $message; }
public static function warning( string $message ): void { self::$output[] = $message; }
public static function add_command( string $name, mixed $command, array $args = array() ): void { self::$commands[ $name ] = $command; self::$command_args[ $name ] = $args; }
public static function line( string $message ): void { self::$output[] = $message; @fwrite(STDOUT, $message . "\n"); }
public static function log( string $message ): void { self::line($message); }
public static function success( string $message ): void { self::line('Success: ' . $message); }
public static function warning( string $message ): void { self::$output[] = $message; @fwrite(STDERR, 'Warning: ' . $message . "\n"); }
public static function error( string $message ): never { throw new \RuntimeException($message); }
}

final class BoundedExitWpdb {
public bool $connected = true;
public function __construct( private string $marker ) {}
public function close(): bool {
$this->connected = false;
file_put_contents($this->marker . '.db', 'closed');
return true;
}
}

/** @var array<string,array<int,array<int,callable>>> */
$GLOBALS['dmc_bounded_exit_actions'] = array();

Expand Down Expand Up @@ -82,6 +95,18 @@ function is_wp_error( mixed $value ): bool { return false; }
function wp_get_ability( string $name ): object {
return new class {
public function execute( array $input ): array {
if ( 'repo@merged-branch' === ( $input['handle'] ?? null ) ) {
bounded_exit_assert('https://github.com/example/repo/pull/123' === ($input['pr'] ?? null), 'Finalize did not preserve the merged PR URL.');
bounded_exit_assert('success' === ($input['owner_terminal_outcome'] ?? null), 'Finalize did not preserve the owner terminal outcome.');
file_put_contents((string) $GLOBALS['dmc_bounded_exit_marker'], 'committed');
return array(
'success' => true,
'handle' => 'repo@merged-branch',
'lifecycle_state' => 'pr_opened',
'metadata' => array( 'pr_url' => (string) $input['pr'], 'owner_terminal_outcome' => 'success' ),
'message' => 'Worktree "repo@merged-branch" marked pr_opened.',
);
}
if ( 'repo' === ( $input['repo'] ?? null ) ) {
return array( 'success' => true, 'worktrees' => array() );
}
Expand All @@ -99,7 +124,8 @@ public function execute( array $input ): array {
}
define('WPINC', 'wp-includes');
define('ABSPATH', __DIR__ . '/fixtures/');
$GLOBALS['argv'] = array( 'wp', 'datamachine-code', 'workspace', 'worktree', 'list', 'repo' );
$GLOBALS['argv'] = array( 'wp', 'datamachine-code', 'workspace', 'worktree', 'finalize', 'repo@merged-branch', '--pr=https://github.com/example/repo/pull/123', '--owner-terminal-outcome=success', '--format=json' );
$GLOBALS['dmc_bounded_exit_marker'] = $marker;
require_once dirname(__DIR__) . '/data-machine-code.php';

bounded_exit_assert(
Expand All @@ -111,39 +137,34 @@ public function execute( array $input ): array {
exit(0);
}

// Mirrors WordPress' native shutdown bridge, followed by an unrelated native
// cleanup callback that DMC must neither remove nor bypass.
$GLOBALS['wpdb'] = new BoundedExitWpdb($marker);
// Mirrors WordPress' native shutdown bridge. A later native owner remains
// safe, but would retain the process while the request-owned DB stays open.
register_shutdown_function(static function (): void { do_action('shutdown'); });
register_shutdown_function(static function () use ( $marker ): void { file_put_contents($marker, 'native-cleanup'); });
register_shutdown_function(static function () use ( $marker, $mode ): void {
if ( 'success' === $mode && $GLOBALS['wpdb']->connected ) {
usleep(1500000);
}
file_put_contents($marker . '.native', 'native-cleanup');
});
datamachine_code_register_cli_commands();
bounded_exit_assert(isset(WP_CLI::$commands['datamachine-code workspace']), 'Minimal runtime did not register the workspace command.');
$command_class = WP_CLI::$commands['datamachine-code workspace'];
$command = new $command_class();
$started = microtime(true);
$command->list_repos(array(), array());
$command->show(array( 'repo' ), array());
$worktree_list = WP_CLI::$commands['datamachine-code workspace worktree list'] ?? null;
bounded_exit_assert(is_callable($worktree_list), 'Minimal runtime did not register the filtered worktree-list leaf command.');
$worktree_list(array( 'repo' ), array());
bounded_exit_assert(microtime(true) - $started < 0.5, 'Registered workspace list/show dispatch exceeded its bounded command deadline.');
bounded_exit_assert(in_array('Name: repo', WP_CLI::$output, true), 'Workspace show did not dispatch through the registered command.');
if ( 'broken_pipe' === $mode ) {
@fwrite(STDOUT, "buffered-output {$mode}\n");
@fflush(STDOUT);
} else {
fwrite(STDOUT, "buffered-output {$mode}\n");
fflush(STDOUT);
}
$command_name = 'datamachine-code workspace worktree finalize';
$finalize = WP_CLI::$commands[ $command_name ] ?? null;
$after_invoke = WP_CLI::$command_args[ $command_name ]['after_invoke'] ?? null;
bounded_exit_assert(is_callable($finalize) && is_callable($after_invoke), 'Finalize leaf did not register its bounded owning-layer callback.');
$finalize(array( 'repo@merged-branch' ), array( 'pr' => 'https://github.com/example/repo/pull/123', 'owner-terminal-outcome' => 'success', 'format' => 'json' ));
if ( 'failure' === $mode ) {
// WP-CLI runs after_invoke after command output and before its final exit.
fwrite(STDERR, 'after_invoke failure');
exit(1);
}
$after_invoke();
exit(0);
}

foreach ( array( 'success' => 0, 'failure' => 1, 'broken_pipe' => 0, 'embedded' => 0 ) as $mode => $expected_status ) {
$marker = tempnam(sys_get_temp_dir(), 'dmc-bounded-exit-');
$started = microtime(true);
$process = proc_open(array( PHP_BINARY, __FILE__, 'child', $mode, $marker), array( 1 => array( 'pipe', 'w' ), 2 => array( 'pipe', 'w' ) ), $pipes);
bounded_exit_assert(is_resource($process), "Could not start {$mode} lifecycle process.");
if ( 'broken_pipe' === $mode ) {
Expand All @@ -156,6 +177,7 @@ public function execute( array $input ): array {
$error = stream_get_contents($pipes[2]);
fclose($pipes[2]);
$status = proc_close($process);
$elapsed = microtime(true) - $started;

bounded_exit_assert($expected_status === $status, "{$mode} lifecycle returned {$status}: {$error}");
if ( 'embedded' === $mode ) {
Expand All @@ -164,9 +186,20 @@ public function execute( array $input ): array {
if ( 'broken_pipe' === $mode ) {
bounded_exit_assert('' === $output, 'Broken-pipe parent unexpectedly retained child stdout.');
} else {
bounded_exit_assert(str_contains($output, "buffered-output {$mode}"), "{$mode} output was not flushed before shutdown.");
bounded_exit_assert(str_contains($output, 'Success: Worktree "repo@merged-branch" marked pr_opened.'), "{$mode} finalizer receipt was not flushed before shutdown.");
}
bounded_exit_assert('committed' === file_get_contents($marker), "{$mode} finalizer mutation did not commit before output.");
bounded_exit_assert('native-cleanup' === file_get_contents($marker . '.native'), "{$mode} native cleanup was suppressed.");
if ( 'failure' === $mode ) {
bounded_exit_assert(! file_exists($marker . '.db'), 'A failure before after_invoke unexpectedly closed the database.');
} else {
bounded_exit_assert('closed' === file_get_contents($marker . '.db'), "{$mode} database connection survived successful output.");
bounded_exit_assert($elapsed < 0.5, sprintf('%s exceeded the bounded process deadline: %.3fs.', $mode, $elapsed));
}
unlink($marker . '.native');
if ( file_exists($marker . '.db') ) {
unlink($marker . '.db');
}
bounded_exit_assert('native-cleanup' === file_get_contents($marker), "{$mode} native cleanup was suppressed.");
}
unlink($marker);
}
Expand Down
2 changes: 1 addition & 1 deletion tests/workspace-command-startup-bounds.php
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ function startup_bounds_remove_tree( string $path ): void {
foreach ( \DataMachineCode\Cli\Commands\WorkspaceCommand::worktree_command_definitions() as $operation => $definition ) {
$name = 'datamachine-code workspace worktree ' . $operation;
startup_bounds_assert(isset(WP_CLI::$commands[ $name ]), sprintf('WP-CLI did not register %s.', $operation));
startup_bounds_assert($definition === ( WP_CLI::$command_args[ $name ] ?? null ), sprintf('WP-CLI did not receive the %s help contract.', $operation));
startup_bounds_assert(array_merge($definition, array( 'after_invoke' => 'datamachine_code_finish_bounded_workspace_cli_request' )) === ( WP_CLI::$command_args[ $name ] ?? null ), sprintf('WP-CLI did not receive the %s help and lifecycle contract.', $operation));
}
startup_bounds_assert(0 === $GLOBALS['dmc_test_get_option_calls'], 'Nested help initialized database-backed discovery.');
startup_bounds_assert(0 === $GLOBALS['dmc_test_mutation_calls'], 'Nested help mutated schema or registry state.');
Expand Down
Loading