Skip to content

Commit 8aae29e

Browse files
authored
Merge pull request #184 from andreilungeanu/main
Always-on process isolation: eliminate conditional complexity
2 parents 0b7ad74 + 64d594e commit 8aae29e

File tree

4 files changed

+50
-116
lines changed

4 files changed

+50
-116
lines changed

src/Mcp/ToolExecutor.php

Lines changed: 7 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,10 @@ public function execute(string $toolClass, array $arguments = []): ToolResult
2323
return ToolResult::error("Tool not registered or not allowed: {$toolClass}");
2424
}
2525

26-
if ($this->shouldUseProcessIsolation()) {
27-
return $this->executeInProcess($toolClass, $arguments);
28-
}
29-
30-
return $this->executeInline($toolClass, $arguments);
26+
return $this->executeInSubprocess($toolClass, $arguments);
3127
}
3228

33-
protected function executeInProcess(string $toolClass, array $arguments): ToolResult
29+
protected function executeInSubprocess(string $toolClass, array $arguments): ToolResult
3430
{
3531
$command = $this->buildCommand($toolClass, $arguments);
3632

@@ -45,7 +41,7 @@ protected function executeInProcess(string $toolClass, array $arguments): ToolRe
4541
$process = new Process(
4642
command: $command,
4743
env: $cleanEnv,
48-
timeout: $this->getTimeout()
44+
timeout: $this->getTimeout($arguments)
4945
);
5046

5147
try {
@@ -62,7 +58,7 @@ protected function executeInProcess(string $toolClass, array $arguments): ToolRe
6258
} catch (ProcessTimedOutException $e) {
6359
$process->stop();
6460

65-
return ToolResult::error("Tool execution timed out after {$this->getTimeout()} seconds");
61+
return ToolResult::error("Tool execution timed out after {$this->getTimeout($arguments)} seconds");
6662

6763
} catch (ProcessFailedException $e) {
6864
$errorOutput = $process->getErrorOutput().$process->getOutput();
@@ -71,30 +67,11 @@ protected function executeInProcess(string $toolClass, array $arguments): ToolRe
7167
}
7268
}
7369

74-
protected function executeInline(string $toolClass, array $arguments): ToolResult
75-
{
76-
try {
77-
/** @var \Laravel\Mcp\Server\Tool $tool */
78-
$tool = app($toolClass);
79-
80-
return $tool->handle($arguments);
81-
} catch (\Throwable $e) {
82-
return ToolResult::error("Inline tool execution failed: {$e->getMessage()}");
83-
}
84-
}
85-
86-
protected function shouldUseProcessIsolation(): bool
70+
protected function getTimeout(array $arguments): int
8771
{
88-
if (app()->environment('testing')) {
89-
return false;
90-
}
72+
$timeout = (int) ($arguments['timeout'] ?? 180);
9173

92-
return config('boost.process_isolation.enabled', true);
93-
}
94-
95-
protected function getTimeout(): int
96-
{
97-
return config('boost.process_isolation.timeout', 180);
74+
return max(1, min(600, $timeout));
9875
}
9976

10077
/**

src/Mcp/Tools/Tinker.php

Lines changed: 5 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ public function schema(ToolInputSchema $schema): ToolInputSchema
3030
->description('PHP code to execute (without opening <?php tags)')
3131
->required()
3232
->integer('timeout')
33-
->description('Maximum execution time in seconds (default: 30)');
33+
->description('Maximum execution time in seconds (default: 180)');
3434
}
3535

3636
/**
@@ -42,30 +42,14 @@ public function handle(array $arguments): ToolResult
4242
{
4343
$code = str_replace(['<?php', '?>'], '', (string) Arr::get($arguments, 'code'));
4444

45-
$timeout = min(180, (int) (Arr::get($arguments, 'timeout', 30)));
46-
set_time_limit($timeout);
47-
ini_set('memory_limit', '128M');
48-
49-
// Use PCNTL alarm for additional timeout control if available (Unix only)
50-
if (function_exists('pcntl_async_signals') && function_exists('pcntl_signal')) {
51-
pcntl_async_signals(true);
52-
pcntl_signal(SIGALRM, function () {
53-
throw new Exception('Code execution timed out');
54-
});
55-
pcntl_alarm($timeout);
56-
}
45+
ini_set('memory_limit', '256M');
5746

5847
ob_start();
5948

6049
try {
6150
$result = eval($code);
6251

63-
if (function_exists('pcntl_alarm')) {
64-
pcntl_alarm(0);
65-
}
66-
6752
$output = ob_get_contents();
68-
ob_end_clean();
6953

7054
$response = [
7155
'result' => $result,
@@ -79,20 +63,16 @@ public function handle(array $arguments): ToolResult
7963
}
8064

8165
return ToolResult::json($response);
82-
8366
} catch (Throwable $e) {
84-
if (function_exists('pcntl_alarm')) {
85-
pcntl_alarm(0);
86-
}
87-
88-
ob_end_clean();
89-
9067
return ToolResult::json([
9168
'error' => $e->getMessage(),
9269
'type' => get_class($e),
9370
'file' => $e->getFile(),
9471
'line' => $e->getLine(),
9572
]);
73+
74+
} finally {
75+
ob_end_clean();
9676
}
9777
}
9878
}

tests/Feature/Mcp/ToolExecutorTest.php

Lines changed: 38 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,7 @@
66
use Laravel\Boost\Mcp\Tools\Tinker;
77
use Laravel\Mcp\Server\Tools\ToolResult;
88

9-
test('can execute tool inline', function () {
10-
// Disable process isolation for this test
11-
config(['boost.process_isolation.enabled' => false]);
12-
13-
$executor = app(ToolExecutor::class);
14-
$result = $executor->execute(ApplicationInfo::class, []);
15-
16-
expect($result)->toBeInstanceOf(ToolResult::class);
17-
});
18-
19-
test('can execute tool with process isolation', function () {
20-
// Enable process isolation for this test
21-
config(['boost.process_isolation.enabled' => true]);
22-
9+
test('can execute tool in subprocess', function () {
2310
// Create a mock that overrides buildCommand to work with testbench
2411
$executor = Mockery::mock(ToolExecutor::class)->makePartial()
2512
->shouldAllowMockingProtectedMethods();
@@ -54,8 +41,6 @@
5441
});
5542

5643
test('subprocess proves fresh process isolation', function () {
57-
config(['boost.process_isolation.enabled' => true]);
58-
5944
$executor = Mockery::mock(ToolExecutor::class)->makePartial()
6045
->shouldAllowMockingProtectedMethods();
6146
$executor->shouldReceive('buildCommand')
@@ -76,8 +61,6 @@
7661
});
7762

7863
test('subprocess sees modified autoloaded code changes', function () {
79-
config(['boost.process_isolation.enabled' => true]);
80-
8164
$executor = Mockery::mock(ToolExecutor::class)->makePartial()
8265
->shouldAllowMockingProtectedMethods();
8366
$executor->shouldReceive('buildCommand')
@@ -149,3 +132,40 @@ function buildSubprocessCommand(string $toolClass, array $arguments): array
149132

150133
return [PHP_BINARY, '-r', $testScript];
151134
}
135+
136+
test('respects custom timeout parameter', function () {
137+
$executor = Mockery::mock(ToolExecutor::class)->makePartial()
138+
->shouldAllowMockingProtectedMethods();
139+
140+
$executor->shouldReceive('buildCommand')
141+
->andReturnUsing(fn ($toolClass, $arguments) => buildSubprocessCommand($toolClass, $arguments));
142+
143+
// Test with custom timeout - should succeed with fast code
144+
$result = $executor->execute(Tinker::class, [
145+
'code' => 'return "timeout test";',
146+
'timeout' => 30
147+
]);
148+
149+
expect($result->isError)->toBeFalse();
150+
});
151+
152+
test('clamps timeout values correctly', function () {
153+
$executor = new ToolExecutor();
154+
155+
// Test timeout clamping using reflection to access protected method
156+
$reflection = new ReflectionClass($executor);
157+
$method = $reflection->getMethod('getTimeout');
158+
$method->setAccessible(true);
159+
160+
// Test default
161+
expect($method->invoke($executor, []))->toBe(180);
162+
163+
// Test custom value
164+
expect($method->invoke($executor, ['timeout' => 60]))->toBe(60);
165+
166+
// Test minimum clamp
167+
expect($method->invoke($executor, ['timeout' => 0]))->toBe(1);
168+
169+
// Test maximum clamp
170+
expect($method->invoke($executor, ['timeout' => 1000]))->toBe(600);
171+
});

tests/Feature/Mcp/Tools/TinkerTest.php

Lines changed: 0 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -140,46 +140,3 @@
140140

141141
expect($tool->shouldRegister())->toBeTrue();
142142
});
143-
144-
test('uses custom timeout parameter', function () {
145-
$tool = new Tinker;
146-
$result = $tool->handle(['code' => 'return 2 + 2;', 'timeout' => 10]);
147-
148-
expect($result)->isToolResult()
149-
->toolJsonContentToMatchArray([
150-
'result' => 4,
151-
'type' => 'integer',
152-
]);
153-
});
154-
155-
test('uses default timeout when not specified', function () {
156-
$tool = new Tinker;
157-
$result = $tool->handle(['code' => 'return 2 + 2;']);
158-
159-
expect($result)->isToolResult()
160-
->toolJsonContentToMatchArray([
161-
'result' => 4,
162-
'type' => 'integer',
163-
]);
164-
});
165-
166-
test('times out when code takes too long', function () {
167-
$tool = new Tinker;
168-
169-
// Code that will take more than 1 second to execute
170-
$slowCode = '
171-
$start = microtime(true);
172-
while (microtime(true) - $start < 1.2) {
173-
usleep(50000); // Don\'t waste entire CPU
174-
}
175-
return "should not reach here";
176-
';
177-
178-
$result = $tool->handle(['code' => $slowCode, 'timeout' => 1]);
179-
180-
expect($result)->isToolResult()
181-
->toolJsonContent(function ($data) {
182-
expect($data)->toHaveKey('error')
183-
->and($data['error'])->toMatch('/(Maximum execution time|Code execution timed out)/');
184-
});
185-
});

0 commit comments

Comments
 (0)