paperclip/packages/adapter-utils/src/acpx-engine/startup-timing.test.ts

619 lines
23 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import type { AdapterRuntimeEvent } from "../types.js";
import type { StartupSpan, StartupTracer } from "./startup-timing.js";
import {
clampSpanLabel,
emitSkippedStartupStep,
getActiveStepContext,
measureStartupStep,
normalizeProviderFamily,
runWithoutActiveStep,
SANDBOX_STARTUP_SPAN_ATTR_PREFIX,
SANDBOX_STARTUP_SPAN_ATTRS,
setSandboxRootSpanAttributes,
} from "./startup-timing.js";
const A = SANDBOX_STARTUP_SPAN_ATTRS;
/**
* A recording span for the mock tracer. It captures the attribute set, the
* status, and the end count so a test can assert the emitted span shape.
*/
class MockSpan implements StartupSpan {
readonly attributes: Record<string, string | number | boolean> = {};
status: { code: number; message?: string } | undefined;
endCount = 0;
constructor(
readonly name: string,
initial: Record<string, string | number | boolean> | undefined,
) {
if (initial) Object.assign(this.attributes, initial);
}
setAttribute(key: string, value: string | number | boolean): void {
this.attributes[key] = value;
}
setStatus(status: { code: number; message?: string }): void {
this.status = status;
}
end(): void {
this.endCount += 1;
}
}
/** A recording tracer that keeps every span it opens for assertions. */
function makeMockTracer(): { tracer: StartupTracer; spans: MockSpan[] } {
const spans: MockSpan[] = [];
const tracer: StartupTracer = {
startSpan(name, options) {
const span = new MockSpan(name, options?.attributes);
spans.push(span);
return span;
},
};
return { tracer, spans };
}
describe("measureStartupStep", () => {
it("emits one run.startup.step event with the step name and measured durationMs", async () => {
let t = 0;
const now = () => t;
const events: AdapterRuntimeEvent[] = [];
const onEvent = vi.fn(async (event: AdapterRuntimeEvent) => {
events.push(event);
});
const result = await measureStartupStep({ onEvent }, now, "stage.sync", async () => {
t = 150; // clock advances while the wrapped step runs
return "ok";
});
expect(result).toBe("ok");
expect(onEvent).toHaveBeenCalledTimes(1);
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({
eventType: "run.startup.step",
stream: "system",
level: "info",
payload: { step: "stage.sync", durationMs: 150 },
});
expect(events[0]!.message).toBe("startup step: stage.sync (150ms)");
});
it("emits only the high-level fields (step, durationMs, outcome) on the payload", async () => {
let t = 0;
const now = () => t;
const events: AdapterRuntimeEvent[] = [];
const onEvent = vi.fn(async (event: AdapterRuntimeEvent) => {
events.push(event);
});
await measureStartupStep({ onEvent }, now, "acp.handshake", async () => {
t = 7000;
return "handle";
});
// The detailed per-step round-trip and provider-duration numbers ride the
// OTel spans now, so the run-log payload keeps only the high-level fields.
const payload = events[0]!.payload as Record<string, unknown>;
expect(Object.keys(payload).sort()).toEqual(["durationMs", "outcome", "step"]);
expect(payload).not.toHaveProperty("roundTrips");
expect(payload).not.toHaveProperty("providerExecMs");
expect(payload).not.toHaveProperty("providerGetMs");
expect(payload).not.toHaveProperty("createRuntimeMs");
expect(payload).not.toHaveProperty("ensureSessionMs");
});
it("returns the wrapped fn result unchanged", async () => {
const now = () => 0;
const onEvent = vi.fn(async () => {});
const value = { nested: [1, 2, 3] };
const result = await measureStartupStep({ onEvent }, now, "workspace.resolve", async () => value);
expect(result).toBe(value);
});
it("still emits the timing event and re-throws when fn rejects", async () => {
let t = 0;
const now = () => t;
const events: AdapterRuntimeEvent[] = [];
const onEvent = vi.fn(async (event: AdapterRuntimeEvent) => {
events.push(event);
});
const boom = new Error("step failed");
await expect(
measureStartupStep({ onEvent }, now, "acp.handshake", async () => {
t = 42;
throw boom;
}),
).rejects.toBe(boom);
expect(onEvent).toHaveBeenCalledTimes(1);
expect(events[0]).toMatchObject({
eventType: "run.startup.step",
payload: { step: "acp.handshake", durationMs: 42 },
});
});
it("swallows onEvent errors without changing the wrapped fn result", async () => {
let t = 0;
const now = () => t;
const onEvent = vi.fn(async () => {
throw new Error("sink failed");
});
const result = await measureStartupStep({ onEvent }, now, "bridge.paperclip", async () => {
t = 17;
return "value";
});
expect(result).toBe("value");
expect(onEvent).toHaveBeenCalledTimes(1);
});
it("swallows onEvent errors without replacing a wrapped fn error", async () => {
let t = 0;
const now = () => t;
const onEvent = vi.fn(async () => {
throw new Error("sink failed");
});
const boom = new Error("step failed");
await expect(
measureStartupStep({ onEvent }, now, "bridge.process-session", async () => {
t = 17;
throw boom;
}),
).rejects.toBe(boom);
expect(onEvent).toHaveBeenCalledTimes(1);
});
it("does not throw when ctx.onEvent is undefined", async () => {
const now = () => 0;
await expect(
measureStartupStep({}, now, "bridge.paperclip", async () => "value"),
).resolves.toBe("value");
});
it("still surfaces the fn error when ctx.onEvent is undefined", async () => {
const now = () => 0;
const boom = new Error("undefined-sink failure");
await expect(
measureStartupStep({}, now, "bridge.process-session", async () => {
throw boom;
}),
).rejects.toBe(boom);
});
it("opens one span and ends it once for a normal step", async () => {
const { tracer, spans } = makeMockTracer();
const onEvent = vi.fn(async () => {});
await measureStartupStep({ onEvent }, () => 0, "stage.sync", async () => "ok", {
tracer,
});
expect(spans).toHaveLength(1);
expect(spans[0]!.name).toBe("stage.sync");
// The span name carries the step; no redundant `step` attribute rides it.
expect(spans[0]!.attributes).not.toHaveProperty("step");
// The step wall time rides the closed, type-suffixed attribute key.
expect(spans[0]!.attributes[A.stepWallMs]).toBe(0);
expect(spans[0]!.endCount).toBe(1);
});
it("ends the span and sets an error status when fn throws, then re-throws", async () => {
const { tracer, spans } = makeMockTracer();
const onEvent = vi.fn(async () => {});
const boom = new Error("step failed");
await expect(
measureStartupStep({ onEvent }, () => 0, "acp.handshake", async () => {
throw boom;
}, { tracer }),
).rejects.toBe(boom);
expect(spans).toHaveLength(1);
expect(spans[0]!.endCount).toBe(1);
// SpanStatusCode.ERROR === 2 in @opentelemetry/api.
expect(spans[0]!.status?.code).toBe(2);
});
it("carries only the allowlisted attributes on the step span, never the removed round-trip detail", async () => {
const events: AdapterRuntimeEvent[] = [];
const onEvent = vi.fn(async (event: AdapterRuntimeEvent) => {
events.push(event);
});
const { tracer, spans } = makeMockTracer();
await measureStartupStep({ onEvent }, () => 0, "stage.sync", async () => "ok", {
tracer,
provider: "daytona",
});
// The step span carries only the closed allowlist. The per-execution
// `sandbox.exec` spans carry the round-trip and provider-duration detail.
expect(Object.keys(spans[0]!.attributes).sort()).toEqual(
[A.provider, A.stepWallMs, A.outcome].sort(),
);
const payload = events[0]!.payload as Record<string, unknown>;
expect(payload).not.toHaveProperty("roundTrips");
expect(payload).not.toHaveProperty("providerExecMs");
expect(payload).not.toHaveProperty("providerGetMs");
});
it("normalizes a plugin-backed provider key to plugin and keeps a built-in family as-is", async () => {
const onEvent = vi.fn(async () => {});
const custom = makeMockTracer();
await measureStartupStep({ onEvent }, () => 0, "stage.sync", async () => "ok", {
tracer: custom.tracer,
provider: "acme-cloud-runner",
});
expect(custom.spans[0]!.attributes[A.provider]).toBe("plugin");
const builtIn = makeMockTracer();
await measureStartupStep({ onEvent }, () => 0, "stage.sync", async () => "ok", {
tracer: builtIn.tracer,
provider: "daytona",
});
expect(builtIn.spans[0]!.attributes[A.provider]).toBe("daytona");
});
it("normalizeProviderFamily maps every non-built-in key to plugin", () => {
for (const key of ["daytona", "kubernetes", "e2b", "cloudflare", "exe-dev", "modal", "novita"]) {
expect(normalizeProviderFamily(key)).toBe(key);
}
for (const key of ["acme", "my-plugin", "", "DAYTONA", undefined]) {
expect(normalizeProviderFamily(key)).toBe("plugin");
}
});
it("emits exactly the allowlisted span-attribute key set and no command / path / ID / error-text key", async () => {
const onEvent = vi.fn(async () => {});
const { tracer, spans } = makeMockTracer();
await measureStartupStep({ onEvent }, () => 0, "acp.handshake", async () => "ok", {
tracer,
provider: "daytona",
});
expect(Object.keys(spans[0]!.attributes).sort()).toEqual(
[A.provider, A.stepWallMs, A.outcome].sort(),
);
// Every key uses the closed prefix, so no free-form command / path / id key
// can ride the span.
for (const key of Object.keys(spans[0]!.attributes)) {
expect(key.startsWith(SANDBOX_STARTUP_SPAN_ATTR_PREFIX)).toBe(true);
}
// No forbidden segment (command / arg / env / output / path / raw id) rides
// the span key set, even after the prefix.
for (const key of Object.keys(spans[0]!.attributes)) {
const suffix = key.slice(SANDBOX_STARTUP_SPAN_ATTR_PREFIX.length);
expect(suffix).not.toMatch(/command|args|env|stdout|stderr|path|url|repo|ref|branch|error|message/);
}
});
it("uses a no-op tracer by default so a call without a tracer changes nothing", async () => {
const events: AdapterRuntimeEvent[] = [];
const onEvent = vi.fn(async (event: AdapterRuntimeEvent) => {
events.push(event);
});
// No tracer supplied. The helper must still emit the event and return the
// value without throwing.
const result = await measureStartupStep({ onEvent }, () => 0, "stage.sync", async () => "ok");
expect(result).toBe("ok");
expect(events[0]!.payload).toMatchObject({ step: "stage.sync" });
});
it("sets a batch tag on the span from the batch option", async () => {
const { tracer, spans } = makeMockTracer();
await measureStartupStep({ onEvent: vi.fn(async () => {}) }, () => 0, "bridge.paperclip", async () => "ok", {
tracer,
batch: "bridge",
});
expect(spans[0]!.attributes[A.batch]).toBe("bridge");
});
it("maps handshake sub-times to fixed span keys and skips a non-finite one", async () => {
const { tracer, spans } = makeMockTracer();
await measureStartupStep({ onEvent: vi.fn(async () => {}) }, () => 0, "acp.handshake", async () => "ok", {
tracer,
spanWallTimes: () => ({ createRuntime: 12, ensureSession: 6988 }),
});
expect(spans[0]!.attributes[A.handshakeCreateRuntimeWallMs]).toBe(12);
expect(spans[0]!.attributes[A.handshakeEnsureSessionWallMs]).toBe(6988);
// A retry reports only its own ensure-session sub-time; the absent create-
// runtime value sets no attribute.
const retry = makeMockTracer();
await measureStartupStep({ onEvent: vi.fn(async () => {}) }, () => 0, "acp.handshake", async () => "ok", {
tracer: retry.tracer,
spanWallTimes: () => ({ ensureSession: 40 }),
});
expect(retry.spans[0]!.attributes[A.handshakeEnsureSessionWallMs]).toBe(40);
expect(retry.spans[0]!.attributes).not.toHaveProperty(A.handshakeCreateRuntimeWallMs);
});
it("sets outcome = ok on a settled step and outcome = failed on a throwing step", async () => {
const okEvents: AdapterRuntimeEvent[] = [];
const ok = makeMockTracer();
await measureStartupStep({ onEvent: vi.fn(async (e: AdapterRuntimeEvent) => { okEvents.push(e); }) },
() => 0, "stage.sync", async () => "ok", { tracer: ok.tracer });
expect(ok.spans[0]!.attributes[A.outcome]).toBe("ok");
expect((okEvents[0]!.payload as Record<string, unknown>).outcome).toBe("ok");
const failEvents: AdapterRuntimeEvent[] = [];
const fail = makeMockTracer();
await expect(
measureStartupStep({ onEvent: vi.fn(async (e: AdapterRuntimeEvent) => { failEvents.push(e); }) },
() => 0, "acp.handshake", async () => { throw new Error("boom"); }, { tracer: fail.tracer }),
).rejects.toThrow("boom");
expect(fail.spans[0]!.attributes[A.outcome]).toBe("failed");
expect((failEvents[0]!.payload as Record<string, unknown>).outcome).toBe("failed");
});
it("sets each step-span attribute key with the closed prefix and a type suffix", async () => {
const { tracer, spans } = makeMockTracer();
await measureStartupStep({ onEvent: vi.fn(async () => {}) }, () => 0, "stage.sync", async () => "ok", {
tracer,
provider: "daytona",
});
const keys = Object.keys(spans[0]!.attributes);
expect(keys.length).toBeGreaterThan(0);
for (const key of keys) {
expect(key.startsWith(SANDBOX_STARTUP_SPAN_ATTR_PREFIX)).toBe(true);
}
// The step wall time uses its type suffix.
expect(keys).toContain(A.stepWallMs);
expect(A.stepWallMs.endsWith(".wall_ms")).toBe(true);
});
});
describe("setSandboxRootSpanAttributes", () => {
it("records wall / work / diff and bounds the context, hashing ids and image", () => {
const span = new MockSpan("sandbox.startup", undefined);
setSandboxRootSpanAttributes(span, { wallMs: 800, workMs: 1000 }, {
coldStart: true,
provider: "acme-custom-runner",
region: "us-east-1",
imageId: "registry.internal/team/secret-codename:sha-1234",
sandboxId: "sbx-secret-internal-id",
leaseId: "lease-secret-internal-id",
});
expect(span.attributes[A.rootWallMs]).toBe(800);
expect(span.attributes[A.rootWorkMs]).toBe(1000);
expect(span.attributes[A.rootDiffMs]).toBe(200);
expect(span.attributes[A.coldStart]).toBe(true);
// Provider clamps to the bounded family; region stays a known value.
expect(span.attributes[A.provider]).toBe("plugin");
expect(span.attributes[A.region]).toBe("us-east-1");
// The image id and both ids ride only as non-reversible hashes.
for (const key of [A.imageId, A.sandboxId, A.leaseId]) {
expect(String(span.attributes[key])).toMatch(/^[0-9a-f]{12}$/);
}
for (const value of Object.values(span.attributes).map(String)) {
expect(value).not.toContain("secret");
expect(value).not.toContain("codename");
expect(value).not.toContain("registry.internal");
}
});
it("maps an unknown region to `unknown` and omits every absent context value", () => {
const span = new MockSpan("sandbox.startup", undefined);
setSandboxRootSpanAttributes(span, { wallMs: 5, workMs: 5 }, { region: "moon-base-1" });
expect(span.attributes[A.region]).toBe("unknown");
expect(span.attributes).not.toHaveProperty(A.coldStart);
expect(span.attributes).not.toHaveProperty(A.provider);
expect(span.attributes).not.toHaveProperty(A.imageId);
expect(span.attributes).not.toHaveProperty(A.sandboxId);
expect(span.attributes).not.toHaveProperty(A.leaseId);
});
});
describe("emitSkippedStartupStep", () => {
it("emits a span and event with outcome = skipped and a zero wall time", async () => {
const { tracer, spans } = makeMockTracer();
const events: AdapterRuntimeEvent[] = [];
const onEvent = vi.fn(async (event: AdapterRuntimeEvent) => {
events.push(event);
});
await emitSkippedStartupStep({ onEvent }, "acp.handshake", { tracer });
expect(spans).toHaveLength(1);
expect(spans[0]!.name).toBe("acp.handshake");
expect(spans[0]!.attributes[A.stepWallMs]).toBe(0);
expect(spans[0]!.attributes[A.outcome]).toBe("skipped");
expect(spans[0]!.endCount).toBe(1);
expect(events).toHaveLength(1);
expect(events[0]!.payload).toMatchObject({
step: "acp.handshake",
durationMs: 0,
outcome: "skipped",
});
});
it("emits the event with no injected tracer and does not throw", async () => {
const events: AdapterRuntimeEvent[] = [];
await emitSkippedStartupStep(
{ onEvent: vi.fn(async (e: AdapterRuntimeEvent) => { events.push(e); }) },
"acp.handshake",
);
expect(events[0]!.payload).toMatchObject({ step: "acp.handshake", outcome: "skipped" });
});
});
describe("getActiveStepContext", () => {
it("returns null when no measured step runs", () => {
expect(getActiveStepContext()).toBeNull();
});
it("exposes the active step context to inner code while fn runs, then clears it", async () => {
const { tracer, spans } = makeMockTracer();
let seen: ReturnType<typeof getActiveStepContext> = null;
await measureStartupStep({ onEvent: vi.fn(async () => {}) }, () => 0, "stage.sync", async () => {
// Inner code reads the active step context through the getter.
seen = getActiveStepContext();
return "ok";
}, {
tracer,
// The server builds a child-context token whose active span is the step
// span. Model it as `{ span }`, the same shape the recording tracer reads.
contextWithSpan: (span) => ({ span }),
});
expect(seen).not.toBeNull();
// The published span is the one open step span.
expect(seen!.span).toBe(spans[0]);
// The parent token points at the step span, so an inner exec span parents
// to it.
expect(seen!.parentContext).toEqual({ span: spans[0] });
// A regular step is on the critical path by default.
expect(seen!.criticalPath).toBe(true);
// The context clears once the step body settles.
expect(getActiveStepContext()).toBeNull();
});
it("carries criticalPath = false when the step opts out (parallel steps)", async () => {
let seen: ReturnType<typeof getActiveStepContext> = null;
await measureStartupStep({ onEvent: vi.fn(async () => {}) }, () => 0, "bridge.paperclip", async () => {
seen = getActiveStepContext();
}, { criticalPath: false });
expect(seen!.criticalPath).toBe(false);
});
it("keeps the ended step store in a timer scheduled inside the step body", async () => {
// Node snapshots the active store on each async resource at creation time.
// A timer scheduled inside a measured step body keeps that step store, even
// after the step span ends. A later run-time exec then reads the ended step
// and parents its `sandbox.exec` span to a dead startup step. This test locks
// that leak, so the fix and its guard test stay honest.
let storeInTimer: ReturnType<typeof getActiveStepContext> = null;
let fireTimer!: () => void;
const timerRan = new Promise<void>((resolve) => {
fireTimer = resolve;
});
await measureStartupStep(
{ onEvent: vi.fn(async () => {}) },
() => 0,
"bridge.process-session",
async () => {
setTimeout(() => {
storeInTimer = getActiveStepContext();
fireTimer();
}, 0);
return "started";
},
{ criticalPath: false },
);
// The step span already ended, so the main line reads no active step.
expect(getActiveStepContext()).toBeNull();
await timerRan;
// Yet the timer callback still reads the ended step context. This is the
// store leak the bridge boundary fix removes.
expect(storeInTimer).not.toBeNull();
expect(storeInTimer!.criticalPath).toBe(false);
});
it("clears the active step for a continuation wrapped in runWithoutActiveStep", async () => {
// The bridge boundary wraps its long-lived poll timer in `runWithoutActiveStep`.
// A timer scheduled inside that empty store scope reads no active step, so a
// later run-time exec opens an unparented span instead of one under the ended
// startup step.
let storeInTimer: ReturnType<typeof getActiveStepContext> = null;
let sawTimer = false;
let fireTimer!: () => void;
const timerRan = new Promise<void>((resolve) => {
fireTimer = resolve;
});
await measureStartupStep(
{ onEvent: vi.fn(async () => {}) },
() => 0,
"bridge.process-session",
async () => {
runWithoutActiveStep(() => {
setTimeout(() => {
storeInTimer = getActiveStepContext();
sawTimer = true;
fireTimer();
}, 0);
});
return "started";
},
{ criticalPath: false },
);
await timerRan;
expect(sawTimer).toBe(true);
expect(storeInTimer).toBeNull();
});
it("returns the work result and restores the previous active step", async () => {
// Outside any measured step the previous store is empty, so the helper both
// returns the work value and leaves the store empty afterward.
const value = runWithoutActiveStep(() => "value");
expect(value).toBe("value");
expect(getActiveStepContext()).toBeNull();
});
});
describe("clampSpanLabel", () => {
it("returns a known command label unchanged and maps an unknown command to `other`", () => {
expect(clampSpanLabel("command", "sh")).toBe("sh");
expect(clampSpanLabel("command", "git")).toBe("git");
// A full command line, a path, or a secret-like argument is not a known
// basename, so it maps to the bounded fallback and never leaks.
expect(clampSpanLabel("command", "bash -lc 'rm -rf /secret/path'")).toBe("other");
expect(clampSpanLabel("command", "/usr/local/bin/node")).toBe("other");
expect(clampSpanLabel("command", undefined)).toBe("other");
});
it("returns a known region unchanged and maps an unknown region to `unknown`", () => {
expect(clampSpanLabel("region", "us-east-1")).toBe("us-east-1");
expect(clampSpanLabel("region", "moon-base-1")).toBe("unknown");
expect(clampSpanLabel("region", undefined)).toBe("unknown");
});
it("hashes an id or image label to a non-reversible short digest and never returns the raw value", () => {
const raw = "lease-super-secret-internal-codename";
for (const label of ["image_id", "sandbox_id", "lease_id"]) {
const clamped = clampSpanLabel(label, raw);
expect(clamped).toBeTruthy();
expect(clamped).not.toBe(raw);
expect(clamped).not.toContain("secret");
expect(clamped).not.toContain("codename");
// A stable 12-hex-character digest prefix.
expect(clamped).toMatch(/^[0-9a-f]{12}$/);
}
// A missing id yields no attribute (fail open — never a raw value).
expect(clampSpanLabel("sandbox_id", undefined)).toBeUndefined();
expect(clampSpanLabel("sandbox_id", "")).toBeUndefined();
});
it("drops an unknown label name so the caller sets no attribute for it", () => {
expect(clampSpanLabel("nonsense", "anything")).toBeUndefined();
expect(clampSpanLabel("stdout", "secret output")).toBeUndefined();
});
});