@osdlc/file-lock 0.0.0-stage → 0.1.0

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 ADDED
@@ -0,0 +1,11 @@
1
+ # @osdlc/file-lock
2
+
3
+ ## 0.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 9f5a029: First release. Cross-process exclusive file locks for Node.js, in two shapes on one mechanism:
8
+
9
+ - A held lock: `claimLock(path, content, { isStale })` writes the content and a random token to a temp file and hard-links it into place, so the lock never exists empty. A lock the rule judges stale is taken over through a marker named from a hash of its content, so one reaper wins and a fresh lock is never deleted. `touchLock`, `updateLock` and `releaseLock` act only while the lock still holds the claim's token.
10
+ - Ready-made rules: `holderGone(pids)`, stale once no recorded pid is alive (`pidAlive`), and `untouchedFor(ms)`.
11
+ - A short critical section: `withLock(path, fn, { staleMs, timeoutMs, retryMs, isStale })` runs a synchronous `fn` holding the lock and throws `LockTimeoutError` when it stays held. Its rule is the lock's age unless `isStale` names another.
@@ -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
- # Temporary Holding Version
1
+ # @osdlc/file-lock
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
+ }
@@ -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";
@@ -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;
@@ -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.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
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/etag-write": "^0.3.1",
56
+ "@osdlc/prettier-config": "^0.3.1",
57
+ "@osdlc/tsconfig": "^0.4.1"
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
  }