|
|
@@ -0,0 +1,423 @@
|
|
|
+/** Kernel behavior through the built flock entry; each case owns its files and processes. */
|
|
|
+import assert from 'node:assert/strict';
|
|
|
+import { fork, spawn } from 'node:child_process';
|
|
|
+import { once } from 'node:events';
|
|
|
+import { closeSync, mkdtempSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
|
+import { constants, tmpdir } from 'node:os';
|
|
|
+import { join } from 'node:path';
|
|
|
+import { createInterface } from 'node:readline';
|
|
|
+import test from 'node:test';
|
|
|
+import { fileURLToPath } from 'node:url';
|
|
|
+import { Worker } from 'node:worker_threads';
|
|
|
+import { tryLockExclusive } from '../packages/entry/lib/flock.js';
|
|
|
+import { loadFlockBinding } from './fixtures/flock-binding.js';
|
|
|
+
|
|
|
+const posix = process.platform === 'linux' || process.platform === 'darwin';
|
|
|
+const timeout = 120_000;
|
|
|
+const nativeOnly = { timeout, skip: posix ? false : 'The flock addon requires Linux or macOS' };
|
|
|
+
|
|
|
+function resources(t) {
|
|
|
+ const disposers = [];
|
|
|
+ // Match the repository's process-e2e budget for both cases and cleanup.
|
|
|
+ t.after(async () => {
|
|
|
+ // Cleanup must await close even when the case's signal is already aborted.
|
|
|
+ const signal = AbortSignal.timeout(timeout);
|
|
|
+ const errors = [];
|
|
|
+ for (const dispose of disposers.reverse()) {
|
|
|
+ try {
|
|
|
+ await dispose(signal);
|
|
|
+ } catch (error) {
|
|
|
+ errors.push(error);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (errors.length) throw new AggregateError(errors, 'flock test cleanup failed');
|
|
|
+ }, { timeout });
|
|
|
+ // On local Linux filesystems, flock and OFD byte-range locks are independent;
|
|
|
+ // network filesystems can translate between them and hide a wrong syscall.
|
|
|
+ const root = mkdtempSync(join(tmpdir(), 'node-addon-system-flock-'));
|
|
|
+ disposers.push(() => rmSync(root, { recursive: true, force: true }));
|
|
|
+ return {
|
|
|
+ file: join(root, 'lock'),
|
|
|
+ defer: (dispose) => disposers.push(dispose),
|
|
|
+ open(name = 'lock') {
|
|
|
+ const fd = openSync(join(root, name), 'a+', 0o600);
|
|
|
+ let closed = false;
|
|
|
+ const close = () => {
|
|
|
+ if (!closed) {
|
|
|
+ closeSync(fd);
|
|
|
+ closed = true;
|
|
|
+ }
|
|
|
+ };
|
|
|
+ disposers.push(close);
|
|
|
+ return { fd, close };
|
|
|
+ },
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+function flockError(error, codes) {
|
|
|
+ assert.ok(codes.includes(error.code), `Unexpected flock error: ${error.code}`);
|
|
|
+ assert.equal(error.errno, constants.errno[error.code]);
|
|
|
+ assert.ok(error.errno > 0);
|
|
|
+ assert.equal(error.syscall, 'flock');
|
|
|
+ return true;
|
|
|
+}
|
|
|
+
|
|
|
+const busy = (error) => flockError(error, ['EAGAIN', 'EWOULDBLOCK']);
|
|
|
+
|
|
|
+async function until(promise, signal) {
|
|
|
+ let abort;
|
|
|
+ const cancelled = new Promise((_, reject) => {
|
|
|
+ abort = () => reject(signal.reason);
|
|
|
+ if (signal.aborted) abort();
|
|
|
+ else signal.addEventListener('abort', abort, { once: true });
|
|
|
+ });
|
|
|
+ try {
|
|
|
+ return await Promise.race([promise, cancelled]);
|
|
|
+ } finally {
|
|
|
+ signal.removeEventListener('abort', abort);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function childEnvironment() {
|
|
|
+ return Object.fromEntries(Object.entries(process.env)
|
|
|
+ .filter(([name]) => !/KEY|SECRET|TOKEN|PASSWORD/i.test(name)));
|
|
|
+}
|
|
|
+
|
|
|
+function observeChild(t, scope, child) {
|
|
|
+ let closed = false;
|
|
|
+ let stderr = '';
|
|
|
+ let processError;
|
|
|
+ const done = new Promise((resolve) => child.once('close', (code, signal) => {
|
|
|
+ closed = true;
|
|
|
+ resolve({ code, signal, stderr, error: processError });
|
|
|
+ }));
|
|
|
+ scope.defer(async (signal) => {
|
|
|
+ if (!closed) child.kill('SIGKILL');
|
|
|
+ await until(done, signal);
|
|
|
+ });
|
|
|
+ child.stderr.setEncoding('utf8');
|
|
|
+ child.stderr.on('data', (chunk) => { stderr += chunk; });
|
|
|
+ child.on('error', (error) => { processError = error; });
|
|
|
+
|
|
|
+ async function response(emitter, event, send = () => Promise.resolve()) {
|
|
|
+ const waiting = new AbortController();
|
|
|
+ const signal = AbortSignal.any([t.signal, waiting.signal]);
|
|
|
+ try {
|
|
|
+ const received = Promise.race([
|
|
|
+ once(emitter, event, { signal }).then(([message]) => message),
|
|
|
+ done.then((result) => {
|
|
|
+ throw new Error(`flock fixture exited before replying: ${JSON.stringify(result)}`);
|
|
|
+ }),
|
|
|
+ ]);
|
|
|
+ const [message] = await until(Promise.all([received, send()]), signal);
|
|
|
+ return message;
|
|
|
+ } finally {
|
|
|
+ waiting.abort();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return { child, response, waitForExit: () => until(done, t.signal) };
|
|
|
+}
|
|
|
+
|
|
|
+async function childFixture(t, scope, fixture, args = [], { execArgv = [], inheritedFd } = {}) {
|
|
|
+ const stdio = ['ignore', 'ignore', 'pipe', 'ipc'];
|
|
|
+ if (inheritedFd !== undefined) stdio.push(inheritedFd);
|
|
|
+ const child = fork(new URL(`./fixtures/${fixture}`, import.meta.url), args, {
|
|
|
+ execArgv,
|
|
|
+ env: childEnvironment(),
|
|
|
+ stdio,
|
|
|
+ });
|
|
|
+ const observed = observeChild(t, scope, child);
|
|
|
+ const exchange = (command) => observed.response(child, 'message', () => (
|
|
|
+ command === undefined ? Promise.resolve() : new Promise((resolve, reject) => {
|
|
|
+ child.send(command, (error) => error ? reject(error) : resolve());
|
|
|
+ })
|
|
|
+ ));
|
|
|
+ assert.deepEqual(await exchange(), { type: 'ready' });
|
|
|
+ return { child, waitForExit: observed.waitForExit, exchange };
|
|
|
+}
|
|
|
+
|
|
|
+async function oracleFixture(t, scope, mode) {
|
|
|
+ const binary = process.platform === 'linux'
|
|
|
+ ? `./bin/${process.report.getReport().header.glibcVersionRuntime ? 'glibc' : 'musl'}/flock-oracle`
|
|
|
+ : './bin/flock-oracle';
|
|
|
+ const child = spawn(fileURLToPath(new URL(binary, import.meta.url)), [scope.file, mode], {
|
|
|
+ env: childEnvironment(),
|
|
|
+ stdio: ['pipe', 'pipe', 'pipe'],
|
|
|
+ });
|
|
|
+ const observed = observeChild(t, scope, child);
|
|
|
+ const lines = createInterface({ input: child.stdout });
|
|
|
+ child.once('close', () => lines.close());
|
|
|
+ let inputError;
|
|
|
+ child.stdin.on('error', (error) => { inputError = error; });
|
|
|
+ const send = (command) => until(new Promise((resolve, reject) => {
|
|
|
+ if (inputError) reject(inputError);
|
|
|
+ else child.stdin.write(`${command}\n`, (error) => error ? reject(error) : resolve());
|
|
|
+ }), t.signal);
|
|
|
+ const exchange = async (command) => JSON.parse(await observed.response(lines, 'line', () => (
|
|
|
+ command === undefined ? Promise.resolve() : send(command)
|
|
|
+ )));
|
|
|
+ assert.deepEqual(await exchange(), { ready: true });
|
|
|
+ return {
|
|
|
+ exchange,
|
|
|
+ async quit() {
|
|
|
+ await send('q');
|
|
|
+ cleanExit(await observed.waitForExit());
|
|
|
+ },
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+function cleanExit(result) {
|
|
|
+ assert.equal(result.signal, null, result.stderr);
|
|
|
+ assert.equal(result.error, undefined);
|
|
|
+ assert.equal(result.code, 0, result.stderr);
|
|
|
+}
|
|
|
+
|
|
|
+test('import succeeds with native addons disabled', { timeout }, async (t) => {
|
|
|
+ const scope = resources(t);
|
|
|
+ const child = await childFixture(t, scope, 'flock-import.js', [], { execArgv: ['--no-addons'] });
|
|
|
+ cleanExit(await child.waitForExit());
|
|
|
+});
|
|
|
+
|
|
|
+for (const platform of ['win32', 'freebsd']) {
|
|
|
+ test(`calling flock on ${platform} rejects without loading an addon`, { timeout }, async (t) => {
|
|
|
+ const scope = resources(t);
|
|
|
+ const child = await childFixture(t, scope, 'flock-import.js', [platform], { execArgv: ['--no-addons'] });
|
|
|
+ cleanExit(await child.waitForExit());
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+test('acquisition resolves asynchronously to void and the same fd can reacquire', nativeOnly, async (t) => {
|
|
|
+ const scope = resources(t);
|
|
|
+ const owner = scope.open();
|
|
|
+ const result = tryLockExclusive(owner.fd);
|
|
|
+ assert.ok(result instanceof Promise);
|
|
|
+ assert.equal(await result, undefined);
|
|
|
+ assert.equal(await tryLockExclusive(owner.fd), undefined);
|
|
|
+});
|
|
|
+
|
|
|
+test('separate opens of one file contend', nativeOnly, async (t) => {
|
|
|
+ const scope = resources(t);
|
|
|
+ const owner = scope.open();
|
|
|
+ const contender = scope.open();
|
|
|
+ await tryLockExclusive(owner.fd);
|
|
|
+ await assert.rejects(tryLockExclusive(contender.fd), busy);
|
|
|
+});
|
|
|
+
|
|
|
+test('different files can be locked concurrently', nativeOnly, async (t) => {
|
|
|
+ const scope = resources(t);
|
|
|
+ const first = scope.open('first');
|
|
|
+ const second = scope.open('second');
|
|
|
+ await Promise.all([tryLockExclusive(first.fd), tryLockExclusive(second.fd)]);
|
|
|
+});
|
|
|
+
|
|
|
+test('closing the locked fd allows an already-open contender to acquire', nativeOnly, async (t) => {
|
|
|
+ const scope = resources(t);
|
|
|
+ const owner = scope.open();
|
|
|
+ const contender = scope.open();
|
|
|
+ await tryLockExclusive(owner.fd);
|
|
|
+ await assert.rejects(tryLockExclusive(contender.fd), busy);
|
|
|
+ owner.close();
|
|
|
+ await tryLockExclusive(contender.fd);
|
|
|
+});
|
|
|
+
|
|
|
+test('closing another fd for the same file does not release the lock', nativeOnly, async (t) => {
|
|
|
+ const scope = resources(t);
|
|
|
+ const owner = scope.open();
|
|
|
+ const other = scope.open();
|
|
|
+ const contender = scope.open();
|
|
|
+ await tryLockExclusive(owner.fd);
|
|
|
+ other.close();
|
|
|
+ await assert.rejects(tryLockExclusive(contender.fd), busy);
|
|
|
+ owner.close();
|
|
|
+ await tryLockExclusive(contender.fd);
|
|
|
+});
|
|
|
+
|
|
|
+test('invalid fd rejects asynchronously with EBADF and positive errno', nativeOnly, async () => {
|
|
|
+ let result;
|
|
|
+ assert.doesNotThrow(() => { result = tryLockExclusive(-1); });
|
|
|
+ assert.ok(result instanceof Promise);
|
|
|
+ await assert.rejects(result, (error) => flockError(error, ['EBADF']));
|
|
|
+});
|
|
|
+
|
|
|
+test('native argument errors reject the JavaScript promise without throwing from the entry', nativeOnly, async () => {
|
|
|
+ let result;
|
|
|
+ assert.doesNotThrow(() => { result = tryLockExclusive(2 ** 31); });
|
|
|
+ assert.ok(result instanceof Promise);
|
|
|
+ await assert.rejects(result, { name: 'RangeError', message: 'fd must be a signed C int' });
|
|
|
+});
|
|
|
+
|
|
|
+test('native callbacks receive asynchronous, request-local success and errno results', nativeOnly, async (t) => {
|
|
|
+ const scope = resources(t);
|
|
|
+ const owner = scope.open();
|
|
|
+ const contender = scope.open();
|
|
|
+ await tryLockExclusive(owner.fd);
|
|
|
+ const binding = loadFlockBinding();
|
|
|
+ const results = await Promise.all([owner.fd, contender.fd, -1].map((fd) => new Promise((resolve) => {
|
|
|
+ let returned = false;
|
|
|
+ const result = binding.tryLock(fd, (errno) => {
|
|
|
+ assert.equal(returned, true);
|
|
|
+ resolve(errno);
|
|
|
+ });
|
|
|
+ assert.equal(result, undefined);
|
|
|
+ returned = true;
|
|
|
+ })));
|
|
|
+ assert.equal(results[0], 0);
|
|
|
+ assert.ok([constants.errno.EAGAIN, constants.errno.EWOULDBLOCK].includes(results[1]));
|
|
|
+ assert.equal(results[2], constants.errno.EBADF);
|
|
|
+});
|
|
|
+
|
|
|
+test('an exception in the native completion callback is reported as uncaught', nativeOnly, async (t) => {
|
|
|
+ const scope = resources(t);
|
|
|
+ const child = await childFixture(t, scope, 'flock-callback-throws.js');
|
|
|
+ const exit = await child.waitForExit();
|
|
|
+ assert.equal(exit.signal, null, exit.stderr);
|
|
|
+ assert.equal(exit.error, undefined);
|
|
|
+ assert.equal(exit.code, 1, exit.stderr);
|
|
|
+ assert.match(exit.stderr, /Error: flock callback failure/);
|
|
|
+});
|
|
|
+
|
|
|
+test('concurrent calls retain their own syscall errno', nativeOnly, async (t) => {
|
|
|
+ const scope = resources(t);
|
|
|
+ const owner = scope.open();
|
|
|
+ const contender = scope.open();
|
|
|
+ await tryLockExclusive(owner.fd);
|
|
|
+ await Promise.all([
|
|
|
+ assert.rejects(tryLockExclusive(contender.fd), busy),
|
|
|
+ assert.rejects(tryLockExclusive(-1), (error) => flockError(error, ['EBADF'])),
|
|
|
+ assert.rejects(tryLockExclusive(contender.fd), busy),
|
|
|
+ assert.rejects(tryLockExclusive(-1), (error) => flockError(error, ['EBADF'])),
|
|
|
+ ]);
|
|
|
+});
|
|
|
+
|
|
|
+test('two child processes exclude each other and normal close transfers ownership', nativeOnly, async (t) => {
|
|
|
+ const scope = resources(t);
|
|
|
+ const observer = scope.open();
|
|
|
+ const children = await Promise.all([
|
|
|
+ childFixture(t, scope, 'flock-child.js', [scope.file]),
|
|
|
+ childFixture(t, scope, 'flock-child.js', [scope.file]),
|
|
|
+ ]);
|
|
|
+ const results = await Promise.all(children.map((child) => child.exchange('tryLock')));
|
|
|
+ assert.equal(results.filter((result) => result.type === 'locked').length, 1);
|
|
|
+ assert.equal(results.filter((result) => result.type === 'error').length, 1);
|
|
|
+ const winnerIndex = results.findIndex((result) => result.type === 'locked');
|
|
|
+ const winner = children[winnerIndex];
|
|
|
+ const loser = children[1 - winnerIndex];
|
|
|
+ busy(results[1 - winnerIndex]);
|
|
|
+ await assert.rejects(tryLockExclusive(observer.fd), busy);
|
|
|
+
|
|
|
+ assert.deepEqual(await winner.exchange('close'), { type: 'closed' });
|
|
|
+ cleanExit(await winner.waitForExit());
|
|
|
+ assert.deepEqual(await loser.exchange('tryLock'), { type: 'locked' });
|
|
|
+ await assert.rejects(tryLockExclusive(observer.fd), busy);
|
|
|
+ assert.deepEqual(await loser.exchange('close'), { type: 'closed' });
|
|
|
+ cleanExit(await loser.waitForExit());
|
|
|
+ await tryLockExclusive(observer.fd);
|
|
|
+});
|
|
|
+
|
|
|
+test('SIGKILL releases a child lock after exit', nativeOnly, async (t) => {
|
|
|
+ const scope = resources(t);
|
|
|
+ const observer = scope.open();
|
|
|
+ const owner = await childFixture(t, scope, 'flock-child.js', [scope.file]);
|
|
|
+ const contender = await childFixture(t, scope, 'flock-child.js', [scope.file]);
|
|
|
+ assert.deepEqual(await owner.exchange('tryLock'), { type: 'locked' });
|
|
|
+ const rejected = await contender.exchange('tryLock');
|
|
|
+ assert.equal(rejected.type, 'error');
|
|
|
+ busy(rejected);
|
|
|
+ assert.equal(owner.child.kill('SIGKILL'), true);
|
|
|
+ const exit = await owner.waitForExit();
|
|
|
+ assert.equal(exit.error, undefined);
|
|
|
+ assert.equal(exit.signal, 'SIGKILL');
|
|
|
+ assert.equal(exit.code, null);
|
|
|
+ assert.deepEqual(await contender.exchange('tryLock'), { type: 'locked' });
|
|
|
+ await assert.rejects(tryLockExclusive(observer.fd), busy);
|
|
|
+});
|
|
|
+
|
|
|
+for (const phase of ['queued', 'callback']) {
|
|
|
+ test(`worker termination during ${phase} drains native work without taking ownership of the fd`, nativeOnly, async (t) => {
|
|
|
+ const scope = resources(t);
|
|
|
+ const owner = scope.open();
|
|
|
+ const contender = scope.open();
|
|
|
+ const worker = new Worker(new URL('./fixtures/flock-worker.js', import.meta.url), {
|
|
|
+ workerData: { fd: owner.fd, phase },
|
|
|
+ execArgv: [],
|
|
|
+ });
|
|
|
+ scope.defer((signal) => until(worker.terminate(), signal));
|
|
|
+ const exited = once(worker, 'exit');
|
|
|
+ const waiting = new AbortController();
|
|
|
+ try {
|
|
|
+ const [message] = await Promise.race([
|
|
|
+ once(worker, 'message', { signal: AbortSignal.any([t.signal, waiting.signal]) }),
|
|
|
+ exited.then(([code]) => { throw new Error(`flock worker exited before ${phase}: ${code}`); }),
|
|
|
+ ]);
|
|
|
+ assert.deepEqual(message, { type: phase, nativeWork: 1 });
|
|
|
+ } finally {
|
|
|
+ waiting.abort();
|
|
|
+ }
|
|
|
+ assert.equal(await until(worker.terminate(), t.signal), 1);
|
|
|
+ await until(exited, t.signal);
|
|
|
+ await tryLockExclusive(owner.fd);
|
|
|
+ await assert.rejects(tryLockExclusive(contender.fd), busy);
|
|
|
+ owner.close();
|
|
|
+ await tryLockExclusive(contender.fd);
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+for (const mode of ['exclusive', 'shared']) {
|
|
|
+ test(`an addon exclusive lock blocks an independent C ${mode} flock until its fd closes`, nativeOnly, async (t) => {
|
|
|
+ const scope = resources(t);
|
|
|
+ const owner = scope.open();
|
|
|
+ await tryLockExclusive(owner.fd);
|
|
|
+ const oracle = await oracleFixture(t, scope, mode);
|
|
|
+ const result = await oracle.exchange('t');
|
|
|
+ assert.ok([constants.errno.EAGAIN, constants.errno.EWOULDBLOCK].includes(result.errno));
|
|
|
+ owner.close();
|
|
|
+ assert.deepEqual(await oracle.exchange('t'), { errno: 0 });
|
|
|
+ await oracle.quit();
|
|
|
+ });
|
|
|
+
|
|
|
+ test(`an independent C ${mode} flock blocks the addon until explicit unlock`, nativeOnly, async (t) => {
|
|
|
+ const scope = resources(t);
|
|
|
+ const contender = scope.open();
|
|
|
+ const oracle = await oracleFixture(t, scope, mode);
|
|
|
+ assert.deepEqual(await oracle.exchange('t'), { errno: 0 });
|
|
|
+ await assert.rejects(tryLockExclusive(contender.fd), busy);
|
|
|
+ assert.deepEqual(await oracle.exchange('u'), { errno: 0 });
|
|
|
+ await tryLockExclusive(contender.fd);
|
|
|
+ await oracle.quit();
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+test('an advisory exclusive lock permits another process to read and write without locking', nativeOnly, async (t) => {
|
|
|
+ const scope = resources(t);
|
|
|
+ const owner = scope.open();
|
|
|
+ const contender = scope.open();
|
|
|
+ writeFileSync(scope.file, 'written before locking');
|
|
|
+ await tryLockExclusive(owner.fd);
|
|
|
+ await assert.rejects(tryLockExclusive(contender.fd), busy);
|
|
|
+ const child = await childFixture(t, scope, 'flock-io-child.js', [scope.file]);
|
|
|
+ assert.deepEqual(await child.exchange('read-write'), {
|
|
|
+ type: 'written', previous: 'written before locking',
|
|
|
+ });
|
|
|
+ cleanExit(await child.waitForExit());
|
|
|
+ assert.equal(readFileSync(scope.file, 'utf8'), 'written without acquiring a lock');
|
|
|
+ await assert.rejects(tryLockExclusive(contender.fd), busy);
|
|
|
+});
|
|
|
+
|
|
|
+test('an inherited fd shares the lock after parent close until the child closes its last reference', nativeOnly, async (t) => {
|
|
|
+ const scope = resources(t);
|
|
|
+ const owner = scope.open();
|
|
|
+ const contender = scope.open();
|
|
|
+ await tryLockExclusive(owner.fd);
|
|
|
+ const child = await childFixture(t, scope, 'flock-inherited-child.js', [], { inheritedFd: owner.fd });
|
|
|
+ assert.deepEqual(await child.exchange('tryLock'), { type: 'locked' });
|
|
|
+ owner.close();
|
|
|
+ await assert.rejects(tryLockExclusive(contender.fd), busy);
|
|
|
+ assert.deepEqual(await child.exchange('close'), { type: 'closed' });
|
|
|
+ await tryLockExclusive(contender.fd);
|
|
|
+ // The child stays alive, so its close acknowledgement—not process exit—releases the lock.
|
|
|
+ assert.equal(child.child.exitCode, null);
|
|
|
+ assert.equal(child.child.signalCode, null);
|
|
|
+ assert.deepEqual(await child.exchange('quit'), { type: 'bye' });
|
|
|
+ cleanExit(await child.waitForExit());
|
|
|
+});
|