The dev host read manifest.json once at startup and froze that copy in the Sidecar, while the backend watcher happily rebuilt the binary on any source edit. Editing the manifest while the host ran — a routine version bump during a release, for example — then made every restart compare the fresh binary against the stale in-memory manifest and fail with "Sidecar identity or protocol does not match manifest" until the whole dev host was restarted. Two layers of fixing: the project root now watches manifest.json and hot swaps the in-memory copy (plus secret-key collection), and the sidecar handshake re-reads the manifest from disk before declaring a mismatch, so the check can no longer race the watcher. A genuinely disagreeing manifest still fails the handshake.
133 lines
6.2 KiB
JavaScript
133 lines
6.2 KiB
JavaScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { fileURLToPath } from "node:url";
|
|
import { once } from "node:events";
|
|
import { setTimeout as delay } from "node:timers/promises";
|
|
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { FrameDecoder, JsonLineDecoder, frame, binaryPayload, Sidecar, JSON_LIMIT } from "../sidecar.mjs";
|
|
|
|
const config = { executable: process.execPath, args: [fileURLToPath(new URL("./echo-sidecar.mjs", import.meta.url))], transport: "stdio-framed", manifest: { id: "example.echo", version: "1.0.0" } };
|
|
test("sidecar shutdown terminates descendants even after the leader exits", async (t) => {
|
|
const sidecar = new Sidecar(config);
|
|
t.after(() => sidecar.stop());
|
|
await sidecar.start();
|
|
const pid = await sidecar.request("spawn-child");
|
|
t.after(() => {
|
|
try {
|
|
process.kill(pid, "SIGKILL");
|
|
} catch {}
|
|
});
|
|
await delay(100);
|
|
await sidecar.stop();
|
|
let alive = true;
|
|
for (let i = 0; i < 100 && alive; i++) {
|
|
try {
|
|
process.kill(pid, 0);
|
|
await delay(20);
|
|
} catch {
|
|
alive = false;
|
|
}
|
|
}
|
|
assert.equal(alive, false, "sidecar descendant must be terminated");
|
|
});
|
|
test("decoder handles split headers, payloads, concatenated frames and binary channels", () => {
|
|
const got = [],
|
|
decoder = new FrameDecoder((packet) => got.push(packet));
|
|
const input = Buffer.concat([frame(0, Buffer.from('{"result":1}')), frame(1, binaryPayload("test/bytes", Buffer.from([0, 255, 13])))]);
|
|
for (const byte of input) decoder.push(Buffer.from([byte]));
|
|
assert.equal(got[0].message.result, 1);
|
|
assert.deepEqual(got[1].data, Buffer.from([0, 255, 13]));
|
|
assert.equal(decoder.buffer.length, 0);
|
|
});
|
|
test("decoder rejects oversized and malformed frames before allocating payloads", () => {
|
|
const header = Buffer.alloc(5);
|
|
header.writeUInt32BE(JSON_LIMIT + 1, 1);
|
|
assert.throws(() => new FrameDecoder(() => {}).push(header), /limit/);
|
|
assert.throws(() => new FrameDecoder(() => {}).push(frame(9, Buffer.alloc(0))), /kind/);
|
|
assert.throws(() => new FrameDecoder(() => {}).push(frame(1, Buffer.from([0, 20]))), /channel/);
|
|
});
|
|
test("generic sidecar correlates out-of-order replies and preserves structured errors", async (t) => {
|
|
const sidecar = new Sidecar(config);
|
|
t.after(() => sidecar.stop());
|
|
await sidecar.start();
|
|
const order = [];
|
|
const a = sidecar.request("slow", { a: 1 }).then((value) => {
|
|
order.push("slow");
|
|
return value;
|
|
});
|
|
const b = sidecar.request("echo", { b: 2 }).then((value) => {
|
|
order.push("fast");
|
|
return value;
|
|
});
|
|
assert.deepEqual(await b, { b: 2 });
|
|
assert.deepEqual(await a, { a: 1 });
|
|
assert.deepEqual(order, ["fast", "slow"]);
|
|
await assert.rejects(sidecar.request("fail"), (e) => e.rpc.data.category === "example");
|
|
});
|
|
test("events, binary, timeout and process death are observable without business RPC knowledge", async (t) => {
|
|
const sidecar = new Sidecar(config);
|
|
t.after(() => sidecar.stop());
|
|
await sidecar.start();
|
|
const event = once(sidecar, "event");
|
|
await sidecar.notify("emit", { event: true });
|
|
assert.equal((await event)[0].params.event, true);
|
|
const binary = once(sidecar, "binary");
|
|
await sidecar.sendBinary("channel", Buffer.from("bytes"));
|
|
assert.equal((await binary)[0].dataBase64, Buffer.from("bytes").toString("base64"));
|
|
await assert.rejects(sidecar.request("slow", {}, 5), /timed out/);
|
|
const slow = sidecar.request("slow");
|
|
const slowAssertion = assert.rejects(slow, /exited/);
|
|
await sidecar.notify("die");
|
|
await slowAssertion;
|
|
assert.equal(sidecar.pending.size, 0);
|
|
});
|
|
test("handshake rejects wrong identity and missing executables terminate promptly", async () => {
|
|
const wrong = new Sidecar({ ...config, manifest: { id: "wrong", version: "1.0.0" } });
|
|
await assert.rejects(wrong.start(), /identity/);
|
|
const missing = new Sidecar({ ...config, executable: "/definitely/missing/mock-sidecar" });
|
|
await assert.rejects(missing.start(), /start/);
|
|
await missing.stop();
|
|
});
|
|
|
|
test("handshake adopts a manifest.json rewritten on disk when the in-memory copy is stale", async (t) => {
|
|
const project = await mkdtemp(join(tmpdir(), "dbx-sidecar-manifest-"));
|
|
t.after(() => rm(project, { recursive: true, force: true }));
|
|
// Simulates a version bump made while the dev host was already running:
|
|
// the sidecar binary reports 1.0.0, the in-memory manifest is behind.
|
|
await writeFile(join(project, "manifest.json"), JSON.stringify({ id: "example.echo", version: "1.0.0" }));
|
|
const sidecar = new Sidecar({ ...config, cwd: project, manifest: { id: "example.echo", version: "0.9.0" } });
|
|
t.after(() => sidecar.stop());
|
|
const info = await sidecar.start();
|
|
assert.equal(sidecar.state, "ready");
|
|
assert.equal(sidecar.manifest.version, "1.0.0");
|
|
assert.equal(info.plugin.version, "1.0.0");
|
|
});
|
|
|
|
test("handshake still rejects when the on-disk manifest also disagrees with the sidecar", async (t) => {
|
|
const project = await mkdtemp(join(tmpdir(), "dbx-sidecar-manifest-"));
|
|
t.after(() => rm(project, { recursive: true, force: true }));
|
|
await writeFile(join(project, "manifest.json"), JSON.stringify({ id: "example.echo", version: "0.9.0" }));
|
|
const sidecar = new Sidecar({ ...config, cwd: project, manifest: { id: "example.echo", version: "0.9.0" } });
|
|
await assert.rejects(sidecar.start(), /identity/);
|
|
});
|
|
|
|
test("JSONL supports split lines, concurrent RPC, events and rejects binary channels", async (t) => {
|
|
const packets = [],
|
|
decoder = new JsonLineDecoder((packet) => packets.push(packet));
|
|
decoder.push(Buffer.from('{"a":'));
|
|
decoder.push(Buffer.from('1}\n\n{"b":2}\n'));
|
|
assert.equal(packets.length, 2);
|
|
assert.equal(packets[1].message.b, 2);
|
|
assert.throws(() => new JsonLineDecoder(() => {}).push(Buffer.alloc(JSON_LIMIT + 1, 65)), /limit/);
|
|
const sidecar = new Sidecar({ ...config, transport: "stdio-jsonl", args: [...config.args, "--jsonl"] });
|
|
t.after(() => sidecar.stop());
|
|
await sidecar.start();
|
|
assert.deepEqual(await sidecar.request("echo", { arbitrary: "value" }), { arbitrary: "value" });
|
|
const event = once(sidecar, "event");
|
|
await sidecar.notify("emit", { ok: true });
|
|
assert.equal((await event)[0].params.ok, true);
|
|
await assert.rejects(sidecar.sendBinary("bytes", Buffer.from("data")), /framed/);
|
|
});
|