@dxos/lock-file 0.8.4-main.dedc0f3 → 0.8.4-main.dfabb4ec29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,9 +1,13 @@
1
1
  {
2
2
  "name": "@dxos/lock-file",
3
- "version": "0.8.4-main.dedc0f3",
3
+ "version": "0.8.4-main.dfabb4ec29",
4
4
  "description": "Lock file .",
5
5
  "homepage": "https://dxos.org",
6
6
  "bugs": "https://github.com/dxos/dxos/issues",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/dxos/dxos"
10
+ },
7
11
  "license": "MIT",
8
12
  "author": "DXOS.org",
9
13
  "sideEffects": true,
@@ -16,21 +20,17 @@
16
20
  }
17
21
  },
18
22
  "types": "dist/types/src/index.d.ts",
19
- "typesVersions": {
20
- "*": {}
21
- },
22
23
  "files": [
23
24
  "dist",
24
25
  "src"
25
26
  ],
26
27
  "dependencies": {
27
- "fs-ext": "2.0.0",
28
- "@dxos/async": "0.8.4-main.dedc0f3",
29
- "@dxos/node-std": "0.8.4-main.dedc0f3"
30
- },
31
- "devDependencies": {
32
- "@types/fs-ext": "2.0.0"
28
+ "koffi": "^2.8.0",
29
+ "nan": "2.23.0",
30
+ "@dxos/node-std": "0.8.4-main.dfabb4ec29",
31
+ "@dxos/async": "0.8.4-main.dfabb4ec29"
33
32
  },
33
+ "devDependencies": {},
34
34
  "publishConfig": {
35
35
  "access": "public"
36
36
  }
@@ -4,19 +4,26 @@
4
4
 
5
5
  import { spawn } from 'node:child_process';
6
6
  import { existsSync, mkdirSync, unlinkSync } from 'node:fs';
7
+ import { rm } from 'node:fs/promises';
8
+ import { tmpdir } from 'node:os';
7
9
  import { join } from 'node:path';
8
-
9
- import { beforeAll, describe, expect, onTestFinished, test } from 'vitest';
10
+ import { afterAll, beforeAll, describe, expect, onTestFinished, test } from 'vitest';
10
11
 
11
12
  import { Trigger } from '@dxos/async';
12
13
 
13
14
  import { LockFile } from './lock-file';
14
15
 
15
16
  const TEST_DIR = '/tmp/dxos/testing/lock-file';
17
+ const TEMP_TEST_DIR = join(tmpdir(), 'lock-file-test-' + Date.now());
16
18
 
17
19
  describe('LockFile', () => {
18
20
  beforeAll(() => {
19
21
  mkdirSync(TEST_DIR, { recursive: true });
22
+ mkdirSync(TEMP_TEST_DIR, { recursive: true });
23
+ });
24
+
25
+ afterAll(async () => {
26
+ await rm(TEMP_TEST_DIR, { recursive: true, force: true });
20
27
  });
21
28
 
22
29
  test('basic', async () => {
@@ -30,7 +37,7 @@ describe('LockFile', () => {
30
37
  await LockFile.release(handle2);
31
38
  });
32
39
 
33
- test('released when process exits', { timeout: 10_000 }, async () => {
40
+ test('released when process exits', { timeout: 20_000 }, async () => {
34
41
  const filename = join(TEST_DIR, `lock-${Math.random()}.lock`);
35
42
  onTestFinished(() => {
36
43
  if (existsSync(filename)) {
@@ -39,16 +46,65 @@ describe('LockFile', () => {
39
46
  });
40
47
 
41
48
  const trigger = new Trigger();
42
- const processHandle = spawn('node', ['-e', `(${lockInProcess.toString()})(${JSON.stringify(filename)})`], {
49
+ const processHandle = spawn('vite-node', [new URL('./locker-subprocess.ts', import.meta.url).pathname, filename], {
43
50
  stdio: 'pipe',
44
51
  });
45
52
 
46
53
  {
47
54
  // Wait for process to start
55
+ processHandle.stdout.on('data', (data: Uint8Array) => {
56
+ process.stdout.write(data);
57
+ if (data.toString().trim().startsWith('#')) {
58
+ expect(data.toString().trim()).to.equal('# locked');
59
+ trigger.wake();
60
+ }
61
+ });
62
+ processHandle.on('exit', (code) => {
63
+ trigger.throw(new Error(`Process exited pre with code ${code}`));
64
+ });
65
+ }
66
+
67
+ await trigger.wait({ timeout: 15_000 });
68
+
69
+ await expect(LockFile.acquire(filename)).rejects.toBeInstanceOf(Error);
70
+
71
+ processHandle.stdin.write('close');
72
+
73
+ // Wait for process to be killed
74
+ await expect
75
+ .poll(async () => {
76
+ return await LockFile.isLocked(filename);
77
+ })
78
+ .toBe(false);
79
+
80
+ const handle = await LockFile.acquire(filename);
81
+ await LockFile.release(handle);
82
+ });
83
+
84
+ test('released when process killed with SIGKILL', { timeout: 10_000 }, async () => {
85
+ const filename = join(TEST_DIR, `lock-${Math.random()}.lock`);
86
+ onTestFinished(() => {
87
+ if (existsSync(filename)) {
88
+ unlinkSync(filename);
89
+ }
90
+ });
48
91
 
92
+ const trigger = new Trigger();
93
+ const processHandle = spawn('vite-node', [new URL('./locker-subprocess.ts', import.meta.url).pathname, filename], {
94
+ stdio: 'pipe',
95
+ });
96
+
97
+ {
98
+ // Wait for process to start
49
99
  processHandle.stdout.on('data', (data: Uint8Array) => {
50
- expect(data.toString().trim()).to.equal('locked');
51
- trigger.wake();
100
+ process.stdout.write(data);
101
+ if (data.toString().trim().startsWith('#')) {
102
+ expect(data.toString().trim()).to.equal('# locked');
103
+ trigger.wake();
104
+ }
105
+ });
106
+ processHandle.on('exit', (code) => {
107
+ trigger.throw(new Error(`Process exited pre with code ${code}`));
52
108
  });
53
109
  }
54
110
 
@@ -56,8 +112,7 @@ describe('LockFile', () => {
56
112
 
57
113
  await expect(LockFile.acquire(filename)).rejects.toBeInstanceOf(Error);
58
114
 
59
- processHandle.stdin.write('close');
60
- processHandle.kill();
115
+ processHandle.kill('SIGKILL');
61
116
 
62
117
  // Wait for process to be killed
63
118
  await expect
@@ -81,44 +136,55 @@ describe('LockFile', () => {
81
136
  expect(await LockFile.isLocked(filename)).to.be.false;
82
137
  }
83
138
  });
84
- });
85
139
 
86
- // NOTE: Self-contained so when function.toString is called the code runs.
87
- const lockInProcess = (filename: string) => {
88
- // eslint-disable-next-line @typescript-eslint/no-require-imports
89
- const { open } = require('node:fs/promises');
90
- // eslint-disable-next-line @typescript-eslint/no-require-imports
91
- const { constants } = require('node:fs');
92
- // eslint-disable-next-line @typescript-eslint/no-require-imports
93
- const { flock } = require('fs-ext');
94
-
95
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
96
- // @ts-ignore
97
- let fileHandle;
98
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
99
- // @ts-ignore
100
- open(filename, constants.O_CREAT).then((handle) => {
101
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
102
- // @ts-ignore
103
- flock(handle.fd, 'exnb', (err) => {
104
- if (err) {
105
- handle.close();
106
- console.error(err);
107
- return;
108
- }
109
- fileHandle = handle;
110
- console.log('locked');
111
- // Hang
112
- setTimeout(() => {}, 1_000_000);
113
- });
140
+ // New tests merged from lock-file.test.ts
141
+ test('should acquire and release lock', async () => {
142
+ const lockFile = join(TEMP_TEST_DIR, 'test.lock');
143
+ const handle = await LockFile.acquire(lockFile);
144
+ expect(handle).toBeDefined();
145
+ expect(handle.fd).toBeGreaterThan(0);
146
+
147
+ // Lock should be held
148
+ await expect(LockFile.isLocked(lockFile)).resolves.toBe(true);
149
+
150
+ await LockFile.release(handle);
151
+
152
+ // Lock should be released
153
+ await expect(LockFile.isLocked(lockFile)).resolves.toBe(false);
114
154
  });
115
155
 
116
- // Close file handle on stdin close.
117
- process.stdin.on('data', (data) => {
118
- if (data.toString().trim() === 'close') {
119
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
120
- // @ts-ignore
121
- fileHandle.close();
122
- }
156
+ test('should fail to acquire lock when already locked', async () => {
157
+ const lockFile = join(TEMP_TEST_DIR, 'test2.lock');
158
+ const handle = await LockFile.acquire(lockFile);
159
+
160
+ // Try to acquire again - should fail
161
+ await expect(LockFile.acquire(lockFile)).rejects.toThrow('flock failed');
162
+
163
+ await LockFile.release(handle);
123
164
  });
124
- };
165
+
166
+ test('isLocked should return false for non-existent file', async () => {
167
+ const nonExistent = join(TEMP_TEST_DIR, 'non-existent.lock');
168
+ await expect(LockFile.isLocked(nonExistent)).resolves.toBe(false);
169
+ });
170
+
171
+ test('should handle concurrent lock attempts', async () => {
172
+ const lockFile = join(TEMP_TEST_DIR, 'test3.lock');
173
+ const handle = await LockFile.acquire(lockFile);
174
+
175
+ // Multiple attempts to acquire should all fail
176
+ const attempts = Array(5)
177
+ .fill(0)
178
+ .map(() => LockFile.acquire(lockFile).catch((err) => err));
179
+
180
+ const results = await Promise.all(attempts);
181
+
182
+ // All attempts should have failed
183
+ results.forEach((result) => {
184
+ expect(result).toBeInstanceOf(Error);
185
+ expect(result.message).toContain('flock failed');
186
+ });
187
+
188
+ await LockFile.release(handle);
189
+ });
190
+ });
package/src/lock-file.ts CHANGED
@@ -5,35 +5,34 @@
5
5
  import { existsSync } from 'node:fs';
6
6
  import { type FileHandle, constants, open } from 'node:fs/promises';
7
7
 
8
- import { flock } from 'fs-ext';
8
+ import { LockfileSys } from './sys';
9
+
10
+ const sys = new LockfileSys();
9
11
 
10
12
  export class LockFile {
11
13
  static async acquire(filename: string): Promise<FileHandle> {
12
- const handle = await open(filename, constants.O_CREAT);
13
- await new Promise<void>((resolve, reject) => {
14
- flock(handle.fd, 'exnb', async (err) => {
15
- if (err) {
16
- reject(err);
17
- await handle.close();
18
- return;
19
- }
20
- resolve();
21
- });
22
- });
23
- return handle;
14
+ await sys.init();
15
+
16
+ const handle = await open(filename, constants.O_CREAT | constants.O_RDWR);
17
+
18
+ try {
19
+ // Try to acquire exclusive non-blocking lock
20
+ sys.flock(handle.fd, 'exnb');
21
+ return handle;
22
+ } catch (err) {
23
+ // Close the file handle if we can't acquire the lock
24
+ await handle.close();
25
+ throw err;
26
+ }
24
27
  }
25
28
 
26
29
  static async release(handle: FileHandle): Promise<void> {
27
- await new Promise<void>((resolve, reject) => {
28
- flock(handle.fd, 'un', (err) => {
29
- if (err) {
30
- reject(err);
31
- return;
32
- }
33
- resolve();
34
- });
35
- });
36
- await handle.close();
30
+ try {
31
+ // Release the lock
32
+ sys.flock(handle.fd, 'un');
33
+ } finally {
34
+ await handle.close();
35
+ }
37
36
  }
38
37
 
39
38
  static async isLocked(filename: string): Promise<boolean> {
@@ -43,7 +42,6 @@ export class LockFile {
43
42
  try {
44
43
  const handle = await LockFile.acquire(filename);
45
44
  await LockFile.release(handle);
46
-
47
45
  return false;
48
46
  } catch (e) {
49
47
  return true;
@@ -0,0 +1,24 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ /* eslint-disable no-console */
6
+
7
+ import { LockFile } from './lock-file';
8
+
9
+ const filename = process.argv[2];
10
+
11
+ console.log('will lock');
12
+
13
+ const handle = await LockFile.acquire(filename);
14
+ // parents looks for # symbol in the output to know when the lock is acquired
15
+ console.log('# locked');
16
+
17
+ // Close file handle on stdin close.
18
+ process.stdin.on('data', async (data) => {
19
+ if (data.toString().trim() === 'close') {
20
+ console.log('will unlock');
21
+ await LockFile.release(handle);
22
+ console.log('unlocked');
23
+ }
24
+ });
package/src/sys.ts ADDED
@@ -0,0 +1,83 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import type * as koffi from 'koffi';
6
+ import { platform } from 'node:os';
7
+
8
+ // flock constants
9
+ const LOCK_SH = 1; // Shared lock
10
+ const LOCK_EX = 2; // Exclusive lock
11
+ const LOCK_NB = 4; // Non-blocking
12
+ const LOCK_UN = 8; // Unlock
13
+
14
+ export class LockfileSys {
15
+ private _init: Promise<void> | null = null;
16
+
17
+ private _koffi: typeof koffi | null = null;
18
+ private _libc: koffi.IKoffiLib | null = null;
19
+ private _flockNative: koffi.KoffiFunction | null = null;
20
+
21
+ async init() {
22
+ await (this._init ??= this._runInit());
23
+ }
24
+
25
+ private async _runInit() {
26
+ this._koffi = await import('koffi');
27
+ switch (platform()) {
28
+ case 'darwin':
29
+ this._libc = this._koffi.load('libc.dylib');
30
+ break;
31
+ case 'linux':
32
+ this._libc = this._koffi.load('libc.so.6');
33
+ break;
34
+ default:
35
+ throw new Error(`Unsupported platform: ${platform()}`);
36
+ }
37
+ this._flockNative = this._libc.func('flock', 'int', ['int', 'int']);
38
+ }
39
+
40
+ flock(fd: number, operation: string) {
41
+ if (!this._flockNative) {
42
+ throw new Error('flock not initialized');
43
+ }
44
+ let op = 0;
45
+
46
+ switch (operation) {
47
+ case 'ex':
48
+ op = LOCK_EX;
49
+ break;
50
+ case 'exnb':
51
+ op = LOCK_EX | LOCK_NB;
52
+ break;
53
+ case 'sh':
54
+ op = LOCK_SH;
55
+ break;
56
+ case 'shnb':
57
+ op = LOCK_SH | LOCK_NB;
58
+ break;
59
+ case 'un':
60
+ op = LOCK_UN;
61
+ break;
62
+ default:
63
+ throw new Error(`Invalid flock operation: ${operation}`);
64
+ }
65
+
66
+ const result = this._flockNative!(fd, op);
67
+
68
+ if (result !== 0) {
69
+ // Get the errno to provide a more meaningful error
70
+ const errno = this._koffi!.errno();
71
+ const errorMessages: { [key: number]: string } = {
72
+ 11: 'Resource temporarily unavailable (EAGAIN/EWOULDBLOCK)',
73
+ 13: 'Permission denied (EACCES)',
74
+ 22: 'Invalid argument (EINVAL)',
75
+ 9: 'Bad file descriptor (EBADF)',
76
+ 35: 'Resource temporarily unavailable (EAGAIN on macOS)',
77
+ };
78
+
79
+ const errorMessage = errorMessages[errno] || `Unknown error (errno: ${errno})`;
80
+ throw new Error(`flock failed: ${errorMessage}`);
81
+ }
82
+ }
83
+ }