@laurence79/wireit 0.14.13-shared-cache.0 → 0.14.13-shared-cache.1
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/README.md +59 -14
- package/lib/analyzer.js +23 -1
- package/lib/execution/service.js +31 -5
- package/lib/execution/shared-service-coordinator.js +664 -0
- package/lib/logging/quiet/run-tracker.js +3 -0
- package/lib/logging/simple-logger.js +12 -0
- package/lib/script-child-process.js +76 -30
- package/package.json +14 -1
- package/schema.json +4 -0
package/README.md
CHANGED
|
@@ -652,6 +652,51 @@ If you have a service that produces output, you should define a _non-service_
|
|
|
652
652
|
script that depends on it, and which exits when the service's output is
|
|
653
653
|
complete.
|
|
654
654
|
|
|
655
|
+
### Shared services
|
|
656
|
+
|
|
657
|
+
By default, every Wireit invocation that needs a service starts its own copy of
|
|
658
|
+
it. If you run two terminals in the same package that both depend on the same
|
|
659
|
+
service — for example a fixed-port emulator — they will each start an instance
|
|
660
|
+
and clash on that resource.
|
|
661
|
+
|
|
662
|
+
Set `service.shared` to `true` to let concurrent invocations in the same package
|
|
663
|
+
directory **share a single instance** instead. The first invocation that needs
|
|
664
|
+
the service starts it; subsequent invocations attach to the running instance.
|
|
665
|
+
The service is torn down only when its last live consumer exits.
|
|
666
|
+
|
|
667
|
+
```json
|
|
668
|
+
{
|
|
669
|
+
"command": "firebase emulators:start",
|
|
670
|
+
"files": ["firebase.json"],
|
|
671
|
+
"service": {
|
|
672
|
+
"shared": true,
|
|
673
|
+
"readyWhen": {
|
|
674
|
+
"lineMatches": "All emulators ready"
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
```
|
|
679
|
+
|
|
680
|
+
Notes and constraints:
|
|
681
|
+
|
|
682
|
+
- **Same directory only.** Sharing is keyed on the service's
|
|
683
|
+
[fingerprint](#fingerprint) within a single package directory. Two checkouts
|
|
684
|
+
(or worktrees) have separate state and do not share.
|
|
685
|
+
- **Fully tracked services only.** The fingerprint is used as the share key, so a
|
|
686
|
+
shared service must be fully tracked — meaning Wireit knows all of its inputs
|
|
687
|
+
(see [incremental build](#incremental-build)). In practice, give it a `files`
|
|
688
|
+
array (it may be empty). If it isn't fully tracked, `shared` is ignored and
|
|
689
|
+
each invocation starts its own instance.
|
|
690
|
+
- **A long-lived consumer keeps it warm.** If one terminal runs a persistent
|
|
691
|
+
consumer (e.g. `npm start`), the shared service stays up across another
|
|
692
|
+
terminal's repeated `npm test` runs, and is stopped only when the last consumer
|
|
693
|
+
leaves.
|
|
694
|
+
- **Teardown is liveness-based.** A consumer that is `Ctrl-C`'d, closed, or even
|
|
695
|
+
`kill -9`'d is detected and dropped; the service is never leaked, and one
|
|
696
|
+
terminal's `Ctrl-C` never stops a service another terminal still needs.
|
|
697
|
+
- **POSIX only.** On Windows, `shared` degrades to the default per-process
|
|
698
|
+
behavior (no sharing, no regression).
|
|
699
|
+
|
|
655
700
|
## Execution cascade
|
|
656
701
|
|
|
657
702
|
By default, a script always needs to run (or restart in the case of
|
|
@@ -935,20 +980,20 @@ The following syntaxes can be used in the `wireit.<script>.dependencies` array:
|
|
|
935
980
|
|
|
936
981
|
The following environment variables affect the behavior of Wireit:
|
|
937
982
|
|
|
938
|
-
| Variable
|
|
939
|
-
|
|
|
940
|
-
| `WIREIT_CACHE`
|
|
941
|
-
| `WIREIT_CACHE_SHARED_DIR`
|
|
942
|
-
| `WIREIT_CACHE_SHARED_READONLY` | When `true`, the [shared cache](#shared-caching) restores entries but never writes them. Useful for untrusted writers. Only applies when `WIREIT_CACHE=shared`.<br><br>Must be exactly `true` or `false`. Defaults to `false`.
|
|
943
|
-
| `WIREIT_FAILURES`
|
|
944
|
-
| `WIREIT_LOGGER`
|
|
945
|
-
| `WIREIT_DEBUG_LOG_FILE`
|
|
946
|
-
| `WIREIT_MAX_OPEN_FILES`
|
|
947
|
-
| `WIREIT_PARALLEL`
|
|
948
|
-
| `WIREIT_WATCH`
|
|
949
|
-
| `WIREIT_WATCH_STRATEGY`
|
|
950
|
-
| `WIREIT_WATCH_POLL_MS`
|
|
951
|
-
| `CI`
|
|
983
|
+
| Variable | Description |
|
|
984
|
+
| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
985
|
+
| `WIREIT_CACHE` | [Caching mode](#caching).<br><br>Defaults to `local` unless `CI` is `true`, in which case defaults to `none`.<br><br>Automatically set to `github` by the [`google/wireit@setup-github-actions-caching/v2`](#github-actions-caching) action.<br><br>Options:<ul><li>[`local`](#local-caching): Cache to local disk.</li><li>[`github`](#github-actions-caching): Cache to GitHub Actions.</li><li>[`shared`](#shared-caching): Cache to a stable, content-addressed directory shared across builds and agents.</li><li>`none`: Disable caching.</li></ul> |
|
|
986
|
+
| `WIREIT_CACHE_SHARED_DIR` | Root directory of the [shared cache](#shared-caching). Only applies when `WIREIT_CACHE=shared`.<br><br>Defaults to `~/.wireit/cache`. |
|
|
987
|
+
| `WIREIT_CACHE_SHARED_READONLY` | When `true`, the [shared cache](#shared-caching) restores entries but never writes them. Useful for untrusted writers. Only applies when `WIREIT_CACHE=shared`.<br><br>Must be exactly `true` or `false`. Defaults to `false`. |
|
|
988
|
+
| `WIREIT_FAILURES` | [How to handle script failures](#failures-and-errors).<br><br>Options:<br><ul><li>[`no-new`](#failures-and-errors) (default): Allow running scripts to finish, but don't start new ones.</li><li>[`continue`](#continue): Allow running scripts to continue, and start new ones unless any of their dependencies failed.</li><li>[`kill`](#kill): Immediately kill running scripts, and don't start new ones.</li></ul> |
|
|
989
|
+
| `WIREIT_LOGGER` | How to present progress and results on the command line.<br><br>Options:<br><ul><li>`quiet` (default for normal execution): Writes a single dynamically updating line summarizing progress. Only passes along stdout and stderr from commands if there's a failure, or if the command is a service.</li><li>`quiet-ci` (default when `env.CI` or `!stdout.isTTY`): like `quiet` but optimized for non-interactive environments, like GitHub Actions runners.</li><li>`simple`: A verbose logger that presents clear information about the work that Wireit is doing.</li><li>`metrics`: Like `simple`, but also presents a summary table of results once a command is finished.</li></ul> |
|
|
990
|
+
| `WIREIT_DEBUG_LOG_FILE` | Path to a file which will receive detailed event logging. |
|
|
991
|
+
| `WIREIT_MAX_OPEN_FILES` | Limits the number of file descriptors Wireit will have open concurrently. Prevents resource exhaustion when checking large numbers of cached files. Set to a lower number if you hit file descriptor limits. |
|
|
992
|
+
| `WIREIT_PARALLEL` | [Maximum number of scripts to run at one time](#parallelism).<br><br>Defaults to 2×logical CPU cores.<br><br>Must be a positive integer or `infinity`. |
|
|
993
|
+
| `WIREIT_WATCH` | Set to `true` to enable [watch mode](#watch-mode). |
|
|
994
|
+
| `WIREIT_WATCH_STRATEGY` | How Wireit determines when a file has changed which warrants a new watch iteration.<br><br>Options:<br><ul><li>`event` (default): Register OS file system watcher callbacks (using [chokidar](https://github.com/paulmillr/chokidar)).</li><li>`poll`: Poll the filesystem every `WIREIT_WATCH_POLL_MS` milliseconds. Less responsive and worse performance than `event`, but a good fallback for when `event` does not work well or at all (e.g. filesystems that don't support filesystem events, or performance and memory problems with large file trees).</li></ul> |
|
|
995
|
+
| `WIREIT_WATCH_POLL_MS` | When `WIREIT_WATCH_STRATEGY` is `poll`, how many milliseconds to wait between each filesystem poll. Defaults to `500`. |
|
|
996
|
+
| `CI` | Affects the default value of `WIREIT_CACHE`.<br><br>Automatically set to `true` by [GitHub Actions](https://docs.github.com/en/actions/learn-github-actions/environment-variables#default-environment-variables) and most other CI (continuous integration) services.<br><br>Must be exactly `true`. If unset or any other value, interpreted as `false`. |
|
|
952
997
|
|
|
953
998
|
### Glob patterns
|
|
954
999
|
|
package/lib/analyzer.js
CHANGED
|
@@ -847,7 +847,29 @@ export class Analyzer {
|
|
|
847
847
|
return undefined;
|
|
848
848
|
}
|
|
849
849
|
let lineMatches = undefined;
|
|
850
|
+
let shared = false;
|
|
850
851
|
if (serviceNode.type === 'object') {
|
|
852
|
+
const sharedNode = findNodeAtLocation(serviceNode, ['shared']);
|
|
853
|
+
if (sharedNode !== undefined) {
|
|
854
|
+
if (sharedNode.type !== 'boolean') {
|
|
855
|
+
placeholder.failures.push({
|
|
856
|
+
type: 'failure',
|
|
857
|
+
reason: 'invalid-config-syntax',
|
|
858
|
+
script: placeholder,
|
|
859
|
+
diagnostic: {
|
|
860
|
+
severity: 'error',
|
|
861
|
+
message: `The "shared" property must be a boolean.`,
|
|
862
|
+
location: {
|
|
863
|
+
file: packageJson.jsonFile,
|
|
864
|
+
range: { length: sharedNode.length, offset: sharedNode.offset },
|
|
865
|
+
},
|
|
866
|
+
},
|
|
867
|
+
});
|
|
868
|
+
}
|
|
869
|
+
else {
|
|
870
|
+
shared = sharedNode.value;
|
|
871
|
+
}
|
|
872
|
+
}
|
|
851
873
|
const waitForNode = findNodeAtLocation(serviceNode, ['readyWhen']);
|
|
852
874
|
if (waitForNode !== undefined) {
|
|
853
875
|
if (waitForNode.type !== 'object') {
|
|
@@ -951,7 +973,7 @@ export class Analyzer {
|
|
|
951
973
|
},
|
|
952
974
|
});
|
|
953
975
|
}
|
|
954
|
-
return { readyWhen: { lineMatches } };
|
|
976
|
+
return { readyWhen: { lineMatches }, shared };
|
|
955
977
|
}
|
|
956
978
|
async #processPackageLocks(placeholder, packageJson, syntaxInfo, files) {
|
|
957
979
|
if (syntaxInfo.wireitConfigNode === undefined) {
|
package/lib/execution/service.js
CHANGED
|
@@ -8,6 +8,8 @@ import { Fingerprint } from '../fingerprint.js';
|
|
|
8
8
|
import { Deferred } from '../util/deferred.js';
|
|
9
9
|
import { ScriptChildProcess } from '../script-child-process.js';
|
|
10
10
|
import { LineMonitor } from '../util/line-monitor.js';
|
|
11
|
+
import { SharedServiceProcess } from './shared-service-coordinator.js';
|
|
12
|
+
import { IS_WINDOWS } from '../util/windows.js';
|
|
11
13
|
function unknownState(state) {
|
|
12
14
|
return new Error(`Unknown service state ${state.id}`);
|
|
13
15
|
}
|
|
@@ -584,15 +586,39 @@ export class ServiceScriptExecution extends BaseExecutionWithCommand {
|
|
|
584
586
|
case 'depsStarting': {
|
|
585
587
|
const detached = this.#state.adoptee?.detach();
|
|
586
588
|
if (detached === undefined) {
|
|
587
|
-
const
|
|
589
|
+
const fingerprint = this.#state.fingerprint;
|
|
590
|
+
// A shared service is coordinated across concurrent wireit invocations
|
|
591
|
+
// through the filesystem rather than being a child of this invocation.
|
|
592
|
+
// It requires POSIX (where a detached process survives its launcher)
|
|
593
|
+
// and a reliable fingerprint to use as the share key; otherwise we
|
|
594
|
+
// transparently fall back to a normal per-process child.
|
|
595
|
+
const shared = this._config.service.shared &&
|
|
596
|
+
!IS_WINDOWS &&
|
|
597
|
+
fingerprint.data.fullyTracked;
|
|
598
|
+
let child;
|
|
599
|
+
let readyMonitor;
|
|
600
|
+
if (shared) {
|
|
601
|
+
const handle = new SharedServiceProcess(this._config, fingerprint, this._logger);
|
|
602
|
+
child = handle;
|
|
603
|
+
// Readiness for a shared service is published in its coordination
|
|
604
|
+
// file (derived by tailing the shared logfile), not observed on a
|
|
605
|
+
// local child's pipes, so we always route through its ready monitor.
|
|
606
|
+
readyMonitor = handle.readyMonitor;
|
|
607
|
+
}
|
|
608
|
+
else {
|
|
609
|
+
const childProcess = new ScriptChildProcess(this._config);
|
|
610
|
+
child = childProcess;
|
|
611
|
+
readyMonitor =
|
|
612
|
+
this._config.service.readyWhen.lineMatches === undefined
|
|
613
|
+
? undefined
|
|
614
|
+
: new LineMonitor(childProcess, this._config.service.readyWhen.lineMatches);
|
|
615
|
+
}
|
|
588
616
|
this.#state = {
|
|
589
617
|
id: 'starting',
|
|
590
618
|
child,
|
|
591
619
|
started: this.#state.started,
|
|
592
|
-
fingerprint
|
|
593
|
-
readyMonitor
|
|
594
|
-
? undefined
|
|
595
|
-
: new LineMonitor(child, this._config.service.readyWhen.lineMatches),
|
|
620
|
+
fingerprint,
|
|
621
|
+
readyMonitor,
|
|
596
622
|
};
|
|
597
623
|
void this.#state.child.started.then(() => {
|
|
598
624
|
this.#onChildStarted();
|
|
@@ -0,0 +1,664 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2024 Google LLC
|
|
4
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
*/
|
|
6
|
+
import * as pathlib from 'path';
|
|
7
|
+
import { closeSync, openSync } from 'fs';
|
|
8
|
+
import * as fs from 'fs/promises';
|
|
9
|
+
import { PassThrough } from 'stream';
|
|
10
|
+
import lockfile from 'proper-lockfile';
|
|
11
|
+
import { Deferred } from '../util/deferred.js';
|
|
12
|
+
import { getScriptDataDir } from '../util/script-data-dir.js';
|
|
13
|
+
import { spawnDetachedServiceProcess } from '../script-child-process.js';
|
|
14
|
+
/**
|
|
15
|
+
* The basename of the coordination file, the lock sentinel, and the logfile,
|
|
16
|
+
* all of which live in the service's `.wireit/<script-hex>/` directory.
|
|
17
|
+
*/
|
|
18
|
+
const COORDINATION_FILE = 'service.json';
|
|
19
|
+
const LOCK_FILE = 'service.lock';
|
|
20
|
+
const LOGFILE = 'service.log';
|
|
21
|
+
/**
|
|
22
|
+
* How often (ms) each observer re-reads the coordination file, polls service
|
|
23
|
+
* liveness, and tails the logfile.
|
|
24
|
+
*/
|
|
25
|
+
const POLL_INTERVAL_MS = 100;
|
|
26
|
+
/**
|
|
27
|
+
* proper-lockfile settings, matching the convention used elsewhere in wireit
|
|
28
|
+
* (see standard.ts). Critical sections here are short (read coord → decide →
|
|
29
|
+
* write coord), so a hard-crashed holder is reclaimed after `stale` ms.
|
|
30
|
+
*/
|
|
31
|
+
const LOCK_STALE_MS = 10_000;
|
|
32
|
+
const LOCK_UPDATE_MS = 2_000;
|
|
33
|
+
/**
|
|
34
|
+
* When restarting a shared service to a new definition, how long (ms) to wait
|
|
35
|
+
* for the previous instance's process group to actually die before giving up
|
|
36
|
+
* and spawning the replacement anyway.
|
|
37
|
+
*/
|
|
38
|
+
const RESTART_DEATH_TIMEOUT_MS = 5_000;
|
|
39
|
+
/**
|
|
40
|
+
* The restart rate-limit guard window. Restarts older than this are ignored when
|
|
41
|
+
* deciding whether concurrent invocations are oscillating the service.
|
|
42
|
+
*/
|
|
43
|
+
const RESTART_WINDOW_MS = 10_000;
|
|
44
|
+
/**
|
|
45
|
+
* If at least this many restarts happen within {@link RESTART_WINDOW_MS} and
|
|
46
|
+
* they involve more than one distinct fingerprint, we treat it as conflicting
|
|
47
|
+
* concurrent definitions and bail instead of continuing to oscillate.
|
|
48
|
+
*/
|
|
49
|
+
const RESTART_LIMIT = 5;
|
|
50
|
+
/**
|
|
51
|
+
* Whether a pid is currently alive. Treats `EPERM` (the process exists but is
|
|
52
|
+
* owned by another user) as alive, and `ESRCH` (no such process) as dead.
|
|
53
|
+
*/
|
|
54
|
+
export function isAlive(pid) {
|
|
55
|
+
if (!Number.isInteger(pid) || pid <= 1) {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
process.kill(pid, 0);
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
return error.code === 'EPERM';
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/** Filter a list of pids down to those still alive. */
|
|
67
|
+
function pruneDead(pids) {
|
|
68
|
+
return pids.filter((pid) => isAlive(pid));
|
|
69
|
+
}
|
|
70
|
+
/** Send a signal to an entire process group, ignoring "already dead" errors. */
|
|
71
|
+
function signalGroup(pgid, signal) {
|
|
72
|
+
if (!Number.isInteger(pgid) || pgid <= 1) {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
process.kill(-pgid, signal);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
// Already gone, or we lost the race — nothing to do.
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function sleep(ms) {
|
|
83
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Observes a single shared service for one wireit invocation.
|
|
87
|
+
*
|
|
88
|
+
* Exposes the same surface that the service state machine
|
|
89
|
+
* ({@link ServiceScriptExecution}) already consumes from {@link
|
|
90
|
+
* ScriptChildProcess} — `started`, `completed`, `stdout`, `stderr`, `kill()` —
|
|
91
|
+
* plus a {@link readyMonitor}, so that the state machine can drive a shared
|
|
92
|
+
* service almost identically to a per-process one. Internally, whether this
|
|
93
|
+
* invocation spawned the service or attached to an existing one, it behaves as
|
|
94
|
+
* an *observer*: it tails the logfile and watches the coordination file, and
|
|
95
|
+
* tears the service down (by pgid) only once it is the last live consumer.
|
|
96
|
+
*
|
|
97
|
+
* POSIX only; the caller is responsible for not constructing this on Windows.
|
|
98
|
+
*/
|
|
99
|
+
export class SharedServiceProcess {
|
|
100
|
+
#config;
|
|
101
|
+
#fingerprint;
|
|
102
|
+
#logger;
|
|
103
|
+
#myPid;
|
|
104
|
+
#dir;
|
|
105
|
+
#coordPath;
|
|
106
|
+
#lockPath;
|
|
107
|
+
#logPath;
|
|
108
|
+
#lineMatches;
|
|
109
|
+
#startedDeferred;
|
|
110
|
+
#completedDeferred;
|
|
111
|
+
#readyDeferred;
|
|
112
|
+
#stdoutStream;
|
|
113
|
+
#stderrStream;
|
|
114
|
+
/** The pgid of the instance we spawned or attached to. */
|
|
115
|
+
#pgid;
|
|
116
|
+
/** Display tail starts here: 0 for a spawner, current EOF for an attacher. */
|
|
117
|
+
#attachOffset;
|
|
118
|
+
/** How far into the logfile we've read (from 0, for readiness scanning). */
|
|
119
|
+
#logReadOffset;
|
|
120
|
+
/** Buffer of the not-yet-newline-terminated tail, for ready-line scanning. */
|
|
121
|
+
#readyScanBuffer;
|
|
122
|
+
/** True once `started` has resolved (spawn confirmed, or attach completed). */
|
|
123
|
+
#handshakeDone;
|
|
124
|
+
/** True once we've begun (or completed) our own teardown via {@link kill}. */
|
|
125
|
+
#tearingDown;
|
|
126
|
+
#closed;
|
|
127
|
+
#pollTimer;
|
|
128
|
+
get stdout() {
|
|
129
|
+
return this.#stdoutStream;
|
|
130
|
+
}
|
|
131
|
+
get stderr() {
|
|
132
|
+
return this.#stderrStream;
|
|
133
|
+
}
|
|
134
|
+
constructor(config, fingerprint, logger) {
|
|
135
|
+
this.#myPid = process.pid;
|
|
136
|
+
this.#startedDeferred = new Deferred();
|
|
137
|
+
this.#completedDeferred = new Deferred();
|
|
138
|
+
this.#readyDeferred = new Deferred();
|
|
139
|
+
this.#stdoutStream = new PassThrough();
|
|
140
|
+
this.#stderrStream = new PassThrough();
|
|
141
|
+
/** Display tail starts here: 0 for a spawner, current EOF for an attacher. */
|
|
142
|
+
this.#attachOffset = 0;
|
|
143
|
+
/** How far into the logfile we've read (from 0, for readiness scanning). */
|
|
144
|
+
this.#logReadOffset = 0;
|
|
145
|
+
/** Buffer of the not-yet-newline-terminated tail, for ready-line scanning. */
|
|
146
|
+
this.#readyScanBuffer = '';
|
|
147
|
+
/** True once `started` has resolved (spawn confirmed, or attach completed). */
|
|
148
|
+
this.#handshakeDone = false;
|
|
149
|
+
/** True once we've begun (or completed) our own teardown via {@link kill}. */
|
|
150
|
+
this.#tearingDown = false;
|
|
151
|
+
this.#closed = false;
|
|
152
|
+
/** Resolves when the service is spawned (us) or confirmed running (attach). */
|
|
153
|
+
this.started = this.#startedDeferred.promise;
|
|
154
|
+
/** Resolves when, for us, the service is finished (exited, or we detached). */
|
|
155
|
+
this.completed = this.#completedDeferred.promise;
|
|
156
|
+
this.readyMonitor = {
|
|
157
|
+
matched: this.#readyDeferred.promise,
|
|
158
|
+
abort: () => {
|
|
159
|
+
if (!this.#readyDeferred.settled) {
|
|
160
|
+
this.#readyDeferred.resolve({ ok: false, error: undefined });
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
this.#config = config;
|
|
165
|
+
this.#fingerprint = fingerprint;
|
|
166
|
+
this.#logger = logger;
|
|
167
|
+
this.#lineMatches = config.service.readyWhen.lineMatches;
|
|
168
|
+
this.#dir = getScriptDataDir(config);
|
|
169
|
+
this.#coordPath = pathlib.join(this.#dir, COORDINATION_FILE);
|
|
170
|
+
this.#lockPath = pathlib.join(this.#dir, LOCK_FILE);
|
|
171
|
+
this.#logPath = pathlib.join(this.#dir, LOGFILE);
|
|
172
|
+
void this.#init();
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Detach this invocation from the shared service. If we are the last live
|
|
176
|
+
* consumer, the whole service process group is torn down; otherwise the
|
|
177
|
+
* service keeps running for the remaining consumers. Either way, our
|
|
178
|
+
* {@link completed} resolves.
|
|
179
|
+
*
|
|
180
|
+
* Mirrors {@link ScriptChildProcess.kill}: returns immediately.
|
|
181
|
+
*/
|
|
182
|
+
kill() {
|
|
183
|
+
if (this.#tearingDown || this.#completedDeferred.settled) {
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
this.#tearingDown = true;
|
|
187
|
+
void this.#teardown();
|
|
188
|
+
}
|
|
189
|
+
async #init() {
|
|
190
|
+
try {
|
|
191
|
+
await fs.mkdir(this.#dir, { recursive: true });
|
|
192
|
+
const decision = await this.#withLock(() => this.#decideUnderLock());
|
|
193
|
+
if (decision.kind === 'conflict') {
|
|
194
|
+
// Concurrent invocations keep restarting this service to conflicting
|
|
195
|
+
// definitions. Bail with a clear error rather than oscillate it.
|
|
196
|
+
const failure = {
|
|
197
|
+
type: 'failure',
|
|
198
|
+
script: this.#config,
|
|
199
|
+
reason: 'service-shared-conflict',
|
|
200
|
+
message: 'Shared service restarted with conflicting definitions by ' +
|
|
201
|
+
'concurrent invocations (for example a different Node version or ' +
|
|
202
|
+
'environment per terminal). Wireit stopped restarting it.',
|
|
203
|
+
};
|
|
204
|
+
this.#logger.log(failure);
|
|
205
|
+
this.#markHandshakeDone();
|
|
206
|
+
this.#failCompleted(failure);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (decision.kind === 'attach') {
|
|
210
|
+
this.#pgid = decision.pgid;
|
|
211
|
+
this.#attachOffset = decision.attachOffset;
|
|
212
|
+
this.#logger.log({
|
|
213
|
+
script: this.#config,
|
|
214
|
+
type: 'info',
|
|
215
|
+
detail: 'service-shared-attached',
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
else {
|
|
219
|
+
// We spawned the detached process under the lock; #pgid is already set.
|
|
220
|
+
// The display tail starts from the beginning of the (freshly truncated)
|
|
221
|
+
// logfile.
|
|
222
|
+
this.#attachOffset = 0;
|
|
223
|
+
this.#watchSpawnError(decision.child);
|
|
224
|
+
this.#logger.log({
|
|
225
|
+
script: this.#config,
|
|
226
|
+
type: 'info',
|
|
227
|
+
detail: 'service-shared-spawned',
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
// The handshake is complete as soon as the spawn-vs-attach decision is
|
|
231
|
+
// made: a spawned child already has a pid, and an attach means a live
|
|
232
|
+
// instance is already running. Resolving `started` here — before the
|
|
233
|
+
// first poll, and synchronously before the state machine's `started`
|
|
234
|
+
// callback runs — guarantees the machine leaves its "starting" state
|
|
235
|
+
// before any exit can be observed.
|
|
236
|
+
this.#markHandshakeDone();
|
|
237
|
+
this.#startPolling();
|
|
238
|
+
}
|
|
239
|
+
catch (error) {
|
|
240
|
+
// Anything unexpected during setup fails this consumer's run, but we still
|
|
241
|
+
// resolve `started` so the state machine advances to a state from which it
|
|
242
|
+
// can observe the failure via `completed`.
|
|
243
|
+
this.#markHandshakeDone();
|
|
244
|
+
this.#failCompleted({
|
|
245
|
+
type: 'failure',
|
|
246
|
+
script: this.#config,
|
|
247
|
+
reason: 'spawn-error',
|
|
248
|
+
message: `Failed to coordinate shared service: ${error instanceof Error ? error.message : String(error)}`,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
// ---- Locked decision: spawn vs attach vs restart -----------------------
|
|
253
|
+
async #decideUnderLock() {
|
|
254
|
+
const coord = await this.#readCoord();
|
|
255
|
+
if (coord !== undefined &&
|
|
256
|
+
coord.state === 'running' &&
|
|
257
|
+
isAlive(coord.pgid)) {
|
|
258
|
+
if (coord.fingerprint === this.#fingerprint.string) {
|
|
259
|
+
return this.#attachUnderLock(coord);
|
|
260
|
+
}
|
|
261
|
+
// A concurrent invocation in the same directory wants a different
|
|
262
|
+
// definition of this service. Restart it so everyone converges.
|
|
263
|
+
return this.#restartUnderLock(coord);
|
|
264
|
+
}
|
|
265
|
+
// No coordination file, or the recorded instance is gone/stale — spawn.
|
|
266
|
+
// Carry forward any recent restart history (pruned to the window) so that a
|
|
267
|
+
// crash-and-respawn during an active oscillation is still rate-limited.
|
|
268
|
+
return this.#spawnUnderLock(this.#recentRestarts(coord?.restarts));
|
|
269
|
+
}
|
|
270
|
+
async #attachUnderLock(coord) {
|
|
271
|
+
const consumers = pruneDead(coord.consumers);
|
|
272
|
+
if (!consumers.includes(this.#myPid)) {
|
|
273
|
+
consumers.push(this.#myPid);
|
|
274
|
+
}
|
|
275
|
+
const attachOffset = await this.#logSize();
|
|
276
|
+
await this.#writeCoord({ ...coord, consumers });
|
|
277
|
+
return { kind: 'attach', pgid: coord.pgid, attachOffset };
|
|
278
|
+
}
|
|
279
|
+
async #spawnUnderLock(restarts) {
|
|
280
|
+
// Truncate the logfile so this instance starts with a clean log.
|
|
281
|
+
const fd = openSync(this.#logPath, 'w');
|
|
282
|
+
let child;
|
|
283
|
+
try {
|
|
284
|
+
child = spawnDetachedServiceProcess(this.#config, fd);
|
|
285
|
+
}
|
|
286
|
+
finally {
|
|
287
|
+
closeSync(fd);
|
|
288
|
+
}
|
|
289
|
+
const pgid = child.pid;
|
|
290
|
+
if (pgid === undefined) {
|
|
291
|
+
// Synchronous spawn failure (rare). Record the failure so other waiters
|
|
292
|
+
// don't try to attach to a non-existent process.
|
|
293
|
+
await this.#writeCoord({
|
|
294
|
+
fingerprint: this.#fingerprint.string,
|
|
295
|
+
pgid: -1,
|
|
296
|
+
logfile: LOGFILE,
|
|
297
|
+
readyState: 'failed',
|
|
298
|
+
state: 'exited',
|
|
299
|
+
consumers: [],
|
|
300
|
+
restarts,
|
|
301
|
+
});
|
|
302
|
+
throw new Error('service process was spawned without a pid');
|
|
303
|
+
}
|
|
304
|
+
this.#pgid = pgid;
|
|
305
|
+
await this.#writeCoord({
|
|
306
|
+
fingerprint: this.#fingerprint.string,
|
|
307
|
+
pgid,
|
|
308
|
+
logfile: LOGFILE,
|
|
309
|
+
readyState: 'starting',
|
|
310
|
+
state: 'running',
|
|
311
|
+
consumers: [this.#myPid],
|
|
312
|
+
restarts,
|
|
313
|
+
});
|
|
314
|
+
return { kind: 'spawn', child };
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Restart-to-latest: a concurrent invocation wants a different definition, so
|
|
318
|
+
* kill the running instance and spawn a replacement with our fingerprint —
|
|
319
|
+
* unless the restart rate-limit guard detects that concurrent invocations are
|
|
320
|
+
* oscillating the service between conflicting definitions, in which case we
|
|
321
|
+
* bail (§8) rather than continue to flap it.
|
|
322
|
+
*/
|
|
323
|
+
async #restartUnderLock(coord) {
|
|
324
|
+
const recent = this.#recentRestarts(coord.restarts) ?? [];
|
|
325
|
+
const prospective = [
|
|
326
|
+
...recent,
|
|
327
|
+
{ at: Date.now(), fingerprint: this.#fingerprint.string },
|
|
328
|
+
];
|
|
329
|
+
const distinctFingerprints = new Set(prospective.map((restart) => restart.fingerprint));
|
|
330
|
+
if (prospective.length >= RESTART_LIMIT && distinctFingerprints.size >= 2) {
|
|
331
|
+
// Leave the currently-running instance alone and signal the conflict.
|
|
332
|
+
return { kind: 'conflict' };
|
|
333
|
+
}
|
|
334
|
+
signalGroup(coord.pgid, 'SIGINT');
|
|
335
|
+
await this.#waitForGroupDeath(coord.pgid, RESTART_DEATH_TIMEOUT_MS);
|
|
336
|
+
return this.#spawnUnderLock(prospective);
|
|
337
|
+
}
|
|
338
|
+
/** Restart records from within the rate-limit window. */
|
|
339
|
+
#recentRestarts(restarts) {
|
|
340
|
+
if (restarts === undefined) {
|
|
341
|
+
return undefined;
|
|
342
|
+
}
|
|
343
|
+
const cutoff = Date.now() - RESTART_WINDOW_MS;
|
|
344
|
+
const recent = restarts.filter((restart) => restart.at >= cutoff);
|
|
345
|
+
return recent.length > 0 ? recent : undefined;
|
|
346
|
+
}
|
|
347
|
+
async #waitForGroupDeath(pgid, timeoutMs) {
|
|
348
|
+
const deadline = Date.now() + timeoutMs;
|
|
349
|
+
let escalated = false;
|
|
350
|
+
while (isAlive(pgid)) {
|
|
351
|
+
if (!escalated && Date.now() > deadline - timeoutMs / 2) {
|
|
352
|
+
signalGroup(pgid, 'SIGKILL');
|
|
353
|
+
escalated = true;
|
|
354
|
+
}
|
|
355
|
+
if (Date.now() > deadline) {
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
await sleep(POLL_INTERVAL_MS);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
// ---- Spawn confirmation -------------------------------------------------
|
|
362
|
+
/**
|
|
363
|
+
* Fast-path detection of a failure to spawn the detached process (e.g. the
|
|
364
|
+
* shell itself is missing). This is best-effort — the liveness poller is the
|
|
365
|
+
* backstop if the `error` event is missed — but it surfaces spawn failures
|
|
366
|
+
* promptly and marks the instance failed so other waiters don't attach.
|
|
367
|
+
*/
|
|
368
|
+
#watchSpawnError(child) {
|
|
369
|
+
child.once('error', (error) => {
|
|
370
|
+
void this.#mutateCoord((coord) => coord.pgid === this.#pgid
|
|
371
|
+
? { ...coord, state: 'exited', readyState: 'failed' }
|
|
372
|
+
: coord);
|
|
373
|
+
this.#failCompleted({
|
|
374
|
+
type: 'failure',
|
|
375
|
+
script: this.#config,
|
|
376
|
+
reason: 'spawn-error',
|
|
377
|
+
message: error.message,
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
#markHandshakeDone() {
|
|
382
|
+
if (!this.#handshakeDone) {
|
|
383
|
+
this.#handshakeDone = true;
|
|
384
|
+
this.#startedDeferred.resolve({ ok: true, value: undefined });
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
// ---- Polling loop: readiness, log streaming, exit detection ------------
|
|
388
|
+
#startPolling() {
|
|
389
|
+
this.#pollTimer = setTimeout(() => void this.#tick(), 0);
|
|
390
|
+
}
|
|
391
|
+
async #tick() {
|
|
392
|
+
if (this.#closed) {
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
try {
|
|
396
|
+
await this.#streamNewLog();
|
|
397
|
+
const coord = await this.#readCoord();
|
|
398
|
+
this.#evaluate(coord);
|
|
399
|
+
}
|
|
400
|
+
catch {
|
|
401
|
+
// Transient fs error (e.g. a write racing our read); try again next tick.
|
|
402
|
+
}
|
|
403
|
+
if (!this.#closed) {
|
|
404
|
+
this.#pollTimer = setTimeout(() => void this.#tick(), POLL_INTERVAL_MS);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
#evaluate(coord) {
|
|
408
|
+
if (this.#closed || this.#tearingDown) {
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
// The instance is gone or has been replaced out from under us.
|
|
412
|
+
if (coord === undefined ||
|
|
413
|
+
coord.state === 'exited' ||
|
|
414
|
+
coord.pgid !== this.#pgid ||
|
|
415
|
+
coord.readyState === 'failed') {
|
|
416
|
+
this.#onServiceGone();
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
// We believe it's running — verify the process group is actually alive.
|
|
420
|
+
if (!isAlive(coord.pgid)) {
|
|
421
|
+
// We're the first observer to notice the crash; record it.
|
|
422
|
+
void this.#mutateCoord((c) => c.pgid === this.#pgid && c.state === 'running'
|
|
423
|
+
? {
|
|
424
|
+
...c,
|
|
425
|
+
state: 'exited',
|
|
426
|
+
readyState: c.readyState === 'ready' ? 'ready' : 'failed',
|
|
427
|
+
exit: { code: null, signal: null },
|
|
428
|
+
}
|
|
429
|
+
: c);
|
|
430
|
+
this.#onServiceGone();
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
// Alive and running — resolve readiness if we can.
|
|
434
|
+
if (!this.#readyDeferred.settled) {
|
|
435
|
+
if (coord.readyState === 'ready') {
|
|
436
|
+
this.#readyDeferred.resolve({ ok: true, value: undefined });
|
|
437
|
+
}
|
|
438
|
+
else if (this.#lineMatches === undefined && this.#handshakeDone) {
|
|
439
|
+
// No ready condition: ready as soon as the spawn is confirmed.
|
|
440
|
+
this.#markReady();
|
|
441
|
+
}
|
|
442
|
+
// Otherwise we have a ready line to wait for; #streamNewLog scans for it.
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
#onServiceGone() {
|
|
446
|
+
if (!this.#readyDeferred.settled) {
|
|
447
|
+
this.#readyDeferred.resolve({ ok: false, error: undefined });
|
|
448
|
+
}
|
|
449
|
+
this.#failCompleted({
|
|
450
|
+
type: 'failure',
|
|
451
|
+
script: this.#config,
|
|
452
|
+
reason: 'service-exited-unexpectedly',
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* Read everything appended to the logfile since last tick. Feed it to the
|
|
457
|
+
* ready-line scanner (from the start of the file) and re-emit the portion at
|
|
458
|
+
* or after our attach offset to {@link stdout}.
|
|
459
|
+
*/
|
|
460
|
+
async #streamNewLog() {
|
|
461
|
+
const start = this.#logReadOffset;
|
|
462
|
+
const { data, nextOffset } = await this.#readLogFrom(start);
|
|
463
|
+
if (data.length === 0) {
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
this.#logReadOffset = nextOffset;
|
|
467
|
+
if (this.#lineMatches !== undefined && !this.#readyDeferred.settled) {
|
|
468
|
+
this.#scanForReady(data.toString('utf8'));
|
|
469
|
+
}
|
|
470
|
+
const end = start + data.length;
|
|
471
|
+
if (end > this.#attachOffset) {
|
|
472
|
+
const sliceStart = Math.max(0, this.#attachOffset - start);
|
|
473
|
+
this.#stdoutStream.write(data.subarray(sliceStart));
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
#scanForReady(text) {
|
|
477
|
+
// Mirror LineMonitor: test every line including the trailing incomplete one,
|
|
478
|
+
// but only retain the trailing incomplete line for next time.
|
|
479
|
+
this.#readyScanBuffer += text;
|
|
480
|
+
const lines = this.#readyScanBuffer.split('\n');
|
|
481
|
+
for (let i = 0; i < lines.length; i++) {
|
|
482
|
+
const line = lines[i];
|
|
483
|
+
if (this.#lineMatches.test(line)) {
|
|
484
|
+
this.#readyScanBuffer = '';
|
|
485
|
+
this.#markReady();
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
this.#readyScanBuffer = lines[lines.length - 1];
|
|
490
|
+
}
|
|
491
|
+
/** Resolve readiness, and persist `readyState=ready` for late attachers. */
|
|
492
|
+
#markReady() {
|
|
493
|
+
if (this.#readyDeferred.settled) {
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
this.#readyDeferred.resolve({ ok: true, value: undefined });
|
|
497
|
+
void this.#mutateCoord((coord) => coord.pgid === this.#pgid &&
|
|
498
|
+
coord.state === 'running' &&
|
|
499
|
+
coord.readyState === 'starting'
|
|
500
|
+
? { ...coord, readyState: 'ready' }
|
|
501
|
+
: coord);
|
|
502
|
+
}
|
|
503
|
+
// ---- Teardown -----------------------------------------------------------
|
|
504
|
+
async #teardown() {
|
|
505
|
+
try {
|
|
506
|
+
await this.#withLock(async () => {
|
|
507
|
+
const coord = await this.#readCoord();
|
|
508
|
+
if (coord === undefined ||
|
|
509
|
+
coord.pgid !== this.#pgid ||
|
|
510
|
+
coord.state !== 'running') {
|
|
511
|
+
// The instance is already gone or has been replaced; nothing to do.
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
const remaining = pruneDead(coord.consumers.filter((pid) => pid !== this.#myPid));
|
|
515
|
+
if (remaining.length === 0) {
|
|
516
|
+
// We're the last live consumer — stop the service and clear the file.
|
|
517
|
+
signalGroup(coord.pgid, 'SIGINT');
|
|
518
|
+
await this.#deleteCoord();
|
|
519
|
+
}
|
|
520
|
+
else {
|
|
521
|
+
await this.#writeCoord({ ...coord, consumers: remaining });
|
|
522
|
+
}
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
catch {
|
|
526
|
+
// Best-effort; a dead consumer is pruned by whoever next holds the lock.
|
|
527
|
+
}
|
|
528
|
+
this.#completeOk();
|
|
529
|
+
}
|
|
530
|
+
// ---- Completion ---------------------------------------------------------
|
|
531
|
+
#failCompleted(failure) {
|
|
532
|
+
if (this.#completedDeferred.settled) {
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
this.#completedDeferred.resolve({ ok: false, error: failure });
|
|
536
|
+
this.#close();
|
|
537
|
+
}
|
|
538
|
+
#completeOk() {
|
|
539
|
+
if (this.#completedDeferred.settled) {
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
this.#completedDeferred.resolve({ ok: true, value: undefined });
|
|
543
|
+
this.#close();
|
|
544
|
+
}
|
|
545
|
+
#close() {
|
|
546
|
+
this.#closed = true;
|
|
547
|
+
if (this.#pollTimer !== undefined) {
|
|
548
|
+
clearTimeout(this.#pollTimer);
|
|
549
|
+
this.#pollTimer = undefined;
|
|
550
|
+
}
|
|
551
|
+
this.#stdoutStream.end();
|
|
552
|
+
this.#stderrStream.end();
|
|
553
|
+
}
|
|
554
|
+
// ---- Coordination file & lock I/O --------------------------------------
|
|
555
|
+
async #withLock(fn) {
|
|
556
|
+
// proper-lockfile locks an existing file by creating an adjacent "<file>.lock"
|
|
557
|
+
// directory (an atomic mkdir). Ensure the sentinel file exists first.
|
|
558
|
+
await fs.writeFile(this.#lockPath, '', 'utf8');
|
|
559
|
+
for (;;) {
|
|
560
|
+
let release;
|
|
561
|
+
try {
|
|
562
|
+
release = await lockfile.lock(this.#lockPath, {
|
|
563
|
+
stale: LOCK_STALE_MS,
|
|
564
|
+
update: LOCK_UPDATE_MS,
|
|
565
|
+
retries: 0,
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
catch (error) {
|
|
569
|
+
if (error.code === 'ELOCKED') {
|
|
570
|
+
await sleep(POLL_INTERVAL_MS / 2);
|
|
571
|
+
continue;
|
|
572
|
+
}
|
|
573
|
+
throw error;
|
|
574
|
+
}
|
|
575
|
+
try {
|
|
576
|
+
return await fn();
|
|
577
|
+
}
|
|
578
|
+
finally {
|
|
579
|
+
await release();
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
/** Read+decide+write the coordination file atomically under the lock. */
|
|
584
|
+
async #mutateCoord(update) {
|
|
585
|
+
try {
|
|
586
|
+
await this.#withLock(async () => {
|
|
587
|
+
const coord = await this.#readCoord();
|
|
588
|
+
if (coord === undefined) {
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
const next = update(coord);
|
|
592
|
+
if (next !== coord) {
|
|
593
|
+
await this.#writeCoord(next);
|
|
594
|
+
}
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
catch {
|
|
598
|
+
// Best-effort coordination metadata update.
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
async #readCoord() {
|
|
602
|
+
let text;
|
|
603
|
+
try {
|
|
604
|
+
text = await fs.readFile(this.#coordPath, 'utf8');
|
|
605
|
+
}
|
|
606
|
+
catch (error) {
|
|
607
|
+
if (error.code === 'ENOENT') {
|
|
608
|
+
return undefined;
|
|
609
|
+
}
|
|
610
|
+
throw error;
|
|
611
|
+
}
|
|
612
|
+
try {
|
|
613
|
+
return JSON.parse(text);
|
|
614
|
+
}
|
|
615
|
+
catch {
|
|
616
|
+
// A torn/partial write — treat as "no usable instance".
|
|
617
|
+
return undefined;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
async #writeCoord(coord) {
|
|
621
|
+
await fs.writeFile(this.#coordPath, JSON.stringify(coord, null, 2), 'utf8');
|
|
622
|
+
}
|
|
623
|
+
async #deleteCoord() {
|
|
624
|
+
try {
|
|
625
|
+
await fs.unlink(this.#coordPath);
|
|
626
|
+
}
|
|
627
|
+
catch (error) {
|
|
628
|
+
if (error.code !== 'ENOENT') {
|
|
629
|
+
throw error;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
async #logSize() {
|
|
634
|
+
try {
|
|
635
|
+
return (await fs.stat(this.#logPath)).size;
|
|
636
|
+
}
|
|
637
|
+
catch {
|
|
638
|
+
return 0;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
async #readLogFrom(offset) {
|
|
642
|
+
let handle;
|
|
643
|
+
try {
|
|
644
|
+
handle = await fs.open(this.#logPath, 'r');
|
|
645
|
+
}
|
|
646
|
+
catch {
|
|
647
|
+
return { data: Buffer.alloc(0), nextOffset: offset };
|
|
648
|
+
}
|
|
649
|
+
try {
|
|
650
|
+
const { size } = await handle.stat();
|
|
651
|
+
if (size <= offset) {
|
|
652
|
+
return { data: Buffer.alloc(0), nextOffset: offset };
|
|
653
|
+
}
|
|
654
|
+
const length = size - offset;
|
|
655
|
+
const buffer = Buffer.alloc(length);
|
|
656
|
+
await handle.read(buffer, 0, length, offset);
|
|
657
|
+
return { data: buffer, nextOffset: size };
|
|
658
|
+
}
|
|
659
|
+
finally {
|
|
660
|
+
await handle.close();
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
//# sourceMappingURL=shared-service-coordinator.js.map
|
|
@@ -335,6 +335,8 @@ export class QuietRunLogger {
|
|
|
335
335
|
this.#running.delete(scriptReferenceToString(event.script));
|
|
336
336
|
return this.#getStatusLine();
|
|
337
337
|
case 'service-ready':
|
|
338
|
+
case 'service-shared-spawned':
|
|
339
|
+
case 'service-shared-attached':
|
|
338
340
|
case 'watch-run-start':
|
|
339
341
|
case 'watch-run-end':
|
|
340
342
|
return noChange;
|
|
@@ -476,6 +478,7 @@ export class QuietRunLogger {
|
|
|
476
478
|
case 'input-file-deleted-unexpectedly':
|
|
477
479
|
case 'output-file-deleted-unexpectedly':
|
|
478
480
|
case 'service-exited-unexpectedly':
|
|
481
|
+
case 'service-shared-conflict':
|
|
479
482
|
case 'cycle':
|
|
480
483
|
case 'dependency-invalid':
|
|
481
484
|
case 'dependency-on-missing-package-json':
|
|
@@ -155,6 +155,10 @@ export class SimpleLogger {
|
|
|
155
155
|
this.console.error(`❌${prefix} Service exited unexpectedly`);
|
|
156
156
|
break;
|
|
157
157
|
}
|
|
158
|
+
case 'service-shared-conflict': {
|
|
159
|
+
this.console.error(`❌${prefix} ${event.message}`);
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
158
162
|
case 'input-file-deleted-unexpectedly': {
|
|
159
163
|
for (const filePath of event.filePaths) {
|
|
160
164
|
this.console.error(`❌${prefix} Input file "${filePath}" was deleted unexpectedly. Is another process writing to the same location?`);
|
|
@@ -247,6 +251,14 @@ export class SimpleLogger {
|
|
|
247
251
|
this.console.log(`⬇️${prefix} Service stopped`);
|
|
248
252
|
break;
|
|
249
253
|
}
|
|
254
|
+
case 'service-shared-spawned': {
|
|
255
|
+
this.console.log(`⬆️${prefix} Shared service started`);
|
|
256
|
+
break;
|
|
257
|
+
}
|
|
258
|
+
case 'service-shared-attached': {
|
|
259
|
+
this.console.log(`🔗${prefix} Attached to shared service`);
|
|
260
|
+
break;
|
|
261
|
+
}
|
|
250
262
|
case 'analysis-started':
|
|
251
263
|
case 'analysis-completed': {
|
|
252
264
|
break;
|
|
@@ -26,6 +26,81 @@ const PATH_ENV_SUFFIX = (() => {
|
|
|
26
26
|
const endOfNodeModuleBins = entries.findIndex((entry) => !entry.endsWith(nodeModulesBinSuffix));
|
|
27
27
|
return entries.slice(endOfNodeModuleBins).join(pathlib.delimiter);
|
|
28
28
|
})();
|
|
29
|
+
/**
|
|
30
|
+
* Generates the PATH environment variable that should be set when a script's
|
|
31
|
+
* command is spawned from the given package directory.
|
|
32
|
+
*/
|
|
33
|
+
function pathEnvironmentVariable(packageDir) {
|
|
34
|
+
// Given package "/foo/bar", walk up the path hierarchy to generate
|
|
35
|
+
// "/foo/bar/node_modules/.bin:/foo/node_modules/.bin:/node_modules/.bin".
|
|
36
|
+
const entries = [];
|
|
37
|
+
let cur = packageDir;
|
|
38
|
+
while (true) {
|
|
39
|
+
entries.push(pathlib.join(cur, 'node_modules', '.bin'));
|
|
40
|
+
const parent = pathlib.dirname(cur);
|
|
41
|
+
if (parent === cur) {
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
cur = parent;
|
|
45
|
+
}
|
|
46
|
+
// Add the inherited PATH variable, minus any "node_modules/.bin" entries that
|
|
47
|
+
// were set by the "npm run" command that spawned Wireit.
|
|
48
|
+
entries.push(PATH_ENV_SUFFIX);
|
|
49
|
+
// Note the PATH delimiter is platform-dependent.
|
|
50
|
+
return entries.join(pathlib.delimiter);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Build the environment that a script's command should be spawned with.
|
|
54
|
+
*/
|
|
55
|
+
function spawnEnvironment(script) {
|
|
56
|
+
return augmentProcessEnvSafelyIfOnWindows({
|
|
57
|
+
FORCE_COLOR: process.stdout.isTTY && process.env.FORCE_COLOR === undefined
|
|
58
|
+
? 'true'
|
|
59
|
+
: process.env.FORCE_COLOR,
|
|
60
|
+
PATH: pathEnvironmentVariable(script.packageDir),
|
|
61
|
+
...script.env,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Spawn a service as a fully detached process whose stdout and stderr are
|
|
66
|
+
* redirected to the given open logfile file descriptor, instead of being piped
|
|
67
|
+
* back to this Wireit invocation. This is the spawn mode for {@link
|
|
68
|
+
* ServiceConfig.shared | shared services}: because the process is detached,
|
|
69
|
+
* `unref`'d, and writes its output directly to a file, it survives the Wireit
|
|
70
|
+
* invocation that launched it, so that other concurrent invocations can attach
|
|
71
|
+
* to it.
|
|
72
|
+
*
|
|
73
|
+
* Returns the raw {@link ChildProcess}. Because we spawn `detached`, the
|
|
74
|
+
* child is the leader of a new process group and session, so `child.pid` is also
|
|
75
|
+
* the process-group id (pass its negation to `process.kill` to signal the whole
|
|
76
|
+
* group). The caller reads `child.pid` synchronously and may listen for the
|
|
77
|
+
* `spawn` and `error` events; the child is already `unref`'d so it will not by
|
|
78
|
+
* itself keep this process alive.
|
|
79
|
+
*
|
|
80
|
+
* POSIX only — shared services degrade to per-process behavior on Windows, so
|
|
81
|
+
* this is never called there.
|
|
82
|
+
*/
|
|
83
|
+
export function spawnDetachedServiceProcess(script, logFd) {
|
|
84
|
+
const child = spawn(script.command.value, script.extraArgs ?? [], {
|
|
85
|
+
cwd: script.packageDir,
|
|
86
|
+
// See the comment in the ScriptChildProcess constructor for why we use
|
|
87
|
+
// "shell: true".
|
|
88
|
+
shell: true,
|
|
89
|
+
env: spawnEnvironment(script),
|
|
90
|
+
// Detached so that the process becomes the leader of its own process group
|
|
91
|
+
// and session. This both lets us kill the whole group with
|
|
92
|
+
// "process.kill(-pid)", and ensures a Ctrl-C delivered to a consuming
|
|
93
|
+
// Wireit invocation's foreground process group does not reach this service.
|
|
94
|
+
detached: true,
|
|
95
|
+
// Redirect the child's stdout and stderr straight to the logfile, so that
|
|
96
|
+
// it keeps producing output (and other invocations keep seeing it) after
|
|
97
|
+
// the launching invocation exits. stdin is ignored.
|
|
98
|
+
stdio: ['ignore', logFd, logFd],
|
|
99
|
+
});
|
|
100
|
+
// Allow the launching invocation to exit without waiting for this process.
|
|
101
|
+
child.unref();
|
|
102
|
+
return child;
|
|
103
|
+
}
|
|
29
104
|
/**
|
|
30
105
|
* A child process spawned during execution of a script.
|
|
31
106
|
*/
|
|
@@ -76,13 +151,7 @@ export class ScriptChildProcess {
|
|
|
76
151
|
// https://nodejs.org/api/child_process.html#default-windows-shell
|
|
77
152
|
// https://github.com/npm/run-script/blob/a5b03bdfc3a499bf7587d7414d5ea712888bfe93/lib/make-spawn-args.js#L11
|
|
78
153
|
shell: true,
|
|
79
|
-
env:
|
|
80
|
-
FORCE_COLOR: process.stdout.isTTY && process.env.FORCE_COLOR === undefined
|
|
81
|
-
? 'true'
|
|
82
|
-
: process.env.FORCE_COLOR,
|
|
83
|
-
PATH: this.#pathEnvironmentVariable,
|
|
84
|
-
...this.#script.env,
|
|
85
|
-
}),
|
|
154
|
+
env: spawnEnvironment(this.#script),
|
|
86
155
|
// Set "detached" on Linux and macOS so that we create a new process
|
|
87
156
|
// group, instead of being added to the process group for this Wireit
|
|
88
157
|
// process.
|
|
@@ -243,28 +312,5 @@ export class ScriptChildProcess {
|
|
|
243
312
|
}
|
|
244
313
|
this.#state = 'killing';
|
|
245
314
|
}
|
|
246
|
-
/**
|
|
247
|
-
* Generates the PATH environment variable that should be set when this
|
|
248
|
-
* script's command is spawned.
|
|
249
|
-
*/
|
|
250
|
-
get #pathEnvironmentVariable() {
|
|
251
|
-
// Given package "/foo/bar", walk up the path hierarchy to generate
|
|
252
|
-
// "/foo/bar/node_modules/.bin:/foo/node_modules/.bin:/node_modules/.bin".
|
|
253
|
-
const entries = [];
|
|
254
|
-
let cur = this.#script.packageDir;
|
|
255
|
-
while (true) {
|
|
256
|
-
entries.push(pathlib.join(cur, 'node_modules', '.bin'));
|
|
257
|
-
const parent = pathlib.dirname(cur);
|
|
258
|
-
if (parent === cur) {
|
|
259
|
-
break;
|
|
260
|
-
}
|
|
261
|
-
cur = parent;
|
|
262
|
-
}
|
|
263
|
-
// Add the inherited PATH variable, minus any "node_modules/.bin" entries
|
|
264
|
-
// that were set by the "npm run" command that spawned Wireit.
|
|
265
|
-
entries.push(PATH_ENV_SUFFIX);
|
|
266
|
-
// Note the PATH delimiter is platform-dependent.
|
|
267
|
-
return entries.join(pathlib.delimiter);
|
|
268
|
-
}
|
|
269
315
|
}
|
|
270
316
|
//# sourceMappingURL=script-child-process.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@laurence79/wireit",
|
|
3
|
-
"version": "0.14.13-shared-cache.
|
|
3
|
+
"version": "0.14.13-shared-cache.1",
|
|
4
4
|
"description": "Upgrade your npm scripts to make them smarter and more efficient",
|
|
5
5
|
"author": "Google LLC",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -57,6 +57,7 @@
|
|
|
57
57
|
"test:parallelism": "wireit",
|
|
58
58
|
"test:quiet": "wireit",
|
|
59
59
|
"test:service": "wireit",
|
|
60
|
+
"test:shared-service": "wireit",
|
|
60
61
|
"test:watch": "wireit"
|
|
61
62
|
},
|
|
62
63
|
"wireit": {
|
|
@@ -111,6 +112,7 @@
|
|
|
111
112
|
"test:parallelism",
|
|
112
113
|
"test:quiet",
|
|
113
114
|
"test:service",
|
|
115
|
+
"test:shared-service",
|
|
114
116
|
"test:watch"
|
|
115
117
|
]
|
|
116
118
|
},
|
|
@@ -404,6 +406,17 @@
|
|
|
404
406
|
"files": [],
|
|
405
407
|
"output": []
|
|
406
408
|
},
|
|
409
|
+
"test:shared-service": {
|
|
410
|
+
"command": "node --test --test-reporter=dot lib/test/shared-service.test.js",
|
|
411
|
+
"env": {
|
|
412
|
+
"NODE_OPTIONS": "--enable-source-maps"
|
|
413
|
+
},
|
|
414
|
+
"dependencies": [
|
|
415
|
+
"build"
|
|
416
|
+
],
|
|
417
|
+
"files": [],
|
|
418
|
+
"output": []
|
|
419
|
+
},
|
|
407
420
|
"test:watch": {
|
|
408
421
|
"command": "node --test --test-reporter=dot lib/test/watch.test.js",
|
|
409
422
|
"env": {
|
package/schema.json
CHANGED
|
@@ -90,6 +90,10 @@
|
|
|
90
90
|
"type": "string"
|
|
91
91
|
}
|
|
92
92
|
}
|
|
93
|
+
},
|
|
94
|
+
"shared": {
|
|
95
|
+
"markdownDescription": "If true, concurrent Wireit invocations in the same package directory share a single instance of this service instead of each starting their own. The first invocation that needs it starts a detached process; later invocations attach to it. The service is torn down when its last live consumer exits.\n\nPOSIX-only: on Windows this is ignored and each invocation starts its own instance.",
|
|
96
|
+
"type": "boolean"
|
|
93
97
|
}
|
|
94
98
|
}
|
|
95
99
|
}
|