@ac-kit/app-system 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.
Files changed (95) hide show
  1. package/LICENSE +28 -0
  2. package/README.md +45 -0
  3. package/dist/async/file-lock.d.ts +38 -0
  4. package/dist/async/file-lock.d.ts.map +1 -0
  5. package/dist/async/file-lock.js +67 -0
  6. package/dist/async/file-lock.js.map +1 -0
  7. package/dist/async/udp-bind-lock.d.ts +75 -0
  8. package/dist/async/udp-bind-lock.d.ts.map +1 -0
  9. package/dist/async/udp-bind-lock.js +85 -0
  10. package/dist/async/udp-bind-lock.js.map +1 -0
  11. package/dist/fs/artifact-cache.d.ts +124 -0
  12. package/dist/fs/artifact-cache.d.ts.map +1 -0
  13. package/dist/fs/artifact-cache.js +183 -0
  14. package/dist/fs/artifact-cache.js.map +1 -0
  15. package/dist/fs/rotate-log-files.d.ts +20 -0
  16. package/dist/fs/rotate-log-files.d.ts.map +1 -0
  17. package/dist/fs/rotate-log-files.js +45 -0
  18. package/dist/fs/rotate-log-files.js.map +1 -0
  19. package/dist/fs/rotating-file-sink.d.ts +42 -0
  20. package/dist/fs/rotating-file-sink.d.ts.map +1 -0
  21. package/dist/fs/rotating-file-sink.js +213 -0
  22. package/dist/fs/rotating-file-sink.js.map +1 -0
  23. package/dist/index.d.ts +23 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +23 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/os/get-processes-snapshot.d.ts +56 -0
  28. package/dist/os/get-processes-snapshot.d.ts.map +1 -0
  29. package/dist/os/get-processes-snapshot.js +197 -0
  30. package/dist/os/get-processes-snapshot.js.map +1 -0
  31. package/dist/os/host-metrics.d.ts +43 -0
  32. package/dist/os/host-metrics.d.ts.map +1 -0
  33. package/dist/os/host-metrics.js +78 -0
  34. package/dist/os/host-metrics.js.map +1 -0
  35. package/dist/os/process-kill-by-pid-file.d.ts +35 -0
  36. package/dist/os/process-kill-by-pid-file.d.ts.map +1 -0
  37. package/dist/os/process-kill-by-pid-file.js +48 -0
  38. package/dist/os/process-kill-by-pid-file.js.map +1 -0
  39. package/dist/os/process-wait-pid.d.ts +18 -0
  40. package/dist/os/process-wait-pid.d.ts.map +1 -0
  41. package/dist/os/process-wait-pid.js +26 -0
  42. package/dist/os/process-wait-pid.js.map +1 -0
  43. package/dist/os/ps.d.ts +98 -0
  44. package/dist/os/ps.d.ts.map +1 -0
  45. package/dist/os/ps.js +165 -0
  46. package/dist/os/ps.js.map +1 -0
  47. package/dist/os/sysfs.d.ts +19 -0
  48. package/dist/os/sysfs.d.ts.map +1 -0
  49. package/dist/os/sysfs.js +66 -0
  50. package/dist/os/sysfs.js.map +1 -0
  51. package/dist/process/child-process-message-sink.d.ts +21 -0
  52. package/dist/process/child-process-message-sink.d.ts.map +1 -0
  53. package/dist/process/child-process-message-sink.js +16 -0
  54. package/dist/process/child-process-message-sink.js.map +1 -0
  55. package/dist/process/error-listeners.d.ts +56 -0
  56. package/dist/process/error-listeners.d.ts.map +1 -0
  57. package/dist/process/error-listeners.js +113 -0
  58. package/dist/process/error-listeners.js.map +1 -0
  59. package/dist/process/exit-manager.d.ts +58 -0
  60. package/dist/process/exit-manager.d.ts.map +1 -0
  61. package/dist/process/exit-manager.js +88 -0
  62. package/dist/process/exit-manager.js.map +1 -0
  63. package/dist/process/has-binary.d.ts +25 -0
  64. package/dist/process/has-binary.d.ts.map +1 -0
  65. package/dist/process/has-binary.js +35 -0
  66. package/dist/process/has-binary.js.map +1 -0
  67. package/dist/process/pid-file.d.ts +17 -0
  68. package/dist/process/pid-file.d.ts.map +1 -0
  69. package/dist/process/pid-file.js +25 -0
  70. package/dist/process/pid-file.js.map +1 -0
  71. package/dist/process/process-pid-file.d.ts +37 -0
  72. package/dist/process/process-pid-file.d.ts.map +1 -0
  73. package/dist/process/process-pid-file.js +57 -0
  74. package/dist/process/process-pid-file.js.map +1 -0
  75. package/dist/process/report-process.d.ts +36 -0
  76. package/dist/process/report-process.d.ts.map +1 -0
  77. package/dist/process/report-process.js +121 -0
  78. package/dist/process/report-process.js.map +1 -0
  79. package/dist/shell/shorten-posix-path.d.ts +10 -0
  80. package/dist/shell/shorten-posix-path.d.ts.map +1 -0
  81. package/dist/shell/shorten-posix-path.js +19 -0
  82. package/dist/shell/shorten-posix-path.js.map +1 -0
  83. package/dist/terminal/node-terminal.d.ts +11 -0
  84. package/dist/terminal/node-terminal.d.ts.map +1 -0
  85. package/dist/terminal/node-terminal.js +162 -0
  86. package/dist/terminal/node-terminal.js.map +1 -0
  87. package/dist/text/charset-decode-stream.d.ts +13 -0
  88. package/dist/text/charset-decode-stream.d.ts.map +1 -0
  89. package/dist/text/charset-decode-stream.js +51 -0
  90. package/dist/text/charset-decode-stream.js.map +1 -0
  91. package/dist/text/charset-encode-stream.d.ts +12 -0
  92. package/dist/text/charset-encode-stream.d.ts.map +1 -0
  93. package/dist/text/charset-encode-stream.js +47 -0
  94. package/dist/text/charset-encode-stream.js.map +1 -0
  95. package/package.json +75 -0
package/LICENSE ADDED
@@ -0,0 +1,28 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2024-2026 Anthony Champagne and contributors
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ 3. Neither the name of the copyright holder nor the names of its
16
+ contributors may be used to endorse or promote products derived from
17
+ this software without specific prior written permission.
18
+
19
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
package/README.md ADDED
@@ -0,0 +1,45 @@
1
+ # @ac-kit/app-system
2
+
3
+ The things a long-running program has to do to the machine it runs on, and which
4
+ are tedious to get right: claim a PID file without racing another copy of
5
+ itself, roll its log files before they fill the disk, ask whether a binary is on
6
+ `PATH`, list running processes, and run its shutdown hooks in a defined order.
7
+
8
+ ```typescript
9
+ import { ExitManager, hasBinary, ps, writePidFile } from "@ac-kit/app-system";
10
+
11
+ if (!(await hasBinary("ffmpeg"))) {
12
+ throw new Error("ffmpeg is required");
13
+ }
14
+
15
+ await writePidFile("/run/mytool.pid", process.pid);
16
+
17
+ const exits = new ExitManager();
18
+ exits.registerCleanUpHandler(async () => {
19
+ await flushEverything();
20
+ });
21
+
22
+ const running = await ps({ fields: ["pid", "args"] });
23
+ ```
24
+
25
+ `registerCleanUpHandler` takes a priority and returns an unregister function, so
26
+ teardown order is declared rather than implied by registration order.
27
+
28
+ ## What it exposes
29
+
30
+ - **Process lifecycle** — `readPidFile`/`writePidFile`, `processPidFile`,
31
+ `processKillByPidFile`, `processWaitPid`, `ExitManager` with ordered
32
+ priorities, and uncaught-error listeners.
33
+ - **Process inspection** — `ps` with filters, `getProcessesSnapshot`,
34
+ `hostMetrics`, and `sysfs` readers.
35
+ - **Files** — `rotateLogFiles`, `RotatingFileSink` (optionally gzip-compressed),
36
+ and an artifact cache.
37
+ - **Locks** — `FileLock` and `UdpBindLock`, for single-instance enforcement.
38
+ - **Terminal and text** — `nodeTerminal`, the Node adapter for
39
+ `@ac-kit/app-terminal`'s `Terminal`, plus charset encode/decode streams.
40
+
41
+ Node only, by definition: this is the host-bound layer.
42
+
43
+ See the
44
+ [generated API docs](https://anthochamp.github.io/node-essentials/api/app-system/)
45
+ for the full reference.
@@ -0,0 +1,38 @@
1
+ import { type ILock } from "@ac-kit/async";
2
+ export type FileLockOptions = {
3
+ /**
4
+ * The interval in milliseconds to poll for the lock file to be released.
5
+ * Default is 50ms.
6
+ */
7
+ pollIntervalMs?: number;
8
+ };
9
+ /**
10
+ * A simple file-based lock mechanism.
11
+ *
12
+ * Creates a `.lock` file to indicate that a resource is locked. The lock is
13
+ * acquired by creating the lock file and released by deleting it. If the lock
14
+ * file already exists, it means the resource is locked by another process.
15
+ *
16
+ * Example usage:
17
+ *
18
+ * ```ts
19
+ * const lock = new LockFile("/path/to/resource");
20
+ * const release = await lock.acquire();
21
+ * try {
22
+ * // Do something with the locked resource
23
+ * } finally {
24
+ * await release();
25
+ * }
26
+ * ```
27
+ */
28
+ export declare class FileLock implements ILock {
29
+ readonly filePath: string;
30
+ private readonly options;
31
+ private lockHandle;
32
+ constructor(filePath: string, options?: FileLockOptions);
33
+ get locked(): boolean;
34
+ tryLock(): Promise<boolean>;
35
+ lock(signal?: AbortSignal | null): Promise<void>;
36
+ unlock(): Promise<void>;
37
+ }
38
+ //# sourceMappingURL=file-lock.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"file-lock.d.ts","sourceRoot":"","sources":["../../src/async/file-lock.ts"],"names":[],"mappings":"AAEA,OAAO,EAAwB,KAAK,KAAK,EAAE,MAAM,eAAe,CAAC;AAIjE,MAAM,MAAM,eAAe,GAAG;IAC7B;;;OAGG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;CACxB,CAAC;AAMF;;;;;;;;;;;;;;;;;;GAkBG;AACH,qBAAa,QAAS,YAAW,KAAK;aAKpB,QAAQ,EAAE,MAAM;IAJjC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA4B;IACpD,OAAO,CAAC,UAAU,CAAsC;IAExD,YACiB,QAAQ,EAAE,MAAM,EAChC,OAAO,CAAC,EAAE,eAAe,EAGzB;IAED,IAAI,MAAM,IAAI,OAAO,CAEpB;IAEK,OAAO,IAAI,OAAO,CAAC,OAAO,CAAC,CAYhC;IAEK,IAAI,CAAC,MAAM,CAAC,EAAE,WAAW,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAKrD;IAEK,MAAM,IAAI,OAAO,CAAC,IAAI,CAAC,CAU5B;CACD"}
@@ -0,0 +1,67 @@
1
+ import * as fsPromises from "node:fs/promises";
2
+ import { LockNotAcquiredError } from "@ac-kit/async";
3
+ import { defaults, waitFor } from "@ac-kit/core";
4
+ import { isNodeErrorWithCode } from "@ac-kit/node";
5
+ const FILE_LOCK_DEFAULT_OPTIONS = {
6
+ pollIntervalMs: 50,
7
+ };
8
+ /**
9
+ * A simple file-based lock mechanism.
10
+ *
11
+ * Creates a `.lock` file to indicate that a resource is locked. The lock is
12
+ * acquired by creating the lock file and released by deleting it. If the lock
13
+ * file already exists, it means the resource is locked by another process.
14
+ *
15
+ * Example usage:
16
+ *
17
+ * ```ts
18
+ * const lock = new LockFile("/path/to/resource");
19
+ * const release = await lock.acquire();
20
+ * try {
21
+ * // Do something with the locked resource
22
+ * } finally {
23
+ * await release();
24
+ * }
25
+ * ```
26
+ */
27
+ export class FileLock {
28
+ filePath;
29
+ options;
30
+ lockHandle = null;
31
+ constructor(filePath, options) {
32
+ this.filePath = filePath;
33
+ this.options = defaults(options, FILE_LOCK_DEFAULT_OPTIONS);
34
+ }
35
+ get locked() {
36
+ return this.lockHandle !== null;
37
+ }
38
+ async tryLock() {
39
+ try {
40
+ // Try to create the lock file exclusively
41
+ this.lockHandle = await fsPromises.open(`${this.filePath}.lock`, "wx");
42
+ }
43
+ catch (error) {
44
+ if (isNodeErrorWithCode(error, "EEXIST")) {
45
+ return false;
46
+ }
47
+ throw error;
48
+ }
49
+ return true;
50
+ }
51
+ async lock(signal) {
52
+ await waitFor(() => this.tryLock(), {
53
+ signal,
54
+ intervalMs: this.options.pollIntervalMs,
55
+ });
56
+ }
57
+ async unlock() {
58
+ if (!this.lockHandle) {
59
+ throw new LockNotAcquiredError();
60
+ }
61
+ const handle = this.lockHandle;
62
+ this.lockHandle = null;
63
+ await handle.close();
64
+ await fsPromises.unlink(`${this.filePath}.lock`);
65
+ }
66
+ }
67
+ //# sourceMappingURL=file-lock.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"file-lock.js","sourceRoot":"","sources":["../../src/async/file-lock.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,UAAU,MAAM,kBAAkB,CAAC;AAE/C,OAAO,EAAE,oBAAoB,EAAc,MAAM,eAAe,CAAC;AACjE,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AACjD,OAAO,EAAE,mBAAmB,EAAE,MAAM,cAAc,CAAC;AAUnD,MAAM,yBAAyB,GAA8B;IAC5D,cAAc,EAAE,EAAE;CAClB,CAAC;AAEF;;;;;;;;;;;;;;;;;;GAkBG;AACH,MAAM,OAAO,QAAQ;IAKH,QAAQ;IAJR,OAAO,CAA4B;IAC5C,UAAU,GAAiC,IAAI,CAAC;IAExD,YACiB,QAAgB,EAChC,OAAyB;wBADT,QAAQ;QAGxB,IAAI,CAAC,OAAO,GAAG,QAAQ,CAAC,OAAO,EAAE,yBAAyB,CAAC,CAAC;IAC7D,CAAC;IAED,IAAI,MAAM;QACT,OAAO,IAAI,CAAC,UAAU,KAAK,IAAI,CAAC;IACjC,CAAC;IAED,KAAK,CAAC,OAAO;QACZ,IAAI,CAAC;YACJ,0CAA0C;YAC1C,IAAI,CAAC,UAAU,GAAG,MAAM,UAAU,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC,QAAQ,OAAO,EAAE,IAAI,CAAC,CAAC;QACxE,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YAChB,IAAI,mBAAmB,CAAC,KAAK,EAAE,QAAQ,CAAC,EAAE,CAAC;gBAC1C,OAAO,KAAK,CAAC;YACd,CAAC;YAED,MAAM,KAAK,CAAC;QACb,CAAC;QACD,OAAO,IAAI,CAAC;IACb,CAAC;IAED,KAAK,CAAC,IAAI,CAAC,MAA2B;QACrC,MAAM,OAAO,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,OAAO,EAAE,EAAE;YACnC,MAAM;YACN,UAAU,EAAE,IAAI,CAAC,OAAO,CAAC,cAAc;SACvC,CAAC,CAAC;IACJ,CAAC;IAED,KAAK,CAAC,MAAM;QACX,IAAI,CAAC,IAAI,CAAC,UAAU,EAAE,CAAC;YACtB,MAAM,IAAI,oBAAoB,EAAE,CAAC;QAClC,CAAC;QAED,MAAM,MAAM,GAAG,IAAI,CAAC,UAAU,CAAC;QAC/B,IAAI,CAAC,UAAU,GAAG,IAAI,CAAC;QAEvB,MAAM,MAAM,CAAC,KAAK,EAAE,CAAC;QACrB,MAAM,UAAU,CAAC,MAAM,CAAC,GAAG,IAAI,CAAC,QAAQ,OAAO,CAAC,CAAC;IAClD,CAAC;CACD"}
@@ -0,0 +1,75 @@
1
+ import { type ILock } from "@ac-kit/async";
2
+ export type UdpBindLockConfig = {
3
+ /**
4
+ * The type of UDP socket to create, either "udp4" for IPv4 or "udp6" for
5
+ * IPv6.
6
+ *
7
+ * It should match the IP version of the `udpBindAddress`.
8
+ */
9
+ udpSocketType: "udp4" | "udp6";
10
+ /**
11
+ * The address to bind the UDP socket to. Default is "localhost"
12
+ *
13
+ * Example: "localhost" or "127.1.2.3" (in which case `udpSocketType` should
14
+ * be "udp4") or "::1" (in which case `udpSocketType` should be "udp6").
15
+ *
16
+ * Note: Using "localhost" is generally safe as it resolves to both IPv4 and
17
+ * IPv6 loopback addresses, but if you want to be certain about the IP
18
+ * version, specify the address explicitly.
19
+ */
20
+ udpBindAddress?: string | null;
21
+ /**
22
+ * The port to bind the UDP socket to.
23
+ *
24
+ * Should be a port that is unlikely to be used by other applications.
25
+ */
26
+ udpBindPort: number;
27
+ };
28
+ export type UdpBindLockOptions = {
29
+ /**
30
+ * Optional polling interval in milliseconds to check if the lock is still
31
+ * held. Default is 100 ms.
32
+ */
33
+ pollIntervalMs?: number;
34
+ };
35
+ /**
36
+ * A class that provides a mechanism to ensure that only one instance of a
37
+ * process is running at a time by attempting to bind to a specific UDP port.
38
+ *
39
+ * This is useful for preventing multiple instances of a script or application
40
+ * from running simultaneously.
41
+ *
42
+ * Note: The lock is automatically released when the process exits, but it is
43
+ * recommended to call `release` explicitly when the lock is no longer needed.
44
+ *
45
+ * Example usage:
46
+ *
47
+ * ```ts
48
+ * const lock = new UdpBindLock({
49
+ * udpSocketType: "udp4",
50
+ * udpBindPort: 12345,
51
+ * });
52
+ *
53
+ * const acquired = await lock.tryAcquire();
54
+ * if (!acquired) {
55
+ * console.error("Another instance is already running.");
56
+ * process.exit(1);
57
+ * }
58
+ *
59
+ * // ... application logic ...
60
+ *
61
+ * await lock.release();
62
+ * ```
63
+ */
64
+ export declare class UdpBindLock implements ILock {
65
+ private readonly config;
66
+ private readonly options;
67
+ private udpSocket;
68
+ constructor(config: UdpBindLockConfig, options?: UdpBindLockOptions);
69
+ get locked(): boolean;
70
+ tryLock(): Promise<boolean>;
71
+ lock(signal?: AbortSignal | null): Promise<void>;
72
+ unlock(): Promise<void>;
73
+ private tryCreateAndBindSocket;
74
+ }
75
+ //# sourceMappingURL=udp-bind-lock.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"udp-bind-lock.d.ts","sourceRoot":"","sources":["../../src/async/udp-bind-lock.ts"],"names":[],"mappings":"AAAA,OAAO,EAAwB,KAAK,KAAK,EAAE,MAAM,eAAe,CAAC;AAIjE,MAAM,MAAM,iBAAiB,GAAG;IAC/B;;;;;OAKG;IACH,aAAa,EAAE,MAAM,GAAG,MAAM,CAAC;IAE/B;;;;;;;;;OASG;IACH,cAAc,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAE/B;;;;OAIG;IACH,WAAW,EAAE,MAAM,CAAC;CACpB,CAAC;AAEF,MAAM,MAAM,kBAAkB,GAAG;IAChC;;;OAGG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;CACxB,CAAC;AAMF;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,qBAAa,WAAY,YAAW,KAAK;IAKvC,OAAO,CAAC,QAAQ,CAAC,MAAM;IAJxB,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA+B;IACvD,OAAO,CAAC,SAAS,CAA4B;IAE7C,YACkB,MAAM,EAAE,iBAAiB,EAC1C,OAAO,CAAC,EAAE,kBAAkB,EAG5B;IAED,IAAI,MAAM,IAAI,OAAO,CAEpB;IAEK,OAAO,IAAI,OAAO,CAAC,OAAO,CAAC,CAEhC;IAEK,IAAI,CAAC,MAAM,CAAC,EAAE,WAAW,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAKrD;IAEK,MAAM,IAAI,OAAO,CAAC,IAAI,CAAC,CAO5B;YAEa,sBAAsB;CA8BpC"}
@@ -0,0 +1,85 @@
1
+ import { LockNotAcquiredError } from "@ac-kit/async";
2
+ import { defaults, waitFor } from "@ac-kit/core";
3
+ import { DgramSocket, isNodeErrorWithCode } from "@ac-kit/node";
4
+ const UDP_BIND_LOCK_DEFAULT_OPTIONS = {
5
+ pollIntervalMs: 100,
6
+ };
7
+ /**
8
+ * A class that provides a mechanism to ensure that only one instance of a
9
+ * process is running at a time by attempting to bind to a specific UDP port.
10
+ *
11
+ * This is useful for preventing multiple instances of a script or application
12
+ * from running simultaneously.
13
+ *
14
+ * Note: The lock is automatically released when the process exits, but it is
15
+ * recommended to call `release` explicitly when the lock is no longer needed.
16
+ *
17
+ * Example usage:
18
+ *
19
+ * ```ts
20
+ * const lock = new UdpBindLock({
21
+ * udpSocketType: "udp4",
22
+ * udpBindPort: 12345,
23
+ * });
24
+ *
25
+ * const acquired = await lock.tryAcquire();
26
+ * if (!acquired) {
27
+ * console.error("Another instance is already running.");
28
+ * process.exit(1);
29
+ * }
30
+ *
31
+ * // ... application logic ...
32
+ *
33
+ * await lock.release();
34
+ * ```
35
+ */
36
+ export class UdpBindLock {
37
+ config;
38
+ options;
39
+ udpSocket = null;
40
+ constructor(config, options) {
41
+ this.config = config;
42
+ this.options = defaults(options, UDP_BIND_LOCK_DEFAULT_OPTIONS);
43
+ }
44
+ get locked() {
45
+ return this.udpSocket !== null;
46
+ }
47
+ async tryLock() {
48
+ return this.tryCreateAndBindSocket();
49
+ }
50
+ async lock(signal) {
51
+ await waitFor(async () => this.tryCreateAndBindSocket(signal), {
52
+ intervalMs: this.options.pollIntervalMs,
53
+ signal,
54
+ });
55
+ }
56
+ async unlock() {
57
+ if (!this.udpSocket) {
58
+ throw new LockNotAcquiredError();
59
+ }
60
+ await this.udpSocket.close();
61
+ this.udpSocket = null;
62
+ }
63
+ async tryCreateAndBindSocket(signal) {
64
+ const udpSocket = DgramSocket.from({
65
+ type: this.config.udpSocketType,
66
+ signal: signal ?? undefined,
67
+ });
68
+ // Prevent the socket from keeping the Node.js process alive
69
+ udpSocket.unref();
70
+ try {
71
+ await udpSocket.bind(this.config.udpBindPort, this.config.udpBindAddress ?? undefined, {
72
+ exclusive: true,
73
+ });
74
+ }
75
+ catch (error) {
76
+ if (isNodeErrorWithCode(error, "EADDRINUSE")) {
77
+ return false;
78
+ }
79
+ throw error;
80
+ }
81
+ this.udpSocket = udpSocket;
82
+ return true;
83
+ }
84
+ }
85
+ //# sourceMappingURL=udp-bind-lock.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"udp-bind-lock.js","sourceRoot":"","sources":["../../src/async/udp-bind-lock.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,oBAAoB,EAAc,MAAM,eAAe,CAAC;AACjE,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AACjD,OAAO,EAAE,WAAW,EAAE,mBAAmB,EAAE,MAAM,cAAc,CAAC;AAuChE,MAAM,6BAA6B,GAAiC;IACnE,cAAc,EAAE,GAAG;CACnB,CAAC;AAEF;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,MAAM,OAAO,WAAW;IAKL,MAAM;IAJP,OAAO,CAA+B;IAC/C,SAAS,GAAuB,IAAI,CAAC;IAE7C,YACkB,MAAyB,EAC1C,OAA4B;sBADX,MAAM;QAGvB,IAAI,CAAC,OAAO,GAAG,QAAQ,CAAC,OAAO,EAAE,6BAA6B,CAAC,CAAC;IACjE,CAAC;IAED,IAAI,MAAM;QACT,OAAO,IAAI,CAAC,SAAS,KAAK,IAAI,CAAC;IAChC,CAAC;IAED,KAAK,CAAC,OAAO;QACZ,OAAO,IAAI,CAAC,sBAAsB,EAAE,CAAC;IACtC,CAAC;IAED,KAAK,CAAC,IAAI,CAAC,MAA2B;QACrC,MAAM,OAAO,CAAC,KAAK,IAAI,EAAE,CAAC,IAAI,CAAC,sBAAsB,CAAC,MAAM,CAAC,EAAE;YAC9D,UAAU,EAAE,IAAI,CAAC,OAAO,CAAC,cAAc;YACvC,MAAM;SACN,CAAC,CAAC;IACJ,CAAC;IAED,KAAK,CAAC,MAAM;QACX,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,CAAC;YACrB,MAAM,IAAI,oBAAoB,EAAE,CAAC;QAClC,CAAC;QAED,MAAM,IAAI,CAAC,SAAS,CAAC,KAAK,EAAE,CAAC;QAC7B,IAAI,CAAC,SAAS,GAAG,IAAI,CAAC;IACvB,CAAC;IAEO,KAAK,CAAC,sBAAsB,CACnC,MAA2B;QAE3B,MAAM,SAAS,GAAG,WAAW,CAAC,IAAI,CAAC;YAClC,IAAI,EAAE,IAAI,CAAC,MAAM,CAAC,aAAa;YAC/B,MAAM,EAAE,MAAM,IAAI,SAAS;SAC3B,CAAC,CAAC;QAEH,4DAA4D;QAC5D,SAAS,CAAC,KAAK,EAAE,CAAC;QAElB,IAAI,CAAC;YACJ,MAAM,SAAS,CAAC,IAAI,CACnB,IAAI,CAAC,MAAM,CAAC,WAAW,EACvB,IAAI,CAAC,MAAM,CAAC,cAAc,IAAI,SAAS,EACvC;gBACC,SAAS,EAAE,IAAI;aACf,CACD,CAAC;QACH,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YAChB,IAAI,mBAAmB,CAAC,KAAK,EAAE,YAAY,CAAC,EAAE,CAAC;gBAC9C,OAAO,KAAK,CAAC;YACd,CAAC;YAED,MAAM,KAAK,CAAC;QACb,CAAC;QAED,IAAI,CAAC,SAAS,GAAG,SAAS,CAAC;QAC3B,OAAO,IAAI,CAAC;IACb,CAAC;CACD"}
@@ -0,0 +1,124 @@
1
+ export type ArtifactLifetime =
2
+ /** Content-addressed; survives across runs. */
3
+ "persistent"
4
+ /** Additionally scoped to the current run; removed when the run ends. */
5
+ | "run";
6
+ export type ArtifactKey = {
7
+ /** Author-chosen, for a readable cache directory. Not part of identity. */
8
+ readonly name: string;
9
+ /**
10
+ * Everything the artifact depends on — source text, compiler flags, toolchain
11
+ * version. Hashed to form the identity.
12
+ */
13
+ readonly inputs: readonly (string | Uint8Array)[];
14
+ /** Extra identity components. Node version, platform and arch are implicit. */
15
+ readonly scope?: readonly string[];
16
+ /** Default `"persistent"`. */
17
+ readonly lifetime?: ArtifactLifetime;
18
+ };
19
+ export type ArtifactResolution = {
20
+ readonly key: ArtifactKey;
21
+ readonly directory: string;
22
+ /**
23
+ * False when this call ran `build`. A cold first run is not comparable with a
24
+ * warm one, so a consumer that reports timings records this.
25
+ */
26
+ readonly cacheHit: boolean;
27
+ };
28
+ export type ArtifactCache = {
29
+ /**
30
+ * Returns the cached directory, or invokes `build` once to populate it. Safe
31
+ * against concurrent callers in other processes.
32
+ */
33
+ ensure(key: ArtifactKey, build: (directory: string, signal: AbortSignal) => Promise<void>, signal?: AbortSignal): Promise<string>;
34
+ /** Absolute cache root. */
35
+ readonly root: string;
36
+ /**
37
+ * Absolute directory holding every `lifetime: "run"` artifact of this run, or
38
+ * `null` when no run id was given. Removing it is the owner's job: the sweep
39
+ * has to work when a child crashed, which is exactly when something was left
40
+ * behind, so it cannot be driven by what the children report.
41
+ */
42
+ readonly runRoot: string | null;
43
+ };
44
+ export type ArtifactCacheOptions = {
45
+ /** Absolute, or relative to the current working directory. */
46
+ readonly root: string;
47
+ /** Enables `lifetime: "run"`. Supplied by the runner, never by the author. */
48
+ readonly runId?: string;
49
+ /** Observes every resolution, for reporting cache hits. */
50
+ readonly onResolved?: (resolution: ArtifactResolution) => void;
51
+ /**
52
+ * How long to wait for a concurrent builder before building anyway. Defaults
53
+ * to 30 000; `0` skips the lock entirely and always builds. Raise it above
54
+ * the slowest expected build to make a duplicated build rare; lowering it
55
+ * only trades waiting for rebuilding, never correctness.
56
+ */
57
+ readonly lockWaitMs?: number;
58
+ };
59
+ /** Thrown when a `lifetime: "run"` artifact is requested outside a run. */
60
+ export declare class ArtifactRunScopeUnavailableError extends Error {
61
+ readonly artifactName: string;
62
+ constructor(artifactName: string);
63
+ }
64
+ /**
65
+ * Cross-process cache for expensive setup output — a compiled reference
66
+ * program, a generated corpus — keyed by a hash of everything the artifact
67
+ * depends on rather than by an author-chosen id, so a stale artifact is
68
+ * impossible rather than merely unlikely.
69
+ *
70
+ * Concurrency is settled in two layers, and only the second one is
71
+ * load-bearing:
72
+ *
73
+ * 1. A `FileLock` beside the destination keeps concurrent callers from compiling
74
+ * the same thing at once. It is acquired best-effort with a `lockWaitMs`
75
+ * budget, so a lock left behind by a killed builder costs one wait and one
76
+ * duplicated build instead of hanging the run forever.
77
+ * 2. The build goes to a temp directory and is `rename`d into place. Rename is
78
+ * atomic on POSIX and within a Windows volume, so however many callers
79
+ * build, exactly one artifact exists and every caller returns the same
80
+ * path.
81
+ *
82
+ * @example
83
+ * Compile a reference program once, reuse it forever
84
+ *
85
+ * ```ts
86
+ * const cache = createArtifactCache({ root: "node_modules/.cache/my-tool" });
87
+ * const source = await readFile("bench/reference.cpp", "utf8");
88
+ *
89
+ * const directory = await cache.ensure(
90
+ * { name: "reference C++", inputs: [source, "-O3"], scope: [compilerVersion] },
91
+ * async (staging, signal) => {
92
+ * await run("g++", ["-O3", "-o", join(staging, "reference"), sourcePath], {
93
+ * signal,
94
+ * });
95
+ * },
96
+ * );
97
+ * const program = join(directory, "reference");
98
+ * ```
99
+ *
100
+ * Changing the source text or `-O3` changes the identity, so the next call
101
+ * rebuilds; nothing has to remember to bump a version.
102
+ *
103
+ * @example
104
+ * Scope an artifact to one run, and sweep it afterwards
105
+ *
106
+ * ```ts
107
+ * const cache = createArtifactCache({ root, runId, onResolved: (r) =>
108
+ * report.data({ artifact: r.key.name, cacheHit: r.cacheHit }),
109
+ * });
110
+ *
111
+ * await cache.ensure(
112
+ * { name: "corpus", inputs: [spec], lifetime: "run" },
113
+ * generateCorpus,
114
+ * );
115
+ *
116
+ * // The owner sweeps: a crashed child cannot be asked what it left behind.
117
+ * await rm(cache.runRoot!, { recursive: true, force: true });
118
+ * ```
119
+ *
120
+ * @param options Root, run scoping, hit reporting and the lock budget. See
121
+ * {@link ArtifactCacheOptions}.
122
+ */
123
+ export declare function createArtifactCache(options: ArtifactCacheOptions): ArtifactCache;
124
+ //# sourceMappingURL=artifact-cache.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"artifact-cache.d.ts","sourceRoot":"","sources":["../../src/fs/artifact-cache.ts"],"names":[],"mappings":"AAcA,MAAM,MAAM,gBAAgB;AAC3B,+CAA+C;AAC7C,YAAY;AACd,yEAAyE;GACvE,KAAK,CAAC;AAET,MAAM,MAAM,WAAW,GAAG;IACzB,2EAA2E;IAC3E,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB;;;OAGG;IACH,QAAQ,CAAC,MAAM,EAAE,SAAS,CAAC,MAAM,GAAG,UAAU,CAAC,EAAE,CAAC;IAClD,+EAA+E;IAC/E,QAAQ,CAAC,KAAK,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACnC,8BAA8B;IAC9B,QAAQ,CAAC,QAAQ,CAAC,EAAE,gBAAgB,CAAC;CACrC,CAAC;AAEF,MAAM,MAAM,kBAAkB,GAAG;IAChC,QAAQ,CAAC,GAAG,EAAE,WAAW,CAAC;IAC1B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B;;;OAGG;IACH,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC;CAC3B,CAAC;AAEF,MAAM,MAAM,aAAa,GAAG;IAC3B;;;OAGG;IACH,MAAM,CACL,GAAG,EAAE,WAAW,EAChB,KAAK,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,WAAW,KAAK,OAAO,CAAC,IAAI,CAAC,EAChE,MAAM,CAAC,EAAE,WAAW,GAClB,OAAO,CAAC,MAAM,CAAC,CAAC;IAEnB,2BAA2B;IAC3B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB;;;;;OAKG;IACH,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;CAChC,CAAC;AAEF,MAAM,MAAM,oBAAoB,GAAG;IAClC,8DAA8D;IAC9D,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,8EAA8E;IAC9E,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB,2DAA2D;IAC3D,QAAQ,CAAC,UAAU,CAAC,EAAE,CAAC,UAAU,EAAE,kBAAkB,KAAK,IAAI,CAAC;IAC/D;;;;;OAKG;IACH,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;CAC7B,CAAC;AAEF,2EAA2E;AAC3E,qBAAa,gCAAiC,SAAQ,KAAK;IAC1D,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAE9B,YAAY,YAAY,EAAE,MAAM,EAM/B;CACD;AAkED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0DG;AACH,wBAAgB,mBAAmB,CAClC,OAAO,EAAE,oBAAoB,GAC3B,aAAa,CAoEf"}
@@ -0,0 +1,183 @@
1
+ import { mkdir, rename, rm } from "node:fs/promises";
2
+ import { join, resolve as resolvePath } from "node:path";
3
+ import { concatBytes, encodeTextUtf8, setUint32Be, slugify, } from "@ac-kit/core";
4
+ import { sha256 } from "@ac-kit/crypto-hash";
5
+ import { createTempDir, isDirectoryAsync } from "@ac-kit/node";
6
+ import { FileLock } from "../async/file-lock.js";
7
+ /** Thrown when a `lifetime: "run"` artifact is requested outside a run. */
8
+ export class ArtifactRunScopeUnavailableError extends Error {
9
+ artifactName;
10
+ constructor(artifactName) {
11
+ super(`artifact "${artifactName}" requires lifetime "run", but the cache has no run id`);
12
+ this.name = "ArtifactRunScopeUnavailableError";
13
+ this.artifactName = artifactName;
14
+ }
15
+ }
16
+ const HASH_LENGTH = 20;
17
+ const DEFAULT_LOCK_WAIT_MS = 30000;
18
+ const NEVER_ABORTED = new AbortController().signal;
19
+ /**
20
+ * Best-effort: `true` means this caller holds the build lock, `false` means it
21
+ * gave up waiting and should build unlocked rather than stall.
22
+ *
23
+ * Giving up is not a failure path. `FileLock` unlinks its file only from
24
+ * `unlock()`, so a builder killed mid-build — a `SIGKILL`ed child, a crashed
25
+ * compiler — leaves a lock nothing will ever remove. Waiting on it forever
26
+ * would hang every later run; waiting for a bounded time and then duplicating
27
+ * the work costs one wasted build.
28
+ */
29
+ async function acquireBuildLock(lock, lockWaitMs, signal) {
30
+ if (lockWaitMs <= 0) {
31
+ return false;
32
+ }
33
+ const deadline = AbortSignal.timeout(lockWaitMs);
34
+ try {
35
+ await lock.lock(signal ? AbortSignal.any([signal, deadline]) : deadline);
36
+ return true;
37
+ }
38
+ catch (error) {
39
+ if (signal?.aborted) {
40
+ throw error;
41
+ }
42
+ return false;
43
+ }
44
+ }
45
+ /** Length-prefixed, so no concatenation of components can collide with another. */
46
+ function framed(part) {
47
+ const bytes = typeof part === "string" ? encodeTextUtf8(part) : part;
48
+ const header = new Uint8Array(4);
49
+ setUint32Be(header, 0, bytes.length);
50
+ return [header, bytes];
51
+ }
52
+ async function hashKey(key, runId) {
53
+ const parts = [];
54
+ for (const component of [
55
+ process.version,
56
+ process.platform,
57
+ process.arch,
58
+ runId ?? "",
59
+ ...(key.scope ?? []),
60
+ ...key.inputs,
61
+ ]) {
62
+ parts.push(...framed(component));
63
+ }
64
+ return (await sha256(concatBytes(parts))).toHex().slice(0, HASH_LENGTH);
65
+ }
66
+ /**
67
+ * Cross-process cache for expensive setup output — a compiled reference
68
+ * program, a generated corpus — keyed by a hash of everything the artifact
69
+ * depends on rather than by an author-chosen id, so a stale artifact is
70
+ * impossible rather than merely unlikely.
71
+ *
72
+ * Concurrency is settled in two layers, and only the second one is
73
+ * load-bearing:
74
+ *
75
+ * 1. A `FileLock` beside the destination keeps concurrent callers from compiling
76
+ * the same thing at once. It is acquired best-effort with a `lockWaitMs`
77
+ * budget, so a lock left behind by a killed builder costs one wait and one
78
+ * duplicated build instead of hanging the run forever.
79
+ * 2. The build goes to a temp directory and is `rename`d into place. Rename is
80
+ * atomic on POSIX and within a Windows volume, so however many callers
81
+ * build, exactly one artifact exists and every caller returns the same
82
+ * path.
83
+ *
84
+ * @example
85
+ * Compile a reference program once, reuse it forever
86
+ *
87
+ * ```ts
88
+ * const cache = createArtifactCache({ root: "node_modules/.cache/my-tool" });
89
+ * const source = await readFile("bench/reference.cpp", "utf8");
90
+ *
91
+ * const directory = await cache.ensure(
92
+ * { name: "reference C++", inputs: [source, "-O3"], scope: [compilerVersion] },
93
+ * async (staging, signal) => {
94
+ * await run("g++", ["-O3", "-o", join(staging, "reference"), sourcePath], {
95
+ * signal,
96
+ * });
97
+ * },
98
+ * );
99
+ * const program = join(directory, "reference");
100
+ * ```
101
+ *
102
+ * Changing the source text or `-O3` changes the identity, so the next call
103
+ * rebuilds; nothing has to remember to bump a version.
104
+ *
105
+ * @example
106
+ * Scope an artifact to one run, and sweep it afterwards
107
+ *
108
+ * ```ts
109
+ * const cache = createArtifactCache({ root, runId, onResolved: (r) =>
110
+ * report.data({ artifact: r.key.name, cacheHit: r.cacheHit }),
111
+ * });
112
+ *
113
+ * await cache.ensure(
114
+ * { name: "corpus", inputs: [spec], lifetime: "run" },
115
+ * generateCorpus,
116
+ * );
117
+ *
118
+ * // The owner sweeps: a crashed child cannot be asked what it left behind.
119
+ * await rm(cache.runRoot!, { recursive: true, force: true });
120
+ * ```
121
+ *
122
+ * @param options Root, run scoping, hit reporting and the lock budget. See
123
+ * {@link ArtifactCacheOptions}.
124
+ */
125
+ export function createArtifactCache(options) {
126
+ const root = resolvePath(options.root);
127
+ const runId = options.runId ?? null;
128
+ const runRoot = runId === null ? null : join(root, `run-${runId}`);
129
+ const onResolved = options.onResolved;
130
+ const lockWaitMs = options.lockWaitMs ?? DEFAULT_LOCK_WAIT_MS;
131
+ return {
132
+ root,
133
+ runRoot,
134
+ async ensure(key, build, signal) {
135
+ const lifetime = key.lifetime ?? "persistent";
136
+ if (lifetime === "run" && runRoot === null) {
137
+ throw new ArtifactRunScopeUnavailableError(key.name);
138
+ }
139
+ const parent = lifetime === "run" && runRoot ? runRoot : root;
140
+ const digest = await hashKey(key, lifetime === "run" ? runId : null);
141
+ const directory = join(parent, `${slugify(key.name, { fallback: "artifact" })}-${digest}`);
142
+ if (await isDirectoryAsync(directory)) {
143
+ onResolved?.({ key, directory, cacheHit: true });
144
+ return directory;
145
+ }
146
+ signal?.throwIfAborted();
147
+ await mkdir(parent, { recursive: true });
148
+ const lock = new FileLock(directory);
149
+ const held = await acquireBuildLock(lock, lockWaitMs, signal);
150
+ try {
151
+ // Whoever held the lock may have finished while this call waited.
152
+ if (await isDirectoryAsync(directory)) {
153
+ onResolved?.({ key, directory, cacheHit: true });
154
+ return directory;
155
+ }
156
+ // Staged inside the destination's own filesystem, so rename is atomic.
157
+ const staging = await createTempDir(".building", parent);
158
+ try {
159
+ await build(staging, signal ?? NEVER_ABORTED);
160
+ await rename(staging, directory);
161
+ }
162
+ catch (error) {
163
+ await rm(staging, { recursive: true, force: true });
164
+ // A concurrent winner already renamed its own staging directory into
165
+ // place; its result is as valid as the one just discarded.
166
+ if (await isDirectoryAsync(directory)) {
167
+ onResolved?.({ key, directory, cacheHit: true });
168
+ return directory;
169
+ }
170
+ throw error;
171
+ }
172
+ }
173
+ finally {
174
+ if (held) {
175
+ await lock.unlock();
176
+ }
177
+ }
178
+ onResolved?.({ key, directory, cacheHit: false });
179
+ return directory;
180
+ },
181
+ };
182
+ }
183
+ //# sourceMappingURL=artifact-cache.js.map