@descryy/runtime-orchestrator 0.2.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +6 -0
- package/dist/attach-to-running-jvm-process.d.ts +45 -98
- package/dist/attach-to-running-jvm-process.d.ts.map +1 -1
- package/dist/attach-to-running-jvm-process.js +206 -136
- package/dist/attach-to-running-jvm-process.js.map +1 -1
- package/dist/attach-to-running-node-process.d.ts +45 -103
- package/dist/attach-to-running-node-process.d.ts.map +1 -1
- package/dist/attach-to-running-node-process.js +90 -115
- package/dist/attach-to-running-node-process.js.map +1 -1
- package/dist/attach-to-running-process.d.ts +32 -70
- package/dist/attach-to-running-process.d.ts.map +1 -1
- package/dist/attach-to-running-process.js +26 -54
- package/dist/attach-to-running-process.js.map +1 -1
- package/dist/cdp-client.d.ts +8 -18
- package/dist/cdp-client.d.ts.map +1 -1
- package/dist/cdp-client.js +9 -22
- package/dist/cdp-client.js.map +1 -1
- package/dist/cgroup-partial-restriction.d.ts +42 -89
- package/dist/cgroup-partial-restriction.d.ts.map +1 -1
- package/dist/cgroup-partial-restriction.js +44 -91
- package/dist/cgroup-partial-restriction.js.map +1 -1
- package/dist/collector-version.d.ts +3 -8
- package/dist/collector-version.d.ts.map +1 -1
- package/dist/collector-version.js +3 -8
- package/dist/collector-version.js.map +1 -1
- package/dist/instrumented-execution.d.ts +30 -73
- package/dist/instrumented-execution.d.ts.map +1 -1
- package/dist/instrumented-execution.js +121 -251
- package/dist/instrumented-execution.js.map +1 -1
- package/dist/jvm-agent/build.d.ts +29 -53
- package/dist/jvm-agent/build.d.ts.map +1 -1
- package/dist/jvm-agent/build.js +34 -61
- package/dist/jvm-agent/build.js.map +1 -1
- package/dist/polling-output-source.d.ts +8 -19
- package/dist/polling-output-source.d.ts.map +1 -1
- package/dist/polling-output-source.js +14 -31
- package/dist/polling-output-source.js.map +1 -1
- package/dist/profile-backend-observation.d.ts +26 -53
- package/dist/profile-backend-observation.d.ts.map +1 -1
- package/dist/profile-backend-observation.js +26 -55
- package/dist/profile-backend-observation.js.map +1 -1
- package/dist/respawn-and-supervise.d.ts +39 -72
- package/dist/respawn-and-supervise.d.ts.map +1 -1
- package/dist/respawn-and-supervise.js +43 -84
- package/dist/respawn-and-supervise.js.map +1 -1
- package/package.json +14 -9
|
@@ -1,30 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* The assembly wiring the runtime's pieces together in production code (previously
|
|
3
|
+
* only connected ad hoc by tests). Runs an `ExecutionController` over a `services`
|
|
4
|
+
* configuration; wraps each spawned process in a live `ProcessOutputSource`
|
|
5
|
+
* (polls the buffer, yields each line once); builds a `CollectorContext` with a
|
|
6
|
+
* real `resolveSourceRoot` closed over the execution's own processes; starts,
|
|
7
|
+
* drains and stops the adapter's collectors; writes every emitted item through
|
|
8
|
+
* `EvidenceStore` so redaction runs (plan §28).
|
|
3
9
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* could build collectors, `resolveServiceRootForOrigin` could map an origin
|
|
7
|
-
* to a source root, and `EvidenceStore` could persist. Only tests ever
|
|
8
|
-
* connected them, each in its own way, which meant the thing the product
|
|
9
|
-
* actually does had no single implementation to be right or wrong.
|
|
10
|
-
*
|
|
11
|
-
* This module is that implementation. It:
|
|
12
|
-
*
|
|
13
|
-
* 1. runs an `ExecutionController` over a `services` configuration;
|
|
14
|
-
* 2. wraps each spawned process in a **live** `ProcessOutputSource` that
|
|
15
|
-
* polls the accumulated buffer and yields each line exactly once;
|
|
16
|
-
* 3. builds a `CollectorContext` carrying a real `resolveSourceRoot`,
|
|
17
|
-
* closed over the execution's own processes — the channel RT-033 added
|
|
18
|
-
* and RT-029 built the lookup for, which until now nothing populated;
|
|
19
|
-
* 4. starts the adapter's collectors, drains them, stops them;
|
|
20
|
-
* 5. writes every emitted item through `EvidenceStore`, so redaction runs
|
|
21
|
-
* rather than being assumed (plan §28).
|
|
22
|
-
*
|
|
23
|
-
* **What it deliberately does not do.** It does not decide whether a
|
|
24
|
-
* finding exists, does not correlate to graph nodes, and does not drive a
|
|
25
|
-
* browser. Those are separate stages with separate contracts, and folding
|
|
26
|
-
* them in here would make the one place that assembles the runtime also the
|
|
27
|
-
* place that judges it.
|
|
10
|
+
* Deliberately does not decide whether a finding exists, correlate to graph
|
|
11
|
+
* nodes, or drive a browser — separate stages, separate contracts.
|
|
28
12
|
*/
|
|
29
13
|
import { delimiter } from "node:path";
|
|
30
14
|
import { resolveServiceRootForOrigin } from "@descryy/runtime-contracts";
|
|
@@ -36,13 +20,9 @@ import { createPollingProcessOutputSource } from "./polling-output-source.js";
|
|
|
36
20
|
import { COLLECTOR_VERSION } from "./collector-version.js";
|
|
37
21
|
/**
|
|
38
22
|
* Builds the origin → source-root lookup a collector reads through
|
|
39
|
-
* `CollectorContext.resolveSourceRoot`.
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
* lookup and nothing called it; RT-033 added the channel and nothing filled
|
|
43
|
-
* it. It closes over the execution's *own* processes, which is what makes
|
|
44
|
-
* the port→service join possible at all — under ephemeral ports the
|
|
45
|
-
* configuration cannot know the port, so only the running process can say.
|
|
23
|
+
* `CollectorContext.resolveSourceRoot`. Closes over the execution's own
|
|
24
|
+
* processes, which is what makes the port→service join possible at all — under
|
|
25
|
+
* ephemeral ports only the running process knows its own port.
|
|
46
26
|
*/
|
|
47
27
|
export function createSourceRootResolver(processes, configuration) {
|
|
48
28
|
return (origin) => resolveServiceRootForOrigin(origin, { processes, configuration });
|
|
@@ -66,9 +46,8 @@ export function applyOutboundLaunch(execution, launch) {
|
|
|
66
46
|
const prepend = new Set(launch.prependToExisting ?? []);
|
|
67
47
|
const env = { ...service.env };
|
|
68
48
|
for (const [key, value] of Object.entries(launch.env ?? {})) {
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
// finding its own modules, which is breaking it in order to watch it.
|
|
49
|
+
// extend, don't replace, a variable the target relies on (PYTHONPATH is the
|
|
50
|
+
// live case) -- replacing it breaks the app's own module resolution
|
|
72
51
|
const existing = prepend.has(key) ? (service.env?.[key] ?? process.env[key] ?? "") : "";
|
|
73
52
|
env[key] = existing === "" ? value : `${value}${delimiter}${existing}`;
|
|
74
53
|
}
|
|
@@ -83,35 +62,19 @@ export function applyOutboundLaunch(execution, launch) {
|
|
|
83
62
|
return { ...execution, configuration: { ...execution.configuration, services } };
|
|
84
63
|
}
|
|
85
64
|
export async function runInstrumentedExecution(input) {
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
// `createBackendCollectors` and nothing else, so no production path
|
|
91
|
-
// observed an outbound call at all.
|
|
65
|
+
// outbound observation needs two things: a collector to read the marker lines,
|
|
66
|
+
// and a launch that installs client instrumentation before app code can capture
|
|
67
|
+
// an unpatched client. The launch is adapter-declared and language-specific; an
|
|
68
|
+
// adapter that declares nothing gets neither.
|
|
92
69
|
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
// before any application code can capture an unpatched client. The second
|
|
96
|
-
// is language-specific, so it is declared on the adapter and merely applied
|
|
97
|
-
// here. An adapter that declares nothing gets neither -- the run proceeds,
|
|
98
|
-
// no outbound collector is started, and nothing claims one was.
|
|
99
|
-
// The budget clock starts here, not at `controller.run()`. The controller
|
|
100
|
-
// arms its own timer for the same number a few statements later, so this
|
|
101
|
-
// deadline is the marginally earlier — and therefore the conservative — of
|
|
102
|
-
// the two; a caller who declared a bound never waits past it because this
|
|
103
|
-
// function took some of it before the controller was even created.
|
|
70
|
+
// budget clock starts here, not at controller.run() -- marginally earlier than
|
|
71
|
+
// the controller's own timer, so a caller never waits past its declared bound
|
|
104
72
|
const budgetStartedAt = Date.now();
|
|
105
73
|
const outboundLaunch = input.adapter.outboundHttpLaunch?.() ?? null;
|
|
106
74
|
const launchedExecution = outboundLaunch === null ? input.execution : applyOutboundLaunch(input.execution, outboundLaunch);
|
|
107
75
|
const configuration = launchedExecution.configuration;
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
* clock. Written as a closure rather than repeated at each `return` because
|
|
111
|
-
* there are four of them and the failure mode of the alternative — one
|
|
112
|
-
* return that quietly reports a different number — is exactly the class of
|
|
113
|
-
* bug this whole field exists to make visible.
|
|
114
|
-
*/
|
|
76
|
+
// closure, not repeated per-return: there are four returns, and one quietly
|
|
77
|
+
// reporting a different number is the bug this field exists to catch
|
|
115
78
|
const budgetOutcome = (observedForMs) => {
|
|
116
79
|
const elapsedMs = Date.now() - budgetStartedAt;
|
|
117
80
|
return {
|
|
@@ -123,20 +86,30 @@ export async function runInstrumentedExecution(input) {
|
|
|
123
86
|
};
|
|
124
87
|
};
|
|
125
88
|
const controller = ExecutionController.create(launchedExecution);
|
|
126
|
-
//
|
|
127
|
-
// `
|
|
128
|
-
// `
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
// `controller.
|
|
136
|
-
//
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
89
|
+
// B7: the execution row, minted here and nowhere else in production.
|
|
90
|
+
// `EvidenceStore.recordExecution` was complete and tested since the
|
|
91
|
+
// `executions` table was added -- schema, redaction, environment capture,
|
|
92
|
+
// upsert -- but this repo's and descry-core's own sources together held
|
|
93
|
+
// exactly two callers of it outside its own tests, both in
|
|
94
|
+
// evidence-store's test suite. Every evidence row this run is about to
|
|
95
|
+
// write carries `controller.execution.executionId`, so the parent row
|
|
96
|
+
// must exist before the first of them does; `controller.execution` is
|
|
97
|
+
// real the moment `create()` returns (it mints the id synchronously),
|
|
98
|
+
// which is before `controller.run()` -- and therefore before this
|
|
99
|
+
// function's own first `store.write()`, in the catch block just below.
|
|
100
|
+
input.store.recordExecution({
|
|
101
|
+
executionId: controller.execution.executionId,
|
|
102
|
+
application: controller.execution.application,
|
|
103
|
+
repository: controller.execution.repository,
|
|
104
|
+
commit: controller.execution.commit,
|
|
105
|
+
configuration: controller.execution.configuration,
|
|
106
|
+
});
|
|
107
|
+
// RT-219: controller.run()'s one throw path (spawnProcess catch/rethrow, after
|
|
108
|
+
// recording the failure in serviceStartResults) used to reject this whole
|
|
109
|
+
// function uncaught -- zero evidence trail. Caught here and written through
|
|
110
|
+
// EvidenceStore tagged "harness", same "resolves normally" contract RT-219
|
|
111
|
+
// established for collector.start(). controller.execution/serviceStartResults
|
|
112
|
+
// are both already real at this point (run() mutates state before it throws).
|
|
140
113
|
let execution;
|
|
141
114
|
try {
|
|
142
115
|
execution = await controller.run(input.runOptions);
|
|
@@ -180,34 +153,23 @@ export async function runInstrumentedExecution(input) {
|
|
|
180
153
|
budget: budgetOutcome(0),
|
|
181
154
|
};
|
|
182
155
|
}
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
// Declared before the sources close over it: each generator polls until
|
|
187
|
-
// this flips, and the flip is what lets `stop()` finish rather than wait
|
|
188
|
-
// forever on a source still watching a live buffer.
|
|
156
|
+
// a handle with no serviceName can't be attributed to a config entry -- skipped
|
|
157
|
+
// declared before the sources close over it: the flip lets stop() finish rather
|
|
158
|
+
// than wait forever on a source still watching a live buffer
|
|
189
159
|
let finished = false;
|
|
190
160
|
const sources = [];
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
// exactly once, so two collectors sharing one source do not both see the
|
|
197
|
-
// output -- they race for it, and the first consumer wins. Sharing it made
|
|
198
|
-
// the backend log collector eat every `DESCRY_EXTERNAL_REQUEST` line and
|
|
199
|
-
// the outbound collector report nothing, which reads exactly like a service
|
|
200
|
-
// that made no outbound calls.
|
|
161
|
+
// second, independent source per process -- NOT shared with the backend
|
|
162
|
+
// collector's: createPollingProcessOutputSource yields each line exactly once,
|
|
163
|
+
// so sharing means two collectors race for it. Measured: sharing made the
|
|
164
|
+
// backend log collector eat every DESCRY_EXTERNAL_REQUEST line and the outbound
|
|
165
|
+
// collector report nothing, reading as "no outbound calls".
|
|
201
166
|
const namedSources = [];
|
|
202
167
|
for (const handle of execution.processes) {
|
|
203
168
|
if (handle.serviceName === null)
|
|
204
169
|
continue;
|
|
205
170
|
const serviceName = handle.serviceName;
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
// by the time a collector's evidence reaches `correlateExecution` that
|
|
209
|
-
// association is gone — a row in a store and no handle in scope — which is
|
|
210
|
-
// what made the gap permanent rather than recoverable.
|
|
171
|
+
// stamped here, at construction -- by the time evidence reaches
|
|
172
|
+
// correlateExecution the handle is out of scope and this association is gone
|
|
211
173
|
sources.push(createPollingProcessOutputSource({
|
|
212
174
|
processId: handle.processId,
|
|
213
175
|
serviceName,
|
|
@@ -225,14 +187,10 @@ export async function runInstrumentedExecution(input) {
|
|
|
225
187
|
});
|
|
226
188
|
}
|
|
227
189
|
const backendCollectors = input.adapter.createBackendCollectors(sources);
|
|
228
|
-
//
|
|
229
|
-
//
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
// `createExternalRequestCollector` itself names no language; the only
|
|
234
|
-
// language-specific value it needs is the adapter's own stack parser, which
|
|
235
|
-
// is what turns a captured call site into a file and a line.
|
|
190
|
+
// only started when the launch above actually installed instrumentation --
|
|
191
|
+
// otherwise it would report "no outbound calls" for a service making them,
|
|
192
|
+
// worse than declining to look. createExternalRequestCollector names no
|
|
193
|
+
// language itself; the adapter's stack parser is the only language-specific bit.
|
|
236
194
|
const outboundCollectors = outboundLaunch === null
|
|
237
195
|
? []
|
|
238
196
|
: namedSources.map(({ source, serviceName }) => createExternalRequestCollector({
|
|
@@ -241,26 +199,14 @@ export async function runInstrumentedExecution(input) {
|
|
|
241
199
|
stackTraceParser: input.adapter.stackTraceParser,
|
|
242
200
|
}));
|
|
243
201
|
const emitted = [];
|
|
244
|
-
//
|
|
245
|
-
//
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
//
|
|
249
|
-
// service's own domain source, so a reader can tell "the project's
|
|
250
|
-
// declared versions don't match what actually ran" from an observation of
|
|
251
|
-
// the application itself without inspecting payload text. Run once per
|
|
252
|
-
// spawned service (each has its own `cwd`, and therefore potentially its
|
|
253
|
-
// own lockfile/`engines.node`), unconditionally -- "cheap, always
|
|
254
|
-
// available, no rerun needed" per the ruling -- but only ever emitted as
|
|
255
|
-
// evidence when an actual mismatch is found, the same "silence must not
|
|
256
|
-
// manufacture evidence" discipline `"harness"` already observes.
|
|
202
|
+
// dependency/environment version-mismatch checks (DEC-NEXT-fault-layer-empirical-
|
|
203
|
+
// confirmation.md): tagged "dependency-check"/"environment-check" rather than the
|
|
204
|
+
// service's own source, so a reader can tell version drift from an app
|
|
205
|
+
// observation. Run once per service (own cwd/lockfile/engines.node),
|
|
206
|
+
// unconditionally, but only emitted as evidence on an actual mismatch.
|
|
257
207
|
//
|
|
258
|
-
//
|
|
259
|
-
//
|
|
260
|
-
// once by `captureEnvironmentMetadata` during `controller.run()` above,
|
|
261
|
-
// never re-probed here. It is non-null whenever `validationError` is
|
|
262
|
-
// null (the early return above already excluded the one case where it
|
|
263
|
-
// would not be).
|
|
208
|
+
// observedNodeVersion is process.version captured once during controller.run();
|
|
209
|
+
// never re-probed here.
|
|
264
210
|
const observedNodeVersion = execution.environmentMetadata?.nodeVersion ?? process.version;
|
|
265
211
|
for (const handle of execution.processes) {
|
|
266
212
|
if (handle.serviceName === null)
|
|
@@ -319,12 +265,9 @@ export async function runInstrumentedExecution(input) {
|
|
|
319
265
|
}));
|
|
320
266
|
}
|
|
321
267
|
}
|
|
322
|
-
//
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
// service's own readiness answer already exists in
|
|
326
|
-
// `controller.serviceStartResults` -- this loop reads that answer, it
|
|
327
|
-
// never re-derives or re-checks it itself.
|
|
268
|
+
// opt-in InboundProxy wiring (RT-192, DEC-271), see frontWithInboundProxy's doc.
|
|
269
|
+
// Built after controller.run() returns, so readiness already exists in
|
|
270
|
+
// serviceStartResults -- this loop reads that answer, never re-derives it.
|
|
328
271
|
const proxies = [];
|
|
329
272
|
if (input.frontWithInboundProxy !== undefined) {
|
|
330
273
|
for (const handle of execution.processes) {
|
|
@@ -335,11 +278,8 @@ export async function runInstrumentedExecution(input) {
|
|
|
335
278
|
continue;
|
|
336
279
|
const readinessOutcome = controller.serviceStartResults.find((result) => result.serviceName === serviceName && result.stage === "readiness");
|
|
337
280
|
if (readinessOutcome?.succeeded !== true || handle.port === null) {
|
|
338
|
-
//
|
|
339
|
-
//
|
|
340
|
-
// in this function gets, rather than a silent no-proxy fallback
|
|
341
|
-
// ("we did not observe this" vs "there was nothing to observe",
|
|
342
|
-
// plan §17, applies here too).
|
|
281
|
+
// requested but never proven live -- reported as COLLECTOR_ERROR, not a
|
|
282
|
+
// silent no-proxy fallback (plan §17: "not observed" vs "nothing there")
|
|
343
283
|
emitted.push(input.store.write({
|
|
344
284
|
executionId: execution.executionId,
|
|
345
285
|
timestamp: new Date().toISOString(),
|
|
@@ -377,28 +317,19 @@ export async function runInstrumentedExecution(input) {
|
|
|
377
317
|
const context = {
|
|
378
318
|
executionId: execution.executionId,
|
|
379
319
|
configuration,
|
|
380
|
-
//
|
|
381
|
-
//
|
|
382
|
-
// array without having been through it (plan §28).
|
|
320
|
+
// redaction runs here, on the way in -- EvidenceStore.write() is the only
|
|
321
|
+
// path that sets redactionStatus (plan §28)
|
|
383
322
|
emit: (evidenceInput) => {
|
|
384
323
|
emitted.push(input.store.write({ ...evidenceInput, executionId: execution.executionId }));
|
|
385
324
|
},
|
|
386
325
|
resolveSourceRoot: createSourceRootResolver(execution.processes, configuration),
|
|
387
326
|
};
|
|
388
|
-
// RT-219
|
|
389
|
-
//
|
|
390
|
-
//
|
|
391
|
-
//
|
|
392
|
-
//
|
|
393
|
-
//
|
|
394
|
-
// that throw rejects this Promise.all and `runInstrumentedExecution`
|
|
395
|
-
// aborts uncaught -- zero evidence trail for why, the exact "collector
|
|
396
|
-
// failure mistaken for absence of failure" shape plan §17 forbids,
|
|
397
|
-
// applied to the harness's own code instead of the application's.
|
|
398
|
-
// Caught here and written through the same EvidenceStore, tagged
|
|
399
|
-
// `source: "harness"` rather than the collector's domain source, so a
|
|
400
|
-
// reader can tell "our own observer broke" from "the observed system
|
|
401
|
-
// did" without inspecting payload text.
|
|
327
|
+
// RT-219: collector.start()'s documented failure path resolves {available:
|
|
328
|
+
// false, reason}, handled below. A collector that instead THROWS out of
|
|
329
|
+
// start() is a different claim -- the harness's own machinery breaking, not
|
|
330
|
+
// "I looked and could not" -- so it's caught here and tagged "harness" rather
|
|
331
|
+
// than the collector's domain source, instead of aborting this Promise.all
|
|
332
|
+
// uncaught with zero evidence trail.
|
|
402
333
|
const startOutcomes = await Promise.all(collectors.map(async (collector) => {
|
|
403
334
|
try {
|
|
404
335
|
return { collector, result: await collector.start(context), threw: false };
|
|
@@ -429,14 +360,10 @@ export async function runInstrumentedExecution(input) {
|
|
|
429
360
|
return { collector, result: { available: false, reason: `start() threw: ${detail}` }, threw: true };
|
|
430
361
|
}
|
|
431
362
|
}));
|
|
432
|
-
//
|
|
433
|
-
//
|
|
434
|
-
// `source: "backend-process"` by the loop below, which would collide
|
|
435
|
-
// the two tags on what is really one failure.
|
|
363
|
+
// a thrown start() was already reported above under "harness" -- excluded here
|
|
364
|
+
// to avoid double-reporting the same failure under "backend-process"
|
|
436
365
|
const unavailable = startOutcomes.filter((entry) => !entry.threw && !entry.result.available);
|
|
437
|
-
//
|
|
438
|
-
// "we did not observe this" and "there was nothing to observe" are
|
|
439
|
-
// different claims (plan §17).
|
|
366
|
+
// reported, never silently absent (plan §17: "not observed" vs "nothing there")
|
|
440
367
|
for (const entry of unavailable) {
|
|
441
368
|
emitted.push(input.store.write({
|
|
442
369
|
executionId: execution.executionId,
|
|
@@ -459,78 +386,44 @@ export async function runInstrumentedExecution(input) {
|
|
|
459
386
|
collectorVersion: COLLECTOR_VERSION,
|
|
460
387
|
}));
|
|
461
388
|
}
|
|
462
|
-
//
|
|
463
|
-
// `available: false` above never reaches here -- its port stayed null on
|
|
464
|
-
// that codepath, matching `InboundProxy.port()`'s own contract).
|
|
389
|
+
// proxies with available:false above never reach here -- port stayed null
|
|
465
390
|
const proxyPorts = {};
|
|
466
391
|
for (const entry of proxies) {
|
|
467
392
|
const port = entry.proxy.port();
|
|
468
393
|
if (port !== null)
|
|
469
394
|
proxyPorts[entry.serviceName] = port;
|
|
470
395
|
}
|
|
471
|
-
//
|
|
472
|
-
//
|
|
473
|
-
//
|
|
474
|
-
//
|
|
475
|
-
//
|
|
476
|
-
//
|
|
477
|
-
//
|
|
478
|
-
// made: when `onReady` threw, `finished = true`, the collector `stop()` loop
|
|
479
|
-
// and `controller.stop()` were all skipped, so the polling output sources
|
|
480
|
-
// kept polling a live buffer, the spawned child was never killed, and the
|
|
481
|
-
// controller's timer held the event loop open. Measured: the call rejects in
|
|
482
|
-
// 270ms with the caller's own error, and the process then never exits.
|
|
483
|
-
//
|
|
484
|
-
// The exception still propagates, uncaught by any handler that changes it,
|
|
485
|
-
// to the same caller with the same stack — the documented contract is
|
|
486
|
-
// untouched. Only the leak goes away.
|
|
396
|
+
// Wrapped because a throw above teardown used to leak the whole execution.
|
|
397
|
+
// onReady is caller code, documented not to be caught -- but "not caught" and
|
|
398
|
+
// "not cleaned up" are different promises: when onReady threw, finished stayed
|
|
399
|
+
// false, so collector.stop()/controller.stop() were skipped, sources kept
|
|
400
|
+
// polling, the spawned child was never killed, and the timer held the event
|
|
401
|
+
// loop open. Measured: rejects in 270ms, process then never exits. Exception
|
|
402
|
+
// still propagates unchanged to the caller; only the leak goes away.
|
|
487
403
|
//
|
|
488
|
-
// Found the
|
|
489
|
-
//
|
|
490
|
-
//
|
|
491
|
-
// `scripts/preflight.mjs` predicts that dependency gap and calls its worst
|
|
492
|
-
// case "a readiness timeout that looks like a slow boot" — milder than what
|
|
493
|
-
// actually happened, which is what makes this a defect rather than an
|
|
494
|
-
// inconvenience: a guard whose stated worst case is better than reality
|
|
495
|
-
// teaches people to trust it less than they should.
|
|
404
|
+
// Found the hard way: a worktree missing node_modules made a test's onReady
|
|
405
|
+
// throw after a correctly bounded readiness wait, then the suite hung for
|
|
406
|
+
// twenty minutes.
|
|
496
407
|
let observationMs = 0;
|
|
497
408
|
let bodyFailure = null;
|
|
498
409
|
let teardownFailure = null;
|
|
499
410
|
try {
|
|
500
411
|
await input.onReady?.({ execution, proxyPorts });
|
|
501
|
-
//
|
|
502
|
-
//
|
|
503
|
-
//
|
|
504
|
-
//
|
|
505
|
-
//
|
|
506
|
-
//
|
|
507
|
-
// every process at the deadline, so the remainder of the window watched
|
|
508
|
-
// dead processes and produced nothing. Measured — a 1500ms budget with a
|
|
509
|
-
// 12000ms window returned after 12288ms with zero evidence.
|
|
510
|
-
//
|
|
511
|
-
// Clamped rather than aborted. There is real work left after this point
|
|
512
|
-
// (draining collectors, reaping children) that owns OS resources and must
|
|
513
|
-
// still run; cancelling it to hit the number exactly would leak the
|
|
514
|
-
// processes the budget exists to bound. So the deadline decides how long we
|
|
515
|
-
// *watch*, and the overshoot from teardown is reported rather than hidden —
|
|
516
|
-
// `budget.exceeded` is what says the run finished past its own bound.
|
|
412
|
+
// observation window is bounded by the declared budget, not just observeForMs
|
|
413
|
+
// -- unconditionally sleeping the full window used to leave timeoutMs bounding
|
|
414
|
+
// only the service, not the wait; measured, a 1500ms budget with a 12000ms
|
|
415
|
+
// window returned after 12288ms with zero evidence (services dead the whole
|
|
416
|
+
// remainder). Clamped rather than aborted: teardown still owns OS resources
|
|
417
|
+
// and must run; the overshoot is reported via budget.exceeded, not hidden.
|
|
517
418
|
const remainingMs = budgetStartedAt + configuration.timeoutMs - Date.now();
|
|
518
419
|
observationMs = Math.max(0, Math.min(input.observeForMs, remainingMs));
|
|
519
420
|
if (observationMs > 0) {
|
|
520
421
|
await new Promise((resolve) => setTimeout(resolve, observationMs));
|
|
521
422
|
}
|
|
522
|
-
//
|
|
523
|
-
//
|
|
524
|
-
//
|
|
525
|
-
// stopped
|
|
526
|
-
// budget expired before the controller ever armed (or after it had already
|
|
527
|
-
// completed) leaves no trace in the state at all.
|
|
528
|
-
//
|
|
529
|
-
// Tagged `source: "harness"` with `COLLECTOR_ERROR`: this is the runtime
|
|
530
|
-
// observing itself rather than any watched domain, and the event type's own
|
|
531
|
-
// documented purpose is to keep "found nothing" apart from "stopped
|
|
532
|
-
// looking". A truncated window is precisely the second one, so it needs no
|
|
533
|
-
// new event type of its own.
|
|
423
|
+
// rule 7: cut-short window is a labelled degradation. execution.state's
|
|
424
|
+
// TIMED_OUT says the services stopped early, not that observation did --
|
|
425
|
+
// tagged "harness"/COLLECTOR_ERROR to keep "found nothing" apart from
|
|
426
|
+
// "stopped looking".
|
|
534
427
|
if (observationMs < input.observeForMs) {
|
|
535
428
|
emitted.push(input.store.write({
|
|
536
429
|
executionId: execution.executionId,
|
|
@@ -558,35 +451,22 @@ export async function runInstrumentedExecution(input) {
|
|
|
558
451
|
}
|
|
559
452
|
}
|
|
560
453
|
catch (error) {
|
|
561
|
-
//
|
|
562
|
-
// `finally` has run.
|
|
454
|
+
// held, not handled -- rethrown below, unchanged, once teardown has run
|
|
563
455
|
bodyFailure = { error };
|
|
564
456
|
}
|
|
565
457
|
finally {
|
|
566
|
-
//
|
|
567
|
-
//
|
|
568
|
-
// still polling a live buffer.
|
|
458
|
+
// order matters: sources must be told the process is done before stop()
|
|
459
|
+
// awaits their drain, or it waits forever on a generator polling a live buffer
|
|
569
460
|
finished = true;
|
|
570
461
|
try {
|
|
571
462
|
await Promise.all(collectors.map((collector) => collector.stop()));
|
|
572
|
-
//
|
|
573
|
-
//
|
|
574
|
-
//
|
|
575
|
-
//
|
|
576
|
-
//
|
|
577
|
-
//
|
|
578
|
-
//
|
|
579
|
-
// happened, and teardown regularly won: measured `TIMED_OUT` at 1534ms and
|
|
580
|
-
// `COMPLETED` at 1515ms for the same case, with the second surfacing only
|
|
581
|
-
// under a loaded full-suite run.
|
|
582
|
-
//
|
|
583
|
-
// That is the one field `descry-core`'s `observe_runtime` reads to choose
|
|
584
|
-
// between `ok` and `timed_out`, so losing the race turned a disclosed
|
|
585
|
-
// degradation into a silent one — a run that returns on time and reports
|
|
586
|
-
// nothing was cut short. This function already knows: `observationMs <
|
|
587
|
-
// input.observeForMs` is exactly the condition it wrote the truncation
|
|
588
|
-
// evidence under. It says so directly rather than hoping to lose a race
|
|
589
|
-
// it set up itself.
|
|
463
|
+
// timeout() vs stop() is decided here, not left to whichever of this
|
|
464
|
+
// function's clamp or the controller's own timer fires first -- measured
|
|
465
|
+
// TIMED_OUT at 1534ms and COMPLETED at 1515ms for the same case under load.
|
|
466
|
+
// descry-core's observe_runtime reads this to choose ok vs timed_out, so
|
|
467
|
+
// losing that race silently hid a real truncation; this function already
|
|
468
|
+
// knows the answer (observationMs < input.observeForMs) and states it
|
|
469
|
+
// directly instead.
|
|
590
470
|
if (observationMs < input.observeForMs) {
|
|
591
471
|
await controller.timeout();
|
|
592
472
|
}
|
|
@@ -595,24 +475,16 @@ export async function runInstrumentedExecution(input) {
|
|
|
595
475
|
}
|
|
596
476
|
}
|
|
597
477
|
catch (teardownError) {
|
|
598
|
-
//
|
|
599
|
-
//
|
|
600
|
-
// below rather than thrown from inside the `finally`, which is the one
|
|
601
|
-
// place a throw really can discard an in-flight exception.
|
|
478
|
+
// held and rethrown below rather than thrown inside finally, which can
|
|
479
|
+
// discard an in-flight exception
|
|
602
480
|
if (bodyFailure === null) {
|
|
603
481
|
teardownFailure = { error: teardownError };
|
|
604
482
|
}
|
|
605
483
|
else {
|
|
606
|
-
//
|
|
607
|
-
//
|
|
608
|
-
//
|
|
609
|
-
//
|
|
610
|
-
// So it is written to the evidence store instead of thrown, tagged
|
|
611
|
-
// `harness` (RT-219) because it is the runtime's own machinery
|
|
612
|
-
// breaking rather than anything about the observed application. It is
|
|
613
|
-
// not in the returned array — there is no return on this path — but
|
|
614
|
-
// the store is durable and is what `evidencePath` points at, so the
|
|
615
|
-
// record survives the throw.
|
|
484
|
+
// caller's error is the actionable one; a teardown failure must not
|
|
485
|
+
// silently replace it -- written to the evidence store instead (tagged
|
|
486
|
+
// "harness", RT-219) rather than thrown. Not in the returned array, but
|
|
487
|
+
// the store is durable so the record survives the throw.
|
|
616
488
|
input.store.write({
|
|
617
489
|
executionId: execution.executionId,
|
|
618
490
|
timestamp: new Date().toISOString(),
|
|
@@ -637,9 +509,7 @@ export async function runInstrumentedExecution(input) {
|
|
|
637
509
|
}
|
|
638
510
|
}
|
|
639
511
|
}
|
|
640
|
-
//
|
|
641
|
-
// caller. The caller's own failure wins over a teardown failure when both
|
|
642
|
-
// happened, because it is the one they can act on.
|
|
512
|
+
// rethrown untouched, same object/stack; caller's failure wins over teardown's
|
|
643
513
|
if (bodyFailure !== null)
|
|
644
514
|
throw bodyFailure.error;
|
|
645
515
|
if (teardownFailure !== null)
|