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