@deepseek-ai/dsh-atomic-write 0.1.1-rc.2 → 0.1.2-alpha.2
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/README.i18n.yaml +2 -2
- package/README.md +107 -19
- package/README.zh.md +108 -20
- package/lib/index.js +33 -3
- package/lib/types/index.d.ts +4 -2
- package/package.json +5 -5
package/README.i18n.yaml
CHANGED
|
@@ -2,5 +2,5 @@
|
|
|
2
2
|
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
|
3
3
|
# after editing either side, bring the other along and re-record with:
|
|
4
4
|
# pnpm run verify-translation-pairing --write packages/util/atomic-write/README.md
|
|
5
|
-
README.md:
|
|
6
|
-
README.zh.md:
|
|
5
|
+
README.md: 69daf671ba9d1269643533a6bb6e64462b8bee05
|
|
6
|
+
README.zh.md: 8a8613c673c4d12634c686cda7f2ced9957492b2
|
package/README.md
CHANGED
|
@@ -1,47 +1,135 @@
|
|
|
1
|
-
|
|
1
|
+
---
|
|
2
|
+
description: "Atomic file replacement and cross-process writer locking for packages that must never leave partial, symlink-hijacked, or wider-permission content on disk."
|
|
3
|
+
kind: "package-library"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# @deepseek-ai/dsh-atomic-write
|
|
2
7
|
|
|
3
8
|
English | [中文](README.zh.md)
|
|
4
9
|
|
|
5
|
-
|
|
10
|
+
## Summary
|
|
11
|
+
|
|
12
|
+
`dsh-atomic-write` replaces a file's contents in one atomic step: readers of the target always observe either the complete old content or the complete new content, never a partial write. It also serializes read-modify-write cycles across processes with a writer lock, so concurrent writers of one file cannot resurrect each other's state. The caller states the permission bits for every replacement and the fresh inode carries them through the swap, so replacing a wider-permission file narrows it without a chmod race. It is a zero-dependency library shared by file-backed stores such as the user-settings document and the credentials store; a `cordis.yml` cannot load it, and crash durability is the caller's policy because there is no `fsync`.
|
|
13
|
+
|
|
14
|
+
## Table of Contents
|
|
15
|
+
|
|
16
|
+
- [Use this package](#use-this-package)
|
|
17
|
+
- [Understand the implementation](#understand-the-implementation)
|
|
18
|
+
- [Further Exploration](#further-exploration)
|
|
19
|
+
- [Model Experience](#model-experience)
|
|
20
|
+
- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
|
|
21
|
+
- [Dev Note](#dev-note)
|
|
22
|
+
|
|
23
|
+
-----
|
|
24
|
+
|
|
25
|
+
<a id="use-this-package"></a>
|
|
26
|
+
## Use this package
|
|
6
27
|
|
|
7
|
-
|
|
28
|
+
Use `writeFileAtomic` when a file-backed store must replace one already-rendered string without ever exposing a partial, symlink-hijacked, or wider-permission state, and `withFileLock` when several processes read-modify-write the same file. The smallest path is one call with the final content and the replacement's permission bits.
|
|
29
|
+
|
|
30
|
+
### Writing a file atomically
|
|
8
31
|
|
|
9
32
|
```ts
|
|
10
|
-
import {
|
|
33
|
+
import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
|
|
11
34
|
|
|
12
35
|
declare const text: string
|
|
13
|
-
declare const render: (previous: string) => string
|
|
14
|
-
|
|
15
36
|
await writeFileAtomic('/home/u/.dsh/settings.yaml', text, { mode: 0o600 })
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Parent directories are created as needed, and readers observe either the old or the new complete content. On Windows, transient replacement interference reported as `EACCES`, `EBUSY`, or `EPERM` is retried for a bounded interval; any remaining failure removes the temporary file and leaves the target untouched.
|
|
40
|
+
|
|
41
|
+
### Coordinating writers
|
|
42
|
+
|
|
43
|
+
For a read-render-commit cycle that a bare atomic commit cannot make safe on its own, hold the writer lock around the operation:
|
|
44
|
+
|
|
45
|
+
```text
|
|
46
|
+
import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
|
|
47
|
+
|
|
48
|
+
declare const render: (previous: string) => string
|
|
49
|
+
declare const readCurrent: () => Promise<string>
|
|
16
50
|
|
|
17
|
-
// Read-modify-write against the same file from several processes.
|
|
18
51
|
await withFileLock('/home/u/.dsh/settings.yaml', async () => {
|
|
19
|
-
|
|
52
|
+
const previous = await readCurrent()
|
|
53
|
+
await writeFileAtomic('/home/u/.dsh/settings.yaml', render(previous), { mode: 0o600 })
|
|
20
54
|
})
|
|
21
55
|
```
|
|
22
56
|
|
|
23
|
-
`
|
|
57
|
+
Only writers contend — readers never take the lock — and a contender backs off exponentially and fails with a timed-out error rather than blocking forever. How long a contender waits is stated per call through `waitMs`: the default is sized for file work alone, so a holder whose cycle includes a network round trip — a credential mutation that refreshes an expired token — states a longer one, because leaving the default would fail every other writer of that file for the duration. The retry cadence stays fixed. A contender never removes an existing lock, because file age cannot prove that its owner stopped.
|
|
58
|
+
|
|
59
|
+
### Failures to plan for
|
|
60
|
+
|
|
61
|
+
The lock's parent directory must already exist, so `withFileLock` rejects an invalid parent hierarchy before running the operation. A process that exits while holding the lock leaves the lock sibling behind; later writers time out, and an operator removes it only after verifying that no writer still owns it.
|
|
62
|
+
|
|
63
|
+
-----
|
|
24
64
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
- **`rename` replaces a symlinked target itself**, never writing through to its referent.
|
|
28
|
-
- **Same-directory sibling** keeps the rename on one filesystem, so the swap stays atomic.
|
|
29
|
-
- Parent directories are created; on any failure the temp is removed and the failure rethrown; readers observe either the old or the new complete content.
|
|
65
|
+
<a id="understand-the-implementation"></a>
|
|
66
|
+
## Understand the implementation
|
|
30
67
|
|
|
31
|
-
|
|
68
|
+
<details>
|
|
69
|
+
<summary>Implementation internals — click to expand</summary>
|
|
32
70
|
|
|
33
|
-
|
|
71
|
+
The package is built on one separation: the atomic commit owns the swap, and the writer lock owns cross-process ordering.
|
|
34
72
|
|
|
73
|
+
### Source map
|
|
74
|
+
|
|
75
|
+
| File | Role |
|
|
76
|
+
|---|---|
|
|
77
|
+
| [`src/index.ts`](src/index.ts) | `writeFileAtomic` and `withFileLock`, the package's whole surface |
|
|
78
|
+
| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; the replacement contract is exercised by unit tests) |
|
|
79
|
+
|
|
80
|
+
### Write path
|
|
81
|
+
|
|
82
|
+
`writeFileAtomic` writes a random-suffix sibling opened with exclusive create (`wx`), then renames it over the target. The exclusive open refuses to follow a symlink planted at a guessable temp path; the same-directory sibling keeps the rename on one filesystem; and the rename replaces a symlinked target itself instead of writing through to its referent. A Windows retry keeps the same complete sibling and uses bounded exponential backoff, so temporary use of the target by software outside the cooperative writer lock cannot turn a safe replacement into an immediate failure; the [retry decision](../../../.agents/notes/implemented/bug-fix/2026-08-29-windows-atomic-replace-retry.md) owns the rationale and rejected alternatives.
|
|
83
|
+
|
|
84
|
+
`withFileLock` creates a `<filename>.lock` sibling with `wx`. `EEXIST` identifies contention directly; `EPERM` does so only when a fresh `lstat` confirms the lock path exists, covering Windows exclusive-create behavior without hiding an unrelated permission failure. The lock records its creator's PID and is removed by the holder in a `finally`; contention backs off exponentially and fails when the per-call `waitMs` deadline (default two seconds) passes.
|
|
85
|
+
|
|
86
|
+
### Why the swap stays safe
|
|
87
|
+
|
|
88
|
+
- **Fresh inode, caller-stated mode** — the temp carries `mode` through the rename, so narrowing a wider-permission file has no chmod race. `mode` is required so the permission decision stays visible at every call site.
|
|
89
|
+
- **Readers never contend** — the rename commit is atomic, so a reader needs no lock.
|
|
90
|
+
- **A contender never deletes a lock** — age cannot distinguish a crashed owner from a paused live writer; recovery is an operator action.
|
|
91
|
+
|
|
92
|
+
</details>
|
|
93
|
+
|
|
94
|
+
-----
|
|
95
|
+
|
|
96
|
+
<a id="further-exploration"></a>
|
|
97
|
+
## Further Exploration
|
|
98
|
+
|
|
99
|
+
Read these pages when you need the consuming stores or the family this primitive belongs to.
|
|
100
|
+
|
|
101
|
+
- [User-settings file store](../../settings/settings-file/README.md) — the settings document every write replaces through this package.
|
|
102
|
+
- [Credentials store](../../credentials/credentials-local/README.md) — the credentials file this package locks and replaces.
|
|
103
|
+
- [util group map](../README.md) — the zero-dependency utility family this package belongs to.
|
|
104
|
+
|
|
105
|
+
-----
|
|
106
|
+
|
|
107
|
+
<a id="model-experience"></a>
|
|
35
108
|
## Model Experience
|
|
36
109
|
|
|
37
|
-
None, as this is a pure filesystem primitive
|
|
110
|
+
None, as this is a pure filesystem write primitive that registers nothing model-facing.
|
|
38
111
|
|
|
39
112
|
#### KV Cache effect
|
|
40
113
|
|
|
41
|
-
|
|
114
|
+
Nothing here enters a request prefix, so provider cache reuse is unaffected.
|
|
42
115
|
|
|
43
116
|
## Known Limitations and Deferred Work
|
|
44
117
|
|
|
118
|
+
<a id="known-limitations-and-deferred-work"></a>
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
These limits define where the package is not the right tool. They are current package constraints, not a task backlog.
|
|
122
|
+
|
|
45
123
|
- **Atomic, not durable** — no `fsync` of the file or its directory, so after a crash the rename may be observed unwound. The file-backed stores here re-read and republish on boot, keeping durability the caller's policy.
|
|
46
124
|
- **String content only** — no `Buffer` or stream form until a consumer needs one.
|
|
47
|
-
- **Orphaned locks require operator recovery** — a process that exits while holding the lock
|
|
125
|
+
- **Orphaned locks require operator recovery** — a process that exits while holding the lock leaves the sibling behind; later writers time out without deleting it.
|
|
126
|
+
|
|
127
|
+
<a id="dev-note"></a>
|
|
128
|
+
### Dev Note
|
|
129
|
+
|
|
130
|
+
<details>
|
|
131
|
+
<summary>Working context for maintainers — click to expand</summary>
|
|
132
|
+
|
|
133
|
+
A durability-replacement that `fsync`s the file and parent directory and preserves owner-only permissions on Windows remains open (tracked as `settings-atomic-durability` in source).
|
|
134
|
+
|
|
135
|
+
</details>
|
package/README.zh.md
CHANGED
|
@@ -1,47 +1,135 @@
|
|
|
1
|
-
|
|
1
|
+
---
|
|
2
|
+
description: "原子文件替换与跨进程写锁,供绝不允许在磁盘上留下不完整、被符号链接劫持或权限过宽内容的包使用。"
|
|
3
|
+
kind: "package-library"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# @deepseek-ai/dsh-atomic-write
|
|
2
7
|
|
|
3
8
|
[English](README.md) | 中文
|
|
4
9
|
|
|
5
|
-
|
|
10
|
+
## 概述
|
|
11
|
+
|
|
12
|
+
`dsh-atomic-write` 一步原子地替换文件内容:目标的读取方总是看到完整的旧内容或完整的新内容,绝不看到部分写入。它还通过写锁跨进程串行化读-渲染-提交循环,因此同一文件的并发写入方无法复活彼此替换掉的状态。调用方为每次替换声明权限位,全新 inode 会带着这些权限位走完交换,因此替换权限过宽的旧文件时会直接收窄,不存在 chmod 竞态。它是一个零依赖库,由用户设置文档与凭据存储这类文件型存储共享;`cordis.yml` 无法加载它,而且由于没有 `fsync`,崩溃持久性由调用方负责。
|
|
13
|
+
|
|
14
|
+
## 目录
|
|
15
|
+
|
|
16
|
+
- [使用本包](#use-this-package)
|
|
17
|
+
- [理解实现](#understand-the-implementation)
|
|
18
|
+
- [进一步探索](#further-exploration)
|
|
19
|
+
- [模型体验](#model-experience)
|
|
20
|
+
- [已知限制与延期工作](#known-limitations-and-deferred-work)
|
|
21
|
+
- [开发备注](#dev-note)
|
|
22
|
+
|
|
23
|
+
-----
|
|
24
|
+
|
|
25
|
+
<a id="use-this-package"></a>
|
|
26
|
+
## 使用本包
|
|
6
27
|
|
|
7
|
-
|
|
28
|
+
当文件型存储必须替换一份已渲染好的字符串、且绝不允许暴露部分写入、符号链接劫持或权限过宽状态时,使用 `writeFileAtomic`;当多个进程读写同一文件时,使用 `withFileLock`。最小路径是一次调用,传入最终内容与替换 inode 的权限位。
|
|
29
|
+
|
|
30
|
+
### 原子写入文件
|
|
8
31
|
|
|
9
32
|
```ts
|
|
10
|
-
import {
|
|
33
|
+
import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
|
|
11
34
|
|
|
12
35
|
declare const text: string
|
|
13
|
-
declare const render: (previous: string) => string
|
|
14
|
-
|
|
15
36
|
await writeFileAtomic('/home/u/.dsh/settings.yaml', text, { mode: 0o600 })
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
父目录会按需创建,读取方只会观察到旧内容或完整的新内容。在 Windows 上,报告为 `EACCES`、`EBUSY` 或 `EPERM` 的瞬时替换干扰会在有界时间内重试;任何剩余失败都会移除临时文件,并保持目标文件不变。
|
|
40
|
+
|
|
41
|
+
### 协调写入方
|
|
42
|
+
|
|
43
|
+
对于单靠原子提交无法保证安全的读-渲染-提交循环,请在操作期间持有写锁:
|
|
44
|
+
|
|
45
|
+
```text
|
|
46
|
+
import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
|
|
47
|
+
|
|
48
|
+
declare const render: (previous: string) => string
|
|
49
|
+
declare const readCurrent: () => Promise<string>
|
|
16
50
|
|
|
17
|
-
// Read-modify-write against the same file from several processes.
|
|
18
51
|
await withFileLock('/home/u/.dsh/settings.yaml', async () => {
|
|
19
|
-
|
|
52
|
+
const previous = await readCurrent()
|
|
53
|
+
await writeFileAtomic('/home/u/.dsh/settings.yaml', render(previous), { mode: 0o600 })
|
|
20
54
|
})
|
|
21
55
|
```
|
|
22
56
|
|
|
23
|
-
`
|
|
57
|
+
只有写入方会竞争——读取方从不取锁——竞争者按指数退避,超时即以错误失败,而不是无限阻塞。竞争者等待多久由每次调用经 `waitMs` 声明:默认值只按纯文件工作量级选定,因此持锁方循环若包含一次网络往返——例如刷新过期 token 的凭据变更——就应声明更长的值,否则该文件的其他写入方在这段时间内都会失败。退避节奏保持固定。竞争者绝不移除已有锁,因为文件存续时间无法证明其持有者已经停止。
|
|
58
|
+
|
|
59
|
+
### 需要规划的失败
|
|
60
|
+
|
|
61
|
+
锁的父目录必须已经存在,因此 `withFileLock` 会在运行操作之前拒绝无效的父目录层级。持锁进程退出时会把锁文件留在原地;后续写入方超时失败,操作者只有在确认没有写入方仍持有该锁后才会移除它。
|
|
62
|
+
|
|
63
|
+
-----
|
|
24
64
|
|
|
25
|
-
-
|
|
26
|
-
|
|
27
|
-
- **`rename` 替换的是符号链接目标本身**,绝不写穿到其指向的文件。
|
|
28
|
-
- **同目录兄弟文件**保证 rename 落在同一文件系统上,交换保持原子。
|
|
29
|
-
- 自动创建父目录;任何失败都会移除临时文件并重新抛出该失败;读取方只会观察到旧内容或完整的新内容。
|
|
65
|
+
<a id="understand-the-implementation"></a>
|
|
66
|
+
## 理解实现
|
|
30
67
|
|
|
31
|
-
|
|
68
|
+
<details>
|
|
69
|
+
<summary>实现细节——点击展开</summary>
|
|
32
70
|
|
|
33
|
-
|
|
71
|
+
本包建立在一个分离之上:原子提交负责交换,写锁负责跨进程排序。
|
|
34
72
|
|
|
73
|
+
### 源码地图
|
|
74
|
+
|
|
75
|
+
| 文件 | 职责 |
|
|
76
|
+
|---|---|
|
|
77
|
+
| [`src/index.ts`](src/index.ts) | `writeFileAtomic` 与 `withFileLock`,即本包的全部接口 |
|
|
78
|
+
| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;替换约定由单元测试覆盖) |
|
|
79
|
+
|
|
80
|
+
### 写入路径
|
|
81
|
+
|
|
82
|
+
`writeFileAtomic` 先以独占创建(`wx`)打开一个随机后缀的同级文件并写入内容,然后 rename 到目标上。独占打开拒绝跟随预先埋在可猜测临时路径上的符号链接;同目录兄弟文件保证 rename 落在同一文件系统上;rename 替换的是符号链接目标本身,绝不写穿到其指向的文件。Windows 重试会保留同一份完整的兄弟文件,并采用有界指数退避,因此协作式写锁之外的软件瞬时占用目标时,不会让安全替换立即失败;[重试决策](../../../.agents/notes/implemented/bug-fix/2026-08-29-windows-atomic-replace-retry.zh.md)记录了理由与被拒绝的替代方案。
|
|
83
|
+
|
|
84
|
+
`withFileLock` 以 `wx` 创建 `<filename>.lock` 同级文件。`EEXIST` 直接表示竞争;只有一次新的 `lstat` 确认锁路径存在时,`EPERM` 才表示竞争,从而兼容 Windows 的独占创建行为,又不掩盖无关的权限故障。锁记录创建者的 PID,由持有者在 `finally` 中移除;竞争按指数退避,在每次调用声明的 `waitMs` 期限(默认两秒)过后失败。
|
|
85
|
+
|
|
86
|
+
### 交换为何安全
|
|
87
|
+
|
|
88
|
+
- **全新 inode,调用方声明的权限位**——临时文件带着 `mode` 走完 rename,因此收窄权限过宽的文件没有 chmod 竞态。`mode` 为必填,让权限决策始终可见于每个调用点。
|
|
89
|
+
- **读取方从不竞争**——rename 提交是原子的,读取方无需加锁。
|
|
90
|
+
- **竞争者绝不移除锁**——文件存续时间无法区分已崩溃的所有者与暂停但仍存活的写入方;恢复是操作者的动作。
|
|
91
|
+
|
|
92
|
+
</details>
|
|
93
|
+
|
|
94
|
+
-----
|
|
95
|
+
|
|
96
|
+
<a id="further-exploration"></a>
|
|
97
|
+
## 进一步探索
|
|
98
|
+
|
|
99
|
+
当你需要了解消费它的存储或本原语所属的家族时,阅读以下页面。
|
|
100
|
+
|
|
101
|
+
- [用户设置文件存储](../../settings/settings-file/README.zh.md)——每次写入都通过本包替换的设置文档。
|
|
102
|
+
- [凭据存储](../../credentials/credentials-local/README.zh.md)——本包加锁并替换的凭据文件。
|
|
103
|
+
- [util 组映射](../README.zh.md)——本包所属的零依赖工具家族。
|
|
104
|
+
|
|
105
|
+
-----
|
|
106
|
+
|
|
107
|
+
<a id="model-experience"></a>
|
|
35
108
|
## 模型体验
|
|
36
109
|
|
|
37
|
-
|
|
110
|
+
无:本包是纯文件系统写入原语,不注册任何面向模型的内容。
|
|
38
111
|
|
|
39
112
|
#### KV Cache 影响
|
|
40
113
|
|
|
41
|
-
|
|
114
|
+
此处没有任何内容进入请求前缀,因此提供方缓存复用不受影响。
|
|
115
|
+
|
|
116
|
+
## 已知限制与延期工作
|
|
117
|
+
|
|
118
|
+
<a id="known-limitations-and-deferred-work"></a>
|
|
42
119
|
|
|
43
|
-
|
|
120
|
+
|
|
121
|
+
这些限制说明本包何时不是合适的工具。它们是当前包约束,不是任务积压。
|
|
44
122
|
|
|
45
123
|
- **原子但不保证持久**——不对文件或其所在目录做 `fsync`,因此崩溃后可能观察到 rename 被回退。此处的文件型存储在启动时重新读取并重新发布,把持久性留作调用方的策略。
|
|
46
124
|
- **仅支持字符串内容**——在有消费方需要之前,不提供 `Buffer` 或流式形态。
|
|
47
|
-
-
|
|
125
|
+
- **遗留锁需要操作者恢复**——持锁进程退出时可能留下同级锁文件;后续写入方超时也不会删除它。
|
|
126
|
+
|
|
127
|
+
<a id="dev-note"></a>
|
|
128
|
+
### 开发备注
|
|
129
|
+
|
|
130
|
+
<details>
|
|
131
|
+
<summary>维护者的工作上下文——点击展开</summary>
|
|
132
|
+
|
|
133
|
+
一种对文件及其父目录执行 `fsync`、并在 Windows 上保留仅属主权限的持久性替换方案仍未实现(在源码中记录为 `settings-atomic-durability`)。
|
|
134
|
+
|
|
135
|
+
</details>
|
package/lib/index.js
CHANGED
|
@@ -13,6 +13,34 @@ import { dirname } from "node:path";
|
|
|
13
13
|
* replaced; readers stay lock-free because the rename commit is atomic.
|
|
14
14
|
* @module @deepseek-ai/dsh-atomic-write
|
|
15
15
|
*/
|
|
16
|
+
const WINDOWS_TRANSIENT_RENAME_ERRORS = new Set([
|
|
17
|
+
"EACCES",
|
|
18
|
+
"EBUSY",
|
|
19
|
+
"EPERM"
|
|
20
|
+
]);
|
|
21
|
+
const WINDOWS_RENAME_RETRY_INITIAL_MS = 20;
|
|
22
|
+
const WINDOWS_RENAME_RETRY_MAX_MS = 200;
|
|
23
|
+
const WINDOWS_RENAME_RETRY_LIMIT = 8;
|
|
24
|
+
/** Whether Windows reported temporary interference with an atomic replacement. */
|
|
25
|
+
function isTransientWindowsRenameError(error) {
|
|
26
|
+
if (process.platform !== "win32") return false;
|
|
27
|
+
return WINDOWS_TRANSIENT_RENAME_ERRORS.has(error?.code ?? "");
|
|
28
|
+
}
|
|
29
|
+
/** Replace the target after bounded retries for transient Windows interference. */
|
|
30
|
+
async function renameAtomicTemp(temp, filename) {
|
|
31
|
+
let delay = WINDOWS_RENAME_RETRY_INITIAL_MS;
|
|
32
|
+
for (let retries = 0;; retries += 1) {
|
|
33
|
+
try {
|
|
34
|
+
await rename(temp, filename);
|
|
35
|
+
return;
|
|
36
|
+
} catch (error) {
|
|
37
|
+
if (!isTransientWindowsRenameError(error)) throw error;
|
|
38
|
+
if (retries >= WINDOWS_RENAME_RETRY_LIMIT) throw error;
|
|
39
|
+
}
|
|
40
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
41
|
+
delay = Math.min(delay * 2, WINDOWS_RENAME_RETRY_MAX_MS);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
16
44
|
/**
|
|
17
45
|
* Replace `filename` with `content` in one atomic step, creating parent
|
|
18
46
|
* directories. The content is first written to a random-suffix sibling opened
|
|
@@ -21,8 +49,10 @@ import { dirname } from "node:path";
|
|
|
21
49
|
* rename, so replacing a wider-permission file narrows it without a chmod
|
|
22
50
|
* race. The rename also replaces a symlinked target itself instead of writing
|
|
23
51
|
* through to its referent, and the same-directory sibling keeps the rename on
|
|
24
|
-
* one filesystem.
|
|
25
|
-
*
|
|
52
|
+
* one filesystem. Windows replacement retries transient `EACCES`, `EBUSY`,
|
|
53
|
+
* and `EPERM` failures for a bounded interval while the complete temp file
|
|
54
|
+
* remains the rename source. On any remaining failure the temp file is
|
|
55
|
+
* removed and the failure rethrown. Crash durability (fsync) is out of scope.
|
|
26
56
|
* @param filename - final path receiving the content.
|
|
27
57
|
* @param content - complete next file content.
|
|
28
58
|
* @param options - permission bits for the replacement inode.
|
|
@@ -38,7 +68,7 @@ async function writeFileAtomic(filename, content, options) {
|
|
|
38
68
|
mode: options.mode,
|
|
39
69
|
flag: "wx"
|
|
40
70
|
});
|
|
41
|
-
await
|
|
71
|
+
await renameAtomicTemp(temp, filename);
|
|
42
72
|
} catch (error) {
|
|
43
73
|
await rm(temp, { force: true });
|
|
44
74
|
throw error;
|
package/lib/types/index.d.ts
CHANGED
|
@@ -34,8 +34,10 @@ export interface WriteFileAtomicOptions {
|
|
|
34
34
|
* rename, so replacing a wider-permission file narrows it without a chmod
|
|
35
35
|
* race. The rename also replaces a symlinked target itself instead of writing
|
|
36
36
|
* through to its referent, and the same-directory sibling keeps the rename on
|
|
37
|
-
* one filesystem.
|
|
38
|
-
*
|
|
37
|
+
* one filesystem. Windows replacement retries transient `EACCES`, `EBUSY`,
|
|
38
|
+
* and `EPERM` failures for a bounded interval while the complete temp file
|
|
39
|
+
* remains the rename source. On any remaining failure the temp file is
|
|
40
|
+
* removed and the failure rethrown. Crash durability (fsync) is out of scope.
|
|
39
41
|
* @param filename - final path receiving the content.
|
|
40
42
|
* @param content - complete next file content.
|
|
41
43
|
* @param options - permission bits for the replacement inode.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@deepseek-ai/dsh-atomic-write",
|
|
3
3
|
"description": "Zero-dependency atomic file replacement: exclusive-create random-suffix temp + rename carrying the caller-stated permissions (writeFileAtomic)",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.2-alpha.2",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
7
7
|
},
|
|
@@ -32,11 +32,11 @@
|
|
|
32
32
|
],
|
|
33
33
|
"license": "MIT",
|
|
34
34
|
"peerDependencies": {
|
|
35
|
-
"@deepseek-ai/cordis": "^4.0.
|
|
36
|
-
"@deepseek-ai/dsh-invariants": "^0.1.
|
|
35
|
+
"@deepseek-ai/cordis": "^4.0.2",
|
|
36
|
+
"@deepseek-ai/dsh-invariants": "^0.1.2-alpha.2"
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|
|
39
|
-
"@deepseek-ai/cordis": "^4.0.
|
|
40
|
-
"@deepseek-ai/dsh-invariants": "^0.1.
|
|
39
|
+
"@deepseek-ai/cordis": "^4.0.2",
|
|
40
|
+
"@deepseek-ai/dsh-invariants": "^0.1.2-alpha.2"
|
|
41
41
|
}
|
|
42
42
|
}
|