Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
72.37% covered (warning)
72.37%
55 / 76
33.33% covered (danger)
33.33%
3 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
FSLockManager
73.33% covered (warning)
73.33%
55 / 75
33.33% covered (danger)
33.33%
3 / 9
55.92
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 doLock
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
3.10
 doUnlock
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 doSingleLock
66.67% covered (warning)
66.67%
14 / 21
0.00% covered (danger)
0.00%
0 / 1
13.70
 doSingleUnlock
80.00% covered (warning)
80.00%
16 / 20
0.00% covered (danger)
0.00%
0 / 1
7.39
 closeLockHandles
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
4.37
 pruneKeyLockFiles
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 getLockPath
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 __destruct
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * @file
5 */
6namespace Wikimedia\LockManager;
7
8use StatusValue;
9
10/**
11 * Simple lock management based on server-local temporary files.
12 *
13 * All locks are non-blocking, which avoids deadlocks.
14 *
15 * This should work fine for small sites running from a single web server.
16 * Do not use this with 'lockDirectory' set to an NFS mount unless the
17 * NFS client is at least version 2.6.12. Otherwise, the BSD flock()
18 * locks will be ignored; see http://nfs.sourceforge.net/#section_d.
19 *
20 * @ingroup LockManager
21 * @since 1.19
22 */
23class FSLockManager extends LockManager {
24    /** @var array Mapping of lock types to the type actually used */
25    protected $lockTypeMap = [
26        self::LOCK_SH => self::LOCK_SH,
27        self::LOCK_UW => self::LOCK_SH,
28        self::LOCK_EX => self::LOCK_EX
29    ];
30
31    /** @var string Global dir for all servers */
32    protected $lockDir;
33
34    /** @var array Map of (locked key => lock file handle) */
35    protected $handles = [];
36
37    /** @var bool */
38    protected $isWindows;
39
40    /**
41     * Construct a new instance from configuration.
42     *
43     * @param array $config Includes:
44     *   - lockDirectory : Directory containing the lock files
45     */
46    public function __construct( array $config ) {
47        parent::__construct( $config );
48
49        $this->lockDir = $config['lockDirectory'];
50        $this->isWindows = ( PHP_OS_FAMILY === 'Windows' );
51    }
52
53    /**
54     * @see LockManager::doLock()
55     * @param array $paths
56     * @param int $type
57     * @return StatusValue
58     */
59    protected function doLock( array $paths, $type ) {
60        $status = StatusValue::newGood();
61
62        $lockedPaths = []; // files locked in this attempt
63        foreach ( $paths as $path ) {
64            $status->merge( $this->doSingleLock( $path, $type ) );
65            if ( $status->isOK() ) {
66                $lockedPaths[] = $path;
67            } else {
68                // Abort and unlock everything
69                $status->merge( $this->doUnlock( $lockedPaths, $type ) );
70
71                return $status;
72            }
73        }
74
75        return $status;
76    }
77
78    /**
79     * @see LockManager::doUnlock()
80     * @param array $paths
81     * @param int $type
82     * @return StatusValue
83     */
84    protected function doUnlock( array $paths, $type ) {
85        $status = StatusValue::newGood();
86
87        foreach ( $paths as $path ) {
88            $status->merge( $this->doSingleUnlock( $path, $type ) );
89        }
90
91        return $status;
92    }
93
94    /**
95     * Lock a single resource key
96     *
97     * @param string $path
98     * @param int $type
99     * @return StatusValue
100     */
101    protected function doSingleLock( $path, $type ) {
102        $status = StatusValue::newGood();
103
104        if ( isset( $this->locksHeld[$path][$type] ) ) {
105            ++$this->locksHeld[$path][$type];
106        } elseif ( isset( $this->locksHeld[$path][self::LOCK_EX] ) ) {
107            $this->locksHeld[$path][$type] = 1;
108        } else {
109            if ( isset( $this->handles[$path] ) ) {
110                $handle = $this->handles[$path];
111            } else {
112                // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
113                $handle = @fopen( $this->getLockPath( $path ), 'a+' );
114                if ( !$handle && !is_dir( $this->lockDir ) ) {
115                    // Create the lock directory and try again
116                    // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
117                    if ( @mkdir( $this->lockDir, 0o777, true ) ) {
118                        // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
119                        $handle = @fopen( $this->getLockPath( $path ), 'a+' );
120                    } else {
121                        $this->logger->error( "Cannot create directory '{$this->lockDir}'." );
122                    }
123                }
124            }
125            if ( $handle ) {
126                // Either a shared or exclusive lock
127                $lock = ( $type == self::LOCK_SH ) ? LOCK_SH : LOCK_EX;
128                if ( flock( $handle, $lock | LOCK_NB ) ) {
129                    // Record this lock as active
130                    $this->locksHeld[$path][$type] = 1;
131                    $this->handles[$path] = $handle;
132                } else {
133                    fclose( $handle );
134                    $status->fatal( 'lockmanager-fail-conflict' );
135                }
136            } else {
137                $status->fatal( 'lockmanager-fail-openlock', $path );
138            }
139        }
140
141        return $status;
142    }
143
144    /**
145     * Unlock a single resource key
146     *
147     * @param string $path
148     * @param int $type
149     * @return StatusValue
150     */
151    protected function doSingleUnlock( $path, $type ) {
152        $status = StatusValue::newGood();
153
154        if ( !isset( $this->locksHeld[$path] ) ) {
155            $status->warning( 'lockmanager-notlocked', $path );
156        } elseif ( !isset( $this->locksHeld[$path][$type] ) ) {
157            $status->warning( 'lockmanager-notlocked', $path );
158        } else {
159            $handlesToClose = [];
160            --$this->locksHeld[$path][$type];
161            if ( $this->locksHeld[$path][$type] <= 0 ) {
162                unset( $this->locksHeld[$path][$type] );
163            }
164            if ( $this->locksHeld[$path] === [] ) {
165                unset( $this->locksHeld[$path] ); // no locks on this path
166                if ( isset( $this->handles[$path] ) ) {
167                    $handlesToClose[] = $this->handles[$path];
168                    unset( $this->handles[$path] );
169                }
170            }
171            // Unlock handles to release locks and delete
172            // any lock files that end up with no locks on them...
173            if ( $this->isWindows ) {
174                // Windows: for any process, including this one,
175                // calling unlink() on a locked file will fail
176                $status->merge( $this->closeLockHandles( $path, $handlesToClose ) );
177                $status->merge( $this->pruneKeyLockFiles( $path ) );
178            } else {
179                // Unix: unlink() can be used on files currently open by this
180                // process and we must do so in order to avoid race conditions
181                $status->merge( $this->pruneKeyLockFiles( $path ) );
182                $status->merge( $this->closeLockHandles( $path, $handlesToClose ) );
183            }
184        }
185
186        return $status;
187    }
188
189    /**
190     * @param string $path
191     * @param array $handlesToClose
192     * @return StatusValue
193     */
194    private function closeLockHandles( $path, array $handlesToClose ) {
195        $status = StatusValue::newGood();
196        foreach ( $handlesToClose as $handle ) {
197            if ( !flock( $handle, LOCK_UN ) ) {
198                $status->fatal( 'lockmanager-fail-releaselock', $path );
199            }
200            if ( !fclose( $handle ) ) {
201                $status->warning( 'lockmanager-fail-closelock', $path );
202            }
203        }
204
205        return $status;
206    }
207
208    /**
209     * @param string $path
210     * @return StatusValue
211     */
212    private function pruneKeyLockFiles( $path ) {
213        $status = StatusValue::newGood();
214        if ( !isset( $this->locksHeld[$path] ) ) {
215            # No locks are held for the lock file anymore
216            if ( !unlink( $this->getLockPath( $path ) ) ) {
217                $status->warning( 'lockmanager-fail-deletelock', $path );
218            }
219            unset( $this->handles[$path] );
220        }
221
222        return $status;
223    }
224
225    /**
226     * Get the path to the lock file for a key
227     * @param string $path
228     * @return string
229     */
230    protected function getLockPath( $path ) {
231        return "{$this->lockDir}/{$this->sha1Base36Absolute( $path )}.lock";
232    }
233
234    /**
235     * Make sure remaining locks get cleared
236     */
237    public function __destruct() {
238        while ( count( $this->locksHeld ) ) {
239            foreach ( $this->locksHeld as $path => $locks ) {
240                $this->doSingleUnlock( $path, self::LOCK_EX );
241                $this->doSingleUnlock( $path, self::LOCK_SH );
242            }
243        }
244    }
245}
246/** @deprecated class alias since 1.46 */
247class_alias( FSLockManager::class, 'FSLockManager' );