Repository: laravel/framework (php)

Issue:
The catch callback attached to a chain of jobs is called more than once
- Laravel Version:9.17.0
- PHP Version: 8.1.7

### Description:

The `catch` callback attached to a chain of jobs is called more than once when using the `sync` connection. I expected the catch callback to only be called once. It's only called once for the redis and sqs queue connection. This is best illustrated as an example.
 
```
        Bus::chain([
            new TestJob(1),
            new TestJob(2),
            new TestJob(3),
            new TestJob(4, exception: true),
            new TestJob(5),
            new TestJob(6),
            new TestJob(7),
        ])->catch(function (Throwable $e): void {
            dump('A job within the chain has failed...');
        })->onConnection('sync')->dispatch();
```

The above chain will call catch 4 times.

```
"A job within the chain has failed..."
"A job within the chain has failed..."
"A job within the chain has failed..."
"A job within the chain has failed..."

   Exception

  foobar 4

```

The further down the exception the more times catch will be called for example if the exception at index 4 catch will be called 5 times, if it's at index 5 it will be called 6 times, etc.


### Steps To Reproduce:

Here is an isolated console command to test it:

```
<?php

declare(strict_types=1);

namespace App\Console\Commands;

use Illuminate\Bus\Queueable;
use Illuminate\Console\Command;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Facades\Bus;

final class TestJob implements ShouldQueue
{
    use Dispatchable;
    use InteractsWithQueue;
    use Queueable;

    public function __construct(protected int $id, protected bool $exception = false)
    {
    }

    public function handle(): void
    {
        dump(__METHOD__." {$this->id}");

        if ($this->exception) {
            throw new \Exception("foobar {$this->id}");
        }
    }
}

final class TestChainCommand extends Command
{
    /**
     * The name and signature of the console command.
     *
     * @var string
     */
    protected $signature = 'sandbox:chain';

    /**
     * The console command description.
     *
     * @var string
     */
    protected $description = 'Test chained job exceptions';

    /**
     * Execute the console command.
     */
    public function handle(): int
    {
        Bus::chain([
            new TestJob(1),
            new TestJob(2),
            new TestJob(3),
            new TestJob(4, exception: true),
            new TestJob(5),
            new TestJob(6),
            new TestJob(7),
        ])->catch(function (\Throwable $e): void {
            dump('A job within the chain has failed...');
        })->onConnection('sync')->dispatch();

        return 0;
    }
}
```



Pull request description:
[8.x] Prevent double throwing chained exception on sync queue
<!--
Please only send a pull request to branches that are currently supported: https://laravel.com/docs/releases#support-policy 

If you are unsure which branch your pull request should be sent to, please read: https://laravel.com/docs/contributions#which-branch

Pull requests without a descriptive title, thorough description, or tests will be closed.

In addition, please describe the benefit to end users; the reasons it does not break any existing features; how it makes building web applications easier, etc.
-->

Closes #42883

As noted by issue #42883, when a chain of jobs is dispatched and a closure is provided to the chain's `catch` method, if an exception occur, the callback is called multiple times.

For example, consider this code:

~~~php
Bus::chain([
    new TestJob(1),
    new TestJob(2),
    new TestJob(3),
    new TestJob(4, exception: true),
    new TestJob(5),
    new TestJob(6),
    new TestJob(7),
])->catch(function (Throwable $e): void {
    dump('A job within the chain has failed...');
})->onConnection('sync')->dispatch();
~~~

When `new TestJob(4, exception: true)` throws an exception, the call back will be called 4 times, one for each of these job instances:

- `new TestJob(4, exception: true)`
- `new TestJob(3)`
- `new TestJob(2)`
- `new TestJob(1)`

If `new TestJob(4, exception: true)` was defined further down on the chain, the callback would be called more times, and vice-versa.

This happens as each job in the `sync` queue is executed in the same PHP process, so when the next job is dispatched it is actually "called" within the same execution call stack. 

Thus when an exception is thrown the exception triggers the `SyncQueue` exception handler multiple times, one for each job awaiting the execution call stack to complete.

This PR

- Adds an instance property to `SyncQueue` to track how many jobs were already pushed within its execution call stack
- Adds a guard to `SyncQueue@handleException` method, to ensure the handler is called only once for that execution call stack
- Adds a test case which fails without this PR's code 



Patch:
diff --git a/CHANGELOG-8.x.md b/CHANGELOG-8.x.md
index 6e2761fb0f..ef4fcc0e4f 100644
--- a/CHANGELOG-8.x.md
+++ b/CHANGELOG-8.x.md
@@ -1,6 +1,14 @@
 # Release Notes for 8.x
 
-## [Unreleased](https://github.com/laravel/framework/compare/v8.83.17...8.x)
+## [Unreleased](https://github.com/laravel/framework/compare/v8.83.18...8.x)
+
+
+## [v8.83.18 (2022-06-28)](https://github.com/laravel/framework/compare/v8.83.17...v8.83.18)
+
+### Fixed
+- Fixed bug on forceCreate on a MorphMay relationship not including morph type ([#42929](https://github.com/laravel/framework/pull/42929))
+- Handle cursor paginator when no items are found ([#42963](https://github.com/laravel/framework/pull/42963))
+- Fixed Str::Mask() for repeating chars ([#42956](https://github.com/laravel/framework/pull/42956))
 
 
 ## [v8.83.17 (2022-06-21)](https://github.com/laravel/framework/compare/v8.83.16...v8.83.17)
diff --git a/src/Illuminate/Database/Eloquent/Relations/MorphMany.php b/src/Illuminate/Database/Eloquent/Relations/MorphMany.php
index d4087da6d0..282ba2e860 100755
--- a/src/Illuminate/Database/Eloquent/Relations/MorphMany.php
+++ b/src/Illuminate/Database/Eloquent/Relations/MorphMany.php
@@ -53,10 +53,10 @@ public function match(array $models, Collection $results, $relation)
      * @param  array  $attributes
      * @return \Illuminate\Database\Eloquent\Model
      */
-    public function forceCreate(array $attributes = []) 
+    public function forceCreate(array $attributes = [])
     {
         $attributes[$this->getMorphType()] = $this->morphClass;
 
-        parent::forceCreate($attributes);
+        return parent::forceCreate($attributes);
     }
 }
diff --git a/src/Illuminate/Database/Schema/MySqlSchemaState.php b/src/Illuminate/Database/Schema/MySqlSchemaState.php
index 9bbec7a403..e0b0103d3e 100644
--- a/src/Illuminate/Database/Schema/MySqlSchemaState.php
+++ b/src/Illuminate/Database/Schema/MySqlSchemaState.php
@@ -85,10 +85,10 @@ public function load($path)
      */
     protected function baseDumpCommand()
     {
-        $command = 'mysqldump '.$this->connectionString().' --no-tablespaces --skip-add-locks --skip-comments --skip-set-charset --tz-utc';
+        $command = 'mysqldump '.$this->connectionString().' --no-tablespaces --skip-add-locks --skip-comments --skip-set-charset --tz-utc --column-statistics=0';
 
         if (! $this->connection->isMaria()) {
-            $command .= ' --column-statistics=0 --set-gtid-purged=OFF';
+            $command .= ' --set-gtid-purged=OFF';
         }
 
         return $command.' "${:LARAVEL_LOAD_DATABASE}"';
diff --git a/src/Illuminate/Foundation/Application.php b/src/Illuminate/Foundation/Application.php
index ad13d35a09..70dc25279f 100755
--- a/src/Illuminate/Foundation/Application.php
+++ b/src/Illuminate/Foundation/Application.php
@@ -33,7 +33,7 @@ class Application extends Container implements ApplicationContract, CachesConfig
      *
      * @var string
      */
-    const VERSION = '8.83.17';
+    const VERSION = '8.83.18';
 
     /**
      * The base path for the Laravel installation.
diff --git a/src/Illuminate/Pagination/AbstractCursorPaginator.php b/src/Illuminate/Pagination/AbstractCursorPaginator.php
index 1965588137..12344850b9 100644
--- a/src/Illuminate/Pagination/AbstractCursorPaginator.php
+++ b/src/Illuminate/Pagination/AbstractCursorPaginator.php
@@ -155,6 +155,10 @@ public function previousCursor()
             return null;
         }
 
+        if ($this->items->isEmpty()) {
+            return null;
+        }
+
         return $this->getCursorForItem($this->items->first(), false);
     }
 
@@ -170,6 +174,10 @@ public function nextCursor()
             return null;
         }
 
+        if ($this->items->isEmpty()) {
+            return null;
+        }
+
         return $this->getCursorForItem($this->items->last(), true);
     }
 
diff --git a/src/Illuminate/Queue/SyncQueue.php b/src/Illuminate/Queue/SyncQueue.php
index 33638c213a..0edd639c9e 100755
--- a/src/Illuminate/Queue/SyncQueue.php
+++ b/src/Illuminate/Queue/SyncQueue.php
@@ -12,6 +12,8 @@
 
 class SyncQueue extends Queue implements QueueContract
 {
+    protected $jobsCount = 0;
+
     /**
      * Get the size of the queue.
      *
@@ -37,6 +39,8 @@ public function push($job, $data = '', $queue = null)
     {
         $queueJob = $this->resolveJob($this->createPayload($job, $queue, $data), $queue);
 
+        $this->jobsCount++;
+
         try {
             $this->raiseBeforeJobEvent($queueJob);
 
@@ -45,6 +49,8 @@ public function push($job, $data = '', $queue = null)
             $this->raiseAfterJobEvent($queueJob);
         } catch (Throwable $e) {
             $this->handleException($queueJob, $e);
+        } finally {
+            $this->jobsCount--;
         }
 
         return 0;
@@ -113,11 +119,19 @@ protected function raiseExceptionOccurredJobEvent(Job $job, Throwable $e)
      */
     protected function handleException(Job $queueJob, Throwable $e)
     {
-        $this->raiseExceptionOccurredJobEvent($queueJob, $e);
+        static $isGuarded = false;
+
+        if ($isGuarded) {
+            $isGuarded = false;
+        } else {
+            $isGuarded = $this->jobsCount > 1;
 
-        $queueJob->fail($e);
+            $this->raiseExceptionOccurredJobEvent($queueJob, $e);
 
-        throw $e;
+            $queueJob->fail($e);
+
+            throw $e;
+        }
     }
 
     /**
diff --git a/src/Illuminate/Support/Str.php b/src/Illuminate/Support/Str.php
index 66028c8bee..21e1904038 100644
--- a/src/Illuminate/Support/Str.php
+++ b/src/Illuminate/Support/Str.php
@@ -437,10 +437,18 @@ public static function mask($string, $character, $index, $length = null, $encodi
             return $string;
         }
 
-        $start = mb_substr($string, 0, mb_strpos($string, $segment, 0, $encoding), $encoding);
-        $end = mb_substr($string, mb_strpos($string, $segment, 0, $encoding) + mb_strlen($segment, $encoding));
+        $strlen = mb_strlen($string, $encoding);
+        $startIndex = $index;
 
-        return $start.str_repeat(mb_substr($character, 0, 1, $encoding), mb_strlen($segment, $encoding)).$end;
+        if ($index < 0) {
+            $startIndex = $index < -$strlen ? 0 : $strlen + $index;
+        }
+
+        $start = mb_substr($string, 0, $startIndex, $encoding);
+        $segmentLen = mb_strlen($segment, $encoding);
+        $end = mb_substr($string, $startIndex + $segmentLen);
+
+        return $start.str_repeat(mb_substr($character, 0, 1, $encoding), $segmentLen).$end;
     }
 
     /**


Test patch:
diff --git a/tests/Database/DatabaseEloquentMorphOneOfManyTest.php b/tests/Database/DatabaseEloquentMorphOneOfManyTest.php
index 9a8e70f4c7..244643d239 100644
--- a/tests/Database/DatabaseEloquentMorphOneOfManyTest.php
+++ b/tests/Database/DatabaseEloquentMorphOneOfManyTest.php
@@ -97,9 +97,11 @@ public function testMorphType()
     public function testForceCreateMorphType()
     {
         $product = MorphOneOfManyTestProduct::create();
-        $product->states()->forceCreate([
+        $state = $product->states()->forceCreate([
             'state' => 'active',
         ]);
+
+        $this->assertNotNull($state);
         $this->assertSame(MorphOneOfManyTestProduct::class, $product->current_state->stateful_type);
     }
 
diff --git a/tests/Integration/Queue/JobChainingTest.php b/tests/Integration/Queue/JobChainingTest.php
index b0ad447768..382211e4ed 100644
--- a/tests/Integration/Queue/JobChainingTest.php
+++ b/tests/Integration/Queue/JobChainingTest.php
@@ -12,7 +12,7 @@
 
 class JobChainingTest extends TestCase
 {
-    public static $catchCallbackRan = false;
+    public static $catchCallbackCount = 0;
 
     protected function getEnvironmentSetUp($app)
     {
@@ -30,7 +30,6 @@ protected function tearDown(): void
         JobChainingTestFirstJob::$ran = false;
         JobChainingTestSecondJob::$ran = false;
         JobChainingTestThirdJob::$ran = false;
-        static::$catchCallbackRan = false;
     }
 
     public function testJobsCanBeChainedOnSuccess()
@@ -148,19 +147,56 @@ public function testThirdJobIsNotFiredIfSecondFails()
 
     public function testCatchCallbackIsCalledOnFailure()
     {
+        self::$catchCallbackCount = 0;
+
         Bus::chain([
             new JobChainingTestFirstJob,
             new JobChainingTestFailingJob,
             new JobChainingTestSecondJob,
         ])->catch(static function () {
-            self::$catchCallbackRan = true;
+            self::$catchCallbackCount++;
         })->dispatch();
 
         $this->assertTrue(JobChainingTestFirstJob::$ran);
-        $this->assertTrue(static::$catchCallbackRan);
+        $this->assertSame(1, static::$catchCallbackCount);
         $this->assertFalse(JobChainingTestSecondJob::$ran);
     }
 
+    public function testCatchCallbackIsCalledOnceOnSyncQueue()
+    {
+        self::$catchCallbackCount = 0;
+
+        try {
+            Bus::chain([
+                new JobChainingTestFirstJob(),
+                new JobChainingTestThrowJob(),
+                new JobChainingTestSecondJob(),
+            ])->catch(function () {
+                self::$catchCallbackCount++;
+            })->onConnection('sync')->dispatch();
+        } finally {
+            $this->assertTrue(JobChainingTestFirstJob::$ran);
+            $this->assertSame(1, static::$catchCallbackCount);
+            $this->assertFalse(JobChainingTestSecondJob::$ran);
+        }
+
+        self::$catchCallbackCount = 0;
+
+        try {
+            Bus::chain([
+                new JobChainingTestFirstJob(),
+                new JobChainingTestThrowJob(),
+                new JobChainingTestSecondJob(),
+            ])->catch(function () {
+                self::$catchCallbackCount++;
+            })->onConnection('sync')->dispatch();
+        } finally {
+            $this->assertTrue(JobChainingTestFirstJob::$ran);
+            $this->assertSame(1, static::$catchCallbackCount);
+            $this->assertFalse(JobChainingTestSecondJob::$ran);
+        }
+    }
+
     public function testChainJobsUseSameConfig()
     {
         JobChainingTestFirstJob::dispatch()->allOnQueue('some_queue')->allOnConnection('sync1')->chain([
@@ -293,3 +329,13 @@ public function handle()
         $this->fail();
     }
 }
+
+class JobChainingTestThrowJob implements ShouldQueue
+{
+    use Dispatchable, InteractsWithQueue, Queueable;
+
+    public function handle()
+    {
+        throw new \Exception();
+    }
+}
diff --git a/tests/Pagination/CursorPaginatorTest.php b/tests/Pagination/CursorPaginatorTest.php
index ad2def176f..535fb5b8aa 100644
--- a/tests/Pagination/CursorPaginatorTest.php
+++ b/tests/Pagination/CursorPaginatorTest.php
@@ -4,6 +4,7 @@
 
 use Illuminate\Pagination\Cursor;
 use Illuminate\Pagination\CursorPaginator;
+use Illuminate\Support\Collection;
 use PHPUnit\Framework\TestCase;
 
 class CursorPaginatorTest extends TestCase
@@ -76,6 +77,26 @@ public function testCanTransformPaginatorItems()
         $this->assertSame([['id' => 6], ['id' => 7]], $p->items());
     }
 
+    public function testReturnEmptyCursorWhenItemsAreEmpty()
+    {
+        $cursor = new Cursor(['id' => 25], true);
+
+        $p = new CursorPaginator(Collection::make(), 25, $cursor, [
+            'path' => 'http://website.com/test',
+            'cursorName' => 'cursor',
+            'parameters' => ['id'],
+        ]);
+
+        $this->assertInstanceOf(CursorPaginator::class, $p);
+        $this->assertSame([
+            'data' => [],
+            'path' => 'http://website.com/test',
+            'per_page' => 25,
+            'next_page_url' => null,
+            'prev_page_url' => null,
+        ], $p->toArray());
+    }
+
     protected function getCursor($params, $isNext = true)
     {
         return (new Cursor($params, $isNext))->encode();
diff --git a/tests/Support/SupportStrTest.php b/tests/Support/SupportStrTest.php
index 0daa6248c9..486da8e069 100755
--- a/tests/Support/SupportStrTest.php
+++ b/tests/Support/SupportStrTest.php
@@ -508,6 +508,19 @@ public function testMask()
 
         $this->assertSame('这是一***', Str::mask('这是一段中文', '*', 3));
         $this->assertSame('**一段中文', Str::mask('这是一段中文', '*', 0, 2));
+
+        $this->assertSame('ma*n@email.com', Str::mask('maan@email.com', '*', 2, 1));
+        $this->assertSame('ma***email.com', Str::mask('maan@email.com', '*', 2, 3));
+        $this->assertSame('ma************', Str::mask('maan@email.com', '*', 2));
+
+        $this->assertSame('mari*@email.com', Str::mask('maria@email.com', '*', 4, 1));
+        $this->assertSame('tamar*@email.com', Str::mask('tamara@email.com', '*', 5, 1));
+
+        $this->assertSame('*aria@email.com', Str::mask('maria@email.com', '*', 0, 1));
+        $this->assertSame('maria@email.co*', Str::mask('maria@email.com', '*', -1, 1));
+        $this->assertSame('maria@email.co*', Str::mask('maria@email.com', '*', -1));
+        $this->assertSame('***************', Str::mask('maria@email.com', '*', -15));
+        $this->assertSame('***************', Str::mask('maria@email.com', '*', 0));
     }
 
     public function testMatch()