@aikoql/client 0.0.0-stage → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -2
- package/package.json +30 -4
- package/src/client.ts +500 -0
- package/src/error.ts +92 -0
- package/src/index.ts +20 -0
- package/src/pool.ts +294 -0
- package/src/sdk-conformance.ts +496 -0
- package/src/tcp.ts +135 -0
- package/src/tools.ts +72 -0
- package/src/tx.ts +83 -0
- package/tests/conformance.test.ts +60 -0
- package/tests/corpus-pin.test.ts +96 -0
- package/tests/fault-matrix.test.ts +298 -0
- package/tests/fuzz-estate-pin.test.ts +32 -0
- package/tests/fuzz-estate.test.ts +514 -0
- package/tests/helpers.ts +69 -0
- package/tests/identity.test.ts +26 -0
- package/tests/pool.test.ts +278 -0
- package/tests/wire.test.ts +191 -0
- package/tsconfig.json +16 -0
package/src/tx.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Tx implements the §3.5 transaction handle: the txn_id is a first-class
|
|
2
|
+
// field here — it never leaks as a bare tool argument that a caller
|
|
3
|
+
// threads between call sites. The four txn_* tools are reachable only
|
|
4
|
+
// through Tx methods. Mirrors the Go SDK's transactions.go and the Rust
|
|
5
|
+
// reference (crates/sdk/rust/src/tx.rs).
|
|
6
|
+
|
|
7
|
+
import type { Client } from "./client.ts";
|
|
8
|
+
import { McpError } from "./error.ts";
|
|
9
|
+
|
|
10
|
+
/** One staged write (the txn_stage op shape). */
|
|
11
|
+
export interface StagedOp {
|
|
12
|
+
action: string;
|
|
13
|
+
type_name?: string;
|
|
14
|
+
koid?: string;
|
|
15
|
+
properties?: Record<string, unknown>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The txn_commit outcome: the staged write results and whether this
|
|
19
|
+
* commit was a retry of an already-committed txn_id. */
|
|
20
|
+
export interface CommitResult {
|
|
21
|
+
results?: unknown[];
|
|
22
|
+
deduped?: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A staged write handle: begin on the Client, execute stages ops, commit
|
|
27
|
+
* or rollback closes it. A closed handle refuses further use with
|
|
28
|
+
* INVALID_ARGUMENT.
|
|
29
|
+
*/
|
|
30
|
+
export class Tx {
|
|
31
|
+
private c: Client;
|
|
32
|
+
private txId: string;
|
|
33
|
+
private closed = false;
|
|
34
|
+
|
|
35
|
+
constructor(c: Client, txnId: string) {
|
|
36
|
+
this.c = c;
|
|
37
|
+
this.txId = txnId;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The txn_id (for retries — commit dedupes by it, P5-M20). */
|
|
41
|
+
id(): string {
|
|
42
|
+
return this.txId;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** True once commit or rollback closed the handle (the pool's session
|
|
46
|
+
* reset checks this). */
|
|
47
|
+
done(): boolean {
|
|
48
|
+
return this.closed;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Stages one write. */
|
|
52
|
+
async execute(op: StagedOp, opts: { signal?: AbortSignal } = {}): Promise<void> {
|
|
53
|
+
await this.step("txn_stage", { txn_id: this.txId, op }, opts);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Applies the staged writes and closes the handle. A commit that errors
|
|
57
|
+
* leaves the handle open: the server dedupes by txn_id, so the caller
|
|
58
|
+
* may retry commit or begin with the same id (P5-M20). */
|
|
59
|
+
async commit(opts: { signal?: AbortSignal } = {}): Promise<CommitResult> {
|
|
60
|
+
const raw = await this.step("txn_commit", { txn_id: this.txId }, opts);
|
|
61
|
+
return (raw ?? {}) as CommitResult;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Discards the staged writes and closes the handle. */
|
|
65
|
+
async rollback(opts: { signal?: AbortSignal } = {}): Promise<void> {
|
|
66
|
+
await this.step("txn_rollback", { txn_id: this.txId }, opts);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private async step(
|
|
70
|
+
name: string,
|
|
71
|
+
args: Record<string, unknown>,
|
|
72
|
+
opts: { signal?: AbortSignal },
|
|
73
|
+
): Promise<unknown> {
|
|
74
|
+
if (this.closed) {
|
|
75
|
+
throw McpError.invalidArgument(`transaction ${this.txId} is closed`);
|
|
76
|
+
}
|
|
77
|
+
const raw = await this.c.callTool(name, args, opts);
|
|
78
|
+
if (name === "txn_commit" || name === "txn_rollback") {
|
|
79
|
+
this.closed = true;
|
|
80
|
+
}
|
|
81
|
+
return raw;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// D-13: the shared conformance runner, typescript arm (§7, §23). This
|
|
2
|
+
// test pins only the CLI contract — the vectors carry the semantics.
|
|
3
|
+
// Mirrors crates/sdk/rust/tests/conformance.rs.
|
|
4
|
+
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import { execFile } from "node:child_process";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import test from "node:test";
|
|
10
|
+
|
|
11
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
|
|
13
|
+
// Native→"bash" spawns the WSL shim on Windows (its PATH has no cargo/
|
|
14
|
+
// node); where.exe is a plain PATH walk and finds Git's bash. Prefer its
|
|
15
|
+
// Git hit; plain "bash" elsewhere (CI Linux).
|
|
16
|
+
async function bashExe(): Promise<string> {
|
|
17
|
+
if (process.platform !== "win32") return "bash";
|
|
18
|
+
try {
|
|
19
|
+
const { stdout } = await new Promise<{ stdout: string }>((resolve, reject) =>
|
|
20
|
+
execFile("where.exe", ["bash"], { encoding: "utf8" }, (err, stdout) =>
|
|
21
|
+
err ? reject(err) : resolve({ stdout }),
|
|
22
|
+
),
|
|
23
|
+
);
|
|
24
|
+
const hits = stdout
|
|
25
|
+
.split(/\r?\n/)
|
|
26
|
+
.map((l) => l.trim())
|
|
27
|
+
.filter(Boolean);
|
|
28
|
+
return hits.find((h) => h.toLowerCase().includes("git")) ?? hits[0] ?? "bash";
|
|
29
|
+
} catch {
|
|
30
|
+
return "bash";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function run(args: string[], env: Record<string, string>): Promise<{ code: number; out: string }> {
|
|
35
|
+
return new Promise((resolve, reject) => {
|
|
36
|
+
execFile(args[0]!, args.slice(1), { encoding: "utf8", env }, (err, stdout, stderr) => {
|
|
37
|
+
if (err && (err as { code?: number }).code === undefined) reject(err);
|
|
38
|
+
else resolve({ code: (err as { code?: number } | null)?.code ?? 0, out: `${stdout}${stderr}` });
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
test("sdk-conformance --language typescript", async () => {
|
|
44
|
+
const script = path.resolve(__dirname, "..", "..", "..", "..", "scripts", "sdk-conformance.sh");
|
|
45
|
+
// crates/sdk/typescript/tests → repo root is four levels up.
|
|
46
|
+
assert.ok(
|
|
47
|
+
(await import("node:fs")).existsSync(script),
|
|
48
|
+
`sdk-conformance runner missing at ${script} — D-13 RED`,
|
|
49
|
+
);
|
|
50
|
+
const bin = process.env.AIKOQL_MCP_BIN;
|
|
51
|
+
if (!bin) {
|
|
52
|
+
process.stderr.write("AIKOQL_MCP_BIN not set — real-server conformance skipped\n");
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const { code, out } = await run(
|
|
56
|
+
[await bashExe(), script, "--language", "typescript"],
|
|
57
|
+
{ ...process.env, AIKOQL_MCP_BIN: bin },
|
|
58
|
+
);
|
|
59
|
+
assert.equal(code, 0, `sdk-conformance --language typescript failed (exit ${code}):\n${out}`);
|
|
60
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// The §16 pin: the cross-language golden corpus spec exists at
|
|
2
|
+
// sdk-fuzz-corpus/corpus.json and this SDK's column holds for every case.
|
|
3
|
+
// Removing a case id is a detected coverage loss; a column mismatch is a
|
|
4
|
+
// wire-behavior drift (or an undocumented divergence — document it in the
|
|
5
|
+
// spec's note and re-stamp). Real primitives run where they exist
|
|
6
|
+
// (parseVersion/classifyID/rpcError/streamNotify); the request verdicts
|
|
7
|
+
// are restated inline at their client.ts lines.
|
|
8
|
+
import { test } from "node:test";
|
|
9
|
+
import assert from "node:assert/strict";
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
import { classifyID, parseVersion, rpcError, streamNotify } from "../src/client.ts";
|
|
14
|
+
|
|
15
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const CORPUS = path.resolve(__dirname, "..", "..", "..", "..", "sdk-fuzz-corpus", "corpus.json");
|
|
17
|
+
|
|
18
|
+
const CASE_IDS = [
|
|
19
|
+
"corp-v01", "corp-v02", "corp-v03", "corp-v04", "corp-v05",
|
|
20
|
+
"corp-e01", "corp-e02", "corp-e03", "corp-e04", "corp-e05",
|
|
21
|
+
"corp-r01", "corp-r02", "corp-n01",
|
|
22
|
+
"corp-s01", "corp-s02", "corp-s03",
|
|
23
|
+
"corp-c01", "corp-c02", "corp-c03", "corp-c04",
|
|
24
|
+
];
|
|
25
|
+
const SURFACES = ["version", "rpc_error", "response_id", "nonfinite", "notify", "correlation"];
|
|
26
|
+
|
|
27
|
+
function load(): { cases: Array<{ id: string; surface: string; input: unknown; expected: Record<string, unknown> }> } {
|
|
28
|
+
let raw: string;
|
|
29
|
+
try {
|
|
30
|
+
raw = readFileSync(CORPUS, "utf8");
|
|
31
|
+
} catch (err) {
|
|
32
|
+
throw new Error(
|
|
33
|
+
`the §16 corpus is absent (${CORPUS}): the D-16 golden-corpus slice is missing — ${String(err)}`,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
return JSON.parse(raw) as never;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function classify(surface: string, input: unknown): unknown {
|
|
40
|
+
switch (surface) {
|
|
41
|
+
case "version":
|
|
42
|
+
return parseVersion(input as string);
|
|
43
|
+
case "rpc_error": {
|
|
44
|
+
const frame = JSON.parse(input as string) as { error?: { code?: unknown; message?: string } };
|
|
45
|
+
// The real error renderer: rpcError + the McpError ctor decoration.
|
|
46
|
+
const e = rpcError(frame.error as { code: unknown; message: string });
|
|
47
|
+
return { code: e.code, message: e.message };
|
|
48
|
+
}
|
|
49
|
+
case "response_id":
|
|
50
|
+
case "nonfinite": {
|
|
51
|
+
// The request loop's verdict, restated (client.ts ~:300-315, want=1):
|
|
52
|
+
// non-numeric ids skip (JSON.parse throws on garbage → skip), and a
|
|
53
|
+
// numeric id goes through classifyID.
|
|
54
|
+
let frame: { id?: unknown };
|
|
55
|
+
try {
|
|
56
|
+
frame = JSON.parse(input as string) as { id?: unknown };
|
|
57
|
+
} catch {
|
|
58
|
+
return "skip";
|
|
59
|
+
}
|
|
60
|
+
const rid = frame.id;
|
|
61
|
+
if (typeof rid !== "number") return "skip";
|
|
62
|
+
return classifyID(1, rid);
|
|
63
|
+
}
|
|
64
|
+
case "notify": {
|
|
65
|
+
// The real primitive: streamNotify (client.ts) — the same verdict
|
|
66
|
+
// the aikoqlStream loop runs; ts yields the raw params object as-is.
|
|
67
|
+
const frame = JSON.parse(input as string) as Record<string, unknown>;
|
|
68
|
+
const p = streamNotify(frame, "s1");
|
|
69
|
+
if (p === null) return { verdict: "skip", pair: null };
|
|
70
|
+
return { verdict: "yield", pair: p };
|
|
71
|
+
}
|
|
72
|
+
case "correlation": {
|
|
73
|
+
const pair = input as { want: number; got: number };
|
|
74
|
+
return classifyID(pair.want, pair.got);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
throw new Error(`unknown surface ${surface}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
test("the §16 corpus pin", () => {
|
|
81
|
+
const spec = load();
|
|
82
|
+
const byId = new Map(spec.cases.map((c) => [c.id, c]));
|
|
83
|
+
for (const id of CASE_IDS) {
|
|
84
|
+
assert.ok(byId.has(id), `case ${id} is gone from the §16 corpus — a removed case is a coverage loss`);
|
|
85
|
+
}
|
|
86
|
+
const haveSurfaces = new Set(spec.cases.map((c) => c.surface));
|
|
87
|
+
for (const s of SURFACES) {
|
|
88
|
+
assert.ok(haveSurfaces.has(s), `surface ${s} has no cases in the §16 corpus`);
|
|
89
|
+
}
|
|
90
|
+
for (const c of spec.cases) {
|
|
91
|
+
assert.ok("typescript" in c.expected, `case ${c.id} has no typescript column in the §16 corpus`);
|
|
92
|
+
const got = classify(c.surface, c.input);
|
|
93
|
+
const want = c.expected.typescript;
|
|
94
|
+
assert.deepEqual(got, want, `case ${c.id} (${c.surface}): typescript column drift — got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
// D-16 fault matrix (line wire): the §18 fault proxy sits between the SDK
|
|
2
|
+
// and a real server and mangles the newline-delimited JSON-RPC wire (§7 of
|
|
3
|
+
// the testing plan: every SDK passes the same matrix against the same
|
|
4
|
+
// misbehaving server). Frame accounting: client line #1 = initialize, #2 =
|
|
5
|
+
// the victim call, #3 = the follow-up (no HELLO/AUTH frames on the MCP
|
|
6
|
+
// wire). Mirrors crates/sdk/rust/tests/fault.rs on the line wire.
|
|
7
|
+
//
|
|
8
|
+
// The contracts GREEN must make hold (the proxy --wire line arm + the
|
|
9
|
+
// client's MAX_FRAME cap and closed latch):
|
|
10
|
+
// drop-request/drop-response → victim TIMEOUT (retryable), follow-up ok
|
|
11
|
+
// delay-response → tight deadline TIMEOUT; generous deadline ok
|
|
12
|
+
// duplicate-response → both ok (id correlation skips the duplicate)
|
|
13
|
+
// reorder-response → victim TIMEOUT (response held), follow-up ok
|
|
14
|
+
// truncate-frame → victim TIMEOUT (the missing bytes never arrive),
|
|
15
|
+
// follow-up ok (the wire is self-delimiting)
|
|
16
|
+
// corrupt-frame → the line is noise per the frozen §3.3 semantics →
|
|
17
|
+
// victim TIMEOUT, follow-up ok
|
|
18
|
+
// inject-notification → both ok (an id-less frame is never a response)
|
|
19
|
+
// inject-stale-response → both ok (stale id skipped)
|
|
20
|
+
// close/half-close → victim ok, follow-up fails fast (never TIMEOUT),
|
|
21
|
+
// the client latches closed → UNAVAILABLE from then on
|
|
22
|
+
// slow-server → tight deadline TIMEOUT
|
|
23
|
+
// oversized-response → FRAME_TOO_LARGE before buffering past the 1 MiB
|
|
24
|
+
// cap (§19: a malicious server cannot cause unbounded client memory),
|
|
25
|
+
// follow-up UNAVAILABLE (the stream is desynced — latched)
|
|
26
|
+
|
|
27
|
+
import assert from "node:assert/strict";
|
|
28
|
+
import fs from "node:fs";
|
|
29
|
+
import net from "node:net";
|
|
30
|
+
import os from "node:os";
|
|
31
|
+
import path from "node:path";
|
|
32
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
33
|
+
import { test, type TestContext } from "node:test";
|
|
34
|
+
import { Client, McpError, withDeadline } from "../src/index.ts";
|
|
35
|
+
|
|
36
|
+
const TOKEN = "test-token";
|
|
37
|
+
// npm test / node --test run from the package dir → 3 ups = the repo root.
|
|
38
|
+
const ROOT = path.resolve(process.cwd(), "..", "..", "..");
|
|
39
|
+
|
|
40
|
+
function findBin(name: string): string | undefined {
|
|
41
|
+
const p = path.join(ROOT, "target", "debug", name + (process.platform === "win32" ? ".exe" : ""));
|
|
42
|
+
return fs.existsSync(p) ? p : undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const MCP_BIN = process.env.AIKOQL_MCP_BIN ?? findBin("aikoql-mcp");
|
|
46
|
+
const PROXY_BIN = process.env.AIKOQL_FAULT_PROXY ?? findBin("aikoql-fault-proxy");
|
|
47
|
+
|
|
48
|
+
function requireBins(t: TestContext): boolean {
|
|
49
|
+
if (!MCP_BIN || !PROXY_BIN) {
|
|
50
|
+
t.skip("AIKOQL_MCP_BIN / aikoql-fault-proxy not built — real-server fault matrix skipped");
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function probe(port: number): Promise<boolean> {
|
|
57
|
+
return new Promise((resolve) => {
|
|
58
|
+
const s = net.connect(port, "127.0.0.1");
|
|
59
|
+
s.once("connect", () => {
|
|
60
|
+
s.destroy();
|
|
61
|
+
resolve(true);
|
|
62
|
+
});
|
|
63
|
+
s.once("error", () => resolve(false));
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function waitUp(port: number, child: ChildProcess, errFile: string, what: string): Promise<void> {
|
|
68
|
+
for (let i = 0; i < 100; i++) {
|
|
69
|
+
if (child.exitCode !== null) {
|
|
70
|
+
const err = await fs.promises.readFile(errFile, "utf8").catch(() => "");
|
|
71
|
+
throw new Error(`${what} exited early (${child.exitCode}):\n${err}`);
|
|
72
|
+
}
|
|
73
|
+
if (await probe(port)) return;
|
|
74
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
75
|
+
}
|
|
76
|
+
throw new Error(`${what} never listened on 127.0.0.1:${port}`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A real server + one fault-proxy instance (one fault mode) behind the
|
|
80
|
+
* proxy address. The port-probe idiom races → 3 spawn attempts. */
|
|
81
|
+
async function faultEnv(
|
|
82
|
+
mode: string,
|
|
83
|
+
extra: string[],
|
|
84
|
+
): Promise<{ addr: string; stop: () => void }> {
|
|
85
|
+
let last: Error | undefined;
|
|
86
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
87
|
+
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "aikoql-ts-fault-"));
|
|
88
|
+
const cleanup = async () => {
|
|
89
|
+
await fs.promises.rm(dir, { recursive: true, force: true }).catch(() => {});
|
|
90
|
+
};
|
|
91
|
+
let srv: ChildProcess | undefined;
|
|
92
|
+
let px: ChildProcess | undefined;
|
|
93
|
+
try {
|
|
94
|
+
const srvPort = await freePort();
|
|
95
|
+
const db = path.join(dir, "db.aikoql"); // does not exist → auto-create (aikoql-v2)
|
|
96
|
+
srv = spawn(MCP_BIN!, ["serve", db, "--listen", `127.0.0.1:${srvPort}`, "--tcp-token", `${TOKEN}::admin`], {
|
|
97
|
+
stdio: ["ignore", "ignore", fs.openSync(path.join(dir, "srv.err"), "w")],
|
|
98
|
+
windowsHide: true,
|
|
99
|
+
});
|
|
100
|
+
await waitUp(srvPort, srv, path.join(dir, "srv.err"), "aikoql-mcp");
|
|
101
|
+
|
|
102
|
+
const pxPort = await freePort();
|
|
103
|
+
const errFile = path.join(dir, "proxy.err");
|
|
104
|
+
px = spawn(
|
|
105
|
+
PROXY_BIN!,
|
|
106
|
+
["--listen", `127.0.0.1:${pxPort}`, "--target", `127.0.0.1:${srvPort}`, "--wire", "line", "--mode", mode, ...extra],
|
|
107
|
+
{ stdio: ["ignore", "ignore", fs.openSync(errFile, "w")], windowsHide: true },
|
|
108
|
+
);
|
|
109
|
+
await waitUp(pxPort, px, errFile, "aikoql-fault-proxy");
|
|
110
|
+
const addr = `127.0.0.1:${pxPort}`;
|
|
111
|
+
return {
|
|
112
|
+
addr,
|
|
113
|
+
stop: () => {
|
|
114
|
+
px!.kill();
|
|
115
|
+
srv!.kill();
|
|
116
|
+
void cleanup();
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
} catch (e) {
|
|
120
|
+
last = e as Error;
|
|
121
|
+
px?.kill();
|
|
122
|
+
srv?.kill();
|
|
123
|
+
await cleanup();
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
throw last;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function freePort(): Promise<number> {
|
|
130
|
+
const probeSrv = net.createServer();
|
|
131
|
+
await new Promise<void>((resolve, reject) => {
|
|
132
|
+
probeSrv.once("error", reject);
|
|
133
|
+
probeSrv.listen(0, "127.0.0.1", resolve);
|
|
134
|
+
});
|
|
135
|
+
const port = (probeSrv.address() as net.AddressInfo).port;
|
|
136
|
+
await new Promise<void>((resolve) => probeSrv.close(() => resolve()));
|
|
137
|
+
return port;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function dial(t: TestContext, addr: string): Promise<Client> {
|
|
141
|
+
const c = (await Client.dial(addr)).withToken(TOKEN);
|
|
142
|
+
t.after(() => {
|
|
143
|
+
void c.close().catch(() => {});
|
|
144
|
+
});
|
|
145
|
+
await c.initialize();
|
|
146
|
+
return c;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Runs fn under the deadline and returns the error code ("" = ok). */
|
|
150
|
+
async function codeOf(fn: (signal: AbortSignal) => Promise<unknown>): Promise<string> {
|
|
151
|
+
try {
|
|
152
|
+
await withDeadline(5000, fn);
|
|
153
|
+
return "";
|
|
154
|
+
} catch (e) {
|
|
155
|
+
if (e instanceof McpError) return e.code;
|
|
156
|
+
return "raw:" + String(e);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const call = (c: Client) => (signal: AbortSignal) => c.callTool("health", {}, { signal });
|
|
161
|
+
|
|
162
|
+
async function victim(c: Client, ms: number): Promise<string> {
|
|
163
|
+
try {
|
|
164
|
+
await withDeadline(ms, (signal) => c.callTool("health", {}, { signal }));
|
|
165
|
+
return "";
|
|
166
|
+
} catch (e) {
|
|
167
|
+
if (e instanceof McpError) return e.code;
|
|
168
|
+
return "raw:" + String(e);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
test("fault matrix: drop-request", async (t) => {
|
|
173
|
+
if (!requireBins(t)) return;
|
|
174
|
+
const { addr, stop } = await faultEnv("drop-request", ["--n", "2"]);
|
|
175
|
+
t.after(stop);
|
|
176
|
+
const c = await dial(t, addr);
|
|
177
|
+
const code = await victim(c, 200);
|
|
178
|
+
assert.equal(code, "TIMEOUT");
|
|
179
|
+
assert.equal(await codeOf(call(c)), ""); // the follow-up survives
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("fault matrix: drop-response", async (t) => {
|
|
183
|
+
if (!requireBins(t)) return;
|
|
184
|
+
const { addr, stop } = await faultEnv("drop-response", ["--n", "2"]);
|
|
185
|
+
t.after(stop);
|
|
186
|
+
const c = await dial(t, addr);
|
|
187
|
+
const code = await victim(c, 200);
|
|
188
|
+
assert.equal(code, "TIMEOUT");
|
|
189
|
+
assert.equal(await codeOf(call(c)), "");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("fault matrix: delay-response", async (t) => {
|
|
193
|
+
if (!requireBins(t)) return;
|
|
194
|
+
const { addr, stop } = await faultEnv("delay-response", ["--from", "2", "--delay-ms", "400"]);
|
|
195
|
+
t.after(stop);
|
|
196
|
+
const c = await dial(t, addr);
|
|
197
|
+
const code = await victim(c, 200);
|
|
198
|
+
assert.equal(code, "TIMEOUT");
|
|
199
|
+
const { addr: addr2, stop: stop2 } = await faultEnv("delay-response", ["--from", "2", "--delay-ms", "400"]);
|
|
200
|
+
t.after(stop2);
|
|
201
|
+
const c2 = await dial(t, addr2);
|
|
202
|
+
assert.equal(await victim(c2, 2000), ""); // a generous deadline absorbs the delay
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("fault matrix: duplicate-response", async (t) => {
|
|
206
|
+
if (!requireBins(t)) return;
|
|
207
|
+
const { addr, stop } = await faultEnv("duplicate-response", ["--n", "2"]);
|
|
208
|
+
t.after(stop);
|
|
209
|
+
const c = await dial(t, addr);
|
|
210
|
+
assert.equal(await victim(c, 2000), "");
|
|
211
|
+
assert.equal(await codeOf(call(c)), ""); // the duplicate is a stale id — skipped
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("fault matrix: reorder-response", async (t) => {
|
|
215
|
+
if (!requireBins(t)) return;
|
|
216
|
+
const { addr, stop } = await faultEnv("reorder-response", ["--n", "2"]);
|
|
217
|
+
t.after(stop);
|
|
218
|
+
const c = await dial(t, addr);
|
|
219
|
+
assert.equal(await victim(c, 200), "TIMEOUT");
|
|
220
|
+
assert.equal(await codeOf(call(c)), ""); // request #3 releases response #2
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("fault matrix: truncate-response", async (t) => {
|
|
224
|
+
if (!requireBins(t)) return;
|
|
225
|
+
const { addr, stop } = await faultEnv("truncate-response", ["--n", "2", "--bytes", "8"]);
|
|
226
|
+
t.after(stop);
|
|
227
|
+
const c = await dial(t, addr);
|
|
228
|
+
assert.equal(await victim(c, 200), "TIMEOUT");
|
|
229
|
+
assert.equal(await codeOf(call(c)), ""); // the wire is self-delimiting
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("fault matrix: corrupt-response", async (t) => {
|
|
233
|
+
if (!requireBins(t)) return;
|
|
234
|
+
const { addr, stop } = await faultEnv("corrupt-response", ["--n", "2"]);
|
|
235
|
+
t.after(stop);
|
|
236
|
+
const c = await dial(t, addr);
|
|
237
|
+
assert.equal(await victim(c, 200), "TIMEOUT"); // noise-skip, never a fast error
|
|
238
|
+
assert.equal(await codeOf(call(c)), "");
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("fault matrix: inject-notification", async (t) => {
|
|
242
|
+
if (!requireBins(t)) return;
|
|
243
|
+
const { addr, stop } = await faultEnv("inject-notification", ["--after", "2"]);
|
|
244
|
+
t.after(stop);
|
|
245
|
+
const c = await dial(t, addr);
|
|
246
|
+
assert.equal(await victim(c, 2000), "");
|
|
247
|
+
assert.equal(await codeOf(call(c)), ""); // an id-less frame is never a response
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("fault matrix: inject-stale-response", async (t) => {
|
|
251
|
+
if (!requireBins(t)) return;
|
|
252
|
+
const { addr, stop } = await faultEnv("inject-stale-response", ["--after", "2"]);
|
|
253
|
+
t.after(stop);
|
|
254
|
+
const c = await dial(t, addr);
|
|
255
|
+
assert.equal(await victim(c, 2000), "");
|
|
256
|
+
assert.equal(await codeOf(call(c)), ""); // the replayed initialize response is stale
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("fault matrix: close-after", async (t) => {
|
|
260
|
+
if (!requireBins(t)) return;
|
|
261
|
+
const { addr, stop } = await faultEnv("close-after", ["--n", "2"]);
|
|
262
|
+
t.after(stop);
|
|
263
|
+
const c = await dial(t, addr);
|
|
264
|
+
assert.equal(await victim(c, 2000), "");
|
|
265
|
+
const follow = await codeOf(call(c));
|
|
266
|
+
assert.notEqual(follow, "");
|
|
267
|
+
assert.notEqual(follow, "TIMEOUT"); // fails fast, never hangs
|
|
268
|
+
assert.equal(await codeOf(call(c)), "UNAVAILABLE"); // latched
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("fault matrix: half-close-after", async (t) => {
|
|
272
|
+
if (!requireBins(t)) return;
|
|
273
|
+
const { addr, stop } = await faultEnv("half-close-after", ["--n", "2"]);
|
|
274
|
+
t.after(stop);
|
|
275
|
+
const c = await dial(t, addr);
|
|
276
|
+
assert.equal(await victim(c, 2000), "");
|
|
277
|
+
const follow = await codeOf(call(c));
|
|
278
|
+
assert.notEqual(follow, "");
|
|
279
|
+
assert.notEqual(follow, "TIMEOUT");
|
|
280
|
+
assert.equal(await codeOf(call(c)), "UNAVAILABLE");
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("fault matrix: slow-server", async (t) => {
|
|
284
|
+
if (!requireBins(t)) return;
|
|
285
|
+
const { addr, stop } = await faultEnv("slow-server", ["--from", "2", "--bytes", "4", "--delay-ms", "25"]);
|
|
286
|
+
t.after(stop);
|
|
287
|
+
const c = await dial(t, addr);
|
|
288
|
+
assert.equal(await victim(c, 200), "TIMEOUT");
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test("fault matrix: oversized-response", async (t) => {
|
|
292
|
+
if (!requireBins(t)) return;
|
|
293
|
+
const { addr, stop } = await faultEnv("oversized-response", ["--n", "2", "--claim", "67108864"]);
|
|
294
|
+
t.after(stop);
|
|
295
|
+
const c = await dial(t, addr);
|
|
296
|
+
assert.equal(await victim(c, 5000), "FRAME_TOO_LARGE"); // the 1 MiB cap, not 64 MiB
|
|
297
|
+
assert.equal(await codeOf(call(c)), "UNAVAILABLE"); // the stream is desynced — latched
|
|
298
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// D-16 §10: the TS fuzz estate's own pin (the F-04 pattern — a removed
|
|
2
|
+
// target is a detected coverage loss, never silent). The estate lives in
|
|
3
|
+
// tests/fuzz-estate.test.ts: fast-check property tests over the frozen
|
|
4
|
+
// mirrors (version parse, error mapping, id correlation, frame cap,
|
|
5
|
+
// envelope decode) plus the §17 command model
|
|
6
|
+
// (DISCONNECTED→CONNECTED→INITIALIZED→TRANSACTION→STREAMING→CLOSED).
|
|
7
|
+
|
|
8
|
+
import assert from "node:assert/strict";
|
|
9
|
+
import { readFileSync } from "node:fs";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import test from "node:test";
|
|
13
|
+
|
|
14
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
const estate = path.join(__dirname, "fuzz-estate.test.ts");
|
|
16
|
+
|
|
17
|
+
// The five property tests and the §17 model, by name.
|
|
18
|
+
const pins = [
|
|
19
|
+
"the version-parse property",
|
|
20
|
+
"the error-mapping property",
|
|
21
|
+
"the id-correlation property",
|
|
22
|
+
"the frame-cap property",
|
|
23
|
+
"the envelope property",
|
|
24
|
+
"the §17 command model",
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
test("the fuzz-estate pin", () => {
|
|
28
|
+
const src = readFileSync(estate, "utf8");
|
|
29
|
+
for (const pin of pins) {
|
|
30
|
+
assert.ok(src.includes(pin), `§10 fuzz target missing: ${pin}`);
|
|
31
|
+
}
|
|
32
|
+
});
|