Skip to content
Source
Sign inSign up
Open History to see the last commit

atomicWrite.ts

99 lines4.3 KB

1import * as Effect from "effect/Effect";
2import * as FileSystem from "effect/FileSystem";
3import * as Option from "effect/Option";
4import * as Path from "effect/Path";
5import type * as PlatformError from "effect/PlatformError";
6import * as Result from "effect/Result";
7import * as HostProcess from "@t3tools/shared/HostProcess";
8import { resolveSymlinkTarget } from "@t3tools/shared/symlink";
9​
10/** Windows open-file locks block renames; the same codes can mean permanent denial, so bound retries. */
11export const isWindowsRenameLock = (error: PlatformError.PlatformError) => {
12 const code = (error.reason.cause as NodeJS.ErrnoException | undefined)?.code;
13 return code === "EBUSY" || code === "EPERM" || code === "EACCES";
14};
15​
16// Bound denial delays to about two seconds because settings saves hold their write lock.
17const LOCKED_RENAME_RETRIES = 20;
18const LOCKED_RENAME_RETRY_DELAY = "100 millis";
19const CHUNK_WRITE_LENGTH = 1 << 20;
20​
21/** Resolves symlink targets before renaming so writes preserve the link. Iterable contents stream in bounded batches. */
22export const writeFileStringAtomically = (input: {
23 readonly filePath: string;
24 readonly contents: string | Iterable<string>;
25}) =>
26 Effect.scoped(
27 Effect.gen(function* () {
28 const fs = yield* FileSystem.FileSystem;
29 const path = yield* Path.Path;
30 const platform = yield* HostProcess.Platform;
31 const targetPath = yield* resolveSymlinkTarget(input.filePath);
32 const targetDirectory = path.dirname(targetPath);
33​
34 yield* fs.makeDirectory(targetDirectory, { recursive: true });
35 // Cleanup failure must not fail a write that already landed.
36 const tempDirectory = yield* Effect.acquireRelease(
37 fs.makeTempDirectory({
38 directory: targetDirectory,
39 prefix: `${path.basename(targetPath)}.`,
40 }),
41 (directory) => fs.remove(directory, { recursive: true }).pipe(Effect.ignore({ log: true })),
42 );
43 const tempPath = path.join(tempDirectory, "contents.tmp");
44​
45 const contents = input.contents;
46 if (typeof contents === "string") {
47 yield* fs.writeFileString(tempPath, contents);
48 } else {
49 // Closed before the rename, which Windows refuses for open files.
50 yield* Effect.scoped(
51 Effect.gen(function* () {
52 const file = yield* fs.open(tempPath, { flag: "w" });
53 const encoder = new TextEncoder();
54 let batch: string[] = [];
55 let batchLength = 0;
56 for (const chunk of contents) {
57 batch.push(chunk);
58 batchLength += chunk.length;
59 if (batchLength < CHUNK_WRITE_LENGTH) continue;
60 yield* file.writeAll(encoder.encode(batch.join("")));
61 batch = [];
62 batchLength = 0;
63 }
64 if (batchLength > 0) yield* file.writeAll(encoder.encode(batch.join("")));
65 }),
66 );
67 }
68 if (platform !== "win32") return yield* fs.rename(tempPath, targetPath);
69​
70 // Stop retrying if another save changes the target; read-only targets also return permanent EPERM.
71 const targetVersion = fs.stat(targetPath).pipe(
72 Effect.map((info) => ({
73 id: `${Option.getOrUndefined(info.ino)}:${info.size}:${Option.getOrUndefined(info.mtime)?.getTime()}`,
74 readOnly: (info.mode & 0o200) === 0,
75 })),
76 Effect.catchIf(
77 (error) => error.reason._tag === "NotFound",
78 () => Effect.succeed({ id: "missing", readOnly: false }),
79 ),
80 Effect.option,
81 );
82 const before = yield* targetVersion;
83 const firstAttempt = yield* Effect.result(fs.rename(tempPath, targetPath));
84 if (Result.isSuccess(firstAttempt)) return;
85 const lockError = firstAttempt.failure;
86 if (!isWindowsRenameLock(lockError) || Option.isNone(before) || before.value.readOnly) {
87 return yield* lockError;
88 }
89 for (let retry = 0; retry < LOCKED_RENAME_RETRIES; retry += 1) {
90 yield* Effect.sleep(LOCKED_RENAME_RETRY_DELAY);
91 const current = yield* targetVersion;
92 if (Option.isNone(current) || current.value.id !== before.value.id) break;
93 const attempt = yield* Effect.result(fs.rename(tempPath, targetPath));
94 if (Result.isSuccess(attempt)) return;
95 if (!isWindowsRenameLock(attempt.failure)) return yield* attempt.failure;
96 }
97 return yield* lockError;
98 }),
99 );