@specific.dev/spectest 0.38.0 → 0.41.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/dist/components/k3s.js +1 -24
- package/dist/components/supabase.d.ts +87 -27
- package/dist/components/supabase.js +352 -69
- package/dist/daemon.d.ts +38 -0
- package/dist/daemon.js +405 -946
- package/dist/harness/build-context.d.ts +82 -0
- package/dist/harness/build-context.js +113 -0
- package/dist/harness/buildkit-progress.d.ts +37 -0
- package/dist/harness/buildkit-progress.js +66 -0
- package/dist/harness/container-run.d.ts +89 -0
- package/dist/harness/container-run.js +118 -0
- package/dist/harness/file-mounts.d.ts +91 -0
- package/dist/harness/file-mounts.js +119 -0
- package/dist/harness/hostmatch.d.ts +65 -0
- package/dist/harness/hostmatch.js +108 -0
- package/dist/harness/http-proxy.d.ts +62 -0
- package/dist/harness/http-proxy.js +104 -0
- package/dist/harness/ingress-table.d.ts +148 -0
- package/dist/harness/ingress-table.js +129 -0
- package/dist/harness/log-delta.d.ts +54 -0
- package/dist/harness/log-delta.js +83 -0
- package/dist/harness/main.d.ts +47 -0
- package/dist/harness/main.js +164 -0
- package/dist/harness/methods.d.ts +54 -0
- package/dist/harness/methods.js +65 -0
- package/dist/harness/names-registry.d.ts +63 -0
- package/dist/harness/names-registry.js +90 -0
- package/dist/harness/protocol.d.ts +88 -0
- package/dist/harness/protocol.js +96 -0
- package/dist/harness/ready-poll.d.ts +47 -0
- package/dist/harness/ready-poll.js +67 -0
- package/dist/harness/service-graph.d.ts +29 -0
- package/dist/harness/service-graph.js +92 -0
- package/dist/harness/volume-paths.d.ts +70 -0
- package/dist/harness/volume-paths.js +81 -0
- package/dist/index.d.ts +3 -3
- package/dist/ingress.d.ts +1 -1
- package/dist/inspect.d.ts +23 -0
- package/dist/inspect.js +65 -0
- package/dist/resolver.js +5 -8
- package/dist/vendor/rrweb-plugin-console-record.umd.js +521 -0
- package/dist/vendor/rrweb-record.min.js +5061 -0
- package/package.json +7 -1
- package/src/aws-sigv4.ts +218 -0
- package/src/browser.ts +2040 -0
- package/src/components/aws.ts +554 -0
- package/src/components/email.ts +398 -0
- package/src/components/expo.ts +167 -0
- package/src/components/index.ts +81 -0
- package/src/components/k3s.ts +2061 -0
- package/src/components/postgres.ts +132 -0
- package/src/components/replayFake.ts +1015 -0
- package/src/components/s3.ts +132 -0
- package/src/components/supabase.ts +1699 -0
- package/src/daemon.ts +5489 -0
- package/src/harness/build-context.test.ts +0 -0
- package/src/harness/build-context.ts +146 -0
- package/src/harness/buildkit-progress.test.ts +98 -0
- package/src/harness/buildkit-progress.ts +74 -0
- package/src/harness/container-run.test.ts +209 -0
- package/src/harness/container-run.ts +158 -0
- package/src/harness/file-mounts.test.ts +185 -0
- package/src/harness/file-mounts.ts +145 -0
- package/src/harness/hostmatch.test.ts +148 -0
- package/src/harness/hostmatch.ts +109 -0
- package/src/harness/http-proxy.test.ts +156 -0
- package/src/harness/http-proxy.ts +119 -0
- package/src/harness/ingress-rebind.test.ts +125 -0
- package/src/harness/ingress-table.test.ts +172 -0
- package/src/harness/ingress-table.ts +186 -0
- package/src/harness/log-delta.test.ts +125 -0
- package/src/harness/log-delta.ts +100 -0
- package/src/harness/main.test.ts +211 -0
- package/src/harness/main.ts +196 -0
- package/src/harness/methods.test.ts +63 -0
- package/src/harness/methods.ts +92 -0
- package/src/harness/names-registry.test.ts +137 -0
- package/src/harness/names-registry.ts +108 -0
- package/src/harness/protocol.test.ts +148 -0
- package/src/harness/protocol.ts +163 -0
- package/src/harness/ready-poll.test.ts +172 -0
- package/src/harness/ready-poll.ts +93 -0
- package/src/harness/service-graph.test.ts +97 -0
- package/src/harness/service-graph.ts +97 -0
- package/src/harness/volume-paths.test.ts +102 -0
- package/src/harness/volume-paths.ts +112 -0
- package/src/ids.ts +89 -0
- package/src/index.ts +2725 -0
- package/src/ingress.ts +305 -0
- package/src/inspect.ts +739 -0
- package/src/locator.ts +716 -0
- package/src/mobile.ts +133 -0
- package/src/record-secrets.ts +41 -0
- package/src/recorder.ts +846 -0
- package/src/redis.ts +202 -0
- package/src/replay-bundle.ts +108 -0
- package/src/resolver.ts +348 -0
- package/src/s3.ts +333 -0
- package/src/sql.ts +243 -0
- package/src/terminal.ts +740 -0
- package/src/url-match.ts +67 -0
- package/src/vendor/rrweb-plugin-console-record.umd.js +521 -0
- package/src/vendor/rrweb-record.min.js +5061 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The TypeScript half of the protocol's anti-drift contract.
|
|
3
|
+
*
|
|
4
|
+
* `protocols/supervisor-harness/frames.json` is the single definition of the
|
|
5
|
+
* supervisor↔harness messages. This suite and
|
|
6
|
+
* `crates/spectest-vm-agent/src/protocol.rs`'s tests both parse it, so a
|
|
7
|
+
* field added on one side and forgotten on the other fails here rather
|
|
8
|
+
* than in a VM — the failure mode the `config.rs` ↔ `sdk/src/index.ts`
|
|
9
|
+
* mirrored pair had no defence against.
|
|
10
|
+
*
|
|
11
|
+
* Run with `bun test` from `sdk/`.
|
|
12
|
+
*/
|
|
13
|
+
import { describe, expect, test } from "bun:test";
|
|
14
|
+
import { readFileSync } from "node:fs";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
KNOWN_METHODS,
|
|
19
|
+
PROTOCOL_VERSION,
|
|
20
|
+
decodeFrame,
|
|
21
|
+
encodeFrame,
|
|
22
|
+
fail,
|
|
23
|
+
ok,
|
|
24
|
+
type Frame,
|
|
25
|
+
type Hello,
|
|
26
|
+
} from "./protocol";
|
|
27
|
+
|
|
28
|
+
const FIXTURES = join(import.meta.dir, "../../../protocols/supervisor-harness/frames.json");
|
|
29
|
+
|
|
30
|
+
function fixtures(): { name: string; frame: unknown }[] {
|
|
31
|
+
const doc = JSON.parse(readFileSync(FIXTURES, "utf8"));
|
|
32
|
+
return doc.frames;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe("shared fixtures", () => {
|
|
36
|
+
test("the fixture file is reachable and non-empty", () => {
|
|
37
|
+
expect(fixtures().length).toBeGreaterThan(0);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("every shared fixture decodes", () => {
|
|
41
|
+
for (const { name, frame } of fixtures()) {
|
|
42
|
+
const line = JSON.stringify(frame);
|
|
43
|
+
expect(() => decodeFrame(line), `fixture ${name}`).not.toThrow();
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("every fixture round-trips through encode", () => {
|
|
48
|
+
for (const { name, frame } of fixtures()) {
|
|
49
|
+
const decoded = decodeFrame(JSON.stringify(frame));
|
|
50
|
+
const line = encodeFrame(decoded);
|
|
51
|
+
expect(line.endsWith("\n"), `fixture ${name} must be newline-terminated`).toBe(true);
|
|
52
|
+
expect(line.trimEnd().includes("\n"), `fixture ${name} must be one line`).toBe(false);
|
|
53
|
+
expect(() => decodeFrame(line.trimEnd()), `fixture ${name} re-decode`).not.toThrow();
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
/** Catches the drift this file exists to prevent: a method used in the
|
|
58
|
+
* fixtures that this build never learned about. */
|
|
59
|
+
test("every method used by a request fixture is known here", () => {
|
|
60
|
+
for (const { name, frame } of fixtures()) {
|
|
61
|
+
const f = frame as { kind: string; method?: string };
|
|
62
|
+
if (f.kind !== "request" && f.kind !== "event") continue;
|
|
63
|
+
if (name === "unknown-method-is-parseable") continue; // deliberately unknown
|
|
64
|
+
expect(KNOWN_METHODS.has(f.method!), `fixture ${name} uses unknown method ${f.method}`).toBe(
|
|
65
|
+
true,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("the handshake fixture matches this build's version", () => {
|
|
71
|
+
const hello = fixtures().find((f) => f.name === "hello-request")!;
|
|
72
|
+
const params = (hello.frame as { params: Hello }).params;
|
|
73
|
+
expect(params.protocolVersion).toBe(PROTOCOL_VERSION);
|
|
74
|
+
// The capability slot must exist even when empty — a release that
|
|
75
|
+
// announces nothing can only ever be treated as "oldest".
|
|
76
|
+
expect(Array.isArray(params.capabilities)).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe("forward compatibility", () => {
|
|
81
|
+
/** A newer peer's extra fields must survive, not be rejected or stripped:
|
|
82
|
+
* a frame this build forwards should not lose data it didn't understand. */
|
|
83
|
+
test("unknown fields are ignored and preserved", () => {
|
|
84
|
+
const line = JSON.stringify({
|
|
85
|
+
kind: "request",
|
|
86
|
+
id: "1",
|
|
87
|
+
method: "run",
|
|
88
|
+
params: { caseId: "a", laterField: { x: 1 } },
|
|
89
|
+
alsoLater: 42,
|
|
90
|
+
});
|
|
91
|
+
const frame = decodeFrame(line) as Frame & { alsoLater?: number };
|
|
92
|
+
expect(frame.kind).toBe("request");
|
|
93
|
+
expect(frame.alsoLater).toBe(42);
|
|
94
|
+
expect((frame as { params: Record<string, unknown> }).params.laterField).toEqual({ x: 1 });
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
/** Parsing and dispatch are separate concerns. Dropping the frame would
|
|
98
|
+
* hang the sender forever. */
|
|
99
|
+
test("an unknown method parses so it can be answered with an error", () => {
|
|
100
|
+
const frame = decodeFrame(
|
|
101
|
+
JSON.stringify({ kind: "request", id: "11", method: "somethingInventedLater", params: {} }),
|
|
102
|
+
);
|
|
103
|
+
expect(frame.kind).toBe("request");
|
|
104
|
+
const req = frame as { id: string; method: string };
|
|
105
|
+
expect(req.id).toBe("11");
|
|
106
|
+
expect(KNOWN_METHODS.has(req.method)).toBe(false);
|
|
107
|
+
// …and the id survives, which is what makes the error correlatable.
|
|
108
|
+
expect(fail(req.id, "unsupported method", "unknown_method").id).toBe("11");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("hello parses with capabilities absent (an older peer)", () => {
|
|
112
|
+
const frame = decodeFrame(
|
|
113
|
+
JSON.stringify({ kind: "request", id: "1", method: "hello", params: { protocolVersion: 1 } }),
|
|
114
|
+
) as unknown as { params: Hello };
|
|
115
|
+
expect(frame.params.protocolVersion).toBe(1);
|
|
116
|
+
expect(frame.params.capabilities).toBeUndefined();
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe("routing failures", () => {
|
|
121
|
+
test.each([
|
|
122
|
+
["malformed JSON", "{not json"],
|
|
123
|
+
["not an object", '"a string"'],
|
|
124
|
+
["unknown frame kind", '{"kind":"telepathy"}'],
|
|
125
|
+
["request with no id", '{"kind":"request","method":"run"}'],
|
|
126
|
+
["request with no method", '{"kind":"request","id":"1"}'],
|
|
127
|
+
["response with no ok", '{"kind":"response","id":"1"}'],
|
|
128
|
+
["event with no method", '{"kind":"event"}'],
|
|
129
|
+
])("%s is rejected", (_name, line) => {
|
|
130
|
+
expect(() => decodeFrame(line)).toThrow();
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("replies", () => {
|
|
135
|
+
test("ok carries the id and result", () => {
|
|
136
|
+
expect(ok("7", { a: 1 })).toEqual({ kind: "response", id: "7", ok: true, result: { a: 1 } });
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("fail always carries the id, so a caller is never left waiting", () => {
|
|
140
|
+
expect(fail("7", "boom")).toEqual({
|
|
141
|
+
kind: "response",
|
|
142
|
+
id: "7",
|
|
143
|
+
ok: false,
|
|
144
|
+
error: { message: "boom" },
|
|
145
|
+
});
|
|
146
|
+
expect(fail("7", "boom", "c")!.error!.code).toBe("c");
|
|
147
|
+
});
|
|
148
|
+
});
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The supervisor↔harness protocol — TypeScript half.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors `crates/spectest-vm-agent/src/protocol.rs`. Read that file's
|
|
5
|
+
* header for the design; the rules that matter when editing this one:
|
|
6
|
+
*
|
|
7
|
+
* - `hello` carries a protocol version AND a capability list in both
|
|
8
|
+
* directions, from the first release. A release that announces nothing
|
|
9
|
+
* can only ever be treated as "oldest".
|
|
10
|
+
* - **Unknown fields are ignored in both directions.** Nothing here
|
|
11
|
+
* validates against a closed field set, deliberately — that is the
|
|
12
|
+
* mistake `config.rs` made, where a newer SDK's config was rejected
|
|
13
|
+
* outright by an older server.
|
|
14
|
+
* - A message never changes meaning. It only gains optional fields.
|
|
15
|
+
* - An unrecognised *method* must still parse, so the receiver can answer
|
|
16
|
+
* with an error instead of dropping the frame and hanging the sender.
|
|
17
|
+
*
|
|
18
|
+
* Anti-drift: the messages are defined once in
|
|
19
|
+
* `protocols/supervisor-harness/frames.json`, and both this module's test suite and
|
|
20
|
+
* the Rust one parse that file. A field added on one side and forgotten on
|
|
21
|
+
* the other fails there rather than in a VM.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** Protocol version this build speaks. Must match `PROTOCOL_VERSION` in protocol.rs. */
|
|
25
|
+
export const PROTOCOL_VERSION = 1;
|
|
26
|
+
|
|
27
|
+
/** Methods either side can send. */
|
|
28
|
+
export type Method =
|
|
29
|
+
// down: supervisor → harness
|
|
30
|
+
| "hello"
|
|
31
|
+
| "load"
|
|
32
|
+
| "loadTests"
|
|
33
|
+
| "fingerprint"
|
|
34
|
+
| "bootstrap"
|
|
35
|
+
| "run"
|
|
36
|
+
| "eval"
|
|
37
|
+
| "teardown"
|
|
38
|
+
| "shutdown"
|
|
39
|
+
// up: harness → supervisor
|
|
40
|
+
| "createArtifact"
|
|
41
|
+
| "completeArtifact"
|
|
42
|
+
| "progress"
|
|
43
|
+
| "step";
|
|
44
|
+
|
|
45
|
+
/** Methods this build knows how to dispatch. A method outside this set still
|
|
46
|
+
* *parses* — it is answered with an error, never dropped. */
|
|
47
|
+
export const KNOWN_METHODS: ReadonlySet<string> = new Set<Method>([
|
|
48
|
+
"hello",
|
|
49
|
+
"load",
|
|
50
|
+
"loadTests",
|
|
51
|
+
"fingerprint",
|
|
52
|
+
"bootstrap",
|
|
53
|
+
"run",
|
|
54
|
+
"eval",
|
|
55
|
+
"teardown",
|
|
56
|
+
"shutdown",
|
|
57
|
+
"createArtifact",
|
|
58
|
+
"completeArtifact",
|
|
59
|
+
"progress",
|
|
60
|
+
"step",
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
export interface ProtocolError {
|
|
64
|
+
message: string;
|
|
65
|
+
code?: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface RequestFrame {
|
|
69
|
+
kind: "request";
|
|
70
|
+
/** Correlates the response. */
|
|
71
|
+
id: string;
|
|
72
|
+
/** Typed as `string`, not `Method`: a newer peer's method must parse. */
|
|
73
|
+
method: string;
|
|
74
|
+
params?: Record<string, unknown>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface ResponseFrame {
|
|
78
|
+
kind: "response";
|
|
79
|
+
id: string;
|
|
80
|
+
ok: boolean;
|
|
81
|
+
result?: Record<string, unknown>;
|
|
82
|
+
error?: ProtocolError;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Fire-and-forget. No id, no reply — progress that blocked on an ack would
|
|
86
|
+
* make the UI a correctness dependency. */
|
|
87
|
+
export interface EventFrame {
|
|
88
|
+
kind: "event";
|
|
89
|
+
method: string;
|
|
90
|
+
params?: Record<string, unknown>;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export type Frame = RequestFrame | ResponseFrame | EventFrame;
|
|
94
|
+
|
|
95
|
+
/** `hello` payload, sent in both directions. `sdkVersion` is on the
|
|
96
|
+
* harness's reply only. */
|
|
97
|
+
export interface Hello {
|
|
98
|
+
protocolVersion: number;
|
|
99
|
+
capabilities?: string[];
|
|
100
|
+
sdkVersion?: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** One contribution to the L2 declared cache key. The supervisor hashes
|
|
104
|
+
* these without interpreting them — a fingerprint it could interpret
|
|
105
|
+
* would just be the schema coupling again. */
|
|
106
|
+
export interface Contribution {
|
|
107
|
+
id: string;
|
|
108
|
+
kind: string;
|
|
109
|
+
key: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Parse one NDJSON line.
|
|
114
|
+
*
|
|
115
|
+
* Throws only on frames that cannot be *routed* — malformed JSON, or a
|
|
116
|
+
* missing `kind`/`id`/`method`. An unknown method is not such a case: it
|
|
117
|
+
* parses, and the dispatcher answers with an error. Unknown *fields* are
|
|
118
|
+
* left untouched on the returned object rather than stripped, so a value
|
|
119
|
+
* this build ignores still round-trips if forwarded.
|
|
120
|
+
*/
|
|
121
|
+
export function decodeFrame(line: string): Frame {
|
|
122
|
+
let raw: unknown;
|
|
123
|
+
try {
|
|
124
|
+
raw = JSON.parse(line);
|
|
125
|
+
} catch (e) {
|
|
126
|
+
throw new Error(`harness protocol: malformed JSON frame: ${(e as Error).message}`);
|
|
127
|
+
}
|
|
128
|
+
if (typeof raw !== "object" || raw === null) {
|
|
129
|
+
throw new Error("harness protocol: frame is not an object");
|
|
130
|
+
}
|
|
131
|
+
const f = raw as Record<string, unknown>;
|
|
132
|
+
switch (f.kind) {
|
|
133
|
+
case "request":
|
|
134
|
+
if (typeof f.id !== "string") throw new Error("harness protocol: request has no id");
|
|
135
|
+
if (typeof f.method !== "string") throw new Error("harness protocol: request has no method");
|
|
136
|
+
return f as unknown as RequestFrame;
|
|
137
|
+
case "response":
|
|
138
|
+
if (typeof f.id !== "string") throw new Error("harness protocol: response has no id");
|
|
139
|
+
if (typeof f.ok !== "boolean") throw new Error("harness protocol: response has no ok");
|
|
140
|
+
return f as unknown as ResponseFrame;
|
|
141
|
+
case "event":
|
|
142
|
+
if (typeof f.method !== "string") throw new Error("harness protocol: event has no method");
|
|
143
|
+
return f as unknown as EventFrame;
|
|
144
|
+
default:
|
|
145
|
+
throw new Error(`harness protocol: unknown frame kind ${JSON.stringify(f.kind)}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Encode one frame as a single NDJSON line, newline included. */
|
|
150
|
+
export function encodeFrame(frame: Frame): string {
|
|
151
|
+
return `${JSON.stringify(frame)}\n`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** A successful reply to `id`. */
|
|
155
|
+
export function ok(id: string, result: Record<string, unknown> = {}): ResponseFrame {
|
|
156
|
+
return { kind: "response", id, ok: true, result };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** A failed reply to `id`. Always carries the id: a request that failed must
|
|
160
|
+
* never leave its caller waiting. */
|
|
161
|
+
export function fail(id: string, message: string, code?: string): ResponseFrame {
|
|
162
|
+
return { kind: "response", id, ok: false, error: code ? { message, code } : { message } };
|
|
163
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
RAMP_DEFAULT,
|
|
5
|
+
RAMP_EXEC,
|
|
6
|
+
delayForAttempt,
|
|
7
|
+
pollUntilReady,
|
|
8
|
+
rampFor,
|
|
9
|
+
} from "./ready-poll";
|
|
10
|
+
|
|
11
|
+
/** A controllable clock, so timing rules are asserted rather than waited on. */
|
|
12
|
+
function fakeClock() {
|
|
13
|
+
let t = 1_000;
|
|
14
|
+
return {
|
|
15
|
+
now: () => t,
|
|
16
|
+
sleep: async (ms: number) => {
|
|
17
|
+
t += ms;
|
|
18
|
+
},
|
|
19
|
+
advance: (ms: number) => {
|
|
20
|
+
t += ms;
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("ramp selection", () => {
|
|
26
|
+
test("exec probes start slower, because each attempt spawns a docker exec", () => {
|
|
27
|
+
expect(rampFor("exec")).toEqual(RAMP_EXEC);
|
|
28
|
+
expect(rampFor("tcp")).toEqual(RAMP_DEFAULT);
|
|
29
|
+
expect(rampFor("http")).toEqual(RAMP_DEFAULT);
|
|
30
|
+
expect(RAMP_EXEC[0]).toBeGreaterThan(RAMP_DEFAULT[0]!);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
/** The first probes are fast so a service that is ready in 60ms is
|
|
34
|
+
* reported at ~60ms rather than rounded up to a flat interval —
|
|
35
|
+
* an error that compounds down a dependsOn chain. */
|
|
36
|
+
test("the ramp starts small and increases", () => {
|
|
37
|
+
for (let i = 1; i < RAMP_DEFAULT.length; i++) {
|
|
38
|
+
expect(RAMP_DEFAULT[i]!).toBeGreaterThanOrEqual(RAMP_DEFAULT[i - 1]!);
|
|
39
|
+
}
|
|
40
|
+
expect(RAMP_DEFAULT[0]).toBeLessThanOrEqual(50);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("the last delay repeats forever rather than running off the end", () => {
|
|
44
|
+
const last = RAMP_DEFAULT[RAMP_DEFAULT.length - 1]!;
|
|
45
|
+
expect(delayForAttempt(RAMP_DEFAULT, 99)).toBe(last);
|
|
46
|
+
expect(delayForAttempt(RAMP_DEFAULT, 0)).toBe(RAMP_DEFAULT[0]);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("pollUntilReady", () => {
|
|
51
|
+
test("a service already up is reported on the first attempt", async () => {
|
|
52
|
+
const clock = fakeClock();
|
|
53
|
+
const out = await pollUntilReady(async () => true, { kind: "tcp", ...clock });
|
|
54
|
+
expect(out).toMatchObject({ ready: true, attempts: 1 });
|
|
55
|
+
expect(out.elapsedMs).toBe(0);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("retries until the probe succeeds", async () => {
|
|
59
|
+
const clock = fakeClock();
|
|
60
|
+
let n = 0;
|
|
61
|
+
const out = await pollUntilReady(async () => ++n >= 4, { kind: "tcp", ...clock });
|
|
62
|
+
expect(out.ready).toBe(true);
|
|
63
|
+
expect(out.attempts).toBe(4);
|
|
64
|
+
// 50 + 100 + 150 of ramp delay before the 4th attempt.
|
|
65
|
+
expect(out.elapsedMs).toBe(300);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("gives up at the deadline", async () => {
|
|
69
|
+
const clock = fakeClock();
|
|
70
|
+
const out = await pollUntilReady(async () => false, {
|
|
71
|
+
kind: "tcp",
|
|
72
|
+
timeoutSecs: 1,
|
|
73
|
+
...clock,
|
|
74
|
+
});
|
|
75
|
+
expect(out.ready).toBe(false);
|
|
76
|
+
expect(out.elapsedMs).toBeLessThanOrEqual(1000);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
/** A deadline check before the first probe would make timeoutSecs: 0
|
|
80
|
+
* mean "never check" — which reads as "no timeout" to whoever wrote it,
|
|
81
|
+
* and would fail a service that was ready all along. */
|
|
82
|
+
test("always probes at least once, even with a zero timeout", async () => {
|
|
83
|
+
const clock = fakeClock();
|
|
84
|
+
let probed = 0;
|
|
85
|
+
const out = await pollUntilReady(
|
|
86
|
+
async () => {
|
|
87
|
+
probed++;
|
|
88
|
+
return true;
|
|
89
|
+
},
|
|
90
|
+
{ kind: "tcp", timeoutSecs: 0, ...clock },
|
|
91
|
+
);
|
|
92
|
+
expect(probed).toBe(1);
|
|
93
|
+
expect(out.ready).toBe(true);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("a negative timeout still probes once and then gives up", async () => {
|
|
97
|
+
const clock = fakeClock();
|
|
98
|
+
let probed = 0;
|
|
99
|
+
const out = await pollUntilReady(
|
|
100
|
+
async () => {
|
|
101
|
+
probed++;
|
|
102
|
+
return false;
|
|
103
|
+
},
|
|
104
|
+
{ kind: "tcp", timeoutSecs: -5, ...clock },
|
|
105
|
+
);
|
|
106
|
+
expect(probed).toBe(1);
|
|
107
|
+
expect(out.ready).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
/** Two properties at once: it must not sleep PAST the deadline, and it
|
|
111
|
+
* must not stop probing BEFORE it. An earlier version gave up as soon
|
|
112
|
+
* as now+delay would overshoot, which abandoned up to a full ramp step
|
|
113
|
+
* (500ms) of the timeout the caller asked for — fine when everything is
|
|
114
|
+
* fast, and exactly wrong under load, when readiness is marginal. */
|
|
115
|
+
test("keeps probing right up to the deadline without overshooting it", async () => {
|
|
116
|
+
const clock = fakeClock();
|
|
117
|
+
let probes = 0;
|
|
118
|
+
const out = await pollUntilReady(
|
|
119
|
+
async () => {
|
|
120
|
+
probes++;
|
|
121
|
+
return false;
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
kind: "tcp",
|
|
125
|
+
timeoutSecs: 0.2,
|
|
126
|
+
now: clock.now,
|
|
127
|
+
sleep: async (ms) => clock.advance(ms),
|
|
128
|
+
},
|
|
129
|
+
);
|
|
130
|
+
expect(out.ready).toBe(false);
|
|
131
|
+
// 200ms with a 50,100,150,… ramp: the clamped final sleep must let it
|
|
132
|
+
// use the whole budget rather than stopping at 150ms.
|
|
133
|
+
expect(out.elapsedMs).toBe(200);
|
|
134
|
+
expect(probes).toBeGreaterThanOrEqual(3);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("the total time slept never exceeds the timeout", async () => {
|
|
138
|
+
const clock = fakeClock();
|
|
139
|
+
const slept: number[] = [];
|
|
140
|
+
const out = await pollUntilReady(async () => false, {
|
|
141
|
+
kind: "tcp",
|
|
142
|
+
timeoutSecs: 0.2, // 200ms: ramp is 50,100,150,… so the third would overshoot
|
|
143
|
+
now: clock.now,
|
|
144
|
+
sleep: async (ms) => {
|
|
145
|
+
slept.push(ms);
|
|
146
|
+
clock.advance(ms);
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
expect(out.ready).toBe(false);
|
|
150
|
+
const total = slept.reduce((a, b) => a + b, 0);
|
|
151
|
+
// `<= 200`, not `< 200`: using the full budget is correct. The earlier
|
|
152
|
+
// assertion encoded the very bug it should have caught — stopping
|
|
153
|
+
// short of the timeout the caller asked for.
|
|
154
|
+
expect(total).toBeLessThanOrEqual(200);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("a probe that throws propagates rather than being read as not-ready", async () => {
|
|
158
|
+
const clock = fakeClock();
|
|
159
|
+
await expect(
|
|
160
|
+
pollUntilReady(async () => {
|
|
161
|
+
throw new Error("docker gone");
|
|
162
|
+
}, { kind: "exec", ...clock }),
|
|
163
|
+
).rejects.toThrow("docker gone");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("exec probes use the slower ramp", async () => {
|
|
167
|
+
const clock = fakeClock();
|
|
168
|
+
let n = 0;
|
|
169
|
+
const out = await pollUntilReady(async () => ++n >= 2, { kind: "exec", ...clock });
|
|
170
|
+
expect(out.elapsedMs).toBe(RAMP_EXEC[0]);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The polling schedule behind a readiness probe.
|
|
3
|
+
*
|
|
4
|
+
* Ported out of `daemon.ts`, separated from the probes themselves so the
|
|
5
|
+
* timing rules can be tested without opening a socket or spawning a
|
|
6
|
+
* `docker exec`.
|
|
7
|
+
*
|
|
8
|
+
* Why a ramp rather than a fixed interval: a flat 500 ms delay quantises
|
|
9
|
+
* *every* service's measured ready latency to a multiple of 500 ms, and
|
|
10
|
+
* that error compounds down a `dependsOn` chain — a four-deep chain of
|
|
11
|
+
* services that are each genuinely ready in 60 ms reports two seconds.
|
|
12
|
+
* Probing fast at first catches quick services honestly; ramping up caps
|
|
13
|
+
* the polling load on genuinely slow ones.
|
|
14
|
+
*
|
|
15
|
+
* Exec probes keep a higher floor because each attempt spawns a
|
|
16
|
+
* `docker exec`, which costs far more than a TCP connect.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export type ProbeKind = "tcp" | "http" | "exec";
|
|
20
|
+
|
|
21
|
+
/** Delays between attempts, in ms. The last entry repeats forever. */
|
|
22
|
+
export const RAMP_DEFAULT: readonly number[] = [50, 100, 150, 250, 400, 500];
|
|
23
|
+
export const RAMP_EXEC: readonly number[] = [250, 250, 400, 400, 500];
|
|
24
|
+
|
|
25
|
+
export const DEFAULT_TIMEOUT_SECS = 60;
|
|
26
|
+
|
|
27
|
+
export function rampFor(kind: ProbeKind): readonly number[] {
|
|
28
|
+
return kind === "exec" ? RAMP_EXEC : RAMP_DEFAULT;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Delay before attempt number `attempt` (0-based), clamped to the ramp's tail. */
|
|
32
|
+
export function delayForAttempt(ramp: readonly number[], attempt: number): number {
|
|
33
|
+
return ramp[Math.min(attempt, ramp.length - 1)]!;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface PollOptions {
|
|
37
|
+
kind: ProbeKind;
|
|
38
|
+
timeoutSecs?: number;
|
|
39
|
+
/** Injectable for tests; defaults to the wall clock. */
|
|
40
|
+
now?: () => number;
|
|
41
|
+
sleep?: (ms: number) => Promise<void>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface PollResult {
|
|
45
|
+
ready: boolean;
|
|
46
|
+
attempts: number;
|
|
47
|
+
elapsedMs: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Poll `probe` until it returns true or the deadline passes.
|
|
52
|
+
*
|
|
53
|
+
* The probe is **always attempted at least once**, even with a zero or
|
|
54
|
+
* negative timeout. A deadline check placed before the first attempt
|
|
55
|
+
* would make `timeoutSecs: 0` mean "never check", which reads as "no
|
|
56
|
+
* timeout" to anyone writing it — and would fail a service that was
|
|
57
|
+
* ready all along.
|
|
58
|
+
*/
|
|
59
|
+
export async function pollUntilReady(
|
|
60
|
+
probe: () => Promise<boolean>,
|
|
61
|
+
opts: PollOptions,
|
|
62
|
+
): Promise<PollResult> {
|
|
63
|
+
const now = opts.now ?? Date.now;
|
|
64
|
+
const sleep = opts.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
|
|
65
|
+
const timeoutSecs = opts.timeoutSecs ?? DEFAULT_TIMEOUT_SECS;
|
|
66
|
+
const ramp = rampFor(opts.kind);
|
|
67
|
+
|
|
68
|
+
const started = now();
|
|
69
|
+
const deadline = started + timeoutSecs * 1000;
|
|
70
|
+
let attempts = 0;
|
|
71
|
+
|
|
72
|
+
for (;;) {
|
|
73
|
+
attempts++;
|
|
74
|
+
if (await probe()) {
|
|
75
|
+
return { ready: true, attempts, elapsedMs: now() - started };
|
|
76
|
+
}
|
|
77
|
+
const remaining = deadline - now();
|
|
78
|
+
if (remaining <= 0) {
|
|
79
|
+
return { ready: false, attempts, elapsedMs: now() - started };
|
|
80
|
+
}
|
|
81
|
+
// Clamp rather than give up early. An earlier version returned as soon
|
|
82
|
+
// as `now + delay` passed the deadline, which stopped probing up to a
|
|
83
|
+
// full ramp step (500 ms) BEFORE the timeout the user asked for — so a
|
|
84
|
+
// service that became ready in that final window was reported as
|
|
85
|
+
// failed. Harmless when everything is fast, and exactly wrong under
|
|
86
|
+
// load, which is when readiness is marginal in the first place.
|
|
87
|
+
//
|
|
88
|
+
// Clamping keeps the original "probe until the deadline" behaviour and
|
|
89
|
+
// still never sleeps past it.
|
|
90
|
+
const delay = Math.min(delayForAttempt(ramp, attempts - 1), remaining);
|
|
91
|
+
await sleep(delay);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { topologicalOrder, validateServiceGraph } from "./service-graph";
|
|
4
|
+
|
|
5
|
+
const svc = (name: string, dependsOn?: string[]) => ({ name, dependsOn });
|
|
6
|
+
|
|
7
|
+
describe("validateServiceGraph", () => {
|
|
8
|
+
test("returns a name→service map for a clean graph", () => {
|
|
9
|
+
const map = validateServiceGraph([svc("db"), svc("api", ["db"])]);
|
|
10
|
+
expect(map.get("api")!.dependsOn).toEqual(["db"]);
|
|
11
|
+
expect(map.size).toBe(2);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("a dependency on a service that doesn't exist names both sides", () => {
|
|
15
|
+
expect(() => validateServiceGraph([svc("api", ["db"])])).toThrow(
|
|
16
|
+
/api depends on unknown service db/,
|
|
17
|
+
);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("a service depending on itself is rejected", () => {
|
|
21
|
+
expect(() => validateServiceGraph([svc("api", ["api"])])).toThrow(/depends on itself/);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("a cycle is rejected", () => {
|
|
25
|
+
expect(() => validateServiceGraph([svc("a", ["b"]), svc("b", ["a"])])).toThrow(/cycle/);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
/** The old message was just "service dependency cycle", leaving the user
|
|
29
|
+
* to find it by eye in a large services map. */
|
|
30
|
+
test("the cycle error names the loop", () => {
|
|
31
|
+
let msg = "";
|
|
32
|
+
try {
|
|
33
|
+
validateServiceGraph([svc("a", ["b"]), svc("b", ["c"]), svc("c", ["a"])]);
|
|
34
|
+
} catch (e) {
|
|
35
|
+
msg = (e as Error).message;
|
|
36
|
+
}
|
|
37
|
+
expect(msg).toContain("a");
|
|
38
|
+
expect(msg).toContain("b");
|
|
39
|
+
expect(msg).toContain("c");
|
|
40
|
+
expect(msg).toContain("→");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
/** Previously the map was built without checking, so a repeated name
|
|
44
|
+
* silently replaced the earlier definition and the graph lied about
|
|
45
|
+
* what was running. */
|
|
46
|
+
test("a duplicate service name is rejected rather than shadowing", () => {
|
|
47
|
+
expect(() => validateServiceGraph([svc("api"), svc("api")])).toThrow(/duplicate service name/);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("a diamond is fine — shared dependencies are not cycles", () => {
|
|
51
|
+
const graph = [svc("db"), svc("a", ["db"]), svc("b", ["db"]), svc("web", ["a", "b"])];
|
|
52
|
+
expect(() => validateServiceGraph(graph)).not.toThrow();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("an empty graph is valid", () => {
|
|
56
|
+
expect(validateServiceGraph([]).size).toBe(0);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("two independent components are both walked", () => {
|
|
60
|
+
// A cycle in the second component must still be found, i.e. the walk
|
|
61
|
+
// does not stop after the first root.
|
|
62
|
+
expect(() =>
|
|
63
|
+
validateServiceGraph([svc("solo"), svc("a", ["b"]), svc("b", ["a"])]),
|
|
64
|
+
).toThrow(/cycle/);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("topologicalOrder", () => {
|
|
69
|
+
test("every service follows its dependencies", () => {
|
|
70
|
+
const order = topologicalOrder([
|
|
71
|
+
svc("web", ["api"]),
|
|
72
|
+
svc("api", ["db"]),
|
|
73
|
+
svc("db"),
|
|
74
|
+
]).map((s) => s.name);
|
|
75
|
+
expect(order.indexOf("db")).toBeLessThan(order.indexOf("api"));
|
|
76
|
+
expect(order.indexOf("api")).toBeLessThan(order.indexOf("web"));
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("a shared dependency appears once", () => {
|
|
80
|
+
const order = topologicalOrder([
|
|
81
|
+
svc("a", ["db"]),
|
|
82
|
+
svc("b", ["db"]),
|
|
83
|
+
svc("db"),
|
|
84
|
+
]).map((s) => s.name);
|
|
85
|
+
expect(order.filter((n) => n === "db")).toHaveLength(1);
|
|
86
|
+
expect(order).toHaveLength(3);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("independent services keep declaration order, so the result is stable", () => {
|
|
90
|
+
const order = topologicalOrder([svc("z"), svc("y"), svc("x")]).map((s) => s.name);
|
|
91
|
+
expect(order).toEqual(["z", "y", "x"]);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("it validates too — a cycle throws rather than looping forever", () => {
|
|
95
|
+
expect(() => topologicalOrder([svc("a", ["b"]), svc("b", ["a"])])).toThrow(/cycle/);
|
|
96
|
+
});
|
|
97
|
+
});
|