Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
f578053
fix: resolve console commands after global options
binaryfire Aug 6, 2026
fa79e97
fix: classify Testbench server commands through ArgvInput
binaryfire Aug 6, 2026
70bd955
feat: expose request-owned start time
binaryfire Aug 6, 2026
1be7631
fix: render health duration from the current request
binaryfire Aug 6, 2026
ff3cbc0
fix: use request-owned timing in Telescope
binaryfire Aug 6, 2026
b858251
fix: use request-owned timing in Sentry tracing
binaryfire Aug 6, 2026
3530cde
docs: document request timing and invocation-local deadlines
binaryfire Aug 6, 2026
272d96c
feat: support context copying in waited coroutines
binaryfire Aug 6, 2026
a5e229d
fix: isolate scheduled task execution lifecycles
binaryfire Aug 6, 2026
9cd47f2
fix: classify Telescope recording by resolved command identity
binaryfire Aug 6, 2026
6e381db
fix: persist Telescope scheduled tasks at task completion
binaryfire Aug 6, 2026
8d41986
fix: finalize scheduled Sentry transactions from task outcomes
binaryfire Aug 6, 2026
4aaf480
fix: remove unused Sentry command configuration
binaryfire Aug 6, 2026
c31f6f5
docs: record request timing and runtime lifecycle design
binaryfire Aug 6, 2026
fa63e45
Merge branch '0.4' into feature/request-start-time
binaryfire Aug 6, 2026
5be5ebc
fix(console): resolve commands after global options
binaryfire Aug 6, 2026
8d4bd37
fix(testbench): resolve server commands after global options
binaryfire Aug 6, 2026
03b40ad
style(telescope): type storage listener callbacks
binaryfire Aug 6, 2026
ea92040
docs(sentry): describe scheduled task handlers
binaryfire Aug 6, 2026
359354b
docs: update request timing implementation plan
binaryfire Aug 6, 2026
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
672 changes: 672 additions & 0 deletions docs/plans/2026-08-06-1121-request-start-time-and-runtime-timing.md

Large diffs are not rendered by default.

5 changes: 1 addition & 4 deletions src/boost/docs/collections.md
Original file line number Diff line number Diff line change
Expand Up @@ -4606,12 +4606,9 @@ To illustrate the usage of this method, imagine an application that submits invo

```php
use App\Models\Invoice;
use Hypervel\Support\CarbonImmutable;

Invoice::pending()->cursor()
->takeUntilTimeout(
CarbonImmutable::createFromTimestamp(HYPERVEL_START)->add(14, 'minutes')
)
->takeUntilTimeout(now()->plus(minutes: 14))
->each(fn (Invoice $invoice) => $invoice->submit());
```

Expand Down
16 changes: 16 additions & 0 deletions src/boost/docs/coroutines.md
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,22 @@ $result = wait(function () {

If no timeout is provided, `wait` will wait up to 10 seconds for the closure to finish.

The child coroutine receives a fresh context by default. You may copy all parent context keys, or only the keys the child needs, using the `copyContext` argument:

```php
use Hypervel\Context\CoroutineContext;

$result = wait(function () {
return CoroutineContext::get('request_id');
}, copyContext: true);

$result = wait(function () {
return CoroutineContext::get('request_id');
}, copyContext: ['request_id']);
```

Copied object values follow the same replication behavior as [`go` and `Coroutine::fork`](#copying-coroutine-context).

If the closure throws an exception, `wait` rethrows it in the waiting coroutine after the child's deferred callbacks have finished.

If the timeout is reached, Hypervel cancels the child by throwing `Swoole\Coroutine\CanceledException` inside it. Hypervel then gives the child up to 10 seconds to finish and run its deferred callbacks before throwing `Hypervel\Coroutine\Exceptions\WaitTimeoutException` in the waiting coroutine.
Expand Down
22 changes: 22 additions & 0 deletions src/boost/docs/requests.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
- [Introduction](#introduction)
- [Interacting With The Request](#interacting-with-the-request)
- [Accessing the Request](#accessing-the-request)
- [Request Start Time and Server Metadata](#request-start-time-and-server-metadata)
- [Request Path, Host, and Method](#request-path-and-method)
- [Request Headers](#request-headers)
- [Request IP Address](#request-ip-address)
Expand Down Expand Up @@ -105,6 +106,27 @@ class UserController extends Controller
}
```

<a name="request-start-time-and-server-metadata"></a>
### Request Start Time and Server Metadata

The `startedAt` method returns a `Hypervel\Support\CarbonImmutable` instance representing when Swoole began processing the current request on the worker, before Hypervel's server bridge and HTTP kernel handled it:

```php
$startedAt = $request->startedAt();
```

You may retrieve an individual server value using the `server` method, or call the method without an argument to retrieve all server metadata. Values received from Swoole use uppercase PHP / Symfony names:

```php
$requestTime = $request->server('REQUEST_TIME_FLOAT');

$server = $request->server();
```

The request start time remains available for the lifetime of the request object, including after the HTTP kernel has terminated the request. The kernel's request lifecycle timer is a separate, later timing boundary used by lifecycle duration handlers.

When handling a WebSocket connection, the request start time describes the initial HTTP handshake, not subsequent WebSocket messages.

<a name="request-path-and-method"></a>
### Request Path, Host, and Method

Expand Down
42 changes: 40 additions & 2 deletions src/console/src/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command as SymfonyCommand;
use Symfony\Component\Console\Exception\CommandNotFoundException;
use Symfony\Component\Console\Exception\ExceptionInterface;
use Symfony\Component\Console\Input\ArgvInput;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Input\InputDefinition;
use Symfony\Component\Console\Input\InputInterface;
Expand Down Expand Up @@ -115,6 +117,31 @@ public static function artisanBinary(): string
);
}

/**
* Resolve the command name from argv before the console application boots.
*
* Symfony's inherited getCommandName() reports the command selected by a
* running application. This method first binds the framework's global
* options so their values are not mistaken for the command name.
*/
public static function resolveCommandName(ArgvInput $input): ?string
{
// No Hypervel console application exists yet, so application-specific
// getEnvironmentOption() overrides cannot participate. Constructing
// Symfony's authoritative definition also enables async PCNTL signals
// when supported; the real console application does the same next.
$definition = (new SymfonyApplication)->getDefinition();
$definition->addOption(self::createEnvironmentOption());

try {
$input->bind($definition);
} catch (ExceptionInterface) {
// Command-specific options cannot be validated until the command is known.
}

return $input->getFirstArgument();
}

/**
* Format the given command as a fully-qualified executable command.
*/
Expand Down Expand Up @@ -519,9 +546,20 @@ protected function getDefaultInputDefinition(): InputDefinition
*/
protected function getEnvironmentOption()
{
$message = 'The environment the command should run under';
return self::createEnvironmentOption();
}

return new InputOption('--env', null, InputOption::VALUE_OPTIONAL, $message);
/**
* Create the global environment option.
*/
private static function createEnvironmentOption(): InputOption
{
return new InputOption(
'--env',
null,
InputOption::VALUE_OPTIONAL,
'The environment the command should run under'
);
}

/**
Expand Down
81 changes: 53 additions & 28 deletions src/console/src/Commands/ScheduleRunCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace Hypervel\Console\Commands;

use Carbon\CarbonInterface;
use Closure;
use Hypervel\Console\Command;
use Hypervel\Console\Events\ScheduledBackgroundTaskFinished;
use Hypervel\Console\Events\ScheduledTaskFailed;
Expand Down Expand Up @@ -207,7 +208,7 @@ protected function runOnce(): void
if ($events->contains->isRepeatable()) {
$this->repeatEvents($events->filter->isRepeatable());
}
});
}, copyContext: [ContextRepository::CONTEXT_KEY]);

if (! $this->eventsRan && ! $this->option('whisper')) {
$this->info('No scheduled commands are ready to run.');
Expand Down Expand Up @@ -245,24 +246,23 @@ protected function repeatEvents(Collection $events): void
}

if ($paused && ! $event->runsWhenPaused()) {
$event->lastChecked = Date::now();
$this->dispatchTaskSkipped($event);

continue;
}

if (! $event->filtersPass($this->hypervel)) {
$this->dispatchTaskSkipped($event);
$this->runTaskInCoroutine(function () use ($event): void {
if (! $event->filtersPass($this->hypervel)) {
$this->dispatchTaskSkipped($event);

continue;
}
return;
}

if ($event->onOneServer) {
$this->runSingleServerEvent($event, $this->startedAt);
} else {
$this->runEvent($event);
}
$this->runScheduledEvent($event, $this->startedAt);

$this->eventsRan = true;
$this->eventsRan = true;
});
}

Sleep::usleep(100_000);
Expand All @@ -283,34 +283,59 @@ protected function runEvents(Collection $events, CarbonInterface $startedAt): vo
}

if ($paused && ! $event->runsWhenPaused()) {
$event->lastChecked = Date::now();
$this->dispatchTaskSkipped($event);

continue;
}

if (! $event->filtersPass($this->hypervel)) {
$this->dispatchTaskSkipped($event);
$this->runTaskInCoroutine(function () use ($event, $startedAt): void {
if (! $event->filtersPass($this->hypervel)) {
$this->dispatchTaskSkipped($event);

continue;
}
return;
}

$this->runScheduledEvent($event, $startedAt);

$this->eventsRan = true;
});
}
}

$runEvent = fn () => $event->onOneServer
? $this->runSingleServerEvent($event, $startedAt)
: $this->runEvent($event);
/**
* Run user task evaluation in a finite coroutine.
*/
protected function runTaskInCoroutine(Closure $callback): void
{
(new Waiter(-1))->wait(
$callback,
copyContext: [ContextRepository::CONTEXT_KEY],
);
}

if ($event->runInBackground) {
$this->concurrent->fork(function () use ($runEvent, $event) {
$runEvent();
/**
* Dispatch a scheduled event in the foreground or background.
*/
protected function runScheduledEvent(Event $event, CarbonInterface $startedAt): void
{
$runEvent = fn () => $event->onOneServer
? $this->runSingleServerEvent($event, $startedAt)
: $this->runEvent($event);

if ($this->dispatcher->hasListeners(ScheduledBackgroundTaskFinished::class)) {
$this->dispatcher->dispatch(new ScheduledBackgroundTaskFinished($event));
}
}, [ContextRepository::CONTEXT_KEY]);
continue;
}
if ($event->runInBackground) {
$this->concurrent->fork(function () use ($runEvent, $event): void {
$runEvent();

$runEvent();
if ($this->dispatcher->hasListeners(ScheduledBackgroundTaskFinished::class)) {
$this->dispatcher->dispatch(new ScheduledBackgroundTaskFinished($event));
}
}, [ContextRepository::CONTEXT_KEY]);

return;
}

$runEvent();
}

/**
Expand Down
3 changes: 2 additions & 1 deletion src/console/src/Scheduling/Event.php
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,8 @@ public function isRepeatable(): bool
public function shouldRepeatNow(): bool
{
return $this->isRepeatable()
&& abs($this->lastChecked?->diffInSeconds()) >= $this->repeatSeconds;
&& $this->lastChecked !== null
&& abs($this->lastChecked->diffInSeconds()) >= $this->repeatSeconds;
}

/**
Expand Down
12 changes: 9 additions & 3 deletions src/coroutine/src/Waiter.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,17 +28,20 @@ public function __construct(float $timeout = 10.0)
* @template TReturn
* @param Closure():TReturn $closure
* @param null|float $timeout Timeout in seconds (null uses default)
* @param array<string>|bool $copyContext When set, parent coroutine context is copied to the child.
* false = fresh context (default), true or empty array = copy all keys, non-empty array = copy listed keys only.
* Object values are shared by reference unless they implement Hypervel\Context\ReplicableContext.
* @return TReturn
* @throws WaitTimeoutException When the wait times out
*/
public function wait(Closure $closure, ?float $timeout = null): mixed
public function wait(Closure $closure, ?float $timeout = null, bool|array $copyContext = false): mixed
{
if ($timeout === null) {
$timeout = $this->popTimeout;
}

$channel = new Channel(1);
$childCoroutineId = Coroutine::create(function () use ($channel, $closure) {
$callable = function () use ($channel, $closure): void {
$result = null;

Coroutine::defer(function () use ($channel, &$result): void {
Expand All @@ -50,7 +53,10 @@ public function wait(Closure $closure, ?float $timeout = null): mixed
} catch (Throwable $exception) {
$result = new ExceptionThrower($exception);
}
});
};
$childCoroutineId = $copyContext === false
? Coroutine::create($callable)
: Coroutine::fork($callable, is_array($copyContext) ? $copyContext : []);

$result = $channel->pop($timeout);
if ($result === false && $channel->isTimeout()) {
Expand Down
7 changes: 5 additions & 2 deletions src/coroutine/src/functions.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,16 @@ function parallel(array $callables, int $concurrent = 0, bool|array $copyContext
* @template TReturn
*
* @param Closure():TReturn $closure
* @param array<string>|bool $copyContext When set, parent coroutine context is copied to the child.
* false = fresh context (default), true or empty array = copy all keys, non-empty array = copy listed keys only.
* Object values are shared by reference unless they implement Hypervel\Context\ReplicableContext.
* @return TReturn
*/
function wait(Closure $closure, ?float $timeout = null)
function wait(Closure $closure, ?float $timeout = null, bool|array $copyContext = false): mixed
{
return Container::getInstance()
->make(Waiter::class)
->wait($closure, $timeout);
->wait($closure, $timeout, $copyContext);
}

/**
Expand Down
4 changes: 3 additions & 1 deletion src/foundation/src/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use Closure;
use Composer\Autoload\ClassLoader;
use Hypervel\Console\Application as ConsoleApplication;
use Hypervel\Container\Container;
use Hypervel\Contracts\Console\Kernel as ConsoleKernelContract;
use Hypervel\Contracts\Container\Container as ContainerContract;
Expand All @@ -28,6 +29,7 @@
use JsonException;
use ReflectionClass;
use RuntimeException;
use Symfony\Component\Console\Input\ArgvInput;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\ConsoleOutput;
use Symfony\Component\HttpKernel\Exception\HttpException;
Expand Down Expand Up @@ -812,7 +814,7 @@ public function runningConsoleCommand(string|array ...$commands): bool
}

return in_array(
$_SERVER['argv'][1] ?? null,
ConsoleApplication::resolveCommandName(new ArgvInput),
is_array($commands[0] ?? null) ? $commands[0] : $commands,
true
);
Expand Down
1 change: 1 addition & 0 deletions src/foundation/src/Configuration/ApplicationBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@ protected function buildRoutingCallback(
}

return response(View::file(__DIR__ . '/../resources/health-up.blade.php', [
'request' => $request,
'status' => $health,
]), status: $status);
});
Expand Down
4 changes: 2 additions & 2 deletions src/foundation/src/Testing/Concerns/MakesHttpRequests.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
use Hypervel\Context\RequestContext;
use Hypervel\Contracts\Http\Kernel as HttpKernel;
use Hypervel\Cookie\CookieValuePrefix;
use Hypervel\Foundation\Testing\Coroutine\Waiter;
use Hypervel\Coroutine\Waiter;
use Hypervel\Foundation\Testing\RequestContextSynchronizer;
use Hypervel\Foundation\Testing\Stubs\FakeMiddleware;
use Hypervel\Http\Request;
Expand Down Expand Up @@ -537,7 +537,7 @@ public function call(
}

return $response;
}, 10.0);
}, 10.0, copyContext: true);
}

/**
Expand Down
Loading