@wildo-ai/platform-core-shared 1.1.10 → 1.1.12
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/dist/esm/core/resources/resources-operations.custom.shared.definitions.js.map +1 -1
- package/dist/esm/core/resources/resources-relationships.custom.shared.definitions.js.map +1 -1
- package/dist/esm/core/resources/resources-types.custom.shared.schemas.d.ts.map +1 -1
- package/dist/esm/core/resources/resources-types.custom.shared.schemas.js.map +1 -1
- package/dist/esm/dev-runtime/dependency-install-record.d.ts +111 -0
- package/dist/esm/dev-runtime/dependency-install-record.d.ts.map +1 -0
- package/dist/esm/dev-runtime/dependency-install-record.js +185 -0
- package/dist/esm/dev-runtime/dependency-install-record.js.map +1 -0
- package/dist/esm/dev-runtime/dev-runtime-command.d.ts +50 -0
- package/dist/esm/dev-runtime/dev-runtime-command.d.ts.map +1 -0
- package/dist/esm/dev-runtime/dev-runtime-command.js +147 -0
- package/dist/esm/dev-runtime/dev-runtime-command.js.map +1 -0
- package/dist/esm/dev-runtime/dev-runtime-processes.d.ts +47 -0
- package/dist/esm/dev-runtime/dev-runtime-processes.d.ts.map +1 -0
- package/dist/esm/dev-runtime/dev-runtime-processes.js +163 -0
- package/dist/esm/dev-runtime/dev-runtime-processes.js.map +1 -0
- package/dist/esm/dev-runtime/dev-runtime-signals.d.ts +54 -0
- package/dist/esm/dev-runtime/dev-runtime-signals.d.ts.map +1 -0
- package/dist/esm/dev-runtime/dev-runtime-signals.js +113 -0
- package/dist/esm/dev-runtime/dev-runtime-signals.js.map +1 -0
- package/dist/esm/dev-runtime/emitted-relative-specifiers.d.ts +14 -0
- package/dist/esm/dev-runtime/emitted-relative-specifiers.d.ts.map +1 -0
- package/dist/esm/dev-runtime/emitted-relative-specifiers.js +71 -0
- package/dist/esm/dev-runtime/emitted-relative-specifiers.js.map +1 -0
- package/dist/esm/dev-runtime/frontend-dev-runtime.d.ts +43 -0
- package/dist/esm/dev-runtime/frontend-dev-runtime.d.ts.map +1 -0
- package/dist/esm/dev-runtime/frontend-dev-runtime.js +42 -0
- package/dist/esm/dev-runtime/frontend-dev-runtime.js.map +1 -0
- package/dist/esm/dev-runtime/node-runtime-dev-supervisor.d.ts +233 -0
- package/dist/esm/dev-runtime/node-runtime-dev-supervisor.d.ts.map +1 -0
- package/dist/esm/dev-runtime/node-runtime-dev-supervisor.js +578 -0
- package/dist/esm/dev-runtime/node-runtime-dev-supervisor.js.map +1 -0
- package/dist/esm/dev-runtime/package-compile-loop.d.ts +125 -0
- package/dist/esm/dev-runtime/package-compile-loop.d.ts.map +1 -0
- package/dist/esm/dev-runtime/package-compile-loop.js +386 -0
- package/dist/esm/dev-runtime/package-compile-loop.js.map +1 -0
- package/dist/esm/dev-runtime.exports.d.ts +19 -0
- package/dist/esm/dev-runtime.exports.d.ts.map +1 -0
- package/dist/esm/dev-runtime.exports.js +19 -0
- package/dist/esm/dev-runtime.exports.js.map +1 -0
- package/dist/esm/index.d.ts +4 -0
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +4 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/runtime/application-backend-env.contracts.d.ts +16 -2
- package/dist/esm/runtime/application-backend-env.contracts.d.ts.map +1 -1
- package/dist/esm/runtime/application-backend-env.contracts.js +20 -1
- package/dist/esm/runtime/application-backend-env.contracts.js.map +1 -1
- package/dist/esm/runtime/application-worker-env.contracts.d.ts +14 -4
- package/dist/esm/runtime/application-worker-env.contracts.d.ts.map +1 -1
- package/dist/esm/runtime/application-worker-env.contracts.js +12 -0
- package/dist/esm/runtime/application-worker-env.contracts.js.map +1 -1
- package/dist/esm/runtime/backend-start-outcome.contracts.d.ts +27 -0
- package/dist/esm/runtime/backend-start-outcome.contracts.d.ts.map +1 -1
- package/dist/esm/runtime/backend-start-outcome.contracts.js +34 -0
- package/dist/esm/runtime/backend-start-outcome.contracts.js.map +1 -1
- package/dist/esm/runtime/frontend-settings-hub-wiring.contracts.d.ts +28 -0
- package/dist/esm/runtime/frontend-settings-hub-wiring.contracts.d.ts.map +1 -0
- package/dist/esm/runtime/frontend-settings-hub-wiring.contracts.js +35 -0
- package/dist/esm/runtime/frontend-settings-hub-wiring.contracts.js.map +1 -0
- package/dist/esm/runtime/http-server-env.contracts.d.ts +14 -1
- package/dist/esm/runtime/http-server-env.contracts.d.ts.map +1 -1
- package/dist/esm/runtime/http-server-env.contracts.js +13 -0
- package/dist/esm/runtime/http-server-env.contracts.js.map +1 -1
- package/dist/esm/runtime/local-dev-runtime-signals.contracts.d.ts +239 -0
- package/dist/esm/runtime/local-dev-runtime-signals.contracts.d.ts.map +1 -0
- package/dist/esm/runtime/local-dev-runtime-signals.contracts.js +256 -0
- package/dist/esm/runtime/local-dev-runtime-signals.contracts.js.map +1 -0
- package/dist/esm/runtime/runtime-topology.contracts.js.map +1 -1
- package/dist/esm/security/authorizations/roles.custom.shared.definitions.js.map +1 -1
- package/dist/esm/wildo-application-executions/wildo-application-executions.custom.shared.resources-config.schemas.d.ts +362 -36
- package/dist/esm/wildo-application-executions/wildo-application-executions.custom.shared.resources-config.schemas.d.ts.map +1 -1
- package/dist/esm/wildo-application-executions/wildo-application-executions.custom.shared.resources-config.schemas.js +4 -3
- package/dist/esm/wildo-application-executions/wildo-application-executions.custom.shared.resources-config.schemas.js.map +1 -1
- package/dist/esm/wildo-application-executions/wildo-application-executions.custom.shared.schemas.d.ts +400 -37
- package/dist/esm/wildo-application-executions/wildo-application-executions.custom.shared.schemas.d.ts.map +1 -1
- package/dist/esm/wildo-application-executions/wildo-application-executions.custom.shared.schemas.js +107 -1
- package/dist/esm/wildo-application-executions/wildo-application-executions.custom.shared.schemas.js.map +1 -1
- package/dist/esm/wildo-application-tasks/wildo-application-tasks.custom.shared.resources-config.schemas.d.ts +26 -26
- package/dist/esm/wildo-application-tasks/wildo-application-tasks.custom.shared.resources-config.schemas.d.ts.map +1 -1
- package/dist/esm/wildo-application-tasks/wildo-application-tasks.custom.shared.resources-config.schemas.js +4 -3
- package/dist/esm/wildo-application-tasks/wildo-application-tasks.custom.shared.resources-config.schemas.js.map +1 -1
- package/dist/esm/wildo-application-tasks/wildo-application-tasks.custom.shared.schemas.d.ts +33 -27
- package/dist/esm/wildo-application-tasks/wildo-application-tasks.custom.shared.schemas.d.ts.map +1 -1
- package/dist/esm/wildo-application-tasks/wildo-application-tasks.custom.shared.schemas.js +26 -2
- package/dist/esm/wildo-application-tasks/wildo-application-tasks.custom.shared.schemas.js.map +1 -1
- package/dist/esm/wildo-applications/wildo-applications.custom.shared.resources-config.schemas.d.ts +3 -3
- package/dist/esm/wildo-applications/wildo-applications.custom.shared.resources-config.schemas.d.ts.map +1 -1
- package/dist/esm/wildo-applications/wildo-applications.custom.shared.resources-config.schemas.js +30 -42
- package/dist/esm/wildo-applications/wildo-applications.custom.shared.resources-config.schemas.js.map +1 -1
- package/dist/esm/wildo-applications/wildo-applications.custom.shared.schemas.d.ts +9 -9
- package/dist/esm/wildo-applications/wildo-applications.custom.shared.schemas.js.map +1 -1
- package/dist/esm/wildo-applications-versions/wildo-applications-versions.custom.shared.resources-config.schemas.js.map +1 -1
- package/dist/esm/wildo-applications-versions/wildo-applications-versions.custom.shared.schemas.d.ts +7 -7
- package/dist/esm/wildo-applications-versions/wildo-applications-versions.custom.shared.schemas.js.map +1 -1
- package/dist/esm/workbench/creation-drive.custom.shared.schemas.d.ts +52 -0
- package/dist/esm/workbench/creation-drive.custom.shared.schemas.d.ts.map +1 -0
- package/dist/esm/workbench/creation-drive.custom.shared.schemas.js +49 -0
- package/dist/esm/workbench/creation-drive.custom.shared.schemas.js.map +1 -0
- package/dist/esm/workbench/creation-journey-timeline.custom.shared.schemas.d.ts +183 -0
- package/dist/esm/workbench/creation-journey-timeline.custom.shared.schemas.d.ts.map +1 -0
- package/dist/esm/workbench/creation-journey-timeline.custom.shared.schemas.js +188 -0
- package/dist/esm/workbench/creation-journey-timeline.custom.shared.schemas.js.map +1 -0
- package/dist/esm/workbench/workbench-aspect-resources.custom.shared.schemas.d.ts.map +1 -1
- package/dist/esm/workbench/workbench-aspect-resources.custom.shared.schemas.js +76 -0
- package/dist/esm/workbench/workbench-aspect-resources.custom.shared.schemas.js.map +1 -1
- package/dist/esm/workbench/workbench-creation-console.custom.shared.schemas.d.ts +170 -7
- package/dist/esm/workbench/workbench-creation-console.custom.shared.schemas.d.ts.map +1 -1
- package/dist/esm/workbench/workbench-creation-console.custom.shared.schemas.js +177 -7
- package/dist/esm/workbench/workbench-creation-console.custom.shared.schemas.js.map +1 -1
- package/dist/esm/workbench/workbench-creation-conversation.custom.shared.schemas.d.ts +21 -3
- package/dist/esm/workbench/workbench-creation-conversation.custom.shared.schemas.d.ts.map +1 -1
- package/dist/esm/workbench/workbench-creation-conversation.custom.shared.schemas.js +7 -0
- package/dist/esm/workbench/workbench-creation-conversation.custom.shared.schemas.js.map +1 -1
- package/dist/esm/workbench/workbench-creation-dossier.custom.shared.schemas.d.ts.map +1 -1
- package/dist/esm/workbench/workbench-creation-dossier.custom.shared.schemas.js.map +1 -1
- package/dist/esm/workbench/workbench-creation-plane.custom.shared.resources-config.schemas.d.ts +611 -405
- package/dist/esm/workbench/workbench-creation-plane.custom.shared.resources-config.schemas.d.ts.map +1 -1
- package/dist/esm/workbench/workbench-creation-plane.custom.shared.resources-config.schemas.js +98 -17
- package/dist/esm/workbench/workbench-creation-plane.custom.shared.resources-config.schemas.js.map +1 -1
- package/dist/esm/workbench/workbench-creation-plane.custom.shared.schemas.d.ts +821 -240
- package/dist/esm/workbench/workbench-creation-plane.custom.shared.schemas.d.ts.map +1 -1
- package/dist/esm/workbench/workbench-creation-plane.custom.shared.schemas.js +795 -41
- package/dist/esm/workbench/workbench-creation-plane.custom.shared.schemas.js.map +1 -1
- package/dist/esm/workbench/workbench-domain-model.custom.shared.schemas.d.ts +34 -0
- package/dist/esm/workbench/workbench-domain-model.custom.shared.schemas.d.ts.map +1 -1
- package/dist/esm/workbench/workbench-domain-model.custom.shared.schemas.js +31 -0
- package/dist/esm/workbench/workbench-domain-model.custom.shared.schemas.js.map +1 -1
- package/dist/esm/workbench/workbench-resources-types.custom.shared.schemas.d.ts +14 -2
- package/dist/esm/workbench/workbench-resources-types.custom.shared.schemas.d.ts.map +1 -1
- package/dist/esm/workbench/workbench-resources-types.custom.shared.schemas.js +25 -3
- package/dist/esm/workbench/workbench-resources-types.custom.shared.schemas.js.map +1 -1
- package/dist/esm/workbench/workbench-resources.custom.shared.resources-config.schemas.d.ts +353 -250
- package/dist/esm/workbench/workbench-resources.custom.shared.resources-config.schemas.d.ts.map +1 -1
- package/dist/esm/workbench/workbench-resources.custom.shared.resources-config.schemas.js +1 -0
- package/dist/esm/workbench/workbench-resources.custom.shared.resources-config.schemas.js.map +1 -1
- package/dist/esm/workbench/workbench-resources.custom.shared.schemas.d.ts +34 -29
- package/dist/esm/workbench/workbench-resources.custom.shared.schemas.d.ts.map +1 -1
- package/dist/esm/workbench/workbench-resources.custom.shared.schemas.js +9 -5
- package/dist/esm/workbench/workbench-resources.custom.shared.schemas.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/package.json +13 -8
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { dirname, join, relative, resolve } from 'node:path';
|
|
5
|
+
import { BACKEND_START_OUTCOME_DIRECTORY, BackendStartFailureCause, BackendStartOutcome, BackendStartOutcomeRecord_Schema, } from '../runtime/backend-start-outcome.contracts.js';
|
|
6
|
+
import { APPLICATION_RUNTIME_SIGNALS_RELATIVE_DIRECTORY, LOCAL_DEV_RUNTIME_SIGNALS_PROTOCOL_VERSION, LOCAL_DEV_SUPERVISOR_OBSERVATION_KIND, LocalDevCompileState, LocalDevSupervisorBlocker, LocalDevSupervisorState, } from '../runtime/local-dev-runtime-signals.contracts.js';
|
|
7
|
+
import { compileObservationPath, isLiveCompileObservation, isLiveSupervisorObservation, readCompileObservation, readSupervisorObservation, runtimeRestartSignalPath, supervisorObservationPath, writeRuntimeSignalRecord, } from './dev-runtime-signals.js';
|
|
8
|
+
import { identifyPortHolder, isProcessAlive, killProcessGroupNow, readProcessIdentity, terminateProcessGroup, usesProcessGroups } from './dev-runtime-processes.js';
|
|
9
|
+
import { classifyDependencyInstall, decideRuntimeAfterDependencyInstall, readDependencyInstallRecord, RuntimeDependencyInstallDecision, } from './dependency-install-record.js';
|
|
10
|
+
import { LocalDevDependencyInstallPhase } from '../runtime/local-dev-runtime-signals.contracts.js';
|
|
11
|
+
/**
|
|
12
|
+
* The dev supervisor of one NODE RUNTIME of an app-creator install — the backend, a minion, a node worker (#3214, #3215,
|
|
13
|
+
* #3216, #3257).
|
|
14
|
+
*
|
|
15
|
+
* It owns the runtime's process the way the monorepo's `runtime-supervisor.mjs` does for a framework developer, and keeps
|
|
16
|
+
* the promises the old `tsc-watch --onSuccess "node dist/index.js"` could not. They are written about the backend, where
|
|
17
|
+
* every one of them was measured; each holds for any runtime it supervises, see below:
|
|
18
|
+
*
|
|
19
|
+
* 1. **It starts the backend only on PUBLISHED builds.** The backend's own package and every workspace package it
|
|
20
|
+
* depends on must report `READY` (emitted, rewritten, verified — see `PackageCompileLoop`). A backend therefore never
|
|
21
|
+
* starts between `tsc` and its alias pass, which is how `ERR_MODULE_NOT_FOUND` reached it (#3216).
|
|
22
|
+
* 2. **It restarts the backend when ANY package it loads publishes a newer build**, the shared library included. Before
|
|
23
|
+
* this, only a change to the backend's own sources restarted it, and a fix landed in the shared library left the
|
|
24
|
+
* backend down on a tree that already booted (#3215).
|
|
25
|
+
* 3. **It owns the backend's process group**: the child leads its own group, and a restart or a stop signals the whole
|
|
26
|
+
* group. A second supervisor for the same package refuses to start, and a backend an earlier generation left behind
|
|
27
|
+
* (its supervisor killed outright) is stopped before a new one starts (#3214).
|
|
28
|
+
* 4. **A port another process holds is named, not retried into the ground.** When a start fails binding its port, the
|
|
29
|
+
* supervisor asks the operating system who listens there. The orphan of an earlier generation — `node <entry>` in this
|
|
30
|
+
* package — is stopped and the backend restarted; anything else pauses the supervisor with
|
|
31
|
+
* `PORT_HELD_BY_FOREIGN_PROCESS`, the holder's pid, parent, start time and working directory in its record and its
|
|
32
|
+
* log, and the backend starts the moment the port is free.
|
|
33
|
+
* 5. **A crash recovers by itself on the next build, and a dependency outage by itself (#3365).** A failed start is
|
|
34
|
+
* respawned with a bounded backoff. A spent budget pauses the supervisor only when the start record says the
|
|
35
|
+
* backend's own startup refused; the next published build of any package it loads starts it again. Any other cause
|
|
36
|
+
* (a dependency that did not answer, or no record) crash-loops: the delay keeps doubling up to
|
|
37
|
+
* {@link CRASH_LOOP_MAX_DELAY_MS} and never stops, so a database that comes back brings the backend back. A child
|
|
38
|
+
* resets the budget only once its own start record says it STARTED — or, for a runtime that writes no start record
|
|
39
|
+
* at all, once it stayed up {@link DEFAULT_HEALTHY_UPTIME_WITHOUT_START_RECORD_MS}.
|
|
40
|
+
* 6. **Nothing outlives it.** A stop terminates the backend and the compiler in parallel; an exit without one (an
|
|
41
|
+
* uncaught error, a forced stop) kills both groups synchronously on the way out.
|
|
42
|
+
*
|
|
43
|
+
* Its state is published at `<app>/.wildo-saas/runtime-signals/supervisors/<label>.json`, the record the dev companion's
|
|
44
|
+
* boot guard already reads, so a paused backend is visible to the build lane in this mode too.
|
|
45
|
+
*
|
|
46
|
+
* 7. **A restart can be ASKED for (#2134).** The supervisor creates `<app>/.wildo-saas/runtime-signals/<label>.signal`,
|
|
47
|
+
* names it in its record (`signalFilePath`), and restarts the runtime when the file's modification time changes, on
|
|
48
|
+
* the build it is already running, with a fresh crash budget. That is the door `wildo secrets set` uses: it regenerates
|
|
49
|
+
* the runtime's `.env` and touches the signal of every runtime that reads the key and whose supervisor is live, so the
|
|
50
|
+
* restarted process reads the new value. Before this the backend of a created application kept its boot-time value
|
|
51
|
+
* until `wildo local dev` was restarted, because this supervisor honoured no request and the CLI found no signal.
|
|
52
|
+
* A request arriving while a package it loads is not publishable waits for it, like any start (promise 1); one
|
|
53
|
+
* arriving while paused or backing off starts the runtime now; one arriving while a foreign process holds the port
|
|
54
|
+
* re-checks the port now. The same file the monorepo's `runtime-supervisor.mjs` honours under the same name, so the
|
|
55
|
+
* CLI needs no branch per mode.
|
|
56
|
+
*
|
|
57
|
+
* Rejected: a route per runtime that reloads its environment in place, as the companion has
|
|
58
|
+
* (`POST /api/companion/runtime/env/apply`). The backend's configuration is read once into services built at boot
|
|
59
|
+
* (provider clients, the mailer), so reloading `process.env` would change nothing they hold, and every runtime would
|
|
60
|
+
* need its own. A restart is the one remedy that is right for every runtime.
|
|
61
|
+
*
|
|
62
|
+
* ## Why one supervisor for every node runtime (#3257)
|
|
63
|
+
*
|
|
64
|
+
* Promises 1, 2, 3, 5 and 6 say nothing about HTTP, so a minion and a node worker get them by construction rather than
|
|
65
|
+
* through a copy that would drift: they start only on published builds of every workspace package they load (the
|
|
66
|
+
* shared library, and the backend whose companion exports a minion imports), restart when one of them publishes, and
|
|
67
|
+
* own their process group, so `wildo local dev` stopping leaves none of them re-parented to init.
|
|
68
|
+
*
|
|
69
|
+
* Promise 4 is the one backend-shaped decision, and it is taken from the START RECORD the engine's
|
|
70
|
+
* `StartApplicationBackendService.initialize` writes for the process (`failureCause: port-in-use`, with the port),
|
|
71
|
+
* looked up by the child's pid. A runtime whose start writes no such record never enters the branch; nothing has to be
|
|
72
|
+
* switched off. A minion binds no port and so never writes that cause. A node worker listens on its declared port and
|
|
73
|
+
* records its start through the same writer since #3288 (`StartWorkerApplicationBackendService.initialize`, filed as
|
|
74
|
+
* `workers.<name>`), so a port another process holds is named for it exactly as for the backend. The orphan of a
|
|
75
|
+
* supervisor killed outright is also stopped at {@link NodeRuntimeDevSupervisor.start}, through the supervisor record.
|
|
76
|
+
*
|
|
77
|
+
* The crash budget's reset (promise 5) also keys on the start record. The backend, a minion and a node worker all write
|
|
78
|
+
* one, but none does when its environment carries no runtime-signals home (a `.env` generated before #3288, an
|
|
79
|
+
* environment that is not LOCAL), and a child that dies before its bootstrap reaches the writer leaves none. Such a
|
|
80
|
+
* runtime would otherwise never reset the budget: five crashes spread over a day would spend it.
|
|
81
|
+
* For a process that left NO record at all, staying up {@link DEFAULT_HEALTHY_UPTIME_WITHOUT_START_RECORD_MS}
|
|
82
|
+
* counts as having started. Uptime stays out of the decision whenever a record exists, which is the case the uptime
|
|
83
|
+
* rule was wrong for (a start refused after twenty seconds of registry initialization).
|
|
84
|
+
*/
|
|
85
|
+
export const CRASH_RESPAWN_MAX_ATTEMPTS = 5;
|
|
86
|
+
/**
|
|
87
|
+
* How long a child that wrote NO start record must stay up before its exit counts as a fresh failure with a fresh
|
|
88
|
+
* budget. Only consulted when the process left no record at all; see the class JSDoc.
|
|
89
|
+
*/
|
|
90
|
+
export const DEFAULT_HEALTHY_UPTIME_WITHOUT_START_RECORD_MS = 60_000;
|
|
91
|
+
const CRASH_RESPAWN_BASE_DELAY_MS = 1_000;
|
|
92
|
+
const CRASH_RESPAWN_MAX_DELAY_MS = 30_000;
|
|
93
|
+
/**
|
|
94
|
+
* The crash-loop cap (#3365): past the budget, a crash whose cause is a dependency (or cannot be told) keeps being
|
|
95
|
+
* restarted with the delay doubling up to five minutes, Kubernetes' CrashLoopBackOff ceiling, and never stops.
|
|
96
|
+
*/
|
|
97
|
+
export const CRASH_LOOP_MAX_DELAY_MS = 300_000;
|
|
98
|
+
/** The cause a crash-loop log line names, from the crashed child's start record. */
|
|
99
|
+
function describeCrashLoopCause(started) {
|
|
100
|
+
if (started?.failureCause === BackendStartFailureCause.DEPENDENCY_UNREACHABLE)
|
|
101
|
+
return 'a service it connects to did not answer or was not ready';
|
|
102
|
+
if (started?.failureCause === BackendStartFailureCause.PORT_IN_USE)
|
|
103
|
+
return 'its port was held and no holder could be identified';
|
|
104
|
+
return started === undefined ? 'it left no start record' : 'its start record names no cause';
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Whether a spent crash budget may PAUSE the runtime (#3365): only when its own start record says its startup refused,
|
|
108
|
+
* which a new build is what fixes. A dependency that did not answer or was not ready yet (`dependency-unreachable`,
|
|
109
|
+
* #3377), and a crash that left
|
|
110
|
+
* no record or one from an engine that names no cause, crash-loop instead: a database that comes back must bring its
|
|
111
|
+
* runtime back by itself. A port held by another process is decided before the budget is read (promise 4).
|
|
112
|
+
*/
|
|
113
|
+
export function spentCrashBudgetPauses(started) {
|
|
114
|
+
return started?.outcome === BackendStartOutcome.FAILED && started.failureCause === BackendStartFailureCause.STARTUP_REFUSED;
|
|
115
|
+
}
|
|
116
|
+
/** Whether the packages a runtime loads are publishable, and the newest build among them. Pure. */
|
|
117
|
+
export function assessRuntimeBuilds(input) {
|
|
118
|
+
let newestBuildAtMs = 0;
|
|
119
|
+
let waitingFor = null;
|
|
120
|
+
const consider = (packageName, observation) => {
|
|
121
|
+
const builtAtMs = observation.lastSuccessfulCompileAt ? Date.parse(observation.lastSuccessfulCompileAt) : 0;
|
|
122
|
+
if (builtAtMs > newestBuildAtMs)
|
|
123
|
+
newestBuildAtMs = builtAtMs;
|
|
124
|
+
if (waitingFor === null && observation.state !== LocalDevCompileState.READY)
|
|
125
|
+
waitingFor = `${packageName} (${observation.state})`;
|
|
126
|
+
};
|
|
127
|
+
consider(input.own.packageName, input.own);
|
|
128
|
+
for (const dependency of input.dependencies) {
|
|
129
|
+
if (dependency.observation)
|
|
130
|
+
consider(dependency.packageName, dependency.observation);
|
|
131
|
+
else if (!input.dependencyGraceElapsed && waitingFor === null)
|
|
132
|
+
waitingFor = `${dependency.packageName} (no compiler record yet)`;
|
|
133
|
+
}
|
|
134
|
+
return { ready: waitingFor === null, waitingFor, newestBuildAtMs };
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Is the process on the port an orphaned runtime of THIS package — `node … <entry>` whose working directory is the
|
|
138
|
+
* package root — rather than something the supervisor must not touch? Pure.
|
|
139
|
+
*/
|
|
140
|
+
export function isOrphanedRuntimeOfPackage(holder, input) {
|
|
141
|
+
if (holder.cwd === null || resolve(holder.cwd) !== resolve(input.packageRoot))
|
|
142
|
+
return false;
|
|
143
|
+
const command = holder.command ?? '';
|
|
144
|
+
const entryRelative = relative(input.packageRoot, resolve(input.packageRoot, input.entry));
|
|
145
|
+
return /\bnode\b/u.test(command) && (command.includes(entryRelative) || command.includes(resolve(input.packageRoot, input.entry)));
|
|
146
|
+
}
|
|
147
|
+
/** The start record this runtime process wrote, if any (the supervisor reads it to learn why a start failed). */
|
|
148
|
+
export function readBackendStartRecordOfProcess(appRoot, pid) {
|
|
149
|
+
const directory = join(appRoot, APPLICATION_RUNTIME_SIGNALS_RELATIVE_DIRECTORY, BACKEND_START_OUTCOME_DIRECTORY);
|
|
150
|
+
let entries;
|
|
151
|
+
try {
|
|
152
|
+
entries = readdirSync(directory).filter((name) => name.endsWith('.json'));
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|
|
157
|
+
for (const entry of entries) {
|
|
158
|
+
try {
|
|
159
|
+
const parsed = BackendStartOutcomeRecord_Schema.safeParse(JSON.parse(readFileSync(join(directory, entry), 'utf8')));
|
|
160
|
+
if (parsed.success && parsed.data.pid === pid)
|
|
161
|
+
return parsed.data;
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
// A record mid-rename is the next poll's.
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return undefined;
|
|
168
|
+
}
|
|
169
|
+
export class NodeRuntimeDevSupervisor {
|
|
170
|
+
options;
|
|
171
|
+
recordPath;
|
|
172
|
+
entryPath;
|
|
173
|
+
log;
|
|
174
|
+
startedAtMs = Date.now();
|
|
175
|
+
record;
|
|
176
|
+
child = null;
|
|
177
|
+
childStartedAtMs = 0;
|
|
178
|
+
expectedExit = new WeakSet();
|
|
179
|
+
stopping = false;
|
|
180
|
+
/**
|
|
181
|
+
* Held while ONE decision runs — a poll, or the handling of a child's exit. Both start children, so they must never
|
|
182
|
+
* interleave: a poll that spawned while an exit handler was stopping an orphan would put a second runtime on the port,
|
|
183
|
+
* and that one's failure would then name the first as the orphan.
|
|
184
|
+
*/
|
|
185
|
+
busy = false;
|
|
186
|
+
pollTimer = null;
|
|
187
|
+
heartbeatTimer = null;
|
|
188
|
+
backoffTimer = null;
|
|
189
|
+
crashCount = 0;
|
|
190
|
+
/** The newest build the supervisor was paused against; a build newer than it lifts a crash pause. */
|
|
191
|
+
pausedAgainstBuildAtMs = null;
|
|
192
|
+
heldPort = null;
|
|
193
|
+
lastPortCheckAtMs = 0;
|
|
194
|
+
lastWaitingFor = null;
|
|
195
|
+
loggedAbsentDependencies = false;
|
|
196
|
+
/** The restart-request file (promise 7) and its modification time when last read; `null` while it cannot be read. */
|
|
197
|
+
signalPath;
|
|
198
|
+
lastSignalStamp = null;
|
|
199
|
+
/** A restart was asked for and is not served yet: the next start serves it. */
|
|
200
|
+
restartRequested = false;
|
|
201
|
+
/**
|
|
202
|
+
* The dependency install (#3552) this supervisor already acted on, by its key. Seeded at {@link start} with an install
|
|
203
|
+
* that had already ended, so a supervisor started after an install never relaunches onto it.
|
|
204
|
+
*/
|
|
205
|
+
handledInstallKey = null;
|
|
206
|
+
/** Whether the current install hold has been logged, so a 16 s install logs once rather than every poll. */
|
|
207
|
+
loggedInstallHold = false;
|
|
208
|
+
constructor(options) {
|
|
209
|
+
this.options = options;
|
|
210
|
+
this.recordPath = supervisorObservationPath(options.appRoot, options.label);
|
|
211
|
+
this.signalPath = runtimeRestartSignalPath(options.appRoot, options.label);
|
|
212
|
+
this.entryPath = resolve(options.packageRoot, options.entry);
|
|
213
|
+
this.log = options.log ?? ((line) => process.stdout.write(`[wildo-dev-runtime] ${options.label}: ${line}\n`));
|
|
214
|
+
const now = new Date().toISOString();
|
|
215
|
+
this.record = {
|
|
216
|
+
kind: LOCAL_DEV_SUPERVISOR_OBSERVATION_KIND,
|
|
217
|
+
schemaVersion: LOCAL_DEV_RUNTIME_SIGNALS_PROTOCOL_VERSION,
|
|
218
|
+
producerKind: 'wildo-dev-runtime',
|
|
219
|
+
producerInstanceId: randomUUID(),
|
|
220
|
+
producerPid: process.pid,
|
|
221
|
+
frameworkRoot: null,
|
|
222
|
+
appRoot: options.appRoot,
|
|
223
|
+
label: options.label,
|
|
224
|
+
runtimeTarget: options.packageName,
|
|
225
|
+
workingDirectory: options.packageRoot,
|
|
226
|
+
state: LocalDevSupervisorState.STARTING,
|
|
227
|
+
childPid: null,
|
|
228
|
+
childStartedAt: null,
|
|
229
|
+
retryCount: 0,
|
|
230
|
+
blocker: null,
|
|
231
|
+
portHolder: null,
|
|
232
|
+
signalFilePath: this.signalPath,
|
|
233
|
+
heartbeatAt: now,
|
|
234
|
+
updatedAt: now,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
get observation() {
|
|
238
|
+
return this.record;
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Start supervising. REFUSES when another live supervisor already owns this runtime: two runtimes of one package is
|
|
242
|
+
* the defect this exists to prevent. A runtime left by a dead supervisor is stopped first.
|
|
243
|
+
*/
|
|
244
|
+
async start() {
|
|
245
|
+
const previous = readSupervisorObservation(this.recordPath);
|
|
246
|
+
if (previous && previous.producerPid !== process.pid && isLiveSupervisorObservation(previous)) {
|
|
247
|
+
throw new Error(`A supervisor already runs ${this.options.label} (process ${previous.producerPid}, ${this.recordPath}). Stop that dev process first: a second one would start a second runtime.`);
|
|
248
|
+
}
|
|
249
|
+
if (previous?.childPid && isProcessAlive(previous.childPid)) {
|
|
250
|
+
const identity = readProcessIdentity(previous.childPid);
|
|
251
|
+
if (identity && isOrphanedRuntimeOfPackage(identity, { packageRoot: this.options.packageRoot, entry: this.options.entry })) {
|
|
252
|
+
this.log(`stopping the runtime an earlier dev run left behind (pid ${previous.childPid}, parent ${identity.ppid ?? 'unknown'}, started ${identity.startedAt ?? 'unknown'})`);
|
|
253
|
+
await terminateProcessGroup(previous.childPid);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
this.prepareRestartSignal();
|
|
257
|
+
const installAtStart = classifyDependencyInstall(readDependencyInstallRecord(this.options.appRoot), Date.now());
|
|
258
|
+
if (installAtStart.phase === LocalDevDependencyInstallPhase.ENDED)
|
|
259
|
+
this.handledInstallKey = installAtStart.key;
|
|
260
|
+
this.publish({ state: LocalDevSupervisorState.STARTING, blocker: null });
|
|
261
|
+
this.heartbeatTimer = setInterval(() => this.publish({}), 5_000);
|
|
262
|
+
this.heartbeatTimer.unref();
|
|
263
|
+
await this.options.compileLoop.start();
|
|
264
|
+
// A stop that arrived while the compiler was starting wins: nothing more is started.
|
|
265
|
+
if (this.stopping)
|
|
266
|
+
return;
|
|
267
|
+
this.pollTimer = setInterval(() => void this.serialized(() => this.decide()), this.options.pollIntervalMs ?? 500);
|
|
268
|
+
await this.serialized(() => this.decide());
|
|
269
|
+
}
|
|
270
|
+
/** Stop the runtime and the compiler, IN PARALLEL so a caller's stop deadline covers both. */
|
|
271
|
+
async stop() {
|
|
272
|
+
if (this.stopping)
|
|
273
|
+
return;
|
|
274
|
+
this.stopping = true;
|
|
275
|
+
for (const timer of [this.pollTimer, this.heartbeatTimer, this.backoffTimer])
|
|
276
|
+
if (timer)
|
|
277
|
+
clearTimeout(timer);
|
|
278
|
+
await Promise.all([this.stopChild(3_000), this.options.compileLoop.stop()]);
|
|
279
|
+
this.publish({ state: LocalDevSupervisorState.STOPPED, childPid: null, blocker: null });
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Kill the runtime and the compiler synchronously: the last act of a supervisor that is exiting without a clean stop
|
|
283
|
+
* (an uncaught error, a forced stop), so neither outlives it re-parented to init (#3214).
|
|
284
|
+
*/
|
|
285
|
+
killChildrenNow() {
|
|
286
|
+
killProcessGroupNow(this.child?.pid);
|
|
287
|
+
this.options.compileLoop.killChildrenNow();
|
|
288
|
+
}
|
|
289
|
+
publish(updates) {
|
|
290
|
+
const now = new Date().toISOString();
|
|
291
|
+
const transition = Object.keys(updates).length > 0;
|
|
292
|
+
this.record = { ...this.record, ...updates, retryCount: this.crashCount, heartbeatAt: now, ...(transition ? { updatedAt: now } : {}) };
|
|
293
|
+
try {
|
|
294
|
+
writeRuntimeSignalRecord(this.recordPath, this.record);
|
|
295
|
+
}
|
|
296
|
+
catch (error) {
|
|
297
|
+
this.log(`could not publish the supervisor record (${error.message})`);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
/** Run one decision at a time (see {@link busy}); a decision asked for while one runs is dropped, the next poll asks again. */
|
|
301
|
+
async serialized(work) {
|
|
302
|
+
if (this.stopping || this.busy)
|
|
303
|
+
return;
|
|
304
|
+
this.busy = true;
|
|
305
|
+
try {
|
|
306
|
+
await work();
|
|
307
|
+
}
|
|
308
|
+
finally {
|
|
309
|
+
this.busy = false;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Create the restart-request file when absent, and take its current modification time as the baseline, so only a
|
|
314
|
+
* change made AFTER this supervisor started is a request. A file the supervisor cannot create leaves the runtime
|
|
315
|
+
* supervised as before, with the reason logged: a missing door must never stop the runtime.
|
|
316
|
+
*/
|
|
317
|
+
prepareRestartSignal() {
|
|
318
|
+
try {
|
|
319
|
+
if (!existsSync(this.signalPath)) {
|
|
320
|
+
mkdirSync(dirname(this.signalPath), { recursive: true, mode: 0o700 });
|
|
321
|
+
writeFileSync(this.signalPath, 'initial\n', { encoding: 'utf8', mode: 0o600 });
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
catch (error) {
|
|
325
|
+
this.log(`could not create the restart signal ${this.signalPath} (${error.message}); a restart cannot be requested`);
|
|
326
|
+
}
|
|
327
|
+
this.lastSignalStamp = this.readSignalStamp();
|
|
328
|
+
}
|
|
329
|
+
readSignalStamp() {
|
|
330
|
+
try {
|
|
331
|
+
return statSync(this.signalPath).mtimeMs;
|
|
332
|
+
}
|
|
333
|
+
catch {
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
/** Whether the restart-request file changed since it was last read (promise 7). A file that vanished asks nothing. */
|
|
338
|
+
takeRestartRequest() {
|
|
339
|
+
const stamp = this.readSignalStamp();
|
|
340
|
+
if (stamp === null || stamp === this.lastSignalStamp) {
|
|
341
|
+
if (stamp !== null)
|
|
342
|
+
this.lastSignalStamp = stamp;
|
|
343
|
+
return false;
|
|
344
|
+
}
|
|
345
|
+
this.lastSignalStamp = stamp;
|
|
346
|
+
return true;
|
|
347
|
+
}
|
|
348
|
+
/** The workspace dependencies' compile records, a record nobody live stands behind read as absent. */
|
|
349
|
+
readDependencyObservations(nowMs) {
|
|
350
|
+
return this.options.dependencyPackageNames.map((packageName) => {
|
|
351
|
+
const observation = readCompileObservation(compileObservationPath(this.options.appRoot, packageName));
|
|
352
|
+
return { packageName, observation: observation !== undefined && isLiveCompileObservation(observation, nowMs) ? observation : undefined };
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
async decide() {
|
|
356
|
+
const nowMs = Date.now();
|
|
357
|
+
const dependencyGraceElapsed = nowMs - this.startedAtMs >= (this.options.absentDependencyGraceMs ?? 30_000);
|
|
358
|
+
const dependencies = this.readDependencyObservations(nowMs);
|
|
359
|
+
if (dependencyGraceElapsed && !this.loggedAbsentDependencies) {
|
|
360
|
+
const absent = dependencies.filter((dependency) => dependency.observation === undefined).map((dependency) => dependency.packageName);
|
|
361
|
+
if (absent.length > 0) {
|
|
362
|
+
this.loggedAbsentDependencies = true;
|
|
363
|
+
this.log(`no live compiler reports for ${absent.join(', ')} (is its dev process running?); starting on the dist already on disk, and the runtime will not be restarted when it changes`);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
const builds = assessRuntimeBuilds({ own: this.options.compileLoop.observation, dependencies, dependencyGraceElapsed });
|
|
367
|
+
if (this.applyDependencyInstall(nowMs) === RuntimeDependencyInstallDecision.HOLD)
|
|
368
|
+
return;
|
|
369
|
+
if (this.takeRestartRequest()) {
|
|
370
|
+
this.restartRequested = true;
|
|
371
|
+
this.log(`a restart was requested (${this.signalPath} changed); the next start serves it, so the runtime reads its environment again`);
|
|
372
|
+
// A request re-checks a held port now, and lifts a crash pause: whoever asked expects an attempt, not a wait.
|
|
373
|
+
this.lastPortCheckAtMs = 0;
|
|
374
|
+
if (this.pausedAgainstBuildAtMs !== null) {
|
|
375
|
+
this.pausedAgainstBuildAtMs = null;
|
|
376
|
+
this.crashCount = 0;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
if (this.heldPort !== null) {
|
|
380
|
+
if (nowMs - this.lastPortCheckAtMs < (this.options.portRecheckIntervalMs ?? 5_000))
|
|
381
|
+
return;
|
|
382
|
+
this.lastPortCheckAtMs = nowMs;
|
|
383
|
+
const holder = await identifyPortHolder(this.heldPort);
|
|
384
|
+
if (holder !== null) {
|
|
385
|
+
this.publish({ state: LocalDevSupervisorState.PAUSED, blocker: LocalDevSupervisorBlocker.PORT_HELD_BY_FOREIGN_PROCESS, portHolder: holder });
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
this.log(`port ${this.heldPort} is free again; starting the runtime`);
|
|
389
|
+
this.heldPort = null;
|
|
390
|
+
this.crashCount = 0;
|
|
391
|
+
this.publish({ portHolder: null, blocker: null });
|
|
392
|
+
}
|
|
393
|
+
if (this.pausedAgainstBuildAtMs !== null) {
|
|
394
|
+
if (builds.newestBuildAtMs <= this.pausedAgainstBuildAtMs)
|
|
395
|
+
return;
|
|
396
|
+
this.log('a newer build was published; starting the runtime again');
|
|
397
|
+
this.pausedAgainstBuildAtMs = null;
|
|
398
|
+
this.crashCount = 0;
|
|
399
|
+
}
|
|
400
|
+
if (!builds.ready) {
|
|
401
|
+
if (this.child === null && this.lastWaitingFor !== builds.waitingFor) {
|
|
402
|
+
this.lastWaitingFor = builds.waitingFor;
|
|
403
|
+
this.log(`waiting for ${builds.waitingFor} to publish before starting the runtime`);
|
|
404
|
+
this.publish({ state: LocalDevSupervisorState.STARTING, blocker: `WAITING_FOR_BUILD ${builds.waitingFor}` });
|
|
405
|
+
}
|
|
406
|
+
// A running runtime keeps serving its last good build until every package it loads is publishable again.
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
this.lastWaitingFor = null;
|
|
410
|
+
if (this.child !== null) {
|
|
411
|
+
if (this.restartRequested) {
|
|
412
|
+
this.crashCount = 0;
|
|
413
|
+
await this.stopChild();
|
|
414
|
+
this.spawnChild();
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
const settled = nowMs - builds.newestBuildAtMs >= (this.options.restartDebounceMs ?? 750);
|
|
418
|
+
if (builds.newestBuildAtMs > this.childStartedAtMs && settled) {
|
|
419
|
+
this.log('a package the runtime loads published a newer build; restarting the runtime');
|
|
420
|
+
this.crashCount = 0;
|
|
421
|
+
await this.stopChild();
|
|
422
|
+
this.spawnChild();
|
|
423
|
+
}
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
if (this.backoffTimer !== null) {
|
|
427
|
+
// A build newer than the start that crashed is what a backoff waits for: start on it now, with a fresh budget.
|
|
428
|
+
// So is a requested restart (promise 7).
|
|
429
|
+
if (builds.newestBuildAtMs <= this.childStartedAtMs && !this.restartRequested)
|
|
430
|
+
return;
|
|
431
|
+
clearTimeout(this.backoffTimer);
|
|
432
|
+
this.backoffTimer = null;
|
|
433
|
+
this.crashCount = 0;
|
|
434
|
+
}
|
|
435
|
+
this.spawnChild();
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* A dependency install is a stack event (#3552). While one re-creates `node_modules`, nothing is started or restarted:
|
|
439
|
+
* a start would read a half-installed tree, and a running runtime keeps serving what it loaded. When it ends, the
|
|
440
|
+
* runtime is relaunched with a fresh crash budget — it loaded modules from the tree the install replaced, and a crash
|
|
441
|
+
* the install caused must not leave it paused. The relaunch waits, like every start, until every package it loads is
|
|
442
|
+
* publishable again (the compile loops recompile after an install too).
|
|
443
|
+
*/
|
|
444
|
+
applyDependencyInstall(nowMs) {
|
|
445
|
+
const install = classifyDependencyInstall(readDependencyInstallRecord(this.options.appRoot), nowMs);
|
|
446
|
+
const decision = decideRuntimeAfterDependencyInstall({ install, runtimeStartedAtMs: this.childStartedAtMs, handledInstallKey: this.handledInstallKey });
|
|
447
|
+
if (decision === RuntimeDependencyInstallDecision.HOLD) {
|
|
448
|
+
if (!this.loggedInstallHold) {
|
|
449
|
+
this.loggedInstallHold = true;
|
|
450
|
+
this.log('a dependency install is re-creating node_modules; the runtime is held until it ends');
|
|
451
|
+
}
|
|
452
|
+
if (this.child === null)
|
|
453
|
+
this.publish({ state: LocalDevSupervisorState.PAUSED, childPid: null, blocker: LocalDevSupervisorBlocker.DEPENDENCY_INSTALL_IN_FLIGHT });
|
|
454
|
+
return decision;
|
|
455
|
+
}
|
|
456
|
+
this.loggedInstallHold = false;
|
|
457
|
+
if (install.phase === LocalDevDependencyInstallPhase.ENDED)
|
|
458
|
+
this.handledInstallKey = install.key;
|
|
459
|
+
if (decision === RuntimeDependencyInstallDecision.RELAUNCH) {
|
|
460
|
+
this.log('a dependency install ended after the runtime started; relaunching it on the installed tree with a fresh crash budget');
|
|
461
|
+
this.restartRequested = true;
|
|
462
|
+
this.crashCount = 0;
|
|
463
|
+
this.pausedAgainstBuildAtMs = null;
|
|
464
|
+
if (this.child === null)
|
|
465
|
+
this.publish({ state: LocalDevSupervisorState.STARTING, blocker: null });
|
|
466
|
+
}
|
|
467
|
+
return decision;
|
|
468
|
+
}
|
|
469
|
+
spawnChild() {
|
|
470
|
+
if (this.stopping)
|
|
471
|
+
return;
|
|
472
|
+
// Any start reads the environment afresh, so it serves a pending restart request.
|
|
473
|
+
this.restartRequested = false;
|
|
474
|
+
const child = spawn(process.execPath, [...(this.options.nodeArguments ?? []), this.entryPath], {
|
|
475
|
+
cwd: this.options.packageRoot,
|
|
476
|
+
detached: usesProcessGroups(),
|
|
477
|
+
stdio: ['ignore', 'inherit', 'inherit'],
|
|
478
|
+
});
|
|
479
|
+
this.child = child;
|
|
480
|
+
this.childStartedAtMs = Date.now();
|
|
481
|
+
const spawnedAtMs = this.childStartedAtMs;
|
|
482
|
+
this.publish({ state: LocalDevSupervisorState.RUNNING, childPid: child.pid ?? null, childStartedAt: new Date(spawnedAtMs).toISOString(), blocker: null });
|
|
483
|
+
// Uptime is taken when the child EXITS, not when the exit is handled: handling may wait out a decision that runs `lsof`
|
|
484
|
+
// or stops a process group, which would otherwise count as time the child stayed up.
|
|
485
|
+
child.once('exit', (code, signal) => void this.handleChildExitWhenFree(child, code, signal, Date.now() - spawnedAtMs));
|
|
486
|
+
}
|
|
487
|
+
async stopChild(graceMs) {
|
|
488
|
+
const child = this.child;
|
|
489
|
+
if (child === null)
|
|
490
|
+
return;
|
|
491
|
+
this.child = null;
|
|
492
|
+
this.expectedExit.add(child);
|
|
493
|
+
if (child.pid)
|
|
494
|
+
await terminateProcessGroup(child.pid, graceMs);
|
|
495
|
+
}
|
|
496
|
+
/** An exit is a decision too: it waits for a running poll to finish rather than interleave with it (see {@link busy}). */
|
|
497
|
+
async handleChildExitWhenFree(child, code, signal, uptimeMs) {
|
|
498
|
+
while (this.busy && !this.stopping)
|
|
499
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
500
|
+
await this.serialized(() => this.handleChildExit(child, code, signal, uptimeMs));
|
|
501
|
+
}
|
|
502
|
+
async handleChildExit(child, code, signal, uptimeMs) {
|
|
503
|
+
if (this.expectedExit.has(child) || this.stopping)
|
|
504
|
+
return;
|
|
505
|
+
if (this.child === child)
|
|
506
|
+
this.child = null;
|
|
507
|
+
const started = child.pid ? readBackendStartRecordOfProcess(this.options.appRoot, child.pid) : undefined;
|
|
508
|
+
if (started?.outcome === BackendStartOutcome.FAILED && started.failureCause === BackendStartFailureCause.PORT_IN_USE && started.port) {
|
|
509
|
+
const holder = await identifyPortHolder(started.port);
|
|
510
|
+
// Never this supervisor's own current child: only a process it did not start can be an orphan or a foreigner.
|
|
511
|
+
const ownChild = holder !== null && holder.pid !== null && holder.pid === this.child?.pid;
|
|
512
|
+
if (holder !== null && !ownChild && holder.pid !== null && isOrphanedRuntimeOfPackage(holder, { packageRoot: this.options.packageRoot, entry: this.options.entry })) {
|
|
513
|
+
this.log(`port ${started.port} is held by a runtime an earlier dev run left behind (pid ${holder.pid}, parent ${holder.ppid ?? 'unknown'}, started ${holder.startedAt ?? 'unknown'}); stopping it and starting the current build`);
|
|
514
|
+
await terminateProcessGroup(holder.pid);
|
|
515
|
+
if (this.child === null)
|
|
516
|
+
this.spawnChild();
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
if (holder !== null && !ownChild) {
|
|
520
|
+
this.heldPort = started.port;
|
|
521
|
+
this.lastPortCheckAtMs = Date.now();
|
|
522
|
+
this.log(`a process this supervisor did not spawn holds port ${started.port}: pid ${holder.pid ?? 'unknown (lsof unavailable)'}, parent ${holder.ppid ?? 'unknown'}${holder.ppid === 1 ? ' (an orphan)' : ''}, `
|
|
523
|
+
+ `started ${holder.startedAt ?? 'unknown'}, cwd ${holder.cwd ?? 'unknown'}, command ${holder.command ?? 'unknown'}. `
|
|
524
|
+
+ 'The runtime is NOT running this tree. Stop that process (or restart `wildo local dev`); the runtime starts as soon as the port is free.');
|
|
525
|
+
this.publish({ state: LocalDevSupervisorState.PAUSED, childPid: null, blocker: LocalDevSupervisorBlocker.PORT_HELD_BY_FOREIGN_PROCESS, portHolder: holder });
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
if (this.child !== null)
|
|
530
|
+
return;
|
|
531
|
+
// A crash while an install re-creates node_modules is the install's, not the runtime's (#3552): count nothing, and let
|
|
532
|
+
// the end of the install start it again with a fresh budget (see `applyDependencyInstall`).
|
|
533
|
+
if (classifyDependencyInstall(readDependencyInstallRecord(this.options.appRoot), Date.now()).phase === LocalDevDependencyInstallPhase.IN_FLIGHT) {
|
|
534
|
+
this.log(`the runtime exited (${signal ?? `code ${code}`}) while a dependency install is running; not counted, it starts again when the install ends`);
|
|
535
|
+
this.publish({ state: LocalDevSupervisorState.PAUSED, childPid: null, blocker: LocalDevSupervisorBlocker.DEPENDENCY_INSTALL_IN_FLIGHT });
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
/*
|
|
539
|
+
* A child that STARTED (its own record says so) and later died is a fresh failure with a fresh budget. Uptime is not
|
|
540
|
+
* the test: a start refused after registry initialization takes twenty seconds, and counting that as healthy reset
|
|
541
|
+
* the budget at every attempt, so a backend that could never start was respawned forever. Uptime decides only for a
|
|
542
|
+
* child that left no record at all (#3257): a runtime with no runtime-signals home writes none, and would otherwise
|
|
543
|
+
* never reset its budget, so five crashes spread over a day would spend it.
|
|
544
|
+
*/
|
|
545
|
+
const stayedUpWithoutStartRecord = started === undefined
|
|
546
|
+
&& uptimeMs >= (this.options.healthyUptimeWithoutStartRecordMs ?? DEFAULT_HEALTHY_UPTIME_WITHOUT_START_RECORD_MS);
|
|
547
|
+
if (started?.outcome === BackendStartOutcome.STARTED || stayedUpWithoutStartRecord)
|
|
548
|
+
this.crashCount = 0;
|
|
549
|
+
this.crashCount += 1;
|
|
550
|
+
if (this.crashCount > CRASH_RESPAWN_MAX_ATTEMPTS && spentCrashBudgetPauses(started)) {
|
|
551
|
+
this.pausedAgainstBuildAtMs = assessRuntimeBuilds({
|
|
552
|
+
own: this.options.compileLoop.observation,
|
|
553
|
+
dependencies: this.readDependencyObservations(Date.now()),
|
|
554
|
+
dependencyGraceElapsed: true,
|
|
555
|
+
}).newestBuildAtMs;
|
|
556
|
+
this.log(`the runtime exited (${signal ?? `code ${code}`}) at ${this.crashCount} consecutive starts; paused until a package it loads publishes a newer build`);
|
|
557
|
+
this.publish({ state: LocalDevSupervisorState.PAUSED, childPid: null, blocker: LocalDevSupervisorBlocker.CRASH_RESPAWN_EXHAUSTED });
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
const crashLoop = this.crashCount > CRASH_RESPAWN_MAX_ATTEMPTS;
|
|
561
|
+
const delayMs = Math.min(CRASH_RESPAWN_BASE_DELAY_MS * 2 ** (this.crashCount - 1), crashLoop ? CRASH_LOOP_MAX_DELAY_MS : CRASH_RESPAWN_MAX_DELAY_MS);
|
|
562
|
+
if (crashLoop) {
|
|
563
|
+
this.log(`the runtime exited (${signal ?? `code ${code}`}) at ${this.crashCount} consecutive starts, `
|
|
564
|
+
+ `${describeCrashLoopCause(started)}; `
|
|
565
|
+
+ `crash-loop backoff, restarting in ${Math.round(delayMs / 1000)}s (the delay doubles up to ${CRASH_LOOP_MAX_DELAY_MS / 60_000} min and never stops)`);
|
|
566
|
+
this.publish({ state: LocalDevSupervisorState.BACKOFF, childPid: null, blocker: LocalDevSupervisorBlocker.CRASH_LOOP_BACKOFF });
|
|
567
|
+
}
|
|
568
|
+
else {
|
|
569
|
+
this.log(`the runtime exited (${signal ?? `code ${code}`}); restarting in ${Math.round(delayMs / 1000)}s (${this.crashCount}/${CRASH_RESPAWN_MAX_ATTEMPTS})`);
|
|
570
|
+
this.publish({ state: LocalDevSupervisorState.BACKOFF, childPid: null, blocker: null });
|
|
571
|
+
}
|
|
572
|
+
this.backoffTimer = setTimeout(() => {
|
|
573
|
+
this.backoffTimer = null;
|
|
574
|
+
void this.serialized(() => this.decide());
|
|
575
|
+
}, delayMs);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
//# sourceMappingURL=node-runtime-dev-supervisor.js.map
|