diff --git a/lib/fs.js b/lib/fs.js index b15f1381..9a312847 100644 --- a/lib/fs.js +++ b/lib/fs.js @@ -150,6 +150,7 @@ let fs = { const simples = [ 'open', 'close', 'access', 'readFile', 'writeFile', 'write', 'read', 'readlink', 'chmod', 'unlink', 'readdir', 'stat', 'rename', 'lstat', + 'appendFile' ]; for (const s of simples) { fs[s] = B.promisify(_fs[s]); @@ -158,6 +159,8 @@ for (const s of simples) { const syncFunctions = [ 'createReadStream', 'createWriteStream', + 'writeFileSync', + 'statSync' ]; for (const s of syncFunctions) { fs[s] = _fs[s]; diff --git a/lib/util.js b/lib/util.js index 560995df..79e7a7db 100644 --- a/lib/util.js +++ b/lib/util.js @@ -17,6 +17,7 @@ import { v1 as uuidV1, v3 as uuidV3, v4 as uuidV4, v5 as uuidV5 } from 'uuid'; +import { waitForCondition } from 'asyncbox'; const W3C_WEB_ELEMENT_IDENTIFIER = 'element-6066-11e4-a52e-4f735466cecf'; @@ -443,11 +444,69 @@ async function toInMemoryBase64 (srcPath, opts = {}) { return Buffer.concat(resultBuffers); } +/** + * Create an async function which, when called, will not proceed until a certain file is no + * longer present on the system. This allows for preventing concurrent behavior across processes + * using a known lockfile path. + * + * @param {string} lockFile The full path to the file used for the lock + * @param {number} timeout [120] The max time in seconds to wait for the lock + * @returns {AsyncFunction} async function that takes another async function defining the locked + * behavior + */ +function getLockFileGuard (lockFile, timeout = 120) { + const waitForNoLock = async function waitForNoLock () { + try { + await waitForCondition(async () => { + try { + await fs.stat(lockFile); + return false; + } catch (ign) { + return true; + } + }, { + waitMs: timeout * 1000, + intervalMs: 750 + }); + } catch (ign) { + throw new Error(`Could not acquire lock on file '${lockFile}', after ` + + `timeout of ${timeout} seconds`); + } + }; + + return async (behavior) => { + let lockExists = false; + try { + // we first want to use sync commands to check if the lockfile already exists, so that no + // other call or process can jump in on this with us + fs.statSync(lockFile); + lockExists = true; + } catch (ign) {} + + if (lockExists) { + // if the lockfile does exist, then start async spinning until it doesn't + await waitForNoLock(); + } + + // at this point the lockfile is gone, either because it never was, or because we waited for + // another instance of the lock to clear. now write the lock file (again, do it sync so that in + // the initial case, it happens on the same spin of the event loop) + fs.writeFileSync(lockFile, 'appium lock'); + + try { + return await behavior(); + } finally { + // whether the behavior succeeded or not, get rid of the lock + await fs.unlink(lockFile); + } + }; +} + export { hasValue, escapeSpace, escapeSpecialChars, localIp, cancellableDelay, multiResolve, safeJsonParse, wrapElement, unwrapElement, filterObject, toReadableSizeString, isSubPath, W3C_WEB_ELEMENT_IDENTIFIER, isSameDestination, compareVersions, coerceVersion, quote, unleakString, jsonStringify, pluralize, GiB, MiB, KiB, toInMemoryBase64, - uuidV1, uuidV3, uuidV4, uuidV5, shellParse, + uuidV1, uuidV3, uuidV4, uuidV5, shellParse, getLockFileGuard }; diff --git a/test/util-e2e-specs.js b/test/util-e2e-specs.js index c1578151..5f4ec866 100644 --- a/test/util-e2e-specs.js +++ b/test/util-e2e-specs.js @@ -1,10 +1,11 @@ +import B from 'bluebird'; import chai from 'chai'; import chaiAsPromised from 'chai-as-promised'; import path from 'path'; import * as util from '../lib/util'; import { tempDir, fs } from '../index'; - +chai.should(); chai.use(chaiAsPromised); describe('#util', function () { @@ -33,4 +34,103 @@ describe('#util', function () { }); }); + describe('getLockFileGuard()', function () { + let tmpRoot; + let lockFile; + let testFile; + + async function lockExists () { + try { + await fs.stat(lockFile); + return true; + } catch (ign) { + return false; + } + } + + async function guardedBehavior (text, msBeforeActing) { + await B.delay(msBeforeActing); + await fs.appendFile(testFile, text, 'utf8'); + return text; + } + + async function testFileContents () { + return (await fs.readFile(testFile)).toString('utf8'); + } + + beforeEach(async function () { + tmpRoot = await tempDir.openDir(); + lockFile = path.resolve(tmpRoot, 'test.lock'); + testFile = path.resolve(tmpRoot, 'test'); + await fs.writeFile(testFile, 'a', 'utf8'); + }); + + afterEach(async function () { + try { + await fs.unlink(lockFile); + await fs.unlink(testFile); + } catch (ign) {} + }); + + it('should lock a file during the given behavior', async function () { + const guard = util.getLockFileGuard(lockFile); + await lockExists().should.eventually.be.false; + const guardPromise = guard(async () => await guardedBehavior('b', 500)); + await B.delay(200); + await lockExists().should.eventually.be.true; + await guardPromise; + await lockExists().should.eventually.be.false; + await testFileContents().should.eventually.eql('ab'); + }); + + it('should block other behavior until the lock is released', async function () { + // first prove that without a lock, we get races + await testFileContents().should.eventually.eql('a'); + const unguardedPromise1 = guardedBehavior('b', 500); + const unguardedPromise2 = guardedBehavior('c', 100); + await unguardedPromise1; + await unguardedPromise2; + await testFileContents().should.eventually.eql('acb'); + + // now prove that with a lock, we don't get any interlopers + const guard = util.getLockFileGuard(lockFile); + const guardPromise1 = guard(async () => await guardedBehavior('b', 500)); + const guardPromise2 = guard(async () => await guardedBehavior('c', 100)); + await guardPromise1; + await guardPromise2; + await testFileContents().should.eventually.eql('acbbc'); + }); + + it('should return the result of the guarded behavior', async function () { + const guard = util.getLockFileGuard(lockFile); + const guardPromise1 = guard(async () => await guardedBehavior('hello', 500)); + const guardPromise2 = guard(async () => await guardedBehavior('world', 100)); + const ret1 = await guardPromise1; + const ret2 = await guardPromise2; + ret1.should.eql('hello'); + ret2.should.eql('world'); + }); + + it('should time out if the lock is not released', async function () { + this.timeout(5000); + const guard = util.getLockFileGuard(lockFile, 0.5); + const p1 = guard(async () => await guardedBehavior('hello', 1200)); + const p2 = guard(async () => await guardedBehavior('world', 10)); + await p2.should.eventually.be.rejectedWith(/not acquire lock/); + await p1.should.eventually.eql('hello'); + }); + + it('should still release lock if guarded behavior fails', async function () { + this.timeout(5000); + const guard = util.getLockFileGuard(lockFile); + const p1 = guard(async () => { + await B.delay(500); + throw new Error('bad'); + }); + const p2 = guard(async () => await guardedBehavior('world', 100)); + await p1.should.eventually.be.rejectedWith(/bad/); + await p2.should.eventually.eql('world'); + }); + }); + });