flock.test.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423
  1. /** Kernel behavior through the built flock entry; each case owns its files and processes. */
  2. import assert from 'node:assert/strict';
  3. import { fork, spawn } from 'node:child_process';
  4. import { once } from 'node:events';
  5. import { closeSync, mkdtempSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
  6. import { constants, tmpdir } from 'node:os';
  7. import { join } from 'node:path';
  8. import { createInterface } from 'node:readline';
  9. import test from 'node:test';
  10. import { fileURLToPath } from 'node:url';
  11. import { Worker } from 'node:worker_threads';
  12. import { tryLockExclusive } from '../packages/entry/lib/flock.js';
  13. import { loadFlockBinding } from './fixtures/flock-binding.js';
  14. const posix = process.platform === 'linux' || process.platform === 'darwin';
  15. const timeout = 120_000;
  16. const nativeOnly = { timeout, skip: posix ? false : 'The flock addon requires Linux or macOS' };
  17. function resources(t) {
  18. const disposers = [];
  19. // Match the repository's process-e2e budget for both cases and cleanup.
  20. t.after(async () => {
  21. // Cleanup must await close even when the case's signal is already aborted.
  22. const signal = AbortSignal.timeout(timeout);
  23. const errors = [];
  24. for (const dispose of disposers.reverse()) {
  25. try {
  26. await dispose(signal);
  27. } catch (error) {
  28. errors.push(error);
  29. }
  30. }
  31. if (errors.length) throw new AggregateError(errors, 'flock test cleanup failed');
  32. }, { timeout });
  33. // On local Linux filesystems, flock and OFD byte-range locks are independent;
  34. // network filesystems can translate between them and hide a wrong syscall.
  35. const root = mkdtempSync(join(tmpdir(), 'node-addon-system-flock-'));
  36. disposers.push(() => rmSync(root, { recursive: true, force: true }));
  37. return {
  38. file: join(root, 'lock'),
  39. defer: (dispose) => disposers.push(dispose),
  40. open(name = 'lock') {
  41. const fd = openSync(join(root, name), 'a+', 0o600);
  42. let closed = false;
  43. const close = () => {
  44. if (!closed) {
  45. closeSync(fd);
  46. closed = true;
  47. }
  48. };
  49. disposers.push(close);
  50. return { fd, close };
  51. },
  52. };
  53. }
  54. function flockError(error, codes) {
  55. assert.ok(codes.includes(error.code), `Unexpected flock error: ${error.code}`);
  56. assert.equal(error.errno, constants.errno[error.code]);
  57. assert.ok(error.errno > 0);
  58. assert.equal(error.syscall, 'flock');
  59. return true;
  60. }
  61. const busy = (error) => flockError(error, ['EAGAIN', 'EWOULDBLOCK']);
  62. async function until(promise, signal) {
  63. let abort;
  64. const cancelled = new Promise((_, reject) => {
  65. abort = () => reject(signal.reason);
  66. if (signal.aborted) abort();
  67. else signal.addEventListener('abort', abort, { once: true });
  68. });
  69. try {
  70. return await Promise.race([promise, cancelled]);
  71. } finally {
  72. signal.removeEventListener('abort', abort);
  73. }
  74. }
  75. function childEnvironment() {
  76. return Object.fromEntries(Object.entries(process.env)
  77. .filter(([name]) => !/KEY|SECRET|TOKEN|PASSWORD/i.test(name)));
  78. }
  79. function observeChild(t, scope, child) {
  80. let closed = false;
  81. let stderr = '';
  82. let processError;
  83. const done = new Promise((resolve) => child.once('close', (code, signal) => {
  84. closed = true;
  85. resolve({ code, signal, stderr, error: processError });
  86. }));
  87. scope.defer(async (signal) => {
  88. if (!closed) child.kill('SIGKILL');
  89. await until(done, signal);
  90. });
  91. child.stderr.setEncoding('utf8');
  92. child.stderr.on('data', (chunk) => { stderr += chunk; });
  93. child.on('error', (error) => { processError = error; });
  94. async function response(emitter, event, send = () => Promise.resolve()) {
  95. const waiting = new AbortController();
  96. const signal = AbortSignal.any([t.signal, waiting.signal]);
  97. try {
  98. const received = Promise.race([
  99. once(emitter, event, { signal }).then(([message]) => message),
  100. done.then((result) => {
  101. throw new Error(`flock fixture exited before replying: ${JSON.stringify(result)}`);
  102. }),
  103. ]);
  104. const [message] = await until(Promise.all([received, send()]), signal);
  105. return message;
  106. } finally {
  107. waiting.abort();
  108. }
  109. }
  110. return { child, response, waitForExit: () => until(done, t.signal) };
  111. }
  112. async function childFixture(t, scope, fixture, args = [], { execArgv = [], inheritedFd } = {}) {
  113. const stdio = ['ignore', 'ignore', 'pipe', 'ipc'];
  114. if (inheritedFd !== undefined) stdio.push(inheritedFd);
  115. const child = fork(new URL(`./fixtures/${fixture}`, import.meta.url), args, {
  116. execArgv,
  117. env: childEnvironment(),
  118. stdio,
  119. });
  120. const observed = observeChild(t, scope, child);
  121. const exchange = (command) => observed.response(child, 'message', () => (
  122. command === undefined ? Promise.resolve() : new Promise((resolve, reject) => {
  123. child.send(command, (error) => error ? reject(error) : resolve());
  124. })
  125. ));
  126. assert.deepEqual(await exchange(), { type: 'ready' });
  127. return { child, waitForExit: observed.waitForExit, exchange };
  128. }
  129. async function oracleFixture(t, scope, mode) {
  130. const binary = process.platform === 'linux'
  131. ? `./bin/${process.report.getReport().header.glibcVersionRuntime ? 'glibc' : 'musl'}/flock-oracle`
  132. : './bin/flock-oracle';
  133. const child = spawn(fileURLToPath(new URL(binary, import.meta.url)), [scope.file, mode], {
  134. env: childEnvironment(),
  135. stdio: ['pipe', 'pipe', 'pipe'],
  136. });
  137. const observed = observeChild(t, scope, child);
  138. const lines = createInterface({ input: child.stdout });
  139. child.once('close', () => lines.close());
  140. let inputError;
  141. child.stdin.on('error', (error) => { inputError = error; });
  142. const send = (command) => until(new Promise((resolve, reject) => {
  143. if (inputError) reject(inputError);
  144. else child.stdin.write(`${command}\n`, (error) => error ? reject(error) : resolve());
  145. }), t.signal);
  146. const exchange = async (command) => JSON.parse(await observed.response(lines, 'line', () => (
  147. command === undefined ? Promise.resolve() : send(command)
  148. )));
  149. assert.deepEqual(await exchange(), { ready: true });
  150. return {
  151. exchange,
  152. async quit() {
  153. await send('q');
  154. cleanExit(await observed.waitForExit());
  155. },
  156. };
  157. }
  158. function cleanExit(result) {
  159. assert.equal(result.signal, null, result.stderr);
  160. assert.equal(result.error, undefined);
  161. assert.equal(result.code, 0, result.stderr);
  162. }
  163. test('import succeeds with native addons disabled', { timeout }, async (t) => {
  164. const scope = resources(t);
  165. const child = await childFixture(t, scope, 'flock-import.js', [], { execArgv: ['--no-addons'] });
  166. cleanExit(await child.waitForExit());
  167. });
  168. for (const platform of ['win32', 'freebsd']) {
  169. test(`calling flock on ${platform} rejects without loading an addon`, { timeout }, async (t) => {
  170. const scope = resources(t);
  171. const child = await childFixture(t, scope, 'flock-import.js', [platform], { execArgv: ['--no-addons'] });
  172. cleanExit(await child.waitForExit());
  173. });
  174. }
  175. test('acquisition resolves asynchronously to void and the same fd can reacquire', nativeOnly, async (t) => {
  176. const scope = resources(t);
  177. const owner = scope.open();
  178. const result = tryLockExclusive(owner.fd);
  179. assert.ok(result instanceof Promise);
  180. assert.equal(await result, undefined);
  181. assert.equal(await tryLockExclusive(owner.fd), undefined);
  182. });
  183. test('separate opens of one file contend', nativeOnly, async (t) => {
  184. const scope = resources(t);
  185. const owner = scope.open();
  186. const contender = scope.open();
  187. await tryLockExclusive(owner.fd);
  188. await assert.rejects(tryLockExclusive(contender.fd), busy);
  189. });
  190. test('different files can be locked concurrently', nativeOnly, async (t) => {
  191. const scope = resources(t);
  192. const first = scope.open('first');
  193. const second = scope.open('second');
  194. await Promise.all([tryLockExclusive(first.fd), tryLockExclusive(second.fd)]);
  195. });
  196. test('closing the locked fd allows an already-open contender to acquire', nativeOnly, async (t) => {
  197. const scope = resources(t);
  198. const owner = scope.open();
  199. const contender = scope.open();
  200. await tryLockExclusive(owner.fd);
  201. await assert.rejects(tryLockExclusive(contender.fd), busy);
  202. owner.close();
  203. await tryLockExclusive(contender.fd);
  204. });
  205. test('closing another fd for the same file does not release the lock', nativeOnly, async (t) => {
  206. const scope = resources(t);
  207. const owner = scope.open();
  208. const other = scope.open();
  209. const contender = scope.open();
  210. await tryLockExclusive(owner.fd);
  211. other.close();
  212. await assert.rejects(tryLockExclusive(contender.fd), busy);
  213. owner.close();
  214. await tryLockExclusive(contender.fd);
  215. });
  216. test('invalid fd rejects asynchronously with EBADF and positive errno', nativeOnly, async () => {
  217. let result;
  218. assert.doesNotThrow(() => { result = tryLockExclusive(-1); });
  219. assert.ok(result instanceof Promise);
  220. await assert.rejects(result, (error) => flockError(error, ['EBADF']));
  221. });
  222. test('native argument errors reject the JavaScript promise without throwing from the entry', nativeOnly, async () => {
  223. let result;
  224. assert.doesNotThrow(() => { result = tryLockExclusive(2 ** 31); });
  225. assert.ok(result instanceof Promise);
  226. await assert.rejects(result, { name: 'RangeError', message: 'fd must be a signed C int' });
  227. });
  228. test('native callbacks receive asynchronous, request-local success and errno results', nativeOnly, async (t) => {
  229. const scope = resources(t);
  230. const owner = scope.open();
  231. const contender = scope.open();
  232. await tryLockExclusive(owner.fd);
  233. const binding = loadFlockBinding();
  234. const results = await Promise.all([owner.fd, contender.fd, -1].map((fd) => new Promise((resolve) => {
  235. let returned = false;
  236. const result = binding.tryLock(fd, (errno) => {
  237. assert.equal(returned, true);
  238. resolve(errno);
  239. });
  240. assert.equal(result, undefined);
  241. returned = true;
  242. })));
  243. assert.equal(results[0], 0);
  244. assert.ok([constants.errno.EAGAIN, constants.errno.EWOULDBLOCK].includes(results[1]));
  245. assert.equal(results[2], constants.errno.EBADF);
  246. });
  247. test('an exception in the native completion callback is reported as uncaught', nativeOnly, async (t) => {
  248. const scope = resources(t);
  249. const child = await childFixture(t, scope, 'flock-callback-throws.js');
  250. const exit = await child.waitForExit();
  251. assert.equal(exit.signal, null, exit.stderr);
  252. assert.equal(exit.error, undefined);
  253. assert.equal(exit.code, 1, exit.stderr);
  254. assert.match(exit.stderr, /Error: flock callback failure/);
  255. });
  256. test('concurrent calls retain their own syscall errno', nativeOnly, async (t) => {
  257. const scope = resources(t);
  258. const owner = scope.open();
  259. const contender = scope.open();
  260. await tryLockExclusive(owner.fd);
  261. await Promise.all([
  262. assert.rejects(tryLockExclusive(contender.fd), busy),
  263. assert.rejects(tryLockExclusive(-1), (error) => flockError(error, ['EBADF'])),
  264. assert.rejects(tryLockExclusive(contender.fd), busy),
  265. assert.rejects(tryLockExclusive(-1), (error) => flockError(error, ['EBADF'])),
  266. ]);
  267. });
  268. test('two child processes exclude each other and normal close transfers ownership', nativeOnly, async (t) => {
  269. const scope = resources(t);
  270. const observer = scope.open();
  271. const children = await Promise.all([
  272. childFixture(t, scope, 'flock-child.js', [scope.file]),
  273. childFixture(t, scope, 'flock-child.js', [scope.file]),
  274. ]);
  275. const results = await Promise.all(children.map((child) => child.exchange('tryLock')));
  276. assert.equal(results.filter((result) => result.type === 'locked').length, 1);
  277. assert.equal(results.filter((result) => result.type === 'error').length, 1);
  278. const winnerIndex = results.findIndex((result) => result.type === 'locked');
  279. const winner = children[winnerIndex];
  280. const loser = children[1 - winnerIndex];
  281. busy(results[1 - winnerIndex]);
  282. await assert.rejects(tryLockExclusive(observer.fd), busy);
  283. assert.deepEqual(await winner.exchange('close'), { type: 'closed' });
  284. cleanExit(await winner.waitForExit());
  285. assert.deepEqual(await loser.exchange('tryLock'), { type: 'locked' });
  286. await assert.rejects(tryLockExclusive(observer.fd), busy);
  287. assert.deepEqual(await loser.exchange('close'), { type: 'closed' });
  288. cleanExit(await loser.waitForExit());
  289. await tryLockExclusive(observer.fd);
  290. });
  291. test('SIGKILL releases a child lock after exit', nativeOnly, async (t) => {
  292. const scope = resources(t);
  293. const observer = scope.open();
  294. const owner = await childFixture(t, scope, 'flock-child.js', [scope.file]);
  295. const contender = await childFixture(t, scope, 'flock-child.js', [scope.file]);
  296. assert.deepEqual(await owner.exchange('tryLock'), { type: 'locked' });
  297. const rejected = await contender.exchange('tryLock');
  298. assert.equal(rejected.type, 'error');
  299. busy(rejected);
  300. assert.equal(owner.child.kill('SIGKILL'), true);
  301. const exit = await owner.waitForExit();
  302. assert.equal(exit.error, undefined);
  303. assert.equal(exit.signal, 'SIGKILL');
  304. assert.equal(exit.code, null);
  305. assert.deepEqual(await contender.exchange('tryLock'), { type: 'locked' });
  306. await assert.rejects(tryLockExclusive(observer.fd), busy);
  307. });
  308. for (const phase of ['queued', 'callback']) {
  309. test(`worker termination during ${phase} drains native work without taking ownership of the fd`, nativeOnly, async (t) => {
  310. const scope = resources(t);
  311. const owner = scope.open();
  312. const contender = scope.open();
  313. const worker = new Worker(new URL('./fixtures/flock-worker.js', import.meta.url), {
  314. workerData: { fd: owner.fd, phase },
  315. execArgv: [],
  316. });
  317. scope.defer((signal) => until(worker.terminate(), signal));
  318. const exited = once(worker, 'exit');
  319. const waiting = new AbortController();
  320. try {
  321. const [message] = await Promise.race([
  322. once(worker, 'message', { signal: AbortSignal.any([t.signal, waiting.signal]) }),
  323. exited.then(([code]) => { throw new Error(`flock worker exited before ${phase}: ${code}`); }),
  324. ]);
  325. assert.deepEqual(message, { type: phase, nativeWork: 1 });
  326. } finally {
  327. waiting.abort();
  328. }
  329. assert.equal(await until(worker.terminate(), t.signal), 1);
  330. await until(exited, t.signal);
  331. await tryLockExclusive(owner.fd);
  332. await assert.rejects(tryLockExclusive(contender.fd), busy);
  333. owner.close();
  334. await tryLockExclusive(contender.fd);
  335. });
  336. }
  337. for (const mode of ['exclusive', 'shared']) {
  338. test(`an addon exclusive lock blocks an independent C ${mode} flock until its fd closes`, nativeOnly, async (t) => {
  339. const scope = resources(t);
  340. const owner = scope.open();
  341. await tryLockExclusive(owner.fd);
  342. const oracle = await oracleFixture(t, scope, mode);
  343. const result = await oracle.exchange('t');
  344. assert.ok([constants.errno.EAGAIN, constants.errno.EWOULDBLOCK].includes(result.errno));
  345. owner.close();
  346. assert.deepEqual(await oracle.exchange('t'), { errno: 0 });
  347. await oracle.quit();
  348. });
  349. test(`an independent C ${mode} flock blocks the addon until explicit unlock`, nativeOnly, async (t) => {
  350. const scope = resources(t);
  351. const contender = scope.open();
  352. const oracle = await oracleFixture(t, scope, mode);
  353. assert.deepEqual(await oracle.exchange('t'), { errno: 0 });
  354. await assert.rejects(tryLockExclusive(contender.fd), busy);
  355. assert.deepEqual(await oracle.exchange('u'), { errno: 0 });
  356. await tryLockExclusive(contender.fd);
  357. await oracle.quit();
  358. });
  359. }
  360. test('an advisory exclusive lock permits another process to read and write without locking', nativeOnly, async (t) => {
  361. const scope = resources(t);
  362. const owner = scope.open();
  363. const contender = scope.open();
  364. writeFileSync(scope.file, 'written before locking');
  365. await tryLockExclusive(owner.fd);
  366. await assert.rejects(tryLockExclusive(contender.fd), busy);
  367. const child = await childFixture(t, scope, 'flock-io-child.js', [scope.file]);
  368. assert.deepEqual(await child.exchange('read-write'), {
  369. type: 'written', previous: 'written before locking',
  370. });
  371. cleanExit(await child.waitForExit());
  372. assert.equal(readFileSync(scope.file, 'utf8'), 'written without acquiring a lock');
  373. await assert.rejects(tryLockExclusive(contender.fd), busy);
  374. });
  375. test('an inherited fd shares the lock after parent close until the child closes its last reference', nativeOnly, async (t) => {
  376. const scope = resources(t);
  377. const owner = scope.open();
  378. const contender = scope.open();
  379. await tryLockExclusive(owner.fd);
  380. const child = await childFixture(t, scope, 'flock-inherited-child.js', [], { inheritedFd: owner.fd });
  381. assert.deepEqual(await child.exchange('tryLock'), { type: 'locked' });
  382. owner.close();
  383. await assert.rejects(tryLockExclusive(contender.fd), busy);
  384. assert.deepEqual(await child.exchange('close'), { type: 'closed' });
  385. await tryLockExclusive(contender.fd);
  386. // The child stays alive, so its close acknowledgement—not process exit—releases the lock.
  387. assert.equal(child.child.exitCode, null);
  388. assert.equal(child.child.signalCode, null);
  389. assert.deepEqual(await child.exchange('quit'), { type: 'bye' });
  390. cleanExit(await child.waitForExit());
  391. });