Passed
Pull Request — master (#408)
by
unknown
03:35
created

CronLock::readTimestampFromHandle()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 10
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 3
eloc 6
nc 3
nop 1
dl 0
loc 10
rs 10
c 0
b 0
f 0
1
<?php
2
3
namespace Quantum\Libraries\Cron;
4
5
use Quantum\Libraries\Cron\Exceptions\CronException;
6
7
/**
8
 * CronLock - file based lock with flock()
9
 *
10
 * Changes vs original:
11
 * - taskName sanitized for safe filename
12
 * - release() removes lock file
13
 * - stale cleanup reads timestamp from locked handle (no extra fs()->get)
14
 * - isLocked() is non-destructive (does NOT delete files); cleanup handles stale deletion
15
 * - optional refresh() to update timestamp while running
16
 */
17
class CronLock
18
{
19
    private string $lockDirectory;
20
    private string $taskName;
21
    private string $lockFile;
22
23
    /** @var resource|null */
24
    private $lockHandle = null;
25
26
    private bool $ownsLock = false;
27
    private int $maxLockAge;
28
29
    private const DEFAULT_MAX_LOCK_AGE = 86400;
30
31
    public function __construct(string $taskName, ?string $lockDirectory = null, ?int $maxLockAge = null)
32
    {
33
        $this->taskName = $this->sanitizeTaskName($taskName);
34
        $this->lockDirectory = $this->resolveLockDirectory($lockDirectory);
35
        $this->lockFile = $this->lockDirectory . DS . $this->taskName . '.lock';
36
        $this->maxLockAge = $maxLockAge ?? (int) cron_config('max_lock_age', self::DEFAULT_MAX_LOCK_AGE);
37
38
        $this->ensureLockDirectoryExists();
39
        $this->cleanupStaleLocks();
40
    }
41
42
    public function acquire(): bool
43
    {
44
        $this->lockHandle = @fopen($this->lockFile, 'c+');
0 ignored issues
show
Documentation Bug introduced by
It seems like @fopen($this->lockFile, 'c+') can also be of type false. However, the property $lockHandle is declared as type null|resource. Maybe add an additional type check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.

For example, imagine you have a variable $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. This class holds a proper account, so the id value must no longer be false.

Either this assignment is in error or a type check should be added for that assignment.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
45
        if ($this->lockHandle === false) {
46
            $this->lockHandle = null;
47
            $this->ownsLock = false;
48
            return false;
49
        }
50
51
        if (!flock($this->lockHandle, LOCK_EX | LOCK_NB)) {
52
            fclose($this->lockHandle);
53
            $this->lockHandle = null;
54
            $this->ownsLock = false;
55
            return false;
56
        }
57
58
        $this->writeTimestampToHandle($this->lockHandle);
59
        $this->ownsLock = true;
60
61
        return true;
62
    }
63
64
    /**
65
     * Update lock timestamp (useful for long-running jobs)
66
     */
67
    public function refresh(): bool
68
    {
69
        if (!$this->ownsLock || $this->lockHandle === null) {
70
            return false;
71
        }
72
73
        $this->writeTimestampToHandle($this->lockHandle);
74
        return true;
75
    }
76
77
    public function release(): bool
78
    {
79
        if (!$this->ownsLock || $this->lockHandle === null) {
80
            return true;
81
        }
82
83
        flock($this->lockHandle, LOCK_UN);
84
        fclose($this->lockHandle);
85
86
        $this->lockHandle = null;
87
        $this->ownsLock = false;
88
89
        @fs()->remove($this->lockFile);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for remove(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unhandled  annotation

89
        /** @scrutinizer ignore-unhandled */ @fs()->remove($this->lockFile);

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
90
91
        return true;
92
    }
93
94
    /**
95
     * Check if another process currently holds the lock.
96
     */
97
    public function isLocked(): bool
98
    {
99
        if (!fs()->exists($this->lockFile)) {
100
            return false;
101
        }
102
103
        $handle = @fopen($this->lockFile, 'c+');
104
        if ($handle === false) {
105
            return true;
106
        }
107
108
        if (!flock($handle, LOCK_EX | LOCK_NB)) {
109
            fclose($handle);
110
            return true;
111
        }
112
113
        flock($handle, LOCK_UN);
114
        fclose($handle);
115
116
        return false;
117
    }
118
119
    private function sanitizeTaskName(string $taskName): string
120
    {
121
        $taskName = trim($taskName);
122
        if ($taskName === '') {
123
            return 'default';
124
        }
125
126
        // Keep safe filename chars only
127
        $taskName = preg_replace('/[^a-zA-Z0-9._-]+/', '_', $taskName) ?? 'default';
128
        $taskName = trim($taskName, '._-');
129
130
        return $taskName !== '' ? $taskName : 'default';
131
    }
132
133
    private function resolveLockDirectory(?string $lockDirectory): string
134
    {
135
        $path = $lockDirectory ?? cron_config('lock_path');
136
        return $path === null ? $this->getDefaultLockDirectory() : $path;
137
    }
138
139
    private function getDefaultLockDirectory(): string
140
    {
141
        return base_dir() . DS . 'runtime' . DS . 'cron' . DS . 'locks';
142
    }
143
144
    private function ensureLockDirectoryExists(): void
145
    {
146
        if ($this->lockDirectory === '') {
147
            throw CronException::lockDirectoryNotWritable('');
148
        }
149
150
        $this->createDirectory($this->lockDirectory);
151
152
        if (!fs()->isWritable($this->lockDirectory)) {
153
            throw CronException::lockDirectoryNotWritable($this->lockDirectory);
154
        }
155
    }
156
157
    private function createDirectory(string $directory): void
158
    {
159
        if (fs()->isDirectory($directory)) {
160
            return;
161
        }
162
163
        $parent = dirname($directory);
164
        if ($parent && $parent !== $directory) {
165
            $this->createDirectory($parent);
166
        }
167
168
        // @phpstan-ignore-next-line
169
        if (!fs()->makeDirectory($directory) && !fs()->isDirectory($directory)) {
170
            throw CronException::lockDirectoryNotWritable($directory);
171
        }
172
    }
173
174
    /**
175
     * Removes stale lock files that are NOT currently locked by any process.
176
     * Safe because we take LOCK_EX before removing.
177
     */
178
    private function cleanupStaleLocks(): void
179
    {
180
        if (!fs()->isDirectory($this->lockDirectory)) {
181
            return;
182
        }
183
184
        $files = fs()->glob($this->lockDirectory . DS . '*.lock') ?: [];
185
        $now = time();
186
187
        foreach ($files as $file) {
188
            $handle = @fopen($file, 'c+');
189
            if ($handle === false) {
190
                continue;
191
            }
192
193
            // If someone holds it, skip
194
            if (!flock($handle, LOCK_EX | LOCK_NB)) {
195
                fclose($handle);
196
                continue;
197
            }
198
199
            $timestamp = $this->readTimestampFromHandle($handle);
200
201
            if ($timestamp !== null && ($now - $timestamp) > $this->maxLockAge) {
202
                @flock($handle, LOCK_UN);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for flock(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unhandled  annotation

202
                /** @scrutinizer ignore-unhandled */ @flock($handle, LOCK_UN);

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
203
                @fclose($handle);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for fclose(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unhandled  annotation

203
                /** @scrutinizer ignore-unhandled */ @fclose($handle);

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
204
                @fs()->remove($file);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for remove(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unhandled  annotation

204
                /** @scrutinizer ignore-unhandled */ @fs()->remove($file);

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
205
                continue;
206
            }
207
208
            @flock($handle, LOCK_UN);
209
            @fclose($handle);
210
        }
211
    }
212
213
    private function writeTimestampToHandle($handle): void
214
    {
215
        @ftruncate($handle, 0);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for ftruncate(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unhandled  annotation

215
        /** @scrutinizer ignore-unhandled */ @ftruncate($handle, 0);

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
216
        @rewind($handle);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for rewind(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unhandled  annotation

216
        /** @scrutinizer ignore-unhandled */ @rewind($handle);

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
217
        @fwrite($handle, (string) time());
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for fwrite(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unhandled  annotation

217
        /** @scrutinizer ignore-unhandled */ @fwrite($handle, (string) time());

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
218
        @fflush($handle);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for fflush(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unhandled  annotation

218
        /** @scrutinizer ignore-unhandled */ @fflush($handle);

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
219
    }
220
221
    private function readTimestampFromHandle($handle): ?int
222
    {
223
        @rewind($handle);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for rewind(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unhandled  annotation

223
        /** @scrutinizer ignore-unhandled */ @rewind($handle);

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
224
        $content = stream_get_contents($handle);
225
        if ($content === false) {
226
            return null;
227
        }
228
229
        $timestamp = (int) trim((string) $content);
230
        return $timestamp > 0 ? $timestamp : null;
231
    }
232
}
233