@openwop/openwop-conformance 2.34.1 → 2.34.2
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/CHANGELOG.md +7 -0
- package/README.md +1 -1
- package/dist/spec-artifacts.lock.json +2 -2
- package/package.json +2 -2
- package/requirements.json +5 -5
- package/schemas/CORPUS-STAMP.json +3 -3
- package/src/lib/durability-watch.ts +29 -5
- package/src/scenarios/v2-durability-recovery.test.ts +10 -1
- package/src/setup.ts +17 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# `@openwop/openwop-conformance` Changelog
|
|
2
2
|
|
|
3
|
+
## [2.34.2] — 2026-09-21 — a host that died on schedule failed the rows that were watching it recover
|
|
4
|
+
|
|
5
|
+
- **The RFC 0158 kill rows read the host through two calls that did not survive the host dying.** `waitBack()` treated a refused connection as "down, keep waiting"; the watch's `readStatus` / `readLog` called `driver.get` bare. A seam may answer and *then* die — `during-execution` dies at the first `node.started`, seconds after its response — so `waitBack` saw a 200 from the still-living process, the process died mid-watch, and `fetch failed` escaped the scenario as an **`executed-fail` — for recovering.** Measured by a tier-1 host at its exact production image under a real restart supervisor, behind a port proxy that accepts and then closes: four of five rows `executed-fail` on `UND_ERR_SOCKET: other side closed`, reproduced twice; the one that passed was the row whose kill lands before the seam answers.
|
|
6
|
+
- **A transport failure inside the watch is now an unreadable observation, never a verdict:** a status that cannot be read is `null`, a log that cannot be read is `readable: false` — both already meant "conclude nothing, poll again" — and the budget still bounds the wait, so a host that never comes back still records no resumption. `Watch.transportErrors` counts them. Unit-tested in `lib/durability-watch.test.ts` (dies after answering and comes back → resumption observed; never comes back → none; an unreadable read never latches completed-un-re-executed).
|
|
7
|
+
- **One kill no longer contaminates the rest of the run.** Every seam row now waits for the host to answer at all before probing the seam, and the bound read retries once across a restart. Suite setup retried discovery twice back-to-back, giving a restarting host about a millisecond: each file set up during the restart logged "discovery unreadable" and recorded its fixture gates `blocked`. Setup now waits out a transport failure (refused / reset / closed) for up to 45 s, 1 s apart, without spending an attempt; an HTTP answer and a 20 s timeout still count as attempts, so a host that is up and wrong is not retried into looking right.
|
|
8
|
+
- False FAIL only. Suite patch; corpus release stays `2.34.0`. Reported by openwop-app.
|
|
9
|
+
|
|
3
10
|
## [2.34.1] — 2026-09-21 — a 90 s window convicted a host for retrying at its production pace
|
|
4
11
|
|
|
5
12
|
- **`v2-webhook-durable-delivery` capped every wait at a hard 90 s, and a conformant host with a slower schedule could not advertise `webhooks.deadLetter` without failing its bundle.** A host retrying at 15 / 30 / 60 / 120 s with `maxAttempts: 5` makes its attempts at t0, +15, +45, +105, +225 s. The dead-letter leg waited for a second attempt, waited 90 s more, and read the sink ~120 s before that host exhausts: **`0173.webhook-durable-delivery.dead-letter` → `executed-fail`, "an exhausted delivery MUST be routed to the sink, not dropped"**, about a delivery still in flight; `0188.dead-letter-content-free` → `blocked`, which denies certification. To pass, the host would have had to cut its production retry window from ~225 s to ~75 s for every real subscriber — the instrument choosing the host's durability. Reported by a tier-2 host from its own constants *before* advertising the facet; reproduced on the reference host configured to the same schedule.
|
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
# --legacy-peer-deps is REQUIRED, not optional: the exact peer pin is what npm's
|
|
12
12
|
# default resolver refuses. npm 10.9 fails outright with
|
|
13
13
|
# "Cannot read properties of null (reading 'edgesOut')" — use npm >= 11.
|
|
14
|
-
npm install --legacy-peer-deps @openwop/openwop-conformance@2.34.
|
|
14
|
+
npm install --legacy-peer-deps @openwop/openwop-conformance@2.34.2 @openwop/spec-artifacts@2.34.2
|
|
15
15
|
# or run without install:
|
|
16
16
|
npx @openwop/openwop-conformance --base-url https://api.example.com --api-key hk_test_...
|
|
17
17
|
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openwop/openwop-conformance",
|
|
3
|
-
"version": "2.34.
|
|
3
|
+
"version": "2.34.2",
|
|
4
4
|
"description": "Production-ready black-box conformance suite for OpenWOP v1.0 compliant servers.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -56,6 +56,6 @@
|
|
|
56
56
|
"@openwop/spec-artifacts": "file:../spec-artifacts"
|
|
57
57
|
},
|
|
58
58
|
"peerDependencies": {
|
|
59
|
-
"@openwop/spec-artifacts": "2.34.
|
|
59
|
+
"@openwop/spec-artifacts": "2.34.2"
|
|
60
60
|
}
|
|
61
61
|
}
|
package/requirements.json
CHANGED
|
@@ -26650,7 +26650,7 @@
|
|
|
26650
26650
|
{
|
|
26651
26651
|
"id": "openwop.it.v2-durability-recovery.accepted-work-survives-a-kill-before-dispatch-and-dispatches-on-resume",
|
|
26652
26652
|
"file": "v2-durability-recovery.test.ts",
|
|
26653
|
-
"line":
|
|
26653
|
+
"line": 292,
|
|
26654
26654
|
"title": "accepted work survives a kill before dispatch and dispatches on resume",
|
|
26655
26655
|
"explicitId": "openwop.requirement.0158.kill-after-accept",
|
|
26656
26656
|
"citations": [
|
|
@@ -26664,7 +26664,7 @@
|
|
|
26664
26664
|
{
|
|
26665
26665
|
"id": "openwop.it.v2-durability-recovery.work-executing-at-a-real-process-death-is-never-reported-complete-and-resumes",
|
|
26666
26666
|
"file": "v2-durability-recovery.test.ts",
|
|
26667
|
-
"line":
|
|
26667
|
+
"line": 346,
|
|
26668
26668
|
"title": "work executing at a real process death is never reported complete, and resumes",
|
|
26669
26669
|
"explicitId": "openwop.requirement.0158.kill-during-execution",
|
|
26670
26670
|
"citations": [
|
|
@@ -26683,7 +26683,7 @@
|
|
|
26683
26683
|
{
|
|
26684
26684
|
"id": "openwop.it.v2-durability-recovery.the-same-accepted-work-delivered-twice-fires-each-effect-exactly-once",
|
|
26685
26685
|
"file": "v2-durability-recovery.test.ts",
|
|
26686
|
-
"line":
|
|
26686
|
+
"line": 402,
|
|
26687
26687
|
"title": "the same accepted work delivered twice fires each effect exactly once",
|
|
26688
26688
|
"explicitId": "openwop.requirement.0158.duplicate-delivery",
|
|
26689
26689
|
"citations": [
|
|
@@ -26702,7 +26702,7 @@
|
|
|
26702
26702
|
{
|
|
26703
26703
|
"id": "openwop.it.v2-durability-recovery.the-declared-recovery-bound-is-derived-from-the-mechanism-that-enforces-it",
|
|
26704
26704
|
"file": "v2-durability-recovery.test.ts",
|
|
26705
|
-
"line":
|
|
26705
|
+
"line": 476,
|
|
26706
26706
|
"title": "the declared recovery bound is derived from the mechanism that enforces it",
|
|
26707
26707
|
"explicitId": "openwop.requirement.0158.bound-is-derived",
|
|
26708
26708
|
"citations": [
|
|
@@ -26716,7 +26716,7 @@
|
|
|
26716
26716
|
{
|
|
26717
26717
|
"id": "openwop.it.v2-durability-recovery.deterministically-failing-work-reaches-a-terminal-state-and-stops-being-retried",
|
|
26718
26718
|
"file": "v2-durability-recovery.test.ts",
|
|
26719
|
-
"line":
|
|
26719
|
+
"line": 507,
|
|
26720
26720
|
"title": "deterministically failing work reaches a terminal state and stops being retried",
|
|
26721
26721
|
"explicitId": "openwop.requirement.0158.poison-exhaustion",
|
|
26722
26722
|
"citations": [
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"_comment": "Provenance of @openwop/spec-artifacts (RFC 0168 §D.2). files: SHA-256 per file; the conformance suite compares the installed peer against dist/spec-artifacts.lock.json at start.",
|
|
3
3
|
"package": "@openwop/spec-artifacts",
|
|
4
|
-
"version": "2.34.
|
|
4
|
+
"version": "2.34.2",
|
|
5
5
|
"corpusTag": "v2.34.0",
|
|
6
6
|
"files": {
|
|
7
7
|
"api/.redocly.lint-ignore.yaml": "bf5a8350b88a72fa43f59605ed8d903ed24b6cfccda5e45509c9f6ed9ee4e712",
|
|
@@ -201,7 +201,7 @@
|
|
|
201
201
|
"schemas/workspace-file.schema.json": "464de85c2a068243084ee9c1d969bc7cd5d8f7948574e58450d6493c38a0e1e4",
|
|
202
202
|
"spec/v1/alias-detectors.json": "40069d5976eeb6ba1384a648e57e5cfd673db3fce36175115fb8293bee9664d4",
|
|
203
203
|
"spec/v1/capability-declaration-classes.json": "e7729aed5c4b4e1dd02abab0530f14cc95f5d4070fe51fb139e7f5cccefa00c6",
|
|
204
|
-
"spec/v1/core-standard-manifest.json": "
|
|
204
|
+
"spec/v1/core-standard-manifest.json": "67e8053f7355380ba2b7a1b3f56dfb761de38565ee9cdd9a86302307cb956736",
|
|
205
205
|
"spec/v1/deprecations.json": "307083ce29c23fd406015951f99a30d78d6187ff061d38dc9732f62191b40f3f",
|
|
206
206
|
"spec/v1/deprecations.schema.json": "18c87e78bedc210431f795ae44c5b5d202f2f3317850d5cf86867d4f1fa1cdfb",
|
|
207
207
|
"spec/v1/event-codemap.json": "3da60d884157793a360da532a9fcbbfb5285636db325a74cec94b34622186d97",
|
|
@@ -282,5 +282,5 @@
|
|
|
282
282
|
"spec/v2/release.json": "eaf652e3a01f02a1d9c4eee7d7ea2c2a0396fe5694b5dce4cb01ca2af2891cc5",
|
|
283
283
|
"spec/v2/retention-floors.json": "eaf3722d95c79947af1d4269ef85117e126518c588cfcf1a2b21b97269f51624"
|
|
284
284
|
},
|
|
285
|
-
"corpusCommit": "
|
|
285
|
+
"corpusCommit": "1a06f6f3c070c043a959f82ed3e91e22d26c658e"
|
|
286
286
|
}
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
* Readers are injected so the ordering itself is testable without a host.
|
|
30
30
|
*/
|
|
31
31
|
export interface Observation { readonly readable: boolean; readonly runStarted: number; readonly nodeStarted: number; readonly restored: number }
|
|
32
|
-
export interface Watch { readonly resumedAfterMs: number | null; readonly last: Observation; readonly completedUnresumed: boolean; readonly waitedMs: number }
|
|
32
|
+
export interface Watch { readonly resumedAfterMs: number | null; readonly last: Observation; readonly completedUnresumed: boolean; readonly waitedMs: number; readonly transportErrors: number }
|
|
33
33
|
export interface WatchIo {
|
|
34
34
|
readStatus(): Promise<string | null>;
|
|
35
35
|
readLog(): Promise<Observation>;
|
|
@@ -37,17 +37,41 @@ export interface WatchIo {
|
|
|
37
37
|
sleep(ms: number): Promise<void>;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
const UNREADABLE: Observation = { readable: false, runStarted: 0, nodeStarted: 0, restored: 0 };
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A TRANSPORT failure is an unreadable observation, never a verdict (2.34.2).
|
|
44
|
+
*
|
|
45
|
+
* The kill rows are the one place the suite causes the host to disappear, and a
|
|
46
|
+
* seam may kill at a point AFTER it has answered - `during-execution` dies at
|
|
47
|
+
* the first `node.started`, which can land seconds later. `waitBack()` already
|
|
48
|
+
* treated a refused connection as "down, keep waiting"; these two reads did
|
|
49
|
+
* not, so a process that died mid-watch threw `fetch failed` out of the
|
|
50
|
+
* scenario and failed the row - for recovering. Measured on a tier-1 host at
|
|
51
|
+
* its production image, behind a port proxy that accepts and then closes: four
|
|
52
|
+
* of five RFC 0158 rows `executed-fail` on `UND_ERR_SOCKET: other side closed`,
|
|
53
|
+
* twice. A status that cannot be read is `null`; a log that cannot be read is
|
|
54
|
+
* `readable: false` - both already mean "conclude nothing, poll again", and the
|
|
55
|
+
* budget still bounds the wait, so a host that never comes back still fails.
|
|
56
|
+
*/
|
|
57
|
+
async function orNull<T>(read: () => Promise<T>, fallback: T): Promise<{ value: T; failed: boolean }> {
|
|
58
|
+
try { return { value: await read(), failed: false }; } catch { return { value: fallback, failed: true }; }
|
|
59
|
+
}
|
|
60
|
+
|
|
40
61
|
export async function watchForResumption(io: WatchIo, budgetMs: number, resumed: (o: Observation) => boolean, pollMs = 500): Promise<Watch> {
|
|
41
62
|
const t0 = io.now();
|
|
42
63
|
let completedUnresumed = false;
|
|
64
|
+
let transportErrors = 0;
|
|
43
65
|
for (;;) {
|
|
44
|
-
const
|
|
45
|
-
const
|
|
66
|
+
const st = await orNull(() => io.readStatus(), null); // FIRST
|
|
67
|
+
const lg = await orNull(() => io.readLog(), UNREADABLE); // SECOND — never older than the status it is judged against
|
|
68
|
+
if (st.failed || lg.failed) transportErrors++;
|
|
69
|
+
const status = st.value; const last = lg.value;
|
|
46
70
|
const waitedMs = io.now() - t0;
|
|
47
71
|
const isResumed = last.readable && resumed(last);
|
|
48
72
|
if (status === 'completed' && last.readable && !isResumed) completedUnresumed = true;
|
|
49
|
-
if (isResumed) return { resumedAfterMs: waitedMs, last, completedUnresumed, waitedMs };
|
|
50
|
-
if (waitedMs >= budgetMs) return { resumedAfterMs: null, last, completedUnresumed, waitedMs };
|
|
73
|
+
if (isResumed) return { resumedAfterMs: waitedMs, last, completedUnresumed, waitedMs, transportErrors };
|
|
74
|
+
if (waitedMs >= budgetMs) return { resumedAfterMs: null, last, completedUnresumed, waitedMs, transportErrors };
|
|
51
75
|
await io.sleep(pollMs);
|
|
52
76
|
}
|
|
53
77
|
}
|
|
@@ -127,6 +127,12 @@ type SeamState =
|
|
|
127
127
|
* would terminate a host that never claimed the rung.
|
|
128
128
|
*/
|
|
129
129
|
async function probeSeam(): Promise<SeamState> {
|
|
130
|
+
// 2.34.2: the rows before this one KILLED the host. A row whose watch ran out
|
|
131
|
+
// its budget can hand over a host that is still restarting, and a bare GET
|
|
132
|
+
// then threw `fetch failed` and failed a row that never touched the kill -
|
|
133
|
+
// four rows of one measured cut were contaminated by the first. Wait for the
|
|
134
|
+
// service to answer at all (immediately, when it is up), then probe.
|
|
135
|
+
await waitBack(scaledTimeoutMs(RESUME_WINDOW_MS));
|
|
130
136
|
const r = await driver.get(KILL_SEAM);
|
|
131
137
|
if (r.status === 404 || r.status === 405) {
|
|
132
138
|
return { kind: 'absent', why: `no RFC 0158 durability seam at ${KILL_SEAM} (HTTP ${r.status}) — §E: the seam advertises nothing and a host that never runs the durability exercises exposes no such route, so this host claims no durable-execution rung` };
|
|
@@ -236,7 +242,10 @@ async function declaredBoundMs(fired: unknown): Promise<{ ms: number; declared:
|
|
|
236
242
|
// interval against a bound that does not govern the staged run.
|
|
237
243
|
const governing = (fired as { recoveryBoundMs?: unknown } | null)?.recoveryBoundMs;
|
|
238
244
|
if (typeof governing === 'number' && Number.isFinite(governing) && governing > 0) return { ms: governing, declared: true, recoveryClass };
|
|
239
|
-
|
|
245
|
+
// A seam may answer and THEN die (during-execution dies at the first
|
|
246
|
+
// node.started), so this read can meet a closed socket: wait the restart out
|
|
247
|
+
// once and read again rather than fail the row on transport (2.34.2).
|
|
248
|
+
const r = await driver.get('/host/durability/bound').catch(async () => { await waitBack(scaledTimeoutMs(RESUME_WINDOW_MS)); return driver.get('/host/durability/bound'); });
|
|
240
249
|
const bound = (r.json as { bound?: unknown } | null)?.bound;
|
|
241
250
|
return r.status === 200 && typeof bound === 'number' && Number.isFinite(bound) && bound > 0
|
|
242
251
|
? { ms: bound, declared: true, recoveryClass }
|
package/src/setup.ts
CHANGED
|
@@ -50,6 +50,17 @@ import { takeNotedEvidence } from './lib/durability-evidence.js';
|
|
|
50
50
|
// vacuous `inapplicable`. Measured 2026-09-05 on a host answering in 200 ms.
|
|
51
51
|
const SUITE_INIT_TIMEOUT_MS = 20_000;
|
|
52
52
|
const SUITE_INIT_ATTEMPTS = 2;
|
|
53
|
+
/**
|
|
54
|
+
* 2.34.2: a TRANSPORT failure (refused, reset, closed) is retried for up to
|
|
55
|
+
* this long, 1 s apart, before discovery is declared unreadable. Two
|
|
56
|
+
* back-to-back attempts gave a host restarted by the RFC 0158 kill rows about
|
|
57
|
+
* a millisecond to come back: every file set up while it was restarting logged
|
|
58
|
+
* "discovery unreadable" and recorded its fixture gates `blocked`, so one kill
|
|
59
|
+
* contaminated the rest of the run. An HTTP answer is not retried here - a host
|
|
60
|
+
* that answers 500 is up and its answer stands. A timeout (the 20 s abort) is
|
|
61
|
+
* not a transport failure either and still counts as an attempt.
|
|
62
|
+
*/
|
|
63
|
+
const SUITE_INIT_RESTART_WINDOW_MS = 45_000;
|
|
53
64
|
|
|
54
65
|
async function loadHostFixtures(): Promise<void> {
|
|
55
66
|
const baseUrl = process.env.OPENWOP_BASE_URL?.trim();
|
|
@@ -67,6 +78,7 @@ async function loadHostFixtures(): Promise<void> {
|
|
|
67
78
|
if (targetMajor() === 2) headers['OpenWOP-Version'] = '2.0';
|
|
68
79
|
|
|
69
80
|
let lastFailure = 'unknown';
|
|
81
|
+
const initStarted = Date.now();
|
|
70
82
|
for (let attempt = 1; attempt <= SUITE_INIT_ATTEMPTS; attempt += 1) {
|
|
71
83
|
const controller = new AbortController();
|
|
72
84
|
const timer = setTimeout(() => controller.abort(), SUITE_INIT_TIMEOUT_MS);
|
|
@@ -82,6 +94,11 @@ async function loadHostFixtures(): Promise<void> {
|
|
|
82
94
|
return;
|
|
83
95
|
} catch (err) {
|
|
84
96
|
lastFailure = `${(err as Error).message ?? 'unknown'} on attempt ${attempt}`;
|
|
97
|
+
const aborted = (err as Error).name === 'AbortError';
|
|
98
|
+
if (!aborted && Date.now() - initStarted < SUITE_INIT_RESTART_WINDOW_MS) {
|
|
99
|
+
attempt -= 1; // a restarting host: wait, and do not spend an attempt on it
|
|
100
|
+
await new Promise((r) => setTimeout(r, 1_000));
|
|
101
|
+
}
|
|
85
102
|
} finally {
|
|
86
103
|
clearTimeout(timer);
|
|
87
104
|
}
|