@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 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 | Description |
939
- | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
940
- | `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> |
941
- | `WIREIT_CACHE_SHARED_DIR` | Root directory of the [shared cache](#shared-caching). Only applies when `WIREIT_CACHE=shared`.<br><br>Defaults to `~/.wireit/cache`. |
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` | [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> |
944
- | `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> |
945
- | `WIREIT_DEBUG_LOG_FILE` | Path to a file which will receive detailed event logging. |
946
- | `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. |
947
- | `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`. |
948
- | `WIREIT_WATCH` | Set to `true` to enable [watch mode](#watch-mode). |
949
- | `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> |
950
- | `WIREIT_WATCH_POLL_MS` | When `WIREIT_WATCH_STRATEGY` is `poll`, how many milliseconds to wait between each filesystem poll. Defaults to `500`. |
951
- | `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`. |
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) {
@@ -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 child = new ScriptChildProcess(this._config);
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: this.#state.fingerprint,
593
- readyMonitor: this._config.service.readyWhen.lineMatches === undefined
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: augmentProcessEnvSafelyIfOnWindows({
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.0",
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
  }