@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.
- package/LICENSE +28 -0
- package/README.md +45 -0
- package/dist/async/file-lock.d.ts +38 -0
- package/dist/async/file-lock.d.ts.map +1 -0
- package/dist/async/file-lock.js +67 -0
- package/dist/async/file-lock.js.map +1 -0
- package/dist/async/udp-bind-lock.d.ts +75 -0
- package/dist/async/udp-bind-lock.d.ts.map +1 -0
- package/dist/async/udp-bind-lock.js +85 -0
- package/dist/async/udp-bind-lock.js.map +1 -0
- package/dist/fs/artifact-cache.d.ts +124 -0
- package/dist/fs/artifact-cache.d.ts.map +1 -0
- package/dist/fs/artifact-cache.js +183 -0
- package/dist/fs/artifact-cache.js.map +1 -0
- package/dist/fs/rotate-log-files.d.ts +20 -0
- package/dist/fs/rotate-log-files.d.ts.map +1 -0
- package/dist/fs/rotate-log-files.js +45 -0
- package/dist/fs/rotate-log-files.js.map +1 -0
- package/dist/fs/rotating-file-sink.d.ts +42 -0
- package/dist/fs/rotating-file-sink.d.ts.map +1 -0
- package/dist/fs/rotating-file-sink.js +213 -0
- package/dist/fs/rotating-file-sink.js.map +1 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +23 -0
- package/dist/index.js.map +1 -0
- package/dist/os/get-processes-snapshot.d.ts +56 -0
- package/dist/os/get-processes-snapshot.d.ts.map +1 -0
- package/dist/os/get-processes-snapshot.js +197 -0
- package/dist/os/get-processes-snapshot.js.map +1 -0
- package/dist/os/host-metrics.d.ts +43 -0
- package/dist/os/host-metrics.d.ts.map +1 -0
- package/dist/os/host-metrics.js +78 -0
- package/dist/os/host-metrics.js.map +1 -0
- package/dist/os/process-kill-by-pid-file.d.ts +35 -0
- package/dist/os/process-kill-by-pid-file.d.ts.map +1 -0
- package/dist/os/process-kill-by-pid-file.js +48 -0
- package/dist/os/process-kill-by-pid-file.js.map +1 -0
- package/dist/os/process-wait-pid.d.ts +18 -0
- package/dist/os/process-wait-pid.d.ts.map +1 -0
- package/dist/os/process-wait-pid.js +26 -0
- package/dist/os/process-wait-pid.js.map +1 -0
- package/dist/os/ps.d.ts +98 -0
- package/dist/os/ps.d.ts.map +1 -0
- package/dist/os/ps.js +165 -0
- package/dist/os/ps.js.map +1 -0
- package/dist/os/sysfs.d.ts +19 -0
- package/dist/os/sysfs.d.ts.map +1 -0
- package/dist/os/sysfs.js +66 -0
- package/dist/os/sysfs.js.map +1 -0
- package/dist/process/child-process-message-sink.d.ts +21 -0
- package/dist/process/child-process-message-sink.d.ts.map +1 -0
- package/dist/process/child-process-message-sink.js +16 -0
- package/dist/process/child-process-message-sink.js.map +1 -0
- package/dist/process/error-listeners.d.ts +56 -0
- package/dist/process/error-listeners.d.ts.map +1 -0
- package/dist/process/error-listeners.js +113 -0
- package/dist/process/error-listeners.js.map +1 -0
- package/dist/process/exit-manager.d.ts +58 -0
- package/dist/process/exit-manager.d.ts.map +1 -0
- package/dist/process/exit-manager.js +88 -0
- package/dist/process/exit-manager.js.map +1 -0
- package/dist/process/has-binary.d.ts +25 -0
- package/dist/process/has-binary.d.ts.map +1 -0
- package/dist/process/has-binary.js +35 -0
- package/dist/process/has-binary.js.map +1 -0
- package/dist/process/pid-file.d.ts +17 -0
- package/dist/process/pid-file.d.ts.map +1 -0
- package/dist/process/pid-file.js +25 -0
- package/dist/process/pid-file.js.map +1 -0
- package/dist/process/process-pid-file.d.ts +37 -0
- package/dist/process/process-pid-file.d.ts.map +1 -0
- package/dist/process/process-pid-file.js +57 -0
- package/dist/process/process-pid-file.js.map +1 -0
- package/dist/process/report-process.d.ts +36 -0
- package/dist/process/report-process.d.ts.map +1 -0
- package/dist/process/report-process.js +121 -0
- package/dist/process/report-process.js.map +1 -0
- package/dist/shell/shorten-posix-path.d.ts +10 -0
- package/dist/shell/shorten-posix-path.d.ts.map +1 -0
- package/dist/shell/shorten-posix-path.js +19 -0
- package/dist/shell/shorten-posix-path.js.map +1 -0
- package/dist/terminal/node-terminal.d.ts +11 -0
- package/dist/terminal/node-terminal.d.ts.map +1 -0
- package/dist/terminal/node-terminal.js +162 -0
- package/dist/terminal/node-terminal.js.map +1 -0
- package/dist/text/charset-decode-stream.d.ts +13 -0
- package/dist/text/charset-decode-stream.d.ts.map +1 -0
- package/dist/text/charset-decode-stream.js +51 -0
- package/dist/text/charset-decode-stream.js.map +1 -0
- package/dist/text/charset-encode-stream.d.ts +12 -0
- package/dist/text/charset-encode-stream.d.ts.map +1 -0
- package/dist/text/charset-encode-stream.js +47 -0
- package/dist/text/charset-encode-stream.js.map +1 -0
- 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
|