diff --git a/package-lock.json b/package-lock.json index 1f7d7c53..aa1b28e5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -616,12 +616,6 @@ "integrity": "sha512-KZfv4ea6bEbdQhfwpxtDuTPO2mHAAXMQqPOZyS4MgNyCymKoLHp0FVzzYq3H2zCeIotN4h1453TahLCCm8rf2w==", "dev": true }, - "@types/lockfile": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@types/lockfile/-/lockfile-1.0.1.tgz", - "integrity": "sha512-65WZedEm4AnOsBDdsapJJG42MhROu3n4aSSiu87JXF/pSdlubxZxp3S1yz3kTfkJ2KBPud4CpjoHVAptOm9Zmw==", - "dev": true - }, "@types/lodash": { "version": "4.14.159", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.159.tgz", @@ -6607,15 +6601,6 @@ "p-locate": "^4.1.0" } }, - "lockfile": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/lockfile/-/lockfile-1.0.4.tgz", - "integrity": "sha512-cvbTwETRfsFh4nHsL1eGWapU1XFi5Ot9E85sWAwia7Y7EgB7vfqcZhTKZ+l7hCGxSPoushMv5GKhT5PdLv03WA==", - "dev": true, - "requires": { - "signal-exit": "^3.0.2" - } - }, "lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", diff --git a/package.json b/package.json index fc633955..9bcb1933 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,6 @@ "@types/dockerode": "^2.5.34", "@types/event-stream": "^3.3.34", "@types/express": "^4.17.3", - "@types/lockfile": "^1.0.1", "@types/lodash": "^4.14.159", "@types/memoizee": "^0.4.4", "@types/mocha": "^8.2.2", @@ -100,7 +99,6 @@ "knex": "^0.20.13", "lint-staged": "^10.2.11", "livepush": "^3.5.1", - "lockfile": "^1.0.4", "lodash": "^4.17.21", "memoizee": "^0.4.14", "mixpanel": "^0.10.3", diff --git a/src/lib/lockfile.ts b/src/lib/lockfile.ts index cef2968d..64b2794a 100644 --- a/src/lib/lockfile.ts +++ b/src/lib/lockfile.ts @@ -107,9 +107,11 @@ export async function lock(path: string, uid = LOCKFILE_UID) { } } -export async function unlock(path: string) { +export async function unlock(path: string): Promise { // Removing the updates.lock file releases the lock - return await unlinkAll(path); + await unlinkAll(path); + // Remove lockfile's in-memory tracking of a file + delete locksTaken[path]; } export function unlockSync(path: string) { diff --git a/src/lib/update-lock.ts b/src/lib/update-lock.ts index d0fac6a4..59f35585 100644 --- a/src/lib/update-lock.ts +++ b/src/lib/update-lock.ts @@ -1,5 +1,4 @@ import * as Bluebird from 'bluebird'; -import * as lockFileLib from 'lockfile'; import * as _ from 'lodash'; import { promises as fs } from 'fs'; import * as path from 'path'; @@ -8,30 +7,15 @@ import * as Lock from 'rwlock'; import * as constants from './constants'; import { ENOENT, - EEXIST, UpdatesLockedError, InternalInconsistencyError, } from './errors'; import { getPathOnHost, pathExistsOnHost } from './fs-utils'; import * as config from '../config'; - -type asyncLockFile = typeof lockFileLib & { - unlockAsync(path: string): Bluebird; - lockAsync(path: string): Bluebird; -}; -const lockFile = Bluebird.promisifyAll(lockFileLib) as asyncLockFile; -export type LockCallback = ( - appId: number, - opts: { force: boolean }, - fn: () => PromiseLike, -) => Bluebird; +import * as lockfile from './lockfile'; export function lockPath(appId: number, serviceName?: string): string { - return path.join( - '/tmp/balena-supervisor/services', - appId.toString(), - serviceName ?? '', - ); + return path.join(lockfile.BASE_LOCK_DIR, appId.toString(), serviceName ?? ''); } function lockFilesOnHost(appId: number, serviceName: string): string[] { @@ -69,19 +53,6 @@ export function abortIfHUPInProgress({ }); } -const locksTaken: { [lockName: string]: boolean } = {}; - -// Try to clean up any existing locks when the program exits -process.on('exit', () => { - for (const lockName of _.keys(locksTaken)) { - try { - lockFile.unlockSync(lockName); - } catch (e) { - // Ignore unlocking errors - } - } -}); - type LockFn = (key: string | number) => Bluebird<() => void>; const locker = new Lock(); export const writeLock: LockFn = Bluebird.promisify(locker.async.writeLock, { @@ -92,54 +63,32 @@ export const readLock: LockFn = Bluebird.promisify(locker.async.readLock, { }); function dispose(release: () => void): Bluebird { - return Bluebird.map(_.keys(locksTaken), (lockName) => { - delete locksTaken[lockName]; - return lockFile.unlockAsync(lockName); + return Bluebird.map(lockfile.getLocksTaken(), (lockName) => { + return lockfile.unlock(lockName); }) .finally(release) .return(); } -const lockExistsErrHandler = (err: Error, release: () => void) => { - let errMsg = err.message; - if (EEXIST(err)) { - // Extract appId|appUuid and serviceName from lockfile path for log message - // appId: [0-9]{7}, appUuid: [0-9a-w]{32}, short appUuid: [0-9a-w]{7} - const pathMatch = err.message.match( - /\/([0-9]{7}|[0-9a-w]{32}|[0-9a-w]{7})\/(.*)\/(?:resin-)?updates.lock/, - ); - if (pathMatch && pathMatch.length === 3) { - errMsg = `Lockfile exists for ${JSON.stringify({ - serviceName: pathMatch[2], - [/^[0-9]{7}$/.test(pathMatch[1]) ? 'appId' : 'appUuid']: pathMatch[1], - })}`; - } - } - return dispose(release).throw(new UpdatesLockedError(errMsg)); -}; - /** * Try to take the locks for an application. If force is set, it will remove * all existing lockfiles before performing the operation * - * TODO: convert to native Promises. May require native implementation of Bluebird's dispose / using + * TODO: convert to native Promises and async/await. May require native implementation of Bluebird's dispose / using * * TODO: Remove skipLock as it's not a good interface. If lock is called it should try to take the lock * without an option to skip. */ export function lock( - appId: number | null, + appId: number, { force = false, skipLock = false }: { force: boolean; skipLock?: boolean }, fn: () => Resolvable, ): Bluebird { - if (skipLock) { + if (skipLock || appId == null) { return Bluebird.resolve(fn()); } const takeTheLock = () => { - if (appId == null) { - return; - } return config .get('lockOverride') .then((lockOverride) => { @@ -152,20 +101,36 @@ export function lock( return Bluebird.mapSeries( lockFilesOnHost(appId, serviceName), (tmpLockName) => { - return Bluebird.try(() => { - if (force || lockOverride) { - return lockFile.unlockAsync(tmpLockName); - } - }) - .then(() => lockFile.lockAsync(tmpLockName)) - .then(() => { - locksTaken[tmpLockName] = true; + return ( + Bluebird.try(() => { + if (force || lockOverride) { + return lockfile.unlock(tmpLockName); + } }) - .catchReturn(ENOENT, undefined); + .then(() => { + return lockfile.lock(tmpLockName); + }) + // If lockfile exists, throw a user-friendly error. + // Otherwise throw the error as-is. + // This will interrupt the call to Bluebird.using, so + // dispose needs to be called even though it's referenced + // by .disposer later. + .catch((error) => { + return dispose(release).throw( + lockfile.LockfileExistsError.is(error) + ? new UpdatesLockedError( + `Lockfile exists for ${JSON.stringify({ + serviceName, + appId, + })}`, + ) + : (error as Error), + ); + }) + ); }, ); - }) - .catch((err) => lockExistsErrHandler(err, release)); + }); }) .disposer(dispose); }) @@ -177,9 +142,6 @@ export function lock( }; const disposer = takeTheLock(); - if (disposer) { - return Bluebird.using(disposer, fn as () => PromiseLike); - } else { - return Bluebird.resolve(fn()); - } + + return Bluebird.using(disposer, fn as () => PromiseLike); } diff --git a/src/supervisor.ts b/src/supervisor.ts index 5e3cd219..878615a1 100644 --- a/src/supervisor.ts +++ b/src/supervisor.ts @@ -8,10 +8,8 @@ import { normaliseLegacyDatabase } from './lib/legacy'; import * as osRelease from './lib/os-release'; import * as logger from './logger'; import SupervisorAPI from './supervisor-api'; - import log from './lib/supervisor-console'; import version = require('./lib/supervisor-version'); - import * as avahi from './lib/avahi'; import * as firewall from './lib/firewall'; import logMonitor from './logging/monitor'; diff --git a/test/src/lib/lockfile.spec.ts b/test/src/lib/lockfile.spec.ts index b23c3159..e9468705 100644 --- a/test/src/lib/lockfile.spec.ts +++ b/test/src/lib/lockfile.spec.ts @@ -6,7 +6,6 @@ import mock = require('mock-fs'); import * as lockfile from '../../../src/lib/lockfile'; import * as fsUtils from '../../../src/lib/fs-utils'; -import { ChildProcessError } from '../../../src/lib/errors'; describe('lib/lockfile', () => { const lockPath = `${lockfile.BASE_LOCK_DIR}/1234567/updates.lock`; @@ -70,8 +69,14 @@ describe('lib/lockfile', () => { }); }); - afterEach(() => { + afterEach(async () => { execStub.restore(); + + // Even though mock-fs is restored, this is needed to delete any in-memory storage of locks + for (const lock of lockfile.getLocksTaken()) { + await lockfile.unlock(lock); + } + mock.restore(); }); @@ -109,15 +114,14 @@ describe('lib/lockfile', () => { // no errors, but we want it to throw an error just for this unit test execStub.restore(); - const childProcessError = new Error() as ChildProcessError; - childProcessError.code = 73; - childProcessError.stderr = 'lockfile: Test error'; - + const childProcessError = new lockfile.LockfileExistsError( + '/tmp/test/path', + ); execStub = stub(fsUtils, 'exec').throws(childProcessError); try { await lockfile.lock(lockPath); - expect.fail('lockfile.lock should have thrown an error'); + expect.fail('lockfile.lock should throw an error'); } catch (err) { expect(err).to.exist; } @@ -164,4 +168,18 @@ describe('lib/lockfile', () => { expect.fail((err as Error)?.message ?? err); }); }); + + it('should try to clean up existing locks on process exit', async () => { + // Mock directory with sticky + write permissions + mockDir(STICKY_WRITE_PERMISSIONS.unix, { createLock: false }); + + // Lock file, which stores lock path in memory + await lockfile.lock(lockPath); + + // @ts-ignore + process.emit('exit'); + + // Verify lockfile removal + await checkLockDirFiles(lockPath, { shouldExist: false }); + }); }); diff --git a/test/src/lib/update-lock.spec.ts b/test/src/lib/update-lock.spec.ts index 9817e0a8..f68aa4ca 100644 --- a/test/src/lib/update-lock.spec.ts +++ b/test/src/lib/update-lock.spec.ts @@ -1,48 +1,40 @@ import { expect } from 'chai'; import { SinonSpy, SinonStub, spy, stub } from 'sinon'; import * as path from 'path'; -import * as Bluebird from 'bluebird'; - -import rewire = require('rewire'); +import { promises as fs } from 'fs'; import mockFs = require('mock-fs'); +import * as updateLock from '../../../src/lib/update-lock'; import * as constants from '../../../src/lib/constants'; import { UpdatesLockedError } from '../../../src/lib/errors'; +import * as config from '../../../src/config'; +import * as lockfile from '../../../src/lib/lockfile'; +import * as fsUtils from '../../../src/lib/fs-utils'; describe('lib/update-lock', () => { - const updateLock = rewire('../../../src/lib/update-lock'); - const breadcrumbFiles = [ - 'rollback-health-breadcrumb', - 'rollback-altboot-breadcrumb', - ]; - - const mockBreadcrumbs = (breadcrumb?: string) => { - mockFs({ - [path.join( - constants.rootMountPoint, - constants.stateMountPoint, - breadcrumb ? breadcrumb : '', - )]: '', - }); - }; + const appId = 1234567; + const serviceName = 'test'; const mockLockDir = ({ - appId, - service, createLockfile = true, }: { - appId: number; - service: string; createLockfile?: boolean; }) => { + const lockDirFiles: any = {}; + if (createLockfile) { + lockDirFiles['updates.lock'] = mockFs.file({ + uid: lockfile.LOCKFILE_UID, + }); + lockDirFiles['resin-updates.lock'] = mockFs.file({ + uid: lockfile.LOCKFILE_UID, + }); + } mockFs({ [path.join( constants.rootMountPoint, updateLock.lockPath(appId), - service, - )]: { - [createLockfile ? 'updates.lock' : 'ignore-this.lock']: '', - }, + serviceName, + )]: lockDirFiles, }); }; @@ -57,29 +49,33 @@ describe('lib/update-lock', () => { constants.rootMountPoint = process.env.ROOT_MOUNTPOINT; }); - describe('Lockfile path methods', () => { - const testAppId = 1234567; - const testService = 'test'; - + describe('lockPath', () => { it('should return path prefix of service lockfiles on host', () => { - expect(updateLock.lockPath(testAppId)).to.equal( - `/tmp/balena-supervisor/services/${testAppId}`, + expect(updateLock.lockPath(appId)).to.equal( + `/tmp/balena-supervisor/services/${appId}`, ); - expect(updateLock.lockPath(testAppId, testService)).to.equal( - `/tmp/balena-supervisor/services/${testAppId}/${testService}`, + expect(updateLock.lockPath(appId, serviceName)).to.equal( + `/tmp/balena-supervisor/services/${appId}/${serviceName}`, ); }); - - it('should return the complete paths of (non-)legacy lockfiles on host', () => { - const lockFilesOnHost = updateLock.__get__('lockFilesOnHost'); - expect(lockFilesOnHost(testAppId, testService)).to.deep.equal([ - `${constants.rootMountPoint}/tmp/balena-supervisor/services/${testAppId}/${testService}/updates.lock`, - `${constants.rootMountPoint}/tmp/balena-supervisor/services/${testAppId}/${testService}/resin-updates.lock`, - ]); - }); }); describe('abortIfHUPInProgress', () => { + const breadcrumbFiles = [ + 'rollback-health-breadcrumb', + 'rollback-altboot-breadcrumb', + ]; + + const mockBreadcrumbs = (breadcrumb?: string) => { + mockFs({ + [path.join( + constants.rootMountPoint, + constants.stateMountPoint, + breadcrumb ? breadcrumb : '', + )]: '', + }); + }; + afterEach(() => mockFs.restore()); it('should throw if any breadcrumbs exist on host', async () => { @@ -109,152 +105,169 @@ describe('lib/update-lock', () => { }); describe('Lock/dispose functionality', () => { - const lockFile = updateLock.__get__('lockFile'); - const locksTaken = updateLock.__get__('locksTaken'); - const dispose = updateLock.__get__('dispose'); - const lockExistsErrHandler = updateLock.__get__('lockExistsErrHandler'); + const getLockParentDir = (): string => + `${constants.rootMountPoint}${updateLock.lockPath(appId, serviceName)}`; - const releaseFn = stub(); - const testLockPaths = ['/tmp/test/1', '/tmp/test/2']; + const expectLocks = async (exists: boolean = true) => { + expect(await fs.readdir(getLockParentDir())).to.deep.equal( + exists ? ['resin-updates.lock', 'updates.lock'] : [], + ); + }; - let unlockSyncStub: SinonStub; - let unlockAsyncSpy: SinonSpy; - let lockAsyncSpy: SinonSpy; + let unlockSpy: SinonSpy; + let lockSpy: SinonSpy; + let execStub: SinonStub; + + let configGetStub: SinonStub; beforeEach(() => { + unlockSpy = spy(lockfile, 'unlock'); + lockSpy = spy(lockfile, 'lock'); + // lockfile.lock calls exec to interface with the lockfile binary, + // so mock it here as we don't have access to the binary in the test env // @ts-ignore - unlockSyncStub = stub(lockFile, 'unlockSync').callsFake((lockPath) => { - // Throw error on process.exit for one of the two lockpaths - if (lockPath === testLockPaths[1]) { - throw new Error( - 'handled unlockSync error which should not crash test process', - ); - } + execStub = stub(fsUtils, 'exec').callsFake(async (command, opts) => { + // Sanity check for the command call + expect(command.trim().startsWith('lockfile')).to.be.true; + + // Remove any `lockfile` command options to leave just the command and the target filepath + const [, targetPath] = command + .replace(/-v|-nnn|-r\s+\d+|-l\s+\d+|-s\s+\d+|-!|-ml|-mu/g, '') + .split(/\s+/); + + // Emulate the lockfile binary exec call + await fsUtils.touch(targetPath); + await fs.chown(targetPath, opts!.uid!, 0); }); - unlockAsyncSpy = spy(lockFile, 'unlockAsync'); - lockAsyncSpy = spy(lockFile, 'lockAsync'); + + // config.get is called in updateLock.lock to get `lockOverride` value, + // so mock it here to definitively avoid any side effects + configGetStub = stub(config, 'get').resolves(false); }); - afterEach(() => { - for (const key of Object.keys(locksTaken)) { - delete locksTaken[key]; - } - unlockSyncStub.restore(); - unlockAsyncSpy.restore(); - lockAsyncSpy.restore(); - }); + afterEach(async () => { + unlockSpy.restore(); + lockSpy.restore(); + execStub.restore(); - it('should try to clean up existing locks on process exit', () => { - testLockPaths.forEach((p) => (locksTaken[p] = true)); + configGetStub.restore(); - // @ts-ignore - process.emit('exit'); - testLockPaths.forEach((p) => { - expect(unlockSyncStub).to.have.been.calledWith(p); - }); - }); - - it('should dispose of locks', async () => { - for (const lock of testLockPaths) { - locksTaken[lock] = true; + // Even though mock-fs is restored, this is needed to delete any in-memory storage of locks + for (const lock of lockfile.getLocksTaken()) { + await lockfile.unlock(lock); } - await dispose(releaseFn); - - expect(locksTaken).to.deep.equal({}); - expect(releaseFn).to.have.been.called; - testLockPaths.forEach((p) => { - expect(unlockAsyncSpy).to.have.been.calledWith(p); - }); + mockFs.restore(); }); - describe('lockExistsErrHandler', () => { - it('should handle EEXIST', async () => { - const appIdentifiers = [ - { id: '1234567', service: 'test1', type: 'appId' }, - { - id: 'c89a7cb83d974518479591ffaf7c2417', - service: 'test2', - type: 'appUuid', + it('should take the lock, run the function, then dispose of locks', async () => { + // Set up fake filesystem for lockfiles + mockLockDir({ createLockfile: false }); + + await expect( + updateLock.lock(appId, { force: false }, async () => { + // At this point the locks should be taken and not removed + // until this function has been resolved + await expectLocks(true); + return Promise.resolve(); + }), + ).to.eventually.be.fulfilled; + + // Both `updates.lock` and `resin-updates.lock` should have been taken + expect(lockSpy.args).to.have.length(2); + + // Everything that was locked should have been unlocked + expect(lockSpy.args).to.deep.equal(unlockSpy.args); + }); + + it('should throw UpdatesLockedError if lockfile exists', async () => { + // Set up fake filesystem for lockfiles + mockLockDir({ createLockfile: true }); + + const lockPath = `${getLockParentDir()}/updates.lock`; + + execStub.throws(new lockfile.LockfileExistsError(lockPath)); + + try { + await updateLock.lock(appId, { force: false }, async () => { + await expectLocks(false); + return Promise.resolve(); + }); + expect.fail('updateLock.lock should throw an UpdatesLockedError'); + } catch (err) { + expect(err).to.be.instanceOf(UpdatesLockedError); + } + + // Should only have attempted to take `updates.lock` + expect(lockSpy.args.flat()).to.deep.equal([lockPath]); + + // Since the lock-taking failed, there should be no locks to dispose of + expect(lockfile.getLocksTaken()).to.have.length(0); + + // Since nothing was locked, nothing should be unlocked + expect(unlockSpy.args).to.have.length(0); + }); + + it('should dispose of taken locks on any other errors', async () => { + // Set up fake filesystem for lockfiles + mockLockDir({ createLockfile: false }); + + try { + await updateLock.lock( + appId, + { force: false }, + // At this point 2 lockfiles have been written, so this is testing + // that even if the function rejects, lockfiles will be disposed of + async () => { + await expectLocks(); + return Promise.reject(new Error('Test error')); }, - { id: 'c89a7cb', service: 'test3', type: 'appUuid' }, - ]; - for (const { id, service, type } of appIdentifiers) { - // Handle legacy & nonlegacy lockfile names - for (const lockfile of ['updates.lock', 'resin-updates.lock']) { - const error = { - code: 'EEXIST', - message: `EEXIST: open "/tmp/balena-supervisor/services/${id}/${service}/${lockfile}"`, - }; - await expect(lockExistsErrHandler(error, releaseFn)) - .to.eventually.be.rejectedWith( - `Lockfile exists for ${JSON.stringify({ - serviceName: service, - [type]: id, - })}`, - ) - .and.be.an.instanceOf(UpdatesLockedError); - } - } - }); + ); + } catch { + /* noop */ + // This just catches the 'Test error' above + } - it('should handle any other errors', async () => { - await expect(lockExistsErrHandler(new Error('Test error'), releaseFn)) - .to.eventually.be.rejectedWith('Test error') - .and.be.an.instanceOf(UpdatesLockedError); - }); + // Both `updates.lock` and `resin-updates.lock` should have been taken + expect(lockSpy.args).to.have.length(2); + + // Everything that was locked should have been unlocked + expect(lockSpy.args).to.deep.equal(unlockSpy.args); }); - describe('lock', () => { - let bluebirdUsing: SinonSpy; - let bluebirdResolve: SinonSpy; - const lockParamFn = stub().resolves(); + it('resolves input function without locking when appId is null', async () => { + mockLockDir({ createLockfile: true }); - beforeEach(() => { - bluebirdUsing = spy(Bluebird, 'using'); - bluebirdResolve = spy(Bluebird, 'resolve'); - }); + await expect( + updateLock.lock(null as any, { force: false }, stub().resolves()), + ).to.be.fulfilled; - afterEach(() => { - bluebirdUsing.restore(); - bluebirdResolve.restore(); + // Since appId is null, updateLock.lock should just run the function, so + // there should be no interfacing with the lockfile module + expect(unlockSpy).to.not.have.been.called; + expect(lockSpy).to.not.have.been.called; + }); - mockFs.restore(); - }); + it('unlocks lockfile to resolve function if force option specified', async () => { + mockLockDir({ createLockfile: true }); - it('resolves input function without dispose pattern when appId is null', async () => { - mockLockDir({ appId: 1234567, service: 'test', createLockfile: true }); - await expect(updateLock.lock(null, { force: false }, lockParamFn)).to.be - .fulfilled; - expect(bluebirdResolve).to.have.been.called; - }); + await expect(updateLock.lock(1234567, { force: true }, stub().resolves())) + .to.be.fulfilled; - it('resolves input function without dispose pattern when no lockfiles exist', async () => { - mockLockDir({ appId: 1234567, service: 'test', createLockfile: false }); - await expect(updateLock.lock(1234567, { force: false }, lockParamFn)).to - .be.fulfilled; - expect(bluebirdResolve).to.have.been.called; - }); + expect(unlockSpy).to.have.been.called; + expect(lockSpy).to.have.been.called; + }); - it('uses dispose pattern if lockfile present and throws error', async () => { - mockLockDir({ appId: 1234567, service: 'test' }); - await expect(updateLock.lock(1234567, { force: false }, lockParamFn)) - .to.eventually.be.rejectedWith( - 'Lockfile exists for {"serviceName":"test","appId":"1234567"}', - ) - .and.be.an.instanceOf(UpdatesLockedError); - expect(lockAsyncSpy).to.have.been.called; - expect(bluebirdUsing).to.have.been.called; - }); + it('unlocks lockfile to resolve function if lockOverride option specified', async () => { + configGetStub.resolves(true); + mockLockDir({ createLockfile: true }); - it('unlocks lockfile to resolve function if force option specified', async () => { - mockLockDir({ appId: 1234567, service: 'test' }); - await expect(updateLock.lock(1234567, { force: true }, lockParamFn)).to - .be.fulfilled; - expect(unlockAsyncSpy).to.have.been.called; - expect(lockAsyncSpy).to.have.been.called; - expect(bluebirdUsing).to.have.been.called; - }); + await expect( + updateLock.lock(1234567, { force: false }, stub().resolves()), + ).to.be.fulfilled; + + expect(unlockSpy).to.have.been.called; + expect(lockSpy).to.have.been.called; }); }); });