@osdlc/file-lock 0.0.0-stage → 0.0.1-stage
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/CHANGELOG.md +1 -0
- package/CONTRIBUTING.md +57 -0
- package/LICENSE-APACHE +202 -0
- package/LICENSE-MIT +21 -0
- package/README.md +274 -2
- package/SECURITY.md +23 -0
- package/dist/held.d.ts +119 -0
- package/dist/held.js +263 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +8 -0
- package/dist/rules.d.ts +28 -0
- package/dist/rules.js +41 -0
- package/dist/section.d.ts +47 -0
- package/dist/section.js +78 -0
- package/package.json +67 -3
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# @osdlc/file-lock
|
package/CONTRIBUTING.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Contributing
|
|
2
|
+
|
|
3
|
+
Thanks for your interest in `@osdlc/file-lock`.
|
|
4
|
+
|
|
5
|
+
## Issues
|
|
6
|
+
|
|
7
|
+
Report bugs and ask for features in the
|
|
8
|
+
[issue tracker](https://github.com/osdlc/osdlc/issues). For a bug, include the
|
|
9
|
+
library version, your runtime (Node or Bun) and version, your operating
|
|
10
|
+
system and file system, and a minimal example. Report security problems
|
|
11
|
+
privately. See [SECURITY.md](./SECURITY.md).
|
|
12
|
+
|
|
13
|
+
## Pull requests
|
|
14
|
+
|
|
15
|
+
This package lives in [osdlc/osdlc](https://github.com/osdlc/osdlc) under
|
|
16
|
+
`packages/ts/file-lock`. Open pull requests there. They are reviewed and
|
|
17
|
+
merged there.
|
|
18
|
+
|
|
19
|
+
## Running the tests
|
|
20
|
+
|
|
21
|
+
The repository is a pnpm workspace. Install from the repository root, then
|
|
22
|
+
work in the package directory. The tests run on Vitest under Node 22 or
|
|
23
|
+
later, against the TypeScript source. The smoke check below runs the built
|
|
24
|
+
package, so build before it.
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
pnpm install
|
|
28
|
+
cd packages/ts/file-lock
|
|
29
|
+
pnpm run build
|
|
30
|
+
pnpm test
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`pnpm run smoke` runs `tests/smoke.mjs`, a short check of the built package
|
|
34
|
+
under Node. It is not published. `pnpm run coverage` reports test coverage.
|
|
35
|
+
|
|
36
|
+
Other checks:
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
pnpm run typecheck
|
|
40
|
+
pnpm run lint
|
|
41
|
+
pnpm run format:check
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Code style
|
|
45
|
+
|
|
46
|
+
- TypeScript, ES modules, and explicit `.ts` import paths.
|
|
47
|
+
- Prettier formats the code and oxlint checks it. Run `pnpm run format` and
|
|
48
|
+
`pnpm run lint` before you commit.
|
|
49
|
+
- Add a test for each behaviour change or bug fix.
|
|
50
|
+
- A new export gets TSDoc and a use in `examples/`, which imports the package
|
|
51
|
+
by name. A test fails if an export has no example.
|
|
52
|
+
- An experimental export carries `@experimental` and is listed in the
|
|
53
|
+
README's Stability section. A test checks that the two agree.
|
|
54
|
+
- For a change users can see, add a changeset: run `pnpm changeset` at the
|
|
55
|
+
repository root. Start a breaking change's summary with `**Breaking:**`.
|
|
56
|
+
Do not edit `CHANGELOG.md` or `version` by hand. The release writes them.
|
|
57
|
+
See [packages/README.md](https://github.com/osdlc/osdlc/blob/main/packages/README.md).
|
package/LICENSE-APACHE
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
|
|
2
|
+
Apache License
|
|
3
|
+
Version 2.0, January 2004
|
|
4
|
+
http://www.apache.org/licenses/
|
|
5
|
+
|
|
6
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
7
|
+
|
|
8
|
+
1. Definitions.
|
|
9
|
+
|
|
10
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
11
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
12
|
+
|
|
13
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
14
|
+
the copyright owner that is granting the License.
|
|
15
|
+
|
|
16
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
17
|
+
other entities that control, are controlled by, or are under common
|
|
18
|
+
control with that entity. For the purposes of this definition,
|
|
19
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
20
|
+
direction or management of such entity, whether by contract or
|
|
21
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
22
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
23
|
+
|
|
24
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
25
|
+
exercising permissions granted by this License.
|
|
26
|
+
|
|
27
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
28
|
+
including but not limited to software source code, documentation
|
|
29
|
+
source, and configuration files.
|
|
30
|
+
|
|
31
|
+
"Object" form shall mean any form resulting from mechanical
|
|
32
|
+
transformation or translation of a Source form, including but
|
|
33
|
+
not limited to compiled object code, generated documentation,
|
|
34
|
+
and conversions to other media types.
|
|
35
|
+
|
|
36
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
37
|
+
Object form, made available under the License, as indicated by a
|
|
38
|
+
copyright notice that is included in or attached to the work
|
|
39
|
+
(an example is provided in the Appendix below).
|
|
40
|
+
|
|
41
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
42
|
+
form, that is based on (or derived from) the Work and for which the
|
|
43
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
44
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
45
|
+
of this License, Derivative Works shall not include works that remain
|
|
46
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
47
|
+
the Work and Derivative Works thereof.
|
|
48
|
+
|
|
49
|
+
"Contribution" shall mean any work of authorship, including
|
|
50
|
+
the original version of the Work and any modifications or additions
|
|
51
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
52
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
53
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
54
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
55
|
+
means any form of electronic, verbal, or written communication sent
|
|
56
|
+
to the Licensor or its representatives, including but not limited to
|
|
57
|
+
communication on electronic mailing lists, source code control systems,
|
|
58
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
59
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
60
|
+
excluding communication that is conspicuously marked or otherwise
|
|
61
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
62
|
+
|
|
63
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
64
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
65
|
+
subsequently incorporated within the Work.
|
|
66
|
+
|
|
67
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
68
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
69
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
70
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
71
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
72
|
+
Work and such Derivative Works in Source or Object form.
|
|
73
|
+
|
|
74
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
75
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
76
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
77
|
+
(except as stated in this section) patent license to make, have made,
|
|
78
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
79
|
+
where such license applies only to those patent claims licensable
|
|
80
|
+
by such Contributor that are necessarily infringed by their
|
|
81
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
82
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
83
|
+
institute patent litigation against any entity (including a
|
|
84
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
85
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
86
|
+
or contributory patent infringement, then any patent licenses
|
|
87
|
+
granted to You under this License for that Work shall terminate
|
|
88
|
+
as of the date such litigation is filed.
|
|
89
|
+
|
|
90
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
91
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
92
|
+
modifications, and in Source or Object form, provided that You
|
|
93
|
+
meet the following conditions:
|
|
94
|
+
|
|
95
|
+
(a) You must give any other recipients of the Work or
|
|
96
|
+
Derivative Works a copy of this License; and
|
|
97
|
+
|
|
98
|
+
(b) You must cause any modified files to carry prominent notices
|
|
99
|
+
stating that You changed the files; and
|
|
100
|
+
|
|
101
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
102
|
+
that You distribute, all copyright, patent, trademark, and
|
|
103
|
+
attribution notices from the Source form of the Work,
|
|
104
|
+
excluding those notices that do not pertain to any part of
|
|
105
|
+
the Derivative Works; and
|
|
106
|
+
|
|
107
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
108
|
+
distribution, then any Derivative Works that You distribute must
|
|
109
|
+
include a readable copy of the attribution notices contained
|
|
110
|
+
within such NOTICE file, excluding those notices that do not
|
|
111
|
+
pertain to any part of the Derivative Works, in at least one
|
|
112
|
+
of the following places: within a NOTICE text file distributed
|
|
113
|
+
as part of the Derivative Works; within the Source form or
|
|
114
|
+
documentation, if provided along with the Derivative Works; or,
|
|
115
|
+
within a display generated by the Derivative Works, if and
|
|
116
|
+
wherever such third-party notices normally appear. The contents
|
|
117
|
+
of the NOTICE file are for informational purposes only and
|
|
118
|
+
do not modify the License. You may add Your own attribution
|
|
119
|
+
notices within Derivative Works that You distribute, alongside
|
|
120
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
121
|
+
that such additional attribution notices cannot be construed
|
|
122
|
+
as modifying the License.
|
|
123
|
+
|
|
124
|
+
You may add Your own copyright statement to Your modifications and
|
|
125
|
+
may provide additional or different license terms and conditions
|
|
126
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
127
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
128
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
129
|
+
the conditions stated in this License.
|
|
130
|
+
|
|
131
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
132
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
133
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
134
|
+
this License, without any additional terms or conditions.
|
|
135
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
136
|
+
the terms of any separate license agreement you may have executed
|
|
137
|
+
with Licensor regarding such Contributions.
|
|
138
|
+
|
|
139
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
140
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
141
|
+
except as required for reasonable and customary use in describing the
|
|
142
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
143
|
+
|
|
144
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
145
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
146
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
147
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
148
|
+
implied, including, without limitation, any warranties or conditions
|
|
149
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
150
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
151
|
+
appropriateness of using or redistributing the Work and assume any
|
|
152
|
+
risks associated with Your exercise of permissions under this License.
|
|
153
|
+
|
|
154
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
155
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
156
|
+
unless required by applicable law (such as deliberate and grossly
|
|
157
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
158
|
+
liable to You for damages, including any direct, indirect, special,
|
|
159
|
+
incidental, or consequential damages of any character arising as a
|
|
160
|
+
result of this License or out of the use or inability to use the
|
|
161
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
162
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
163
|
+
other commercial damages or losses), even if such Contributor
|
|
164
|
+
has been advised of the possibility of such damages.
|
|
165
|
+
|
|
166
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
167
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
168
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
169
|
+
or other liability obligations and/or rights consistent with this
|
|
170
|
+
License. However, in accepting such obligations, You may act only
|
|
171
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
172
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
173
|
+
defend, and hold each Contributor harmless for any liability
|
|
174
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
175
|
+
of your accepting any such warranty or additional liability.
|
|
176
|
+
|
|
177
|
+
END OF TERMS AND CONDITIONS
|
|
178
|
+
|
|
179
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
180
|
+
|
|
181
|
+
To apply the Apache License to your work, attach the following
|
|
182
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
183
|
+
replaced with your own identifying information. (Don't include
|
|
184
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
185
|
+
comment syntax for the file format. We also recommend that a
|
|
186
|
+
file or class name and description of purpose be included on the
|
|
187
|
+
same "printed page" as the copyright notice for easier
|
|
188
|
+
identification within third-party archives.
|
|
189
|
+
|
|
190
|
+
Copyright 2026 Sizer LLC
|
|
191
|
+
|
|
192
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
193
|
+
you may not use this file except in compliance with the License.
|
|
194
|
+
You may obtain a copy of the License at
|
|
195
|
+
|
|
196
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
197
|
+
|
|
198
|
+
Unless required by applicable law or agreed to in writing, software
|
|
199
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
200
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
201
|
+
See the License for the specific language governing permissions and
|
|
202
|
+
limitations under the License.
|
package/LICENSE-MIT
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 SK Sizer
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,275 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @osdlc/file-lock
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Cross-process exclusive file locks for Node.js. No dependencies beyond Node's
|
|
4
|
+
own `fs` and `crypto`.
|
|
5
|
+
|
|
6
|
+
There are two shapes, built on one mechanism:
|
|
7
|
+
|
|
8
|
+
- **A held lock**, `claimLock`, for something a process owns for as long as
|
|
9
|
+
it runs: a port lease, one loop per repository, one digest per session. The
|
|
10
|
+
lock names its holder, and you choose when a holder counts as gone: its
|
|
11
|
+
process exited, or it stopped touching the lock.
|
|
12
|
+
- **A short critical section**, `withLock`, for a re-read, check and rewrite
|
|
13
|
+
of one file that two processes may do at once.
|
|
14
|
+
|
|
15
|
+
## Why
|
|
16
|
+
|
|
17
|
+
Two processes that read a file, change it and write it back can lose each
|
|
18
|
+
other's change. A lock file next to the data stops that, but the simple
|
|
19
|
+
versions break in ways that only show up under load or after a crash:
|
|
20
|
+
|
|
21
|
+
- An `O_EXCL` create followed by a write can leave an empty lock if the
|
|
22
|
+
process dies between the two. Nobody can tell whose it is.
|
|
23
|
+
- "Delete the lock if it looks stale" lets a slow waiter delete the fresh lock
|
|
24
|
+
that a faster waiter just took, so two holders run at once.
|
|
25
|
+
- "Delete the lock on exit" lets an owner whose lock was taken over delete the
|
|
26
|
+
new holder's lock.
|
|
27
|
+
|
|
28
|
+
This package closes each of those. A claim writes the whole content to a temp
|
|
29
|
+
file and hard-links it into place, so the lock never exists empty. A stale lock
|
|
30
|
+
is taken over through a marker that only one reaper can create, and it is
|
|
31
|
+
removed only if it still reads exactly as it did when it was judged stale.
|
|
32
|
+
Every claim carries a random token, and release, touch and rewrite act only
|
|
33
|
+
while the lock still holds that token.
|
|
34
|
+
|
|
35
|
+
When not to use it:
|
|
36
|
+
|
|
37
|
+
- The lock must hold across machines, or the directory is on a network file
|
|
38
|
+
system. `link()` and `rename()` are relied on to be atomic. For workers on
|
|
39
|
+
several machines that share a git remote, use
|
|
40
|
+
[`@osdlc/ref-lease`](../ref-lease/README.md).
|
|
41
|
+
- You need a shared (reader) lock, or a lock the kernel drops when the process
|
|
42
|
+
dies. Those are OS advisory locks (`flock`, `fcntl`), which Node has no
|
|
43
|
+
built-in binding for.
|
|
44
|
+
- The section is async and long. `withLock` blocks the thread while it waits.
|
|
45
|
+
Use `claimLock` and `releaseLock` around async work instead.
|
|
46
|
+
|
|
47
|
+
## Guide
|
|
48
|
+
|
|
49
|
+
The [guide](https://github.com/osdlc/osdlc/blob/main/packages/ts/file-lock/docs/guide/getting-started.md) walks through the problem with
|
|
50
|
+
timelines and examples: [getting started](https://github.com/osdlc/osdlc/blob/main/packages/ts/file-lock/docs/guide/getting-started.md),
|
|
51
|
+
[choosing a staleness rule](https://github.com/osdlc/osdlc/blob/main/packages/ts/file-lock/docs/guide/staleness.md) and
|
|
52
|
+
[how it works](https://github.com/osdlc/osdlc/blob/main/packages/ts/file-lock/docs/guide/how-it-works.md) on disk.
|
|
53
|
+
|
|
54
|
+
## Install
|
|
55
|
+
|
|
56
|
+
```sh
|
|
57
|
+
npm install @osdlc/file-lock
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`pnpm add @osdlc/file-lock` and `yarn add @osdlc/file-lock` work too.
|
|
61
|
+
|
|
62
|
+
ESM only. Node.js 22 or later. Bun also works.
|
|
63
|
+
|
|
64
|
+
## Quick start
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
|
|
68
|
+
import { tmpdir } from 'node:os'
|
|
69
|
+
import { join } from 'node:path'
|
|
70
|
+
|
|
71
|
+
import { claimLock, holderGone, releaseLock, withLock } from '@osdlc/file-lock'
|
|
72
|
+
|
|
73
|
+
const dir = mkdtempSync(join(tmpdir(), 'file-lock-'))
|
|
74
|
+
|
|
75
|
+
// A short section: no other process runs it on this file at the same time.
|
|
76
|
+
const counter = join(dir, 'counter')
|
|
77
|
+
writeFileSync(counter, '0')
|
|
78
|
+
withLock(`${counter}.lock`, () => {
|
|
79
|
+
writeFileSync(counter, String(Number(readFileSync(counter, 'utf8')) + 1))
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
// A held lock: ours until this process exits, or until we release it.
|
|
83
|
+
const lease = join(dir, '4000.lease')
|
|
84
|
+
const claim = claimLock(
|
|
85
|
+
lease,
|
|
86
|
+
{ pid: process.pid },
|
|
87
|
+
{ isStale: holderGone<{ pid?: number }>((c) => [c.pid]) },
|
|
88
|
+
)
|
|
89
|
+
if (claim.ok) {
|
|
90
|
+
// ... use the port ...
|
|
91
|
+
releaseLock(lease, claim.token)
|
|
92
|
+
} else {
|
|
93
|
+
console.log('held by', claim.holder?.content?.pid)
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## The held lock
|
|
98
|
+
|
|
99
|
+
`claimLock(path, content, { isStale })` writes `content` as
|
|
100
|
+
JSON, with a fresh `token` field added, and returns `{ ok: true, token }` when
|
|
101
|
+
it took the lock. When the lock is held, it reads it and asks `isStale`:
|
|
102
|
+
|
|
103
|
+
- `false`: the holder is live. The result is `{ ok: false, holder }`, where
|
|
104
|
+
`holder` is a `HeldLock`: the parsed `content` (null when the file is not
|
|
105
|
+
a JSON object), the `mtimeMs`, and the `raw` text.
|
|
106
|
+
- `true`: the lock is taken over and the claim is tried again. When several
|
|
107
|
+
claimants take over one stale lock at once, one of them wins and the others
|
|
108
|
+
see it as the live holder.
|
|
109
|
+
|
|
110
|
+
`{ ok: false, holder: null }` means the lock could not be settled: it kept
|
|
111
|
+
changing under every attempt, or it is stale and another claimant's takeover
|
|
112
|
+
is still in progress. Try again shortly. The directory must exist; any error other than "already held" is
|
|
113
|
+
thrown.
|
|
114
|
+
|
|
115
|
+
The content is read back unchecked, cast to the type you name, so a rule should
|
|
116
|
+
check the type of each field it reads. A lock written by another program
|
|
117
|
+
without a `token` still reaches the rule with its fields, but no token
|
|
118
|
+
releases it.
|
|
119
|
+
|
|
120
|
+
With the token in hand:
|
|
121
|
+
|
|
122
|
+
- `touchLock(path, token)` refreshes the lock's mtime: a sign of life for an
|
|
123
|
+
`untouchedFor` rule. Run it on a timer for long work.
|
|
124
|
+
- `updateLock(path, token, update)` replaces the content, keeping the token.
|
|
125
|
+
`update` gets the current content and returns the new fields. The new file
|
|
126
|
+
is renamed into place, so a reader never sees it half written. An error
|
|
127
|
+
thrown by `update` is thrown to you, and the lock is left as it was.
|
|
128
|
+
- `releaseLock(path, token)` removes the lock. It renames the lock aside
|
|
129
|
+
first and checks the token there, so the file it removes is exactly the
|
|
130
|
+
file it checked.
|
|
131
|
+
|
|
132
|
+
Each acts only while the lock still holds `token`, and returns whether it did
|
|
133
|
+
anything. An owner whose lock was taken over, or whose lock is gone, gets
|
|
134
|
+
`false` and leaves the new holder's lock alone. Any other error, such as
|
|
135
|
+
`EACCES` or `ENOSPC`, is thrown rather than reported as a lost lock.
|
|
136
|
+
|
|
137
|
+
The types are `LockContent`, `HeldLock`, `LockClaim`, `ClaimOptions` and
|
|
138
|
+
`StaleRule`.
|
|
139
|
+
|
|
140
|
+
## Staleness rules
|
|
141
|
+
|
|
142
|
+
`isStale` is any function of a `HeldLock`. Two are ready-made:
|
|
143
|
+
|
|
144
|
+
- `holderGone(pids, { alive? })`: stale once no pid that `pids` reads from the
|
|
145
|
+
content is alive. A lock whose content cannot be parsed is never judged
|
|
146
|
+
gone by this rule. A recycled pid keeps the lock held; compare a start time
|
|
147
|
+
in your own rule if that matters. `alive` defaults to `pidAlive(pid)`,
|
|
148
|
+
which sends signal 0 and counts `EPERM` (another user's process) as alive.
|
|
149
|
+
`HolderGoneOptions` is its options type.
|
|
150
|
+
- `untouchedFor(ms, now?)`: stale once the lock was not written or touched for
|
|
151
|
+
more than `ms`. A live holder that outlasts `ms` without touching loses the
|
|
152
|
+
lock, so keep `ms` far above the longest hold.
|
|
153
|
+
|
|
154
|
+
## The short section
|
|
155
|
+
|
|
156
|
+
`withLock(path, fn, { staleMs?, timeoutMs?, retryMs?, isStale? })` runs
|
|
157
|
+
`fn` holding the lock at `path` and releases it when `fn` returns or throws.
|
|
158
|
+
The lock records this process's `pid` (`SectionHolder`), and its rule is
|
|
159
|
+
`untouchedFor(staleMs)` unless you pass `isStale`.
|
|
160
|
+
|
|
161
|
+
| option | default | meaning |
|
|
162
|
+
|---|---|---|
|
|
163
|
+
| `staleMs` | 10 s | a lock untouched for longer is a killed holder's |
|
|
164
|
+
| `timeoutMs` | 30 s | give up after waiting this long; 0 tries once |
|
|
165
|
+
| `retryMs` | 5 ms | pause between attempts |
|
|
166
|
+
| `isStale` | `untouchedFor(staleMs)` | replaces the age rule, for example `holderGone((h) => [h.pid])` for a section that may run long |
|
|
167
|
+
|
|
168
|
+
When the lock stays held, it throws `LockTimeoutError`, whose message is
|
|
169
|
+
`timed out waiting for lock <path>`, with the `path` and the last `holder`
|
|
170
|
+
seen. `LockOptions` is the options type.
|
|
171
|
+
|
|
172
|
+
Waiting blocks the thread with `Atomics.wait`, so the section is for short,
|
|
173
|
+
synchronous work. `fn` must not be async: the lock would be released when it
|
|
174
|
+
returns its promise, so a `fn` that returns one is refused with a `TypeError`.
|
|
175
|
+
If a section outlasts `staleMs` and another waiter takes the lock over, the
|
|
176
|
+
section still returns normally; pass a `holderGone` rule as `isStale` when a
|
|
177
|
+
section may run long.
|
|
178
|
+
|
|
179
|
+
## Read-modify-write with `@osdlc/etag-write`
|
|
180
|
+
|
|
181
|
+
[`@osdlc/etag-write`](../etag-write/README.md) is not a lock. `writeCas` checks
|
|
182
|
+
the etag and then renames, so two processes writing at once can still both
|
|
183
|
+
pass the check, and one of them loses its update. `atomicWrite` alone never
|
|
184
|
+
checks. For a file that more than one process rewrites, take the lock first,
|
|
185
|
+
then read, change and write inside it. The atomic write still matters inside
|
|
186
|
+
the lock: a reader that takes no lock, or a crash mid-write, must never see a
|
|
187
|
+
torn file.
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
import { mkdtempSync, readFileSync } from 'node:fs'
|
|
191
|
+
import { tmpdir } from 'node:os'
|
|
192
|
+
import { join } from 'node:path'
|
|
193
|
+
|
|
194
|
+
import { atomicWrite, etagOfFile, writeCas } from '@osdlc/etag-write'
|
|
195
|
+
import { withLock } from '@osdlc/file-lock'
|
|
196
|
+
|
|
197
|
+
const path = join(mkdtempSync(join(tmpdir(), 'file-lock-rmw-')), 'state.json')
|
|
198
|
+
atomicWrite(path, JSON.stringify({ runs: 0 }))
|
|
199
|
+
|
|
200
|
+
withLock(`${path}.lock`, () => {
|
|
201
|
+
const seen = etagOfFile(path)
|
|
202
|
+
const state = JSON.parse(readFileSync(path, 'utf8')) as { runs: number }
|
|
203
|
+
// Other lock-takers wait here. writeCas still refuses the write if a
|
|
204
|
+
// writer that does not take the lock (a person in an editor) changed it.
|
|
205
|
+
writeCas(path, JSON.stringify({ runs: state.runs + 1 }), seen)
|
|
206
|
+
})
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
## Behaviour to know
|
|
210
|
+
|
|
211
|
+
- **Files beside the lock.** A claim writes `<path>.new-<uuid>` and removes it
|
|
212
|
+
before returning (`withLock` writes it once and keeps it while it waits). A
|
|
213
|
+
release renames the lock to `<path>.release-<uuid>` for microseconds. A
|
|
214
|
+
takeover creates `<path>.stale-<24 hex digits>` for microseconds. A process
|
|
215
|
+
killed in between can leave any of them behind. A marker whose lock is still
|
|
216
|
+
there blocks the takeover of that lock until it is `MARKER_STALE_MS` (10 s)
|
|
217
|
+
old, judged by the inode's ctime; then the next claimant removes it. A
|
|
218
|
+
marker left after its lock was removed blocks nothing and is safe to delete.
|
|
219
|
+
- **The content is pretty-printed JSON** with a trailing newline, and the
|
|
220
|
+
`token` field last.
|
|
221
|
+
- **`updateLock` checks the token, then renames.** A takeover that lands
|
|
222
|
+
between the two is overwritten. A rule that never judges a live holder stale,
|
|
223
|
+
such as `holderGone`, closes that window.
|
|
224
|
+
- **A takeover removes only the lock it judged:** the same bytes and the same
|
|
225
|
+
mtime, so a holder that touches its lock just in time keeps it.
|
|
226
|
+
- **A reaper paused mid-takeover for longer than `MARKER_STALE_MS`** (a
|
|
227
|
+
suspended laptop, a stopped process) has its marker cleared as a dead
|
|
228
|
+
reaper's. If another claimant then replaces the lock, the paused reaper can
|
|
229
|
+
remove the new lock when it resumes. A pause of 10 s inside a few
|
|
230
|
+
microseconds of work is the only way in.
|
|
231
|
+
- **Keep locks in directories only their users can write.** The marker's name
|
|
232
|
+
comes from the lock's content, so anyone who can write the directory can
|
|
233
|
+
create it and hold off a takeover for 10 s at a time.
|
|
234
|
+
- **`withLock` decides staleness by age.** A live holder whose section
|
|
235
|
+
outlasts `staleMs` loses the lock to the next waiter, and both then run.
|
|
236
|
+
- **Hard links are required.** A file system without them (some FUSE and
|
|
237
|
+
network mounts) fails the claim with the error `link()` throws.
|
|
238
|
+
|
|
239
|
+
## How it compares
|
|
240
|
+
|
|
241
|
+
| | `@osdlc/file-lock` | `proper-lockfile` | OS advisory lock (`flock`) |
|
|
242
|
+
|---|---|---|---|
|
|
243
|
+
| Lock never exists empty or half written | yes | yes (a directory) | yes |
|
|
244
|
+
| Holder recorded in the lock | yes | no | no |
|
|
245
|
+
| Stale when the holder process is gone | yes (`holderGone`) | no | yes (the kernel drops it) |
|
|
246
|
+
| Stale when untouched for a while | yes (`untouchedFor`) | yes | no |
|
|
247
|
+
| Release refuses to delete another holder's lock | yes (token) | no | yes |
|
|
248
|
+
| Shared (reader) locks | no | no | yes |
|
|
249
|
+
| Works without a native addon in Node | yes | yes | no |
|
|
250
|
+
| Async API | no (`claimLock` is sync and fast) | yes | depends on the binding |
|
|
251
|
+
|
|
252
|
+
This comparison is from each package's documentation. Pick `proper-lockfile`
|
|
253
|
+
for an async, mtime-refreshed lock with no holder check. Pick an OS lock when a
|
|
254
|
+
native addon is acceptable and the kernel should release the lock on exit.
|
|
255
|
+
|
|
256
|
+
## Stability
|
|
257
|
+
|
|
258
|
+
The package is 0.x, so any minor release may break the API. Within that, each
|
|
259
|
+
export is stable or experimental. A breaking change to a stable export is
|
|
260
|
+
listed in the CHANGELOG under "Breaking". Any release may change or remove an
|
|
261
|
+
experimental export.
|
|
262
|
+
|
|
263
|
+
The experimental exports:
|
|
264
|
+
|
|
265
|
+
- none
|
|
266
|
+
|
|
267
|
+
## Runtimes and platforms
|
|
268
|
+
|
|
269
|
+
Node.js 22 or later, ESM only. Bun works too, through the compiled `dist/`.
|
|
270
|
+
The tests run on Linux and macOS. Windows is not tested: `pidAlive` relies on
|
|
271
|
+
signal 0, and the takeover relies on hard links and ctime.
|
|
272
|
+
|
|
273
|
+
## Licence
|
|
274
|
+
|
|
275
|
+
`MIT OR Apache-2.0`, at your option. See [LICENSE-MIT](./LICENSE-MIT) and [LICENSE-APACHE](./LICENSE-APACHE).
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Security policy
|
|
2
|
+
|
|
3
|
+
## Reporting a vulnerability
|
|
4
|
+
|
|
5
|
+
Please report vulnerabilities privately. Do not open a public issue.
|
|
6
|
+
|
|
7
|
+
Use GitHub private vulnerability reporting on the repository: open the
|
|
8
|
+
[Security tab](https://github.com/osdlc/osdlc/security) and choose
|
|
9
|
+
"Report a vulnerability".
|
|
10
|
+
|
|
11
|
+
Include the version, a short description, and steps to reproduce. A minimal
|
|
12
|
+
code sample helps.
|
|
13
|
+
|
|
14
|
+
## Supported versions
|
|
15
|
+
|
|
16
|
+
Only the latest `0.x` release gets security fixes. The library is pre-1.0, so
|
|
17
|
+
older `0.x` releases are not patched. Upgrade to the latest release.
|
|
18
|
+
|
|
19
|
+
## What to expect
|
|
20
|
+
|
|
21
|
+
This is a small project. We aim to reply within a week. We will tell you
|
|
22
|
+
whether we accept the report and keep you updated until a fix is released.
|
|
23
|
+
We will credit you in the release notes if you want that.
|
package/dist/held.d.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The held lock: a file whose existence means "taken", whose content names the
|
|
3
|
+
* holder, and which carries a random token so only the claim that wrote it
|
|
4
|
+
* can touch, rewrite or release it.
|
|
5
|
+
*
|
|
6
|
+
* - Claim: the whole content, token included, is written to a temp file and
|
|
7
|
+
* hard-linked to the lock path. `link()` fails with `EEXIST` just as
|
|
8
|
+
* `O_EXCL` does, so one of several racing claimants wins, and the lock
|
|
9
|
+
* never exists empty or half written.
|
|
10
|
+
* - Takeover of a stale lock: the caller's rule judges a held lock. One it
|
|
11
|
+
* judges stale is hard-linked to a marker named from a hash of its content
|
|
12
|
+
* (the content can be longer than a file name may be), exclusively, so one
|
|
13
|
+
* reaper wins. The winner removes the lock only if its content is still
|
|
14
|
+
* what was judged, removes the marker, and claims again. A loser claims
|
|
15
|
+
* again too. A reaper killed mid-takeover leaves its marker behind. While
|
|
16
|
+
* the stale lock is still there, the next claimant removes a marker older
|
|
17
|
+
* than {@link MARKER_STALE_MS}. A marker left after its lock was removed
|
|
18
|
+
* matches no later lock, so it blocks nothing; it is litter, safe to delete.
|
|
19
|
+
* - Release, touch and rewrite act only while the lock still holds this
|
|
20
|
+
* claim's token, so an owner whose lock was taken over never disturbs the
|
|
21
|
+
* taker's.
|
|
22
|
+
*/
|
|
23
|
+
/**
|
|
24
|
+
* What a lock file holds: the fields its owner recorded, and the claim's token.
|
|
25
|
+
* The fields are read back from disk unchecked, so a rule should check the
|
|
26
|
+
* types of the ones it reads. `token` is absent in a lock that some other
|
|
27
|
+
* writer created without one.
|
|
28
|
+
*/
|
|
29
|
+
export type LockContent<F extends object = Record<string, unknown>> = F & {
|
|
30
|
+
token?: string;
|
|
31
|
+
};
|
|
32
|
+
/** A lock as found on disk. */
|
|
33
|
+
export interface HeldLock<F extends object = Record<string, unknown>> {
|
|
34
|
+
/** The parsed content, or null when the file is not a JSON object. */
|
|
35
|
+
content: LockContent<F> | null;
|
|
36
|
+
/** When the lock was last written or touched. */
|
|
37
|
+
mtimeMs: number;
|
|
38
|
+
/** The file's text. A takeover removes the lock only while it still reads exactly this. */
|
|
39
|
+
raw: string;
|
|
40
|
+
}
|
|
41
|
+
/** Judges a held lock: true when its holder is gone and the lock may be taken over. */
|
|
42
|
+
export type StaleRule<F extends object = Record<string, unknown>> = (held: HeldLock<F>) => boolean;
|
|
43
|
+
/** Options for {@link claimLock}. */
|
|
44
|
+
export interface ClaimOptions<F extends object = Record<string, unknown>> {
|
|
45
|
+
/** Answers true for a held lock that may be taken over. */
|
|
46
|
+
isStale: StaleRule<F>;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The outcome of {@link claimLock}. `ok: true` carries the token that touches,
|
|
50
|
+
* rewrites and releases the lock. `ok: false` names the live holder, or has
|
|
51
|
+
* `holder: null` when the lock could not be settled: it kept changing, or it
|
|
52
|
+
* is stale and another claimant's takeover is still in progress.
|
|
53
|
+
*/
|
|
54
|
+
export type LockClaim<F extends object = Record<string, unknown>> = {
|
|
55
|
+
ok: true;
|
|
56
|
+
token: string;
|
|
57
|
+
} | {
|
|
58
|
+
ok: false;
|
|
59
|
+
holder: HeldLock<F> | null;
|
|
60
|
+
};
|
|
61
|
+
/**
|
|
62
|
+
* A takeover marker older than this, in milliseconds, belongs to a reaper that
|
|
63
|
+
* died mid-takeover: a live one holds its marker for microseconds. It is judged
|
|
64
|
+
* by the inode's ctime. The marker is a hard link of the lock, so its mtime is
|
|
65
|
+
* the lock's, but linking and unlinking both set the ctime.
|
|
66
|
+
*/
|
|
67
|
+
export declare const MARKER_STALE_MS = 10000;
|
|
68
|
+
/** The takeover marker's name for a lock whose text is `raw`. Internal: exported for the tests. */
|
|
69
|
+
export declare function markerFor(path: string, raw: string): string;
|
|
70
|
+
/**
|
|
71
|
+
* Claim the lock at `path`, recording `content` in it as JSON with a fresh
|
|
72
|
+
* token added (a `token` field in `content` is replaced). A held lock that
|
|
73
|
+
* `isStale` answers true for is taken over, and one of several takers wins;
|
|
74
|
+
* one it answers false for is live, and the result names it. The directory
|
|
75
|
+
* must exist. Errors other than "already held" are thrown.
|
|
76
|
+
*/
|
|
77
|
+
export declare function claimLock<F extends object>(path: string, content: F, options: ClaimOptions<F>): LockClaim<F>;
|
|
78
|
+
/**
|
|
79
|
+
* A claim whose temp file is written once and attempted as often as needed, so
|
|
80
|
+
* `withLock` creates no new file on each retry. Internal: not exported from
|
|
81
|
+
* the package entry.
|
|
82
|
+
*/
|
|
83
|
+
export interface PreparedClaim<F extends object> {
|
|
84
|
+
attempt(): LockClaim<F>;
|
|
85
|
+
/** Remove the temp file. */
|
|
86
|
+
dispose(): void;
|
|
87
|
+
}
|
|
88
|
+
export declare function prepareClaim<F extends object>(path: string, content: F, { isStale }: ClaimOptions<F>): PreparedClaim<F>;
|
|
89
|
+
/**
|
|
90
|
+
* Refresh the lock's mtime while it still holds `token`: the holder's sign of
|
|
91
|
+
* life, for a rule such as `untouchedFor`. Returns false when the lock is gone
|
|
92
|
+
* or holds another token. Any other error (`EACCES`, `EIO`) is thrown.
|
|
93
|
+
*/
|
|
94
|
+
export declare function touchLock(path: string, token: string): boolean;
|
|
95
|
+
/**
|
|
96
|
+
* Replace the content of the lock while it still holds `token`. `update` gets
|
|
97
|
+
* the current content and returns the new fields; the token is kept. The new
|
|
98
|
+
* content is written to a temp file and renamed over the lock, so a reader
|
|
99
|
+
* never sees it half written. Returns false when the lock is gone or holds
|
|
100
|
+
* another token, and leaves it as it was. An error thrown by `update`, or any
|
|
101
|
+
* error other than "gone" (`ENOSPC`, `EACCES`), is thrown.
|
|
102
|
+
*
|
|
103
|
+
* The token check and the rename are two steps: a takeover that lands between
|
|
104
|
+
* them is overwritten. A rule that never judges a live holder stale, such as
|
|
105
|
+
* `holderGone`, closes that window.
|
|
106
|
+
*/
|
|
107
|
+
export declare function updateLock<F extends object>(path: string, token: string, update: (content: LockContent<F>) => F): boolean;
|
|
108
|
+
/**
|
|
109
|
+
* Remove the lock only while it holds `token`: an owner whose lock was taken
|
|
110
|
+
* over must not delete the taker's. Returns whether it removed it: false when
|
|
111
|
+
* the lock is gone or holds another token. Any other error is thrown.
|
|
112
|
+
*
|
|
113
|
+
* The lock is renamed aside, which is atomic, and its token checked there, so
|
|
114
|
+
* the file removed is exactly the file checked. The cheap check first means a
|
|
115
|
+
* lock that is plainly someone else's is never moved. If it was taken over in
|
|
116
|
+
* the instant between that check and the rename, it is linked back, unless a
|
|
117
|
+
* new claim also landed in that instant.
|
|
118
|
+
*/
|
|
119
|
+
export declare function releaseLock(path: string, token: string): boolean;
|
package/dist/held.js
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The held lock: a file whose existence means "taken", whose content names the
|
|
3
|
+
* holder, and which carries a random token so only the claim that wrote it
|
|
4
|
+
* can touch, rewrite or release it.
|
|
5
|
+
*
|
|
6
|
+
* - Claim: the whole content, token included, is written to a temp file and
|
|
7
|
+
* hard-linked to the lock path. `link()` fails with `EEXIST` just as
|
|
8
|
+
* `O_EXCL` does, so one of several racing claimants wins, and the lock
|
|
9
|
+
* never exists empty or half written.
|
|
10
|
+
* - Takeover of a stale lock: the caller's rule judges a held lock. One it
|
|
11
|
+
* judges stale is hard-linked to a marker named from a hash of its content
|
|
12
|
+
* (the content can be longer than a file name may be), exclusively, so one
|
|
13
|
+
* reaper wins. The winner removes the lock only if its content is still
|
|
14
|
+
* what was judged, removes the marker, and claims again. A loser claims
|
|
15
|
+
* again too. A reaper killed mid-takeover leaves its marker behind. While
|
|
16
|
+
* the stale lock is still there, the next claimant removes a marker older
|
|
17
|
+
* than {@link MARKER_STALE_MS}. A marker left after its lock was removed
|
|
18
|
+
* matches no later lock, so it blocks nothing; it is litter, safe to delete.
|
|
19
|
+
* - Release, touch and rewrite act only while the lock still holds this
|
|
20
|
+
* claim's token, so an owner whose lock was taken over never disturbs the
|
|
21
|
+
* taker's.
|
|
22
|
+
*/
|
|
23
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
24
|
+
import { closeSync, fstatSync, linkSync, openSync, readFileSync, renameSync, rmSync, statSync, utimesSync, writeFileSync, } from 'node:fs';
|
|
25
|
+
/**
|
|
26
|
+
* A takeover marker older than this, in milliseconds, belongs to a reaper that
|
|
27
|
+
* died mid-takeover: a live one holds its marker for microseconds. It is judged
|
|
28
|
+
* by the inode's ctime. The marker is a hard link of the lock, so its mtime is
|
|
29
|
+
* the lock's, but linking and unlinking both set the ctime.
|
|
30
|
+
*/
|
|
31
|
+
export const MARKER_STALE_MS = 10_000;
|
|
32
|
+
/** Claim attempts before giving up: enough for a few reapers racing over one stale lock. */
|
|
33
|
+
const CLAIM_ATTEMPTS = 6;
|
|
34
|
+
function parse(raw) {
|
|
35
|
+
try {
|
|
36
|
+
const value = JSON.parse(raw);
|
|
37
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
38
|
+
return null;
|
|
39
|
+
const token = value.token;
|
|
40
|
+
if (token !== undefined && typeof token !== 'string')
|
|
41
|
+
return null;
|
|
42
|
+
return value;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// Not JSON: the rule sees null.
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function tokenOf(path) {
|
|
50
|
+
return parse(readFileSync(path, 'utf-8'))?.token;
|
|
51
|
+
}
|
|
52
|
+
/** The lock's text and its mtime, both from one open of the file, so they describe the same inode. */
|
|
53
|
+
function readHeld(path) {
|
|
54
|
+
const fd = openSync(path, 'r');
|
|
55
|
+
try {
|
|
56
|
+
const mtimeMs = fstatSync(fd).mtimeMs;
|
|
57
|
+
const raw = readFileSync(fd, 'utf-8');
|
|
58
|
+
return { content: parse(raw), mtimeMs, raw };
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
closeSync(fd);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function serialize(content, token) {
|
|
65
|
+
return `${JSON.stringify({ ...content, token }, null, 2)}\n`;
|
|
66
|
+
}
|
|
67
|
+
/** The takeover marker's name for a lock whose text is `raw`. Internal: exported for the tests. */
|
|
68
|
+
export function markerFor(path, raw) {
|
|
69
|
+
return `${path}.stale-${createHash('sha256').update(raw).digest('hex').slice(0, 24)}`;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Claim the lock at `path`, recording `content` in it as JSON with a fresh
|
|
73
|
+
* token added (a `token` field in `content` is replaced). A held lock that
|
|
74
|
+
* `isStale` answers true for is taken over, and one of several takers wins;
|
|
75
|
+
* one it answers false for is live, and the result names it. The directory
|
|
76
|
+
* must exist. Errors other than "already held" are thrown.
|
|
77
|
+
*/
|
|
78
|
+
export function claimLock(path, content, options) {
|
|
79
|
+
const claim = prepareClaim(path, content, options);
|
|
80
|
+
try {
|
|
81
|
+
return claim.attempt();
|
|
82
|
+
}
|
|
83
|
+
finally {
|
|
84
|
+
claim.dispose();
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
export function prepareClaim(path, content, { isStale }) {
|
|
88
|
+
const token = randomUUID();
|
|
89
|
+
const tmp = `${path}.new-${token}`;
|
|
90
|
+
writeFileSync(tmp, serialize(content, token));
|
|
91
|
+
return {
|
|
92
|
+
attempt: () => {
|
|
93
|
+
for (let attempt = 0; attempt < CLAIM_ATTEMPTS; attempt++) {
|
|
94
|
+
try {
|
|
95
|
+
linkSync(tmp, path);
|
|
96
|
+
return { ok: true, token };
|
|
97
|
+
}
|
|
98
|
+
catch (err) {
|
|
99
|
+
if (err.code !== 'EEXIST')
|
|
100
|
+
throw err;
|
|
101
|
+
}
|
|
102
|
+
let held;
|
|
103
|
+
try {
|
|
104
|
+
held = readHeld(path);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
continue; // released between the link and the read: claim again
|
|
108
|
+
}
|
|
109
|
+
if (!isStale(held))
|
|
110
|
+
return { ok: false, holder: held };
|
|
111
|
+
takeOver(path, held);
|
|
112
|
+
}
|
|
113
|
+
// Every holder seen was stale and mid-takeover, or the lock kept changing: no live holder to name.
|
|
114
|
+
return { ok: false, holder: null };
|
|
115
|
+
},
|
|
116
|
+
dispose: () => rmSync(tmp, { force: true }),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
/** Remove the stale lock so one of several takers can claim a new one. A loser just claims again. */
|
|
120
|
+
function takeOver(path, seen) {
|
|
121
|
+
try {
|
|
122
|
+
if (readFileSync(path, 'utf-8') !== seen.raw)
|
|
123
|
+
return; // replaced since it was judged
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const marker = markerFor(path, seen.raw);
|
|
129
|
+
try {
|
|
130
|
+
linkSync(path, marker);
|
|
131
|
+
}
|
|
132
|
+
catch (err) {
|
|
133
|
+
// Another reaper holds the marker, or one died holding it. A marker that
|
|
134
|
+
// old is cleared, and the caller's next attempt takes over.
|
|
135
|
+
if (err.code === 'EEXIST')
|
|
136
|
+
clearDeadMarker(marker);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
try {
|
|
140
|
+
// Still the very lock that was judged: the same bytes, and not touched since.
|
|
141
|
+
const now = readHeld(path);
|
|
142
|
+
if (readFileSync(marker, 'utf-8') === seen.raw &&
|
|
143
|
+
now.raw === seen.raw &&
|
|
144
|
+
now.mtimeMs === seen.mtimeMs) {
|
|
145
|
+
rmSync(path, { force: true });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
// Gone meanwhile: the next attempt sorts it out.
|
|
150
|
+
}
|
|
151
|
+
finally {
|
|
152
|
+
rmSync(marker, { force: true });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
function clearDeadMarker(marker) {
|
|
156
|
+
try {
|
|
157
|
+
if (Date.now() - statSync(marker).ctimeMs >= MARKER_STALE_MS)
|
|
158
|
+
rmSync(marker, { force: true });
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
// Gone meanwhile.
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
/** True for "the file is not there": the one error the token calls report as `false` rather than throw. */
|
|
165
|
+
function isGone(err) {
|
|
166
|
+
return err.code === 'ENOENT';
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Refresh the lock's mtime while it still holds `token`: the holder's sign of
|
|
170
|
+
* life, for a rule such as `untouchedFor`. Returns false when the lock is gone
|
|
171
|
+
* or holds another token. Any other error (`EACCES`, `EIO`) is thrown.
|
|
172
|
+
*/
|
|
173
|
+
export function touchLock(path, token) {
|
|
174
|
+
try {
|
|
175
|
+
if (tokenOf(path) !== token)
|
|
176
|
+
return false;
|
|
177
|
+
const now = new Date();
|
|
178
|
+
utimesSync(path, now, now);
|
|
179
|
+
return true;
|
|
180
|
+
}
|
|
181
|
+
catch (err) {
|
|
182
|
+
if (isGone(err))
|
|
183
|
+
return false; // the owner is finishing, or its lock was taken over
|
|
184
|
+
throw err;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Replace the content of the lock while it still holds `token`. `update` gets
|
|
189
|
+
* the current content and returns the new fields; the token is kept. The new
|
|
190
|
+
* content is written to a temp file and renamed over the lock, so a reader
|
|
191
|
+
* never sees it half written. Returns false when the lock is gone or holds
|
|
192
|
+
* another token, and leaves it as it was. An error thrown by `update`, or any
|
|
193
|
+
* error other than "gone" (`ENOSPC`, `EACCES`), is thrown.
|
|
194
|
+
*
|
|
195
|
+
* The token check and the rename are two steps: a takeover that lands between
|
|
196
|
+
* them is overwritten. A rule that never judges a live holder stale, such as
|
|
197
|
+
* `holderGone`, closes that window.
|
|
198
|
+
*/
|
|
199
|
+
export function updateLock(path, token, update) {
|
|
200
|
+
let content;
|
|
201
|
+
try {
|
|
202
|
+
content = parse(readFileSync(path, 'utf-8'));
|
|
203
|
+
}
|
|
204
|
+
catch (err) {
|
|
205
|
+
if (isGone(err))
|
|
206
|
+
return false; // released or taken over
|
|
207
|
+
throw err;
|
|
208
|
+
}
|
|
209
|
+
if (content === null || content.token !== token)
|
|
210
|
+
return false;
|
|
211
|
+
const next = serialize(update(content), token);
|
|
212
|
+
const tmp = `${path}.new-${randomUUID()}`;
|
|
213
|
+
try {
|
|
214
|
+
writeFileSync(tmp, next);
|
|
215
|
+
renameSync(tmp, path);
|
|
216
|
+
return true;
|
|
217
|
+
}
|
|
218
|
+
catch (err) {
|
|
219
|
+
rmSync(tmp, { force: true });
|
|
220
|
+
if (isGone(err))
|
|
221
|
+
return false; // the directory went away meanwhile
|
|
222
|
+
throw err;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Remove the lock only while it holds `token`: an owner whose lock was taken
|
|
227
|
+
* over must not delete the taker's. Returns whether it removed it: false when
|
|
228
|
+
* the lock is gone or holds another token. Any other error is thrown.
|
|
229
|
+
*
|
|
230
|
+
* The lock is renamed aside, which is atomic, and its token checked there, so
|
|
231
|
+
* the file removed is exactly the file checked. The cheap check first means a
|
|
232
|
+
* lock that is plainly someone else's is never moved. If it was taken over in
|
|
233
|
+
* the instant between that check and the rename, it is linked back, unless a
|
|
234
|
+
* new claim also landed in that instant.
|
|
235
|
+
*/
|
|
236
|
+
export function releaseLock(path, token) {
|
|
237
|
+
const aside = `${path}.release-${randomUUID()}`;
|
|
238
|
+
try {
|
|
239
|
+
if (tokenOf(path) !== token)
|
|
240
|
+
return false;
|
|
241
|
+
renameSync(path, aside);
|
|
242
|
+
}
|
|
243
|
+
catch (err) {
|
|
244
|
+
if (isGone(err))
|
|
245
|
+
return false; // already released, or taken over and released
|
|
246
|
+
throw err;
|
|
247
|
+
}
|
|
248
|
+
try {
|
|
249
|
+
if (tokenOf(aside) === token)
|
|
250
|
+
return true;
|
|
251
|
+
linkSync(aside, path); // not ours: put the taker's lock back
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
254
|
+
catch (err) {
|
|
255
|
+
// EEXIST: a new claim landed while the lock was aside, and that claimant holds it now.
|
|
256
|
+
if (err.code === 'EEXIST')
|
|
257
|
+
return false;
|
|
258
|
+
throw err;
|
|
259
|
+
}
|
|
260
|
+
finally {
|
|
261
|
+
rmSync(aside, { force: true });
|
|
262
|
+
}
|
|
263
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-process exclusive file locks for Node.js: a held lock with an owner
|
|
3
|
+
* token, a caller-supplied staleness rule and race-safe takeover, and a short
|
|
4
|
+
* critical section built on it.
|
|
5
|
+
*/
|
|
6
|
+
export { claimLock, MARKER_STALE_MS, releaseLock, touchLock, updateLock, type ClaimOptions, type HeldLock, type LockClaim, type LockContent, type StaleRule, } from './held.ts';
|
|
7
|
+
export { holderGone, pidAlive, untouchedFor, type HolderGoneOptions } from './rules.ts';
|
|
8
|
+
export { LockTimeoutError, withLock, type LockOptions, type SectionHolder } from './section.ts';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-process exclusive file locks for Node.js: a held lock with an owner
|
|
3
|
+
* token, a caller-supplied staleness rule and race-safe takeover, and a short
|
|
4
|
+
* critical section built on it.
|
|
5
|
+
*/
|
|
6
|
+
export { claimLock, MARKER_STALE_MS, releaseLock, touchLock, updateLock, } from "./held.js";
|
|
7
|
+
export { holderGone, pidAlive, untouchedFor } from "./rules.js";
|
|
8
|
+
export { LockTimeoutError, withLock } from "./section.js";
|
package/dist/rules.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Ready-made staleness rules for `claimLock`. */
|
|
2
|
+
import type { LockContent, StaleRule } from './held.ts';
|
|
3
|
+
/**
|
|
4
|
+
* True when a process with `pid` exists. Signal 0 checks without delivering
|
|
5
|
+
* anything; `EPERM` means the process exists and belongs to another user,
|
|
6
|
+
* which counts as alive. A pid that is not a positive integer is not alive.
|
|
7
|
+
*/
|
|
8
|
+
export declare function pidAlive(pid: number): boolean;
|
|
9
|
+
/** Options for {@link holderGone}. */
|
|
10
|
+
export interface HolderGoneOptions {
|
|
11
|
+
/** The liveness probe. Default {@link pidAlive}. */
|
|
12
|
+
alive?: ((pid: number) => boolean) | undefined;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Stale once no pid that `pids` reads from the content is alive. Values that
|
|
16
|
+
* are not numbers name no live holder. A file that is not a JSON object was
|
|
17
|
+
* not written by `claimLock`, so this rule never judges it stale: it belongs
|
|
18
|
+
* to some other program and is left alone. A pid can be recycled by a later process, which
|
|
19
|
+
* then keeps the lock held; a rule that also compares a start time closes
|
|
20
|
+
* that.
|
|
21
|
+
*/
|
|
22
|
+
export declare function holderGone<F extends object = Record<string, unknown>>(pids: (content: LockContent<F>) => readonly unknown[], options?: HolderGoneOptions): StaleRule<F>;
|
|
23
|
+
/**
|
|
24
|
+
* Stale once the lock has not been written or touched for more than `ms`
|
|
25
|
+
* milliseconds. A live holder whose work outlasts `ms` loses the lock, so
|
|
26
|
+
* keep `ms` far above the longest hold, or refresh the lock with `touchLock`.
|
|
27
|
+
*/
|
|
28
|
+
export declare function untouchedFor<F extends object = Record<string, unknown>>(ms: number, now?: () => number): StaleRule<F>;
|
package/dist/rules.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** Ready-made staleness rules for `claimLock`. */
|
|
2
|
+
/**
|
|
3
|
+
* True when a process with `pid` exists. Signal 0 checks without delivering
|
|
4
|
+
* anything; `EPERM` means the process exists and belongs to another user,
|
|
5
|
+
* which counts as alive. A pid that is not a positive integer is not alive.
|
|
6
|
+
*/
|
|
7
|
+
export function pidAlive(pid) {
|
|
8
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
9
|
+
return false;
|
|
10
|
+
try {
|
|
11
|
+
process.kill(pid, 0);
|
|
12
|
+
return true;
|
|
13
|
+
}
|
|
14
|
+
catch (err) {
|
|
15
|
+
return err.code === 'EPERM';
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Stale once no pid that `pids` reads from the content is alive. Values that
|
|
20
|
+
* are not numbers name no live holder. A file that is not a JSON object was
|
|
21
|
+
* not written by `claimLock`, so this rule never judges it stale: it belongs
|
|
22
|
+
* to some other program and is left alone. A pid can be recycled by a later process, which
|
|
23
|
+
* then keeps the lock held; a rule that also compares a start time closes
|
|
24
|
+
* that.
|
|
25
|
+
*/
|
|
26
|
+
export function holderGone(pids, options = {}) {
|
|
27
|
+
const alive = options.alive ?? pidAlive;
|
|
28
|
+
return (held) => {
|
|
29
|
+
if (held.content === null)
|
|
30
|
+
return false;
|
|
31
|
+
return !pids(held.content).some((pid) => typeof pid === 'number' && alive(pid));
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Stale once the lock has not been written or touched for more than `ms`
|
|
36
|
+
* milliseconds. A live holder whose work outlasts `ms` loses the lock, so
|
|
37
|
+
* keep `ms` far above the longest hold, or refresh the lock with `touchLock`.
|
|
38
|
+
*/
|
|
39
|
+
export function untouchedFor(ms, now = Date.now) {
|
|
40
|
+
return (held) => now() - held.mtimeMs > ms;
|
|
41
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The short critical section: hold a lock for the length of a synchronous
|
|
3
|
+
* function, such as a re-read, validate and rewrite of one file.
|
|
4
|
+
*/
|
|
5
|
+
import { type HeldLock, type StaleRule } from './held.ts';
|
|
6
|
+
/** What a {@link withLock} lock records: the holder's pid. */
|
|
7
|
+
export interface SectionHolder {
|
|
8
|
+
pid?: number;
|
|
9
|
+
}
|
|
10
|
+
/** Options for {@link withLock}. */
|
|
11
|
+
export interface LockOptions {
|
|
12
|
+
/** A lock untouched for longer than this, in milliseconds, is a dead holder's and is taken over. Default 10 s. */
|
|
13
|
+
staleMs?: number | undefined;
|
|
14
|
+
/**
|
|
15
|
+
* Replaces the age rule: when a held lock may be taken over. For a section
|
|
16
|
+
* that can legitimately run long, `holderGone((h) => [h.pid])` waits for the
|
|
17
|
+
* holder process to exit instead of guessing by age. `staleMs` is then unused.
|
|
18
|
+
*/
|
|
19
|
+
isStale?: StaleRule<SectionHolder> | undefined;
|
|
20
|
+
/** Give up and throw {@link LockTimeoutError} after waiting this long. 0 tries once. Default 30 s. */
|
|
21
|
+
timeoutMs?: number | undefined;
|
|
22
|
+
/** Pause between attempts, in milliseconds. Default 5. */
|
|
23
|
+
retryMs?: number | undefined;
|
|
24
|
+
}
|
|
25
|
+
/** {@link withLock} waited `timeoutMs` and the lock stayed held. */
|
|
26
|
+
export declare class LockTimeoutError extends Error {
|
|
27
|
+
/** The lock that stayed held. */
|
|
28
|
+
readonly path: string;
|
|
29
|
+
/** The holder seen last, or null when the lock kept changing. */
|
|
30
|
+
readonly holder: HeldLock<SectionHolder> | null;
|
|
31
|
+
constructor(path: string, holder: HeldLock<SectionHolder> | null);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Run `fn` holding the lock at `path`, and release it when `fn` returns or
|
|
35
|
+
* throws. The lock records this process's pid. Waiting blocks the thread, so
|
|
36
|
+
* keep the section short. `fn` must be synchronous: one that returns a promise
|
|
37
|
+
* is refused with a `TypeError`, because the lock would be released before the
|
|
38
|
+
* awaited work ran. Use `claimLock` and `releaseLock` around async work.
|
|
39
|
+
*
|
|
40
|
+
* By default a lock untouched for `staleMs` is taken to be a killed holder's.
|
|
41
|
+
* That is a guess: a live holder whose section outlasts `staleMs` loses its
|
|
42
|
+
* lock to the next waiter, and both then run. The first one's release leaves
|
|
43
|
+
* the taker's lock alone, and that loss is not reported. Keep `staleMs` far
|
|
44
|
+
* above the longest section, or pass an `isStale` rule that does not guess by
|
|
45
|
+
* age, such as `holderGone`.
|
|
46
|
+
*/
|
|
47
|
+
export declare function withLock<T>(path: string, fn: () => T, options?: LockOptions): T;
|
package/dist/section.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The short critical section: hold a lock for the length of a synchronous
|
|
3
|
+
* function, such as a re-read, validate and rewrite of one file.
|
|
4
|
+
*/
|
|
5
|
+
import { prepareClaim, releaseLock } from "./held.js";
|
|
6
|
+
import { untouchedFor } from "./rules.js";
|
|
7
|
+
/** {@link withLock} waited `timeoutMs` and the lock stayed held. */
|
|
8
|
+
export class LockTimeoutError extends Error {
|
|
9
|
+
/** The lock that stayed held. */
|
|
10
|
+
path;
|
|
11
|
+
/** The holder seen last, or null when the lock kept changing. */
|
|
12
|
+
holder;
|
|
13
|
+
constructor(path, holder) {
|
|
14
|
+
super(`timed out waiting for lock ${path}`);
|
|
15
|
+
this.name = 'LockTimeoutError';
|
|
16
|
+
this.path = path;
|
|
17
|
+
this.holder = holder;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/** Nothing ever notifies it, so `Atomics.wait` on it just sleeps. */
|
|
21
|
+
const SLEEPER = new Int32Array(new SharedArrayBuffer(4));
|
|
22
|
+
/** Block the thread for `ms` without spinning. */
|
|
23
|
+
function sleepSync(ms) {
|
|
24
|
+
Atomics.wait(SLEEPER, 0, 0, ms);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Run `fn` holding the lock at `path`, and release it when `fn` returns or
|
|
28
|
+
* throws. The lock records this process's pid. Waiting blocks the thread, so
|
|
29
|
+
* keep the section short. `fn` must be synchronous: one that returns a promise
|
|
30
|
+
* is refused with a `TypeError`, because the lock would be released before the
|
|
31
|
+
* awaited work ran. Use `claimLock` and `releaseLock` around async work.
|
|
32
|
+
*
|
|
33
|
+
* By default a lock untouched for `staleMs` is taken to be a killed holder's.
|
|
34
|
+
* That is a guess: a live holder whose section outlasts `staleMs` loses its
|
|
35
|
+
* lock to the next waiter, and both then run. The first one's release leaves
|
|
36
|
+
* the taker's lock alone, and that loss is not reported. Keep `staleMs` far
|
|
37
|
+
* above the longest section, or pass an `isStale` rule that does not guess by
|
|
38
|
+
* age, such as `holderGone`.
|
|
39
|
+
*/
|
|
40
|
+
export function withLock(path, fn, options = {}) {
|
|
41
|
+
const { staleMs = 10_000, timeoutMs = 30_000, retryMs = 5 } = options;
|
|
42
|
+
const isStale = options.isStale ?? untouchedFor(staleMs);
|
|
43
|
+
const deadline = Date.now() + timeoutMs;
|
|
44
|
+
// The temp file is written once; each retry only tries the link again.
|
|
45
|
+
const prepared = prepareClaim(path, { pid: process.pid }, { isStale });
|
|
46
|
+
let claim;
|
|
47
|
+
try {
|
|
48
|
+
claim = prepared.attempt();
|
|
49
|
+
while (!claim.ok) {
|
|
50
|
+
if (Date.now() >= deadline)
|
|
51
|
+
throw new LockTimeoutError(path, claim.holder);
|
|
52
|
+
sleepSync(Math.min(retryMs, deadline - Date.now()));
|
|
53
|
+
claim = prepared.attempt();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
prepared.dispose();
|
|
58
|
+
}
|
|
59
|
+
const { token } = claim;
|
|
60
|
+
let result;
|
|
61
|
+
try {
|
|
62
|
+
result = fn();
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
try {
|
|
66
|
+
releaseLock(path, token);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// The section's own error is the one to report.
|
|
70
|
+
}
|
|
71
|
+
throw err;
|
|
72
|
+
}
|
|
73
|
+
releaseLock(path, token);
|
|
74
|
+
if (typeof result?.then === 'function') {
|
|
75
|
+
throw new TypeError(`withLock(${path}): fn returned a promise, so its awaited work ran after the lock was released; use claimLock and releaseLock around async work`);
|
|
76
|
+
}
|
|
77
|
+
return result;
|
|
78
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,70 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@osdlc/file-lock",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.0.1-stage",
|
|
4
|
+
"description": "Cross-process exclusive file locks for Node.js: a held lock with an owner token, a staleness rule you choose (holder process gone, untouched too long) and race-safe takeover of a stale lock, plus a short synchronous critical section built on it.",
|
|
5
|
+
"license": "MIT OR Apache-2.0",
|
|
6
|
+
"author": "sksizer",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/osdlc/osdlc.git",
|
|
10
|
+
"directory": "packages/ts/file-lock"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/osdlc/osdlc/tree/main/packages/ts/file-lock#readme",
|
|
13
|
+
"bugs": "https://github.com/osdlc/osdlc/issues",
|
|
14
|
+
"keywords": [
|
|
15
|
+
"lock",
|
|
16
|
+
"lockfile",
|
|
17
|
+
"file-lock",
|
|
18
|
+
"mutex",
|
|
19
|
+
"exclusive",
|
|
20
|
+
"cross-process",
|
|
21
|
+
"stale-lock",
|
|
22
|
+
"critical-section",
|
|
23
|
+
"typescript"
|
|
24
|
+
],
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public"
|
|
27
|
+
},
|
|
28
|
+
"type": "module",
|
|
29
|
+
"sideEffects": false,
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=22"
|
|
32
|
+
},
|
|
33
|
+
"exports": {
|
|
34
|
+
".": {
|
|
35
|
+
"types": "./dist/index.d.ts",
|
|
36
|
+
"default": "./dist/index.js"
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
"files": [
|
|
40
|
+
"dist/",
|
|
41
|
+
"README.md",
|
|
42
|
+
"CHANGELOG.md",
|
|
43
|
+
"LICENSE-MIT",
|
|
44
|
+
"LICENSE-APACHE",
|
|
45
|
+
"SECURITY.md",
|
|
46
|
+
"CONTRIBUTING.md"
|
|
47
|
+
],
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@types/node": "^26.1.0",
|
|
50
|
+
"@vitest/coverage-v8": "^5.0.5",
|
|
51
|
+
"oxlint": "^1.65.0",
|
|
52
|
+
"prettier": "3.9.6",
|
|
53
|
+
"typescript": "~6.0.3",
|
|
54
|
+
"vitest": "^5.0.5",
|
|
55
|
+
"@osdlc/tsconfig": "^0.4.1",
|
|
56
|
+
"@osdlc/prettier-config": "^0.3.1",
|
|
57
|
+
"@osdlc/etag-write": "^0.3.0"
|
|
58
|
+
},
|
|
59
|
+
"scripts": {
|
|
60
|
+
"build": "tsc -p tsconfig.build.json",
|
|
61
|
+
"clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"",
|
|
62
|
+
"test": "vitest run",
|
|
63
|
+
"coverage": "vitest run --coverage",
|
|
64
|
+
"smoke": "node tests/smoke.mjs",
|
|
65
|
+
"format": "prettier --write .",
|
|
66
|
+
"format:check": "prettier --check .",
|
|
67
|
+
"lint": "oxlint .",
|
|
68
|
+
"typecheck": "tsc --noEmit && tsc -p tsconfig.build.json --noEmit"
|
|
69
|
+
}
|
|
6
70
|
}
|