| 1 | // @effect-diagnostics nodeBuiltinImport:off |
| 2 | import * as NodeFS from "node:fs"; |
| 3 | import * as NodeOS from "node:os"; |
| 4 | import * as NodePath from "node:path"; |
| 5 | import { afterEach, describe, expect, it } from "vite-plus/test"; |
| 6 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 7 | import { it as effectIt } from "@effect/vitest"; |
| 8 | import * as Effect from "effect/Effect"; |
| 9 | import * as FileSystem from "effect/FileSystem"; |
| 10 | import * as HostProcess from "./HostProcess.ts"; |
| 11 | |
| 12 | import { |
| 13 | RotatingFileSink, |
| 14 | RotatingFileSinkConfigurationError, |
| 15 | RotatingFileSinkError, |
| 16 | makeAsyncRotatingFileSink, |
| 17 | } from "./logging.ts"; |
| 18 | |
| 19 | const windowsHost = HostProcess.Platform.defaultValue() === "win32"; |
| 20 | const tempDirectories: string[] = []; |
| 21 | |
| 22 | const makeTempDirectory = (): string => { |
| 23 | const directory = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3code-logging-")); |
| 24 | tempDirectories.push(directory); |
| 25 | return directory; |
| 26 | }; |
| 27 | |
| 28 | const 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 | |
| 37 | afterEach(() => { |
| 38 | for (const directory of tempDirectories.splice(0)) { |
| 39 | NodeFS.rmSync(directory, { recursive: true, force: true }); |
| 40 | } |
| 41 | }); |
| 42 | |
| 43 | effectIt.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 | |
| 99 | describe("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 | }); |