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
1 change: 1 addition & 0 deletions .phpunit.cache/test-results
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"version":2,"defects":{"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testEnqueue":7,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testGetTaskDetails":7,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testTaskHistoryWithErrorDetails":7,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testEnqueueWithEmptyPayload":7,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testEnqueueWithLargePayload":7,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testEnqueueWithSpecialCharacters":7,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testEnqueueWithNestedPayload":7,"ModelsLab\\ModelQ\\Tests\\Integration\\StreamingTest::testStreamingTask":7,"ModelsLab\\ModelQ\\Tests\\Integration\\StreamingTest::testStreamCombinedResult":7,"ModelsLab\\ModelQ\\Tests\\Integration\\WorkerTest::testTaskExecution":7,"ModelsLab\\ModelQ\\Tests\\Integration\\WorkerTest::testTaskWithReturnValue":7,"ModelsLab\\ModelQ\\Tests\\Integration\\WorkerTest::testEchoTask":7,"ModelsLab\\ModelQ\\Tests\\Integration\\WorkerTest::testMultipleConcurrentTasks":7,"ModelsLab\\ModelQ\\Tests\\Integration\\PruneTaskResultsTest::testWorkerLoopDoesNotScanTaskResults":7,"ModelsLab\\ModelQ\\Tests\\Integration\\PruneTaskResultsTest::testOnlyTheKeyThatLostItsTtlIsRead":7,"ModelsLab\\ModelQ\\Tests\\Integration\\PruneTaskResultsTest::testHealthyKeysAreNeverRead":7,"ModelsLab\\ModelQ\\Tests\\Unit\\ConnectionLivenessTest::testWorkerBlockingPopUsesTheConfiguredTimeout":7,"ModelsLab\\ModelQ\\Tests\\Unit\\ConnectionLivenessTest::testReadTimeoutExceedsBlpopTimeout":7,"ModelsLab\\ModelQ\\Tests\\Unit\\ConnectionLivenessTest::testLivenessBoundsAreFinite":7},"times":{"ModelsLab\\ModelQ\\Tests\\Unit\\ExceptionTest::testTaskTimeoutException":0.001,"ModelsLab\\ModelQ\\Tests\\Unit\\ExceptionTest::testTaskTimeoutExceptionWithCustomMessage":0,"ModelsLab\\ModelQ\\Tests\\Unit\\ExceptionTest::testTaskProcessingException":0,"ModelsLab\\ModelQ\\Tests\\Unit\\ExceptionTest::testRetryTaskException":0,"ModelsLab\\ModelQ\\Tests\\Unit\\ExceptionTest::testRetryTaskExceptionWithCustomMessage":0,"ModelsLab\\ModelQ\\Tests\\Unit\\ExceptionTest::testExceptionsAreCatchable":0,"ModelsLab\\ModelQ\\Tests\\Unit\\ExceptionTest::testExceptionsCanBeChained":0,"ModelsLab\\ModelQ\\Tests\\Unit\\ExceptionTest::testTaskTimeoutExceptionWithEmptyTaskId":0,"ModelsLab\\ModelQ\\Tests\\Unit\\ExceptionTest::testTaskProcessingExceptionWithEmptyError":0.001,"ModelsLab\\ModelQ\\Tests\\Unit\\ExceptionTest::testTaskProcessingExceptionWithSpecialCharacters":0,"ModelsLab\\ModelQ\\Tests\\Unit\\ExceptionTest::testTaskTimeoutExceptionWithUnicodeTaskId":0,"ModelsLab\\ModelQ\\Tests\\Unit\\ExceptionTest::testRetryTaskExceptionCanBeRethrown":0,"ModelsLab\\ModelQ\\Tests\\Unit\\ExceptionTest::testExceptionCodePreserved":0,"ModelsLab\\ModelQ\\Tests\\Unit\\ExceptionTest::testDeepExceptionChaining":0,"ModelsLab\\ModelQ\\Tests\\Unit\\MiddlewareTest::testMiddlewareCanBeInstantiated":0,"ModelsLab\\ModelQ\\Tests\\Unit\\MiddlewareTest::testBaseMiddlewareMethodsDoNothing":0,"ModelsLab\\ModelQ\\Tests\\Unit\\MiddlewareTest::testCustomMiddlewareCallsTracked":0,"ModelsLab\\ModelQ\\Tests\\Unit\\MiddlewareTest::testOnErrorReceivesException":0,"ModelsLab\\ModelQ\\Tests\\Unit\\MiddlewareTest::testOnTimeoutCalled":0,"ModelsLab\\ModelQ\\Tests\\Unit\\MiddlewareTest::testMiddlewareWithNullTask":0,"ModelsLab\\ModelQ\\Tests\\Unit\\MiddlewareTest::testBeforeWorkerShutdownCalled":0,"ModelsLab\\ModelQ\\Tests\\Unit\\MiddlewareTest::testAfterWorkerShutdownCalled":0,"ModelsLab\\ModelQ\\Tests\\Unit\\MiddlewareTest::testFullWorkerLifecycle":0,"ModelsLab\\ModelQ\\Tests\\Unit\\MiddlewareTest::testBaseMiddlewareShutdownMethodsDoNothing":0,"ModelsLab\\ModelQ\\Tests\\Unit\\MiddlewareTest::testOnErrorWithNullException":0,"ModelsLab\\ModelQ\\Tests\\Unit\\MiddlewareTest::testOnErrorWithNullTaskAndException":0,"ModelsLab\\ModelQ\\Tests\\Unit\\MiddlewareTest::testMiddlewareWithDifferentExceptionTypes":0,"ModelsLab\\ModelQ\\Tests\\Unit\\QueueDepthTest::testGetQueuedTaskCountUsesLlen":0.001,"ModelsLab\\ModelQ\\Tests\\Unit\\QueueDepthTest::testGetQueuedTaskCountReturnsZeroOnEmptyQueue":0,"ModelsLab\\ModelQ\\Tests\\Unit\\QueueDepthTest::testGetQueuedTaskCountCastsFalseToZero":0,"ModelsLab\\ModelQ\\Tests\\Unit\\QueueDepthTest::testGetProcessingTaskCountUsesScard":0,"ModelsLab\\ModelQ\\Tests\\Unit\\TaskTest::testTaskCreation":0,"ModelsLab\\ModelQ\\Tests\\Unit\\TaskTest::testTaskIdIsUuid":0,"ModelsLab\\ModelQ\\Tests\\Unit\\TaskTest::testTaskTimeout":0,"ModelsLab\\ModelQ\\Tests\\Unit\\TaskTest::testToArray":0,"ModelsLab\\ModelQ\\Tests\\Unit\\TaskTest::testFromArray":0,"ModelsLab\\ModelQ\\Tests\\Unit\\TaskTest::testFromArrayWithMissingOptionalFields":0,"ModelsLab\\ModelQ\\Tests\\Unit\\TaskTest::testSerializationRoundTrip":0,"ModelsLab\\ModelQ\\Tests\\Unit\\TaskTest::testOriginalPayloadPreserved":0,"ModelsLab\\ModelQ\\Tests\\Unit\\TaskTest::testTaskWithAdditionalParams":0,"ModelsLab\\ModelQ\\Tests\\Unit\\TaskTest::testTaskWithoutAdditionalParams":0,"ModelsLab\\ModelQ\\Tests\\Unit\\TaskTest::testToArrayIncludesAdditionalParams":0,"ModelsLab\\ModelQ\\Tests\\Unit\\TaskTest::testToArrayWithEmptyAdditionalParams":0,"ModelsLab\\ModelQ\\Tests\\Unit\\TaskTest::testFromArrayExtractsAdditionalParams":0,"ModelsLab\\ModelQ\\Tests\\Unit\\TaskTest::testFromArrayWithNoAdditionalParams":0,"ModelsLab\\ModelQ\\Tests\\Unit\\TaskTest::testAdditionalParamsRoundTrip":0,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testInstantiation":0.001,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testInstantiationWithCustomRedisClient":0.001,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testTaskRegistration":0,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testMultipleTaskRegistration":0.001,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testTaskRegistrationWithOptions":0.001,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testServerRegistration":0,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testEnqueue":0.001,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testEnqueueWithAdditionalParams":0.001,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testGetAllQueuedTasks":0.001,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testGetTaskStatus":0.001,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testRemoveTaskFromQueue":0.002,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testDeleteQueue":0.002,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testEnqueueDelayedTask":0.001,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testGetProcessingTasks":0.001,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testMiddlewareHooks":0.001,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testGetRedisClient":0.001,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testCancelTask":0.001,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testCancelNonexistentTask":0.001,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testIsTaskCancelled":0.001,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testGetCancelledTasks":0.001,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testMarkTaskAsErrorDrainsQueueAndSetsFailedStatus":0.003,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testMarkTaskAsErrorIsIdempotentAndSafeWhenKeyEvicted":0.003,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testMarkTasksAsErrorBulkDrainsManyWithOneMlTasksScan":0.004,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testMarkTasksAsErrorWithEmptyListIsANoOp":0,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testReportProgress":0.001,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testReportProgressClampsValues":0.001,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testGetTaskProgressNotFound":0,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testReportProgressWithoutMessage":0.001,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testGetTaskDetails":0.001,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testGetTaskDetailsNotFound":0.001,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testGetTaskHistory":0.001,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testGetTaskHistoryWithStatusFilter":0.001,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testGetTaskHistoryWithNameFilter":0.001,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testGetFailedTasks":0.001,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testGetCompletedTasks":0.001,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testGetTasksByName":0.001,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testGetTaskStats":0.002,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testGetTaskHistoryCount":0.001,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testClearTaskHistory":0.001,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testTaskHistoryWithErrorDetails":0.007,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testCleanupExpiredTasks":0.001,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testConfigurableTaskTtl":0,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testConfigurableHistoryRetention":0,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testGetWorkers":0.005,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testGetWorkersMultiple":0,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testGetWorker":0,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testGetWorkerNotFound":0,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testGetWorkerWithSystemInfo":0.001,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testGetWorkersWithMalformedJson":0.053,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testTaskGetStatus":0.001,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testTaskGetStatusNotFound":0,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testTaskGetProgress":0.003,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testTaskGetProgressNotFound":0,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testTaskIsCancelled":0.002,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testEnqueueWithEmptyPayload":0.003,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testEnqueueWithLargePayload":0.009,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testEnqueueWithSpecialCharacters":0.001,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testEnqueueWithNestedPayload":0.001,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testCustomTaskId":0.006,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testRemoveNonexistentTask":0,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testGetTaskStatusNonexistent":0,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testCancelAlreadyCompletedTask":0.002,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testCancelAlreadyFailedTask":0.002,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testMultipleCancellationCalls":0.008,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testGetTaskHistoryPagination":0.005,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testGetProcessingTasksWithStaleEntries":0.002,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testGetProcessingTasksWithWrongStatus":0.001,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testEnqueueWithCustomTaskIdAndAdditionalParams":0.001,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testStopMethod":0,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testDelayedTaskWithZeroDelay":0.001,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testDelayedTaskWithLargeDelay":0.001,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testGetTaskHistoryWithMissingData":0.001,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testGetTaskStatsWithEmptyHistory":0.001,"ModelsLab\\ModelQ\\Tests\\Integration\\ModelQTest::testClearTaskHistoryWithNoOldTasks":0.001,"ModelsLab\\ModelQ\\Tests\\Integration\\QueueDepthIntegrationTest::testQueuedTaskCountMatchesLlenAndOldCountPath":0.003,"ModelsLab\\ModelQ\\Tests\\Integration\\QueueDepthIntegrationTest::testProcessingTaskCountMatchesScard":0.001,"ModelsLab\\ModelQ\\Tests\\Integration\\StreamingTest::testStreamingTask":3.366,"ModelsLab\\ModelQ\\Tests\\Integration\\StreamingTest::testStreamTaskCompletion":3.424,"ModelsLab\\ModelQ\\Tests\\Integration\\StreamingTest::testStreamCombinedResult":3.268,"ModelsLab\\ModelQ\\Tests\\Integration\\WorkerTest::testTaskExecution":2.242,"ModelsLab\\ModelQ\\Tests\\Integration\\WorkerTest::testTaskWithReturnValue":2.166,"ModelsLab\\ModelQ\\Tests\\Integration\\WorkerTest::testEchoTask":2.18,"ModelsLab\\ModelQ\\Tests\\Integration\\WorkerTest::testMultipleConcurrentTasks":2.156,"ModelsLab\\ModelQ\\Tests\\Integration\\WorkerTest::testTaskStatusTransition":2.282,"ModelsLab\\ModelQ\\Tests\\Integration\\PruneTaskResultsTest::testHealthyKeysAreNeverRead":0.007,"ModelsLab\\ModelQ\\Tests\\Integration\\PruneTaskResultsTest::testOnlyTheKeyThatLostItsTtlIsRead":0.004,"ModelsLab\\ModelQ\\Tests\\Integration\\PruneTaskResultsTest::testRecentOrphanIsBoundedNotDeleted":0,"ModelsLab\\ModelQ\\Tests\\Integration\\PruneTaskResultsTest::testWorkerLoopDoesNotScanTaskResults":0,"ModelsLab\\ModelQ\\Tests\\Unit\\ConnectionLivenessTest::testReadTimeoutExceedsBlpopTimeout":0.005,"ModelsLab\\ModelQ\\Tests\\Unit\\ConnectionLivenessTest::testLivenessBoundsAreFinite":0,"ModelsLab\\ModelQ\\Tests\\Unit\\ConnectionLivenessTest::testWorkerBlockingPopUsesTheConfiguredTimeout":0.025}}
35 changes: 33 additions & 2 deletions src/ModelQ.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,23 @@ class ModelQ
public const TASK_TTL = 86400; // 24 hours TTL for all tasks
public const DEFAULT_STREAM_TIMEOUT = 300; // 5 minutes default stream timeout

/**
* Connection liveness.
*
* BLPOP_TIMEOUT is what we ask the *server* to wait. READ_TIMEOUT is what
* the *client* will wait for that answer to arrive. The second must exceed
* the first, or every idle poll aborts the read before Redis has replied.
*
* phpredis defaults both connect and read timeouts to 0, meaning "wait
* forever". A silently dropped TCP connection then parks the reader
* permanently: the server-side BLPOP timeout is irrelevant because the
* reply it would have produced can never reach us.
*/
public const BLPOP_TIMEOUT = 1; // seconds Redis holds the blocking pop
public const CONNECT_TIMEOUT = 5; // seconds to establish a connection
public const READ_TIMEOUT = 10; // seconds to wait for any single reply
public const TCP_KEEPALIVE = 60; // seconds idle before the kernel probes

private Redis $redis;
private string $serverId;
private array $allowedTasks = [];
Expand Down Expand Up @@ -62,7 +79,21 @@ public function __construct(
$this->redis = $redisClient;
} else {
$this->redis = new Redis();
$this->redis->connect($host, $port);
// Bounded connect and read. Without the read timeout phpredis waits
// forever for a reply, so a half-open socket strands the caller
// instead of raising — see the constants above.
$this->redis->connect(
$host,
$port,
self::CONNECT_TIMEOUT,
null,
0,
self::READ_TIMEOUT
);
// Let the kernel notice a peer that has gone away while we are idle.
if (defined('Redis::OPT_TCP_KEEPALIVE')) {
$this->redis->setOption(Redis::OPT_TCP_KEEPALIVE, self::TCP_KEEPALIVE);
}
if ($password) {
$this->redis->auth($password);
}
Expand Down Expand Up @@ -259,7 +290,7 @@ public function startWorkers(int $workers = 1): void

// Process tasks
$this->updateServerStatus('idle');
$taskData = $this->redis->blPop(['ml_tasks'], 1);
$taskData = $this->redis->blPop(['ml_tasks'], self::BLPOP_TIMEOUT);

if (!$taskData) {
continue;
Expand Down
87 changes: 87 additions & 0 deletions tests/Unit/ConnectionLivenessTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
<?php

declare(strict_types=1);

namespace ModelsLab\ModelQ\Tests\Unit;

use ModelsLab\ModelQ\ModelQ;
use PHPUnit\Framework\TestCase;
use Redis;

/**
* Regression tests for the 2026-08-15 silent worker stall.
*
* A dropped TCP connection to Redis left workers parked forever on a blocking
* read. phpredis defaults its read timeout to 0 — "wait forever" — so the
* server-side BLPOP timeout is no protection at all: the reply that would end
* the wait can never arrive over a socket that no longer exists.
*/
class ConnectionLivenessTest extends TestCase
{
/**
* The client must outwait the server, or every idle poll aborts early.
*
* BLPOP asks Redis to hold the pop for BLPOP_TIMEOUT seconds. If the client
* gives up reading before then, a correct empty reply is turned into an
* exception on every single idle loop.
*/
public function testReadTimeoutExceedsBlpopTimeout(): void
{
$this->assertGreaterThan(
ModelQ::BLPOP_TIMEOUT,
ModelQ::READ_TIMEOUT,
'READ_TIMEOUT must exceed BLPOP_TIMEOUT or idle polls abort mid-read'
);
}

/**
* Every liveness bound must actually be bounded.
*
* Zero is phpredis' "wait forever", which is the defect itself.
*/
public function testLivenessBoundsAreFinite(): void
{
foreach ([
'BLPOP_TIMEOUT' => ModelQ::BLPOP_TIMEOUT,
'CONNECT_TIMEOUT' => ModelQ::CONNECT_TIMEOUT,
'READ_TIMEOUT' => ModelQ::READ_TIMEOUT,
'TCP_KEEPALIVE' => ModelQ::TCP_KEEPALIVE,
] as $name => $value) {
$this->assertGreaterThan(0, $value, "$name must be a finite bound, not 0 (wait forever)");
}
}

/**
* The worker's blocking pop must carry the configured bound.
*/
public function testWorkerBlockingPopUsesTheConfiguredTimeout(): void
{
$redis = $this->createMock(Redis::class);
$seenTimeout = null;

// The loop also runs its prune/requeue housekeeping; give those reads an
// empty result so the assertion below is about BLPOP and nothing else.
$redis->method('hGetAll')->willReturn([]);
$redis->method('zRangeByScore')->willReturn([]);
$redis->method('sMembers')->willReturn([]);

$modelq = new ModelQ(redisClient: $redis, serverId: 'liveness-test');

$redis->method('blPop')->willReturnCallback(
function (array $keys, $timeout) use (&$seenTimeout, $modelq) {
$seenTimeout = $timeout;
$modelq->stop(); // one iteration is enough

return false;
}
);

$modelq->startWorkers(1);

$this->assertSame(
ModelQ::BLPOP_TIMEOUT,
$seenTimeout,
'worker issued a blocking pop that does not use BLPOP_TIMEOUT'
);
}
}