1// @effect-diagnostics nodeBuiltinImport:off
2import * as NodeFS from "node:fs";
3import * as NodeOS from "node:os";
4import * as NodePath from "node:path";
5import { afterEach, describe, expect, it } from "vite-plus/test";
6import * as NodeServices from "@effect/platform-node/NodeServices";
7import { it as effectIt } from "@effect/vitest";
8import * as Effect from "effect/Effect";
9import * as FileSystem from "effect/FileSystem";
10import * as HostProcess from "./HostProcess.ts";
11
12import {
13 RotatingFileSink,
14 RotatingFileSinkConfigurationError,
15 RotatingFileSinkError,
16 makeAsyncRotatingFileSink,
17} from "./logging.ts";
18
19const windowsHost = HostProcess.Platform.defaultValue() === "win32";
20const tempDirectories: string[] = [];
21
22const makeTempDirectory = (): string => {
23 const directory = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3code-logging-"));
24 tempDirectories.push(directory);
25 return directory;
26};
27
28const captureError = (run: () => unknown): unknown => {
29 try {
30 run();
31 } catch (cause) {
32 return cause;
33 }
34 throw new Error("Expected operation to throw");
35};
36
37afterEach(() => {
38 for (const directory of tempDirectories.splice(0)) {
39 NodeFS.rmSync(directory, { recursive: true, force: true });
40 }
41});
42
43effectIt.layer(NodeServices.layer)("asynchronous rotating file sink", (it) => {
44 it.effect("serializes concurrent appends across rotation and resumes an existing file", () =>
45 Effect.gen(function* () {
46 const directory = makeTempDirectory();
47 const filePath = NodePath.join(directory, "async.log");
48 NodeFS.writeFileSync(`${filePath}.50`, "obsolete");
49 const lines = Array.from(
50 { length: 100 },
51 (_, index) => `${index.toString().padStart(3, "0")}\n`,
52 );
53 const sink = yield* makeAsyncRotatingFileSink({ filePath, maxBytes: 32, maxFiles: 20 });
54 yield* Effect.forEach(lines, (line) => sink.write(line), {
55 concurrency: "unbounded",
56 discard: true,
57 });
58 const resumed = yield* makeAsyncRotatingFileSink({ filePath, maxBytes: 32, maxFiles: 20 });
59 yield* resumed.write("100\n");
60 expect(NodeFS.existsSync(`${filePath}.50`)).toBe(false);
61 const backups = NodeFS.readdirSync(directory)
62 .filter((name) => name.startsWith("async.log."))
63 .sort((a, b) => Number(b.slice(10)) - Number(a.slice(10)));
64 const contents = [...backups, "async.log"].map((name) =>
65 NodeFS.readFileSync(NodePath.join(directory, name), "utf8"),
66 );
67 expect(contents.every((chunk) => Buffer.byteLength(chunk) <= 32)).toBe(true);
68 expect(contents.join("")).toBe(lines.join("") + "100\n");
69 }),
70 );
71
72 it.effect("reports a failed append and recovers its size before the next rotation", () =>
73 Effect.gen(function* () {
74 const directory = makeTempDirectory();
75 const filePath = NodePath.join(directory, "recovery.log");
76 NodeFS.writeFileSync(filePath, "old\n");
77 const fs = yield* FileSystem.FileSystem;
78 let attempts = 0;
79 const sink = yield* makeAsyncRotatingFileSink({ filePath, maxBytes: 8, maxFiles: 2 }).pipe(
80 Effect.provideService(FileSystem.FileSystem, {
81 ...fs,
82 writeFile: (path, bytes, options) =>
83 ++attempts === 1
84 ? fs.writeFile(directory, bytes, options)
85 : fs.writeFile(path, bytes, options),
86 }),
87 );
88 const failed = yield* sink.write("new\n").pipe(Effect.result);
89 expect(failed._tag).toBe("Failure");
90 if (failed._tag === "Failure") expect(failed.failure.operation).toBe("write");
91 yield* sink.write("new\n");
92 yield* sink.write("end\n");
93 expect(NodeFS.readFileSync(`${filePath}.1`, "utf8")).toBe("old\nnew\n");
94 expect(NodeFS.readFileSync(filePath, "utf8")).toBe("end\n");
95 }),
96 );
97});
98
99describe("RotatingFileSink", () => {
100 it.each([
101 { option: "maxBytes" as const, maxBytes: 0, maxFiles: 1 },
102 { option: "maxFiles" as const, maxBytes: 1, maxFiles: 0 },
103 ])("reports invalid $option configuration structurally", (input) => {
104 const thrown = captureError(
105 () =>
106 new RotatingFileSink({
107 filePath: "/unused/log.ndjson",
108 maxBytes: input.maxBytes,
109 maxFiles: input.maxFiles,
110 }),
111 );
112
113 expect(thrown).toBeInstanceOf(RotatingFileSinkConfigurationError);
114 expect(thrown).toMatchObject({
115 option: input.option,
116 received: 0,
117 minimum: 1,
118 });
119 expect((thrown as Error).message).toBe(`${input.option} must be >= 1 (received 0)`);
120 });
121
122 it("preserves directory initialization failures", () => {
123 const directory = makeTempDirectory();
124 const parentFile = NodePath.join(directory, "not-a-directory");
125 const filePath = NodePath.join(parentFile, "log.ndjson");
126 NodeFS.writeFileSync(parentFile, "occupied");
127
128 const thrown = captureError(() => new RotatingFileSink({ filePath, maxBytes: 1, maxFiles: 1 }));
129
130 expect(thrown).toBeInstanceOf(RotatingFileSinkError);
131 expect(thrown).toMatchObject({ operation: "initialize", filePath });
132 expect((thrown as RotatingFileSinkError).cause).toBeInstanceOf(Error);
133 });
134
135 // An over-long name is the one stat failure that is neither ENOENT nor a
136 // permission problem on posix. Windows reports it as ENOENT, so the sink
137 // correctly treats it as an absent file and there is nothing to assert.
138 it.skipIf(windowsHost)("only treats a missing log file as an empty current size", () => {
139 const directory = makeTempDirectory();
140 const filePath = NodePath.join(directory, "a".repeat(300));
141
142 const thrown = captureError(() => new RotatingFileSink({ filePath, maxBytes: 1, maxFiles: 1 }));
143
144 expect(thrown).toBeInstanceOf(RotatingFileSinkError);
145 expect(thrown).toMatchObject({ operation: "read", filePath });
146 expect((thrown as RotatingFileSinkError).cause).toMatchObject({ code: "ENAMETOOLONG" });
147 });
148
149 it("starts an absent log file at zero bytes", () => {
150 const directory = makeTempDirectory();
151 const filePath = NodePath.join(directory, "log.ndjson");
152 const sink = new RotatingFileSink({ filePath, maxBytes: 100, maxFiles: 1 });
153
154 sink.write("entry");
155
156 expect(NodeFS.readFileSync(filePath, "utf8")).toBe("entry");
157 });
158
159 it("preserves write failures", () => {
160 const directory = makeTempDirectory();
161 const filePath = NodePath.join(directory, "log.ndjson");
162 NodeFS.mkdirSync(filePath);
163 const sink = new RotatingFileSink({
164 filePath,
165 maxBytes: Number.MAX_SAFE_INTEGER,
166 maxFiles: 1,
167 throwOnError: true,
168 });
169
170 const thrown = captureError(() => sink.write("entry"));
171
172 expect(thrown).toBeInstanceOf(RotatingFileSinkError);
173 expect(thrown).toMatchObject({ operation: "write", filePath });
174 expect((thrown as RotatingFileSinkError).cause).toMatchObject({ code: "EISDIR" });
175 });
176
177 it("preserves rotation failures without an artificial write wrapper", () => {
178 const directory = makeTempDirectory();
179 const filePath = NodePath.join(directory, "log.ndjson");
180 NodeFS.writeFileSync(filePath, "a");
181 NodeFS.mkdirSync(`${filePath}.1`);
182 const sink = new RotatingFileSink({
183 filePath,
184 maxBytes: 1,
185 maxFiles: 1,
186 throwOnError: true,
187 });
188
189 const thrown = captureError(() => sink.write("b"));
190
191 expect(thrown).toBeInstanceOf(RotatingFileSinkError);
192 expect(thrown).toMatchObject({ operation: "rotate", filePath });
193 expect((thrown as RotatingFileSinkError).cause).toBeInstanceOf(Error);
194 });
195
196 it("never reports a rotation failure after successfully appending a chunk", () => {
197 const directory = makeTempDirectory();
198 const filePath = NodePath.join(directory, "log.ndjson");
199 NodeFS.mkdirSync(`${filePath}.1`);
200 const sink = new RotatingFileSink({
201 filePath,
202 maxBytes: 1,
203 maxFiles: 1,
204 throwOnError: true,
205 });
206
207 sink.write("oversized");
208
209 expect(NodeFS.readFileSync(filePath, "utf8")).toBe("oversized");
210 const thrown = captureError(() => sink.write("next"));
211 expect(thrown).toMatchObject({ operation: "rotate", filePath });
212 expect(NodeFS.readFileSync(filePath, "utf8")).toBe("oversized");
213 });
214
215 it("preserves backup pruning failures", () => {
216 const directory = makeTempDirectory();
217 const filePath = NodePath.join(directory, "log.ndjson");
218 const overflowBackup = `${filePath}.2`;
219 NodeFS.mkdirSync(overflowBackup);
220 NodeFS.writeFileSync(NodePath.join(overflowBackup, "entry"), "occupied");
221
222 const thrown = captureError(
223 () =>
224 new RotatingFileSink({
225 filePath,
226 maxBytes: 1,
227 maxFiles: 1,
228 throwOnError: true,
229 }),
230 );
231
232 expect(thrown).toBeInstanceOf(RotatingFileSinkError);
233 expect(thrown).toMatchObject({ operation: "prune", filePath });
234 expect((thrown as RotatingFileSinkError).cause).toBeInstanceOf(Error);
235 });
236});