Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
74.03% covered (warning)
74.03%
57 / 77
33.33% covered (danger)
33.33%
3 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
FSLockManager
75.00% covered (warning)
75.00%
57 / 76
33.33% covered (danger)
33.33%
3 / 9
56.25
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
 doLockByType
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
4.02
 doUnlockByType
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 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    /** @inheritDoc */
54    protected function doLockByType( array $pathsByType ) {
55        $status = StatusValue::newGood();
56        $lockedByType = [];
57        foreach ( $pathsByType as $type => $paths ) {
58            foreach ( $paths as $path ) {
59                $status->merge( $this->doSingleLock( $path, $type ) );
60                if ( $status->isOK() ) {
61                    $lockedByType[$type] = $paths;
62                } else {
63                    // Abort and release the subset of locks that were acquired
64                    $this->doUnlockByType( $lockedByType );
65                }
66            }
67        }
68
69        return $status;
70    }
71
72    /** @inheritDoc */
73    protected function doUnlockByType( array $pathsByType ) {
74        $status = StatusValue::newGood();
75        foreach ( $pathsByType as $type => $paths ) {
76            foreach ( $paths as $path ) {
77                $status->merge( $this->doSingleUnlock( $path, $type ) );
78            }
79        }
80
81        return $status;
82    }
83
84    /**
85     * Lock a single resource key
86     *
87     * @param string $path
88     * @param int $type
89     * @return StatusValue
90     */
91    protected function doSingleLock( $path, $type ) {
92        $status = StatusValue::newGood();
93
94        if ( isset( $this->locksHeld[$path][$type] ) ) {
95            ++$this->locksHeld[$path][$type];
96        } elseif ( isset( $this->locksHeld[$path][self::LOCK_EX] ) ) {
97            $this->locksHeld[$path][$type] = 1;
98        } else {
99            if ( isset( $this->handles[$path] ) ) {
100                $handle = $this->handles[$path];
101            } else {
102                // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
103                $handle = @fopen( $this->getLockPath( $path ), 'a+' );
104                if ( !$handle && !is_dir( $this->lockDir ) ) {
105                    // Create the lock directory and try again
106                    // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
107                    if ( @mkdir( $this->lockDir, 0o777, true ) ) {
108                        // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
109                        $handle = @fopen( $this->getLockPath( $path ), 'a+' );
110                    } else {
111                        $this->logger->error( "Cannot create directory '{$this->lockDir}'." );
112                    }
113                }
114            }
115            if ( $handle ) {
116                // Either a shared or exclusive lock
117                $lock = ( $type == self::LOCK_SH ) ? LOCK_SH : LOCK_EX;
118                if ( flock( $handle, $lock | LOCK_NB ) ) {
119                    // Record this lock as active
120                    $this->locksHeld[$path][$type] = 1;
121                    $this->handles[$path] = $handle;
122                } else {
123                    fclose( $handle );
124                    $status->fatal( 'lockmanager-fail-conflict' );
125                }
126            } else {
127                $status->fatal( 'lockmanager-fail-openlock', $path );
128            }
129        }
130
131        return $status;
132    }
133
134    /**
135     * Unlock a single resource key
136     *
137     * @param string $path
138     * @param int $type
139     * @return StatusValue
140     */
141    protected function doSingleUnlock( $path, $type ) {
142        $status = StatusValue::newGood();
143
144        if ( !isset( $this->locksHeld[$path] ) ) {
145            $status->warning( 'lockmanager-notlocked', $path );
146        } elseif ( !isset( $this->locksHeld[$path][$type] ) ) {
147            $status->warning( 'lockmanager-notlocked', $path );
148        } else {
149            $handlesToClose = [];
150            --$this->locksHeld[$path][$type];
151            if ( $this->locksHeld[$path][$type] <= 0 ) {
152                unset( $this->locksHeld[$path][$type] );
153            }
154            if ( $this->locksHeld[$path] === [] ) {
155                unset( $this->locksHeld[$path] ); // no locks on this path
156                if ( isset( $this->handles[$path] ) ) {
157                    $handlesToClose[] = $this->handles[$path];
158                    unset( $this->handles[$path] );
159                }
160            }
161            // Unlock handles to release locks and delete
162            // any lock files that end up with no locks on them...
163            if ( $this->isWindows ) {
164                // Windows: for any process, including this one,
165                // calling unlink() on a locked file will fail
166                $status->merge( $this->closeLockHandles( $path, $handlesToClose ) );
167                $status->merge( $this->pruneKeyLockFiles( $path ) );
168            } else {
169                // Unix: unlink() can be used on files currently open by this
170                // process and we must do so in order to avoid race conditions
171                $status->merge( $this->pruneKeyLockFiles( $path ) );
172                $status->merge( $this->closeLockHandles( $path, $handlesToClose ) );
173            }
174        }
175
176        return $status;
177    }
178
179    /**
180     * @param string $path
181     * @param array $handlesToClose
182     * @return StatusValue
183     */
184    private function closeLockHandles( $path, array $handlesToClose ) {
185        $status = StatusValue::newGood();
186        foreach ( $handlesToClose as $handle ) {
187            if ( !flock( $handle, LOCK_UN ) ) {
188                $status->fatal( 'lockmanager-fail-releaselock', $path );
189            }
190            if ( !fclose( $handle ) ) {
191                $status->warning( 'lockmanager-fail-closelock', $path );
192            }
193        }
194
195        return $status;
196    }
197
198    /**
199     * @param string $path
200     * @return StatusValue
201     */
202    private function pruneKeyLockFiles( $path ) {
203        $status = StatusValue::newGood();
204        if ( !isset( $this->locksHeld[$path] ) ) {
205            # No locks are held for the lock file anymore
206            if ( !unlink( $this->getLockPath( $path ) ) ) {
207                $status->warning( 'lockmanager-fail-deletelock', $path );
208            }
209            unset( $this->handles[$path] );
210        }
211
212        return $status;
213    }
214
215    /**
216     * Get the path to the lock file for a key
217     * @param string $path
218     * @return string
219     */
220    protected function getLockPath( $path ) {
221        return "{$this->lockDir}/{$this->sha1Base36Absolute( $path )}.lock";
222    }
223
224    /**
225     * Make sure remaining locks get cleared
226     */
227    public function __destruct() {
228        while ( count( $this->locksHeld ) ) {
229            foreach ( $this->locksHeld as $path => $locks ) {
230                $this->doSingleUnlock( $path, self::LOCK_EX );
231                $this->doSingleUnlock( $path, self::LOCK_SH );
232            }
233        }
234    }
235}
236/** @deprecated class alias since 1.46 */
237class_alias( FSLockManager::class, 'FSLockManager' );