* fix(core): make "error" the canonical terminal status on a mid-session crash (#1490) On a real mid-session transport crash many SDK transports fire BOTH `onclose` and `onerror` in a transport-dependent order. The `onclose` guard (`if status !== "disconnected"`) let a trailing `onclose` downgrade a crash's `"error"` status back to `"disconnected"`, so the persistent terminal status differed by ordering: - onerror-then-onclose → "disconnected" - onclose-then-onerror → "error" `onclose` now treats `"error"` as terminal (only `"connected"`/ `"connecting"` → `"disconnected"`), so `"error"` is the canonical resting status in both orderings. It still emits the `disconnect` event exactly once so session-teardown consumers fire identically either way. Because the state managers gated list/message/subscription/task teardown on `getStatus() === "disconnected"`, holding the status at `"error"` would have left them stale in the onerror-first ordering — swapping one ordering-dependence for another. Introduce `isTerminalStatus()` (disconnected || error) and key every teardown guard off it so they clear identically regardless of crash ordering. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(core): single disconnect event when explicitly disconnecting from "error" (#1490 re-review) Addresses the non-blocking observation from the PR review: explicitly calling disconnect() from a held-"error" status could fire the `disconnect` event twice. close() can trigger the transport's onclose synchronously; onclose (holding status at "error") fired `disconnect`, then disconnect()'s own guard saw status still != "disconnected" and fired it again — a timing-dependence on whether the SDK closes sync. Add a `disconnecting` ownership flag: onclose defers its status set + `disconnect` event to disconnect() while it is set, so the explicit-disconnect path emits exactly one `disconnect` regardless of sync/async close. Crash-path onclose (flag unset) is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(core): deterministically pin the sync-close disconnect double-fire (#1490 re-review) The real-stdio test asserts the single-disconnect invariant but may not exercise the sync-close branch (the one that double-fired) if the SDK stdio transport closes asynchronously. Add a test that overrides the SDK client's close() to invoke the transport's onclose synchronously, forcing the exact timing the `disconnecting` flag guards. Verified it asserts 2 (fails) without the flag and 1 (passes) with it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b4716a1405
commit
dc31c28102
13 changed files with 447 additions and 51 deletions
|
|
@ -182,11 +182,24 @@ describe("ManagedPromptsState", () => {
|
|||
expect(state.getPrompts()).toEqual([]);
|
||||
});
|
||||
|
||||
it("statusChange to other (non-disconnected) values does not clear prompts", async () => {
|
||||
it("statusChange to error clears prompts (error is terminal, #1490)", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queuePromptPages({ prompts: [prompt("a")] });
|
||||
await state.refresh();
|
||||
expect(state.getPrompts()).toHaveLength(1);
|
||||
|
||||
const changePromise = waitForPromptsChange(state);
|
||||
client.setStatus("error");
|
||||
const next = await changePromise;
|
||||
expect(next).toEqual([]);
|
||||
expect(state.getPrompts()).toEqual([]);
|
||||
});
|
||||
|
||||
it("statusChange to a non-terminal value (connecting) does not clear prompts", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queuePromptPages({ prompts: [prompt("a")] });
|
||||
await state.refresh();
|
||||
client.setStatus("connecting");
|
||||
expect(state.getPrompts().map((p) => p.name)).toEqual(["a"]);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -136,11 +136,24 @@ describe("ManagedRequestorTasksState", () => {
|
|||
expect(state.getTasks()).toEqual([]);
|
||||
});
|
||||
|
||||
it("statusChange to other values does not clear tasks", async () => {
|
||||
it("statusChange to error clears tasks (error is terminal, #1490)", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueTaskPages({ tasks: [task("t1")] });
|
||||
await state.refresh();
|
||||
expect(state.getTasks()).toHaveLength(1);
|
||||
|
||||
const changePromise = waitForChange(state);
|
||||
client.setStatus("error");
|
||||
const next = await changePromise;
|
||||
expect(next).toEqual([]);
|
||||
expect(state.getTasks()).toEqual([]);
|
||||
});
|
||||
|
||||
it("statusChange to a non-terminal value (connecting) does not clear tasks", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueTaskPages({ tasks: [task("t1")] });
|
||||
await state.refresh();
|
||||
client.setStatus("connecting");
|
||||
expect(state.getTasks().map((t) => t.taskId)).toEqual(["t1"]);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -196,11 +196,24 @@ describe("ManagedResourceTemplatesState", () => {
|
|||
expect(state.getResourceTemplates()).toEqual([]);
|
||||
});
|
||||
|
||||
it("statusChange to other values does not clear templates", async () => {
|
||||
it("statusChange to error clears templates (error is terminal, #1490)", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueResourceTemplatePages({ resourceTemplates: [template("a")] });
|
||||
await state.refresh();
|
||||
expect(state.getResourceTemplates()).toHaveLength(1);
|
||||
|
||||
const changePromise = waitForChange(state);
|
||||
client.setStatus("error");
|
||||
const next = await changePromise;
|
||||
expect(next).toEqual([]);
|
||||
expect(state.getResourceTemplates()).toEqual([]);
|
||||
});
|
||||
|
||||
it("statusChange to a non-terminal value (connecting) does not clear templates", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueResourceTemplatePages({ resourceTemplates: [template("a")] });
|
||||
await state.refresh();
|
||||
client.setStatus("connecting");
|
||||
expect(state.getResourceTemplates().map((t) => t.name)).toEqual(["a"]);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -189,11 +189,24 @@ describe("ManagedResourcesState", () => {
|
|||
expect(state.getResources()).toEqual([]);
|
||||
});
|
||||
|
||||
it("statusChange to other (non-disconnected) values does not clear resources", async () => {
|
||||
it("statusChange to error clears resources (error is terminal, #1490)", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueResourcePages({ resources: [resource("a://1")] });
|
||||
await state.refresh();
|
||||
expect(state.getResources()).toHaveLength(1);
|
||||
|
||||
const changePromise = waitForResourcesChange(state);
|
||||
client.setStatus("error");
|
||||
const next = await changePromise;
|
||||
expect(next).toEqual([]);
|
||||
expect(state.getResources()).toEqual([]);
|
||||
});
|
||||
|
||||
it("statusChange to a non-terminal value (connecting) does not clear resources", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueResourcePages({ resources: [resource("a://1")] });
|
||||
await state.refresh();
|
||||
client.setStatus("connecting");
|
||||
expect(state.getResources().map((r) => r.uri)).toEqual(["a://1"]);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -242,11 +242,24 @@ describe("ManagedToolsState", () => {
|
|||
expect(state.getTools()).toEqual([]);
|
||||
});
|
||||
|
||||
it("statusChange to other (non-disconnected) values does not clear tools", async () => {
|
||||
it("statusChange to error clears tools (error is terminal, #1490)", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueToolPages({ tools: [tool("a")] });
|
||||
await state.refresh();
|
||||
expect(state.getTools()).toHaveLength(1);
|
||||
|
||||
const changePromise = waitForToolsChange(state);
|
||||
client.setStatus("error");
|
||||
const next = await changePromise;
|
||||
expect(next).toEqual([]);
|
||||
expect(state.getTools()).toEqual([]);
|
||||
});
|
||||
|
||||
it("statusChange to a non-terminal value (connecting) does not clear tools", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueToolPages({ tools: [tool("a")] });
|
||||
await state.refresh();
|
||||
client.setStatus("connecting");
|
||||
expect(state.getTools().map((t) => t.name)).toEqual(["a"]);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -177,9 +177,20 @@ describe("MessageLogState", () => {
|
|||
expect(dispatched).toBe(true);
|
||||
});
|
||||
|
||||
it("does not clear on non-disconnected status changes", () => {
|
||||
it("clears on a mid-session crash (statusChange -> error, #1490)", () => {
|
||||
client.setStatus("connected");
|
||||
client.dispatchTypedEvent("message", notificationEntry());
|
||||
expect(state.getMessages()).toHaveLength(1);
|
||||
let dispatched = false;
|
||||
state.addEventListener("messagesChange", () => (dispatched = true));
|
||||
client.setStatus("error");
|
||||
expect(state.getMessages()).toEqual([]);
|
||||
expect(dispatched).toBe(true);
|
||||
});
|
||||
|
||||
it("does not clear on a non-terminal status change (connecting)", () => {
|
||||
client.dispatchTypedEvent("message", notificationEntry());
|
||||
client.setStatus("connecting");
|
||||
expect(state.getMessages()).toHaveLength(1);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -81,11 +81,21 @@ describe("PagedPromptsState", () => {
|
|||
expect(await changePromise).toEqual([]);
|
||||
});
|
||||
|
||||
it("statusChange to non-disconnected values is a no-op", async () => {
|
||||
it("statusChange to error clears prompts (error is terminal, #1490)", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queuePromptPages({ prompts: [prompt("a")] });
|
||||
await state.loadPage();
|
||||
const changePromise = waitForChange(state);
|
||||
client.setStatus("error");
|
||||
expect(await changePromise).toEqual([]);
|
||||
expect(state.getPrompts()).toEqual([]);
|
||||
});
|
||||
|
||||
it("statusChange to a non-terminal value (connecting) is a no-op", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queuePromptPages({ prompts: [prompt("a")] });
|
||||
await state.loadPage();
|
||||
client.setStatus("connecting");
|
||||
expect(state.getPrompts().map((p) => p.name)).toEqual(["a"]);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -148,6 +148,16 @@ describe("PagedRequestorTasksState", () => {
|
|||
expect(state.getNextCursor()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("statusChange to error clears tasks and nextCursor (error is terminal, #1490)", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueTaskPages({ tasks: [task("t1")], nextCursor: "c1" });
|
||||
await state.loadPage();
|
||||
const changePromise = waitForChange(state);
|
||||
client.setStatus("error");
|
||||
expect(await changePromise).toEqual([]);
|
||||
expect(state.getNextCursor()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("destroy stops listening and clears state", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueTaskPages({ tasks: [task("t1")] });
|
||||
|
|
|
|||
|
|
@ -89,11 +89,21 @@ describe("PagedResourceTemplatesState", () => {
|
|||
expect(await changePromise).toEqual([]);
|
||||
});
|
||||
|
||||
it("statusChange to non-disconnected values is a no-op", async () => {
|
||||
it("statusChange to error clears templates (error is terminal, #1490)", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueResourceTemplatePages({ resourceTemplates: [template("a")] });
|
||||
await state.loadPage();
|
||||
const changePromise = waitForChange(state);
|
||||
client.setStatus("error");
|
||||
expect(await changePromise).toEqual([]);
|
||||
expect(state.getResourceTemplates()).toEqual([]);
|
||||
});
|
||||
|
||||
it("statusChange to a non-terminal value (connecting) is a no-op", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueResourceTemplatePages({ resourceTemplates: [template("a")] });
|
||||
await state.loadPage();
|
||||
client.setStatus("connecting");
|
||||
expect(state.getResourceTemplates().map((t) => t.name)).toEqual(["a"]);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -83,11 +83,21 @@ describe("PagedResourcesState", () => {
|
|||
expect(await changePromise).toEqual([]);
|
||||
});
|
||||
|
||||
it("statusChange to non-disconnected values is a no-op", async () => {
|
||||
it("statusChange to error clears resources (error is terminal, #1490)", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueResourcePages({ resources: [resource("a://1")] });
|
||||
await state.loadPage();
|
||||
const changePromise = waitForChange(state);
|
||||
client.setStatus("error");
|
||||
expect(await changePromise).toEqual([]);
|
||||
expect(state.getResources()).toEqual([]);
|
||||
});
|
||||
|
||||
it("statusChange to a non-terminal value (connecting) is a no-op", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueResourcePages({ resources: [resource("a://1")] });
|
||||
await state.loadPage();
|
||||
client.setStatus("connecting");
|
||||
expect(state.getResources().map((r) => r.uri)).toEqual(["a://1"]);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -77,11 +77,21 @@ describe("PagedToolsState", () => {
|
|||
expect(state.getTools()).toEqual([]);
|
||||
});
|
||||
|
||||
it("statusChange to non-disconnected values is a no-op", async () => {
|
||||
it("statusChange to error clears tools (error is terminal, #1490)", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueToolPages({ tools: [tool("a")] });
|
||||
await state.loadPage();
|
||||
const changePromise = waitForChange(state);
|
||||
client.setStatus("error");
|
||||
expect(await changePromise).toEqual([]);
|
||||
expect(state.getTools()).toEqual([]);
|
||||
});
|
||||
|
||||
it("statusChange to a non-terminal value (connecting) is a no-op", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueToolPages({ tools: [tool("a")] });
|
||||
await state.loadPage();
|
||||
client.setStatus("connecting");
|
||||
expect(state.getTools().map((t) => t.name)).toEqual(["a"]);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -191,10 +191,22 @@ describe("ResourceSubscriptionsState", () => {
|
|||
expect(state.getSubscriptions()).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not clear subscriptions on non-disconnected status changes", () => {
|
||||
it("clears subscriptions on a mid-session crash (statusChange to error, #1490)", async () => {
|
||||
const state = new ResourceSubscriptionsState(client);
|
||||
client.dispatchTypedEvent("resourceSubscriptionsChange", ["file:///a"]);
|
||||
expect(state.getSubscriptions()).toHaveLength(1);
|
||||
|
||||
const changePromise = waitForSubscriptionsChange(state);
|
||||
client.setStatus("error");
|
||||
const next = await changePromise;
|
||||
expect(next).toEqual([]);
|
||||
expect(state.getSubscriptions()).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not clear subscriptions on a non-terminal status change (connecting)", () => {
|
||||
const state = new ResourceSubscriptionsState(client);
|
||||
client.dispatchTypedEvent("resourceSubscriptionsChange", ["file:///a"]);
|
||||
client.setStatus("connecting");
|
||||
expect(state.getSubscriptions()).toHaveLength(1);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -2612,6 +2612,199 @@ describe("InspectorClient", () => {
|
|||
await client.disconnect();
|
||||
});
|
||||
|
||||
it("holds status at error when a trailing onclose lands after onerror (#1490)", async () => {
|
||||
// The reverse ordering of the test above: onerror lands first (status
|
||||
// "error"), then the trailing onclose arrives. A bare
|
||||
// `if (status !== "disconnected")` onclose guard would downgrade "error"
|
||||
// back to "disconnected", so the persistent terminal status differed by
|
||||
// transport ordering. onclose must now treat "error" as terminal and
|
||||
// leave it untouched, while still emitting `disconnect` so teardown
|
||||
// consumers fire identically in both orderings.
|
||||
client = new InspectorClient(
|
||||
{
|
||||
type: "stdio",
|
||||
command: serverCommand.command,
|
||||
args: serverCommand.args,
|
||||
},
|
||||
{
|
||||
environment: { transport: createTransportNode },
|
||||
},
|
||||
);
|
||||
|
||||
const errors: Error[] = [];
|
||||
let disconnects = 0;
|
||||
client.addEventListener("error", (event) => {
|
||||
errors.push(event.detail);
|
||||
});
|
||||
client.addEventListener("disconnect", () => {
|
||||
disconnects++;
|
||||
});
|
||||
|
||||
await client.connect();
|
||||
expect(client.getStatus()).toBe("connected");
|
||||
|
||||
const baseTransport = (
|
||||
client as unknown as {
|
||||
baseTransport: {
|
||||
onclose?: () => void;
|
||||
onerror?: (error: Error) => void;
|
||||
};
|
||||
}
|
||||
).baseTransport;
|
||||
|
||||
// onerror first → status "error".
|
||||
const failure = new Error("stdio subprocess crashed");
|
||||
baseTransport.onerror?.(failure);
|
||||
expect(client.getStatus()).toBe("error");
|
||||
|
||||
// Trailing onclose must NOT downgrade "error" to "disconnected"...
|
||||
baseTransport.onclose?.();
|
||||
expect(client.getStatus()).toBe("error");
|
||||
// ...but must still fire `disconnect` exactly once so session teardown
|
||||
// (e.g. App resets the active server) happens regardless of ordering.
|
||||
expect(disconnects).toBe(1);
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0]).toBe(failure);
|
||||
|
||||
await client.disconnect();
|
||||
});
|
||||
|
||||
it("emits disconnect on a mid-session crash regardless of onclose/onerror ordering (#1490)", async () => {
|
||||
// Both real-world orderings must settle on the same observable outcome:
|
||||
// final status "error" and exactly one `disconnect` event.
|
||||
const run = async (order: "onclose-first" | "onerror-first") => {
|
||||
const c = new InspectorClient(
|
||||
{
|
||||
type: "stdio",
|
||||
command: serverCommand.command,
|
||||
args: serverCommand.args,
|
||||
},
|
||||
{ environment: { transport: createTransportNode } },
|
||||
);
|
||||
let disconnects = 0;
|
||||
c.addEventListener("disconnect", () => {
|
||||
disconnects++;
|
||||
});
|
||||
await c.connect();
|
||||
const bt = (
|
||||
c as unknown as {
|
||||
baseTransport: {
|
||||
onclose?: () => void;
|
||||
onerror?: (error: Error) => void;
|
||||
};
|
||||
}
|
||||
).baseTransport;
|
||||
const failure = new Error("crash");
|
||||
if (order === "onclose-first") {
|
||||
bt.onclose?.();
|
||||
bt.onerror?.(failure);
|
||||
} else {
|
||||
bt.onerror?.(failure);
|
||||
bt.onclose?.();
|
||||
}
|
||||
// Snapshot before the cleanup disconnect() below, which fires its own
|
||||
// disconnect events as it tears the (still-live) real transport down.
|
||||
const result = { status: c.getStatus(), disconnects };
|
||||
await c.disconnect();
|
||||
return result;
|
||||
};
|
||||
|
||||
const closeFirst = await run("onclose-first");
|
||||
const errorFirst = await run("onerror-first");
|
||||
|
||||
expect(closeFirst).toEqual({ status: "error", disconnects: 1 });
|
||||
expect(errorFirst).toEqual({ status: "error", disconnects: 1 });
|
||||
});
|
||||
|
||||
it("fires disconnect exactly once when explicitly disconnecting from a crashed (error) state (#1490)", async () => {
|
||||
// After a crash holds status at "error", close() can fire the transport's
|
||||
// onclose synchronously. onclose would emit `disconnect` (status held at
|
||||
// "error"), and then disconnect()'s own guard — seeing "error" still
|
||||
// != "disconnected" — would emit it a second time. The `disconnecting`
|
||||
// ownership flag must collapse that to exactly one event regardless of
|
||||
// whether the SDK closes synchronously.
|
||||
client = new InspectorClient(
|
||||
{
|
||||
type: "stdio",
|
||||
command: serverCommand.command,
|
||||
args: serverCommand.args,
|
||||
},
|
||||
{ environment: { transport: createTransportNode } },
|
||||
);
|
||||
await client.connect();
|
||||
|
||||
const baseTransport = (
|
||||
client as unknown as {
|
||||
baseTransport: { onerror?: (error: Error) => void };
|
||||
}
|
||||
).baseTransport;
|
||||
baseTransport.onerror?.(new Error("stdio subprocess crashed"));
|
||||
expect(client.getStatus()).toBe("error");
|
||||
|
||||
// Listener attached AFTER the crash so it counts only the explicit
|
||||
// disconnect()'s events.
|
||||
let disconnects = 0;
|
||||
client.addEventListener("disconnect", () => {
|
||||
disconnects++;
|
||||
});
|
||||
await client.disconnect();
|
||||
|
||||
expect(disconnects).toBe(1);
|
||||
expect(client.getStatus()).toBe("disconnected");
|
||||
});
|
||||
|
||||
it("fires disconnect exactly once on a synchronous close() from error (deterministic sync-close pin, #1490)", async () => {
|
||||
// The test above uses the real stdio transport, whose close() may invoke
|
||||
// onclose asynchronously — so it asserts the invariant but does not
|
||||
// deterministically exercise the sync-close branch that double-fired
|
||||
// (onclose firing INSIDE close(), before disconnect()'s guard runs).
|
||||
// Here we force that exact timing: override the SDK client's close() to
|
||||
// invoke the transport's onclose synchronously. Without the
|
||||
// `disconnecting` flag this asserts 2; with it, 1.
|
||||
client = new InspectorClient(
|
||||
{
|
||||
type: "stdio",
|
||||
command: serverCommand.command,
|
||||
args: serverCommand.args,
|
||||
},
|
||||
{ environment: { transport: createTransportNode } },
|
||||
);
|
||||
await client.connect();
|
||||
|
||||
const baseTransport = (
|
||||
client as unknown as {
|
||||
baseTransport: {
|
||||
onclose?: () => void;
|
||||
onerror?: (error: Error) => void;
|
||||
};
|
||||
}
|
||||
).baseTransport;
|
||||
// Put the client into the crashed "error" state.
|
||||
baseTransport.onerror?.(new Error("stdio subprocess crashed"));
|
||||
expect(client.getStatus()).toBe("error");
|
||||
|
||||
// Force close() to fire onclose synchronously (the sync-close branch),
|
||||
// then delegate to the real close() so the subprocess is still torn down.
|
||||
const sdkClient = (
|
||||
client as unknown as { client: { close: () => Promise<void> } }
|
||||
).client;
|
||||
const realClose = sdkClient.close.bind(sdkClient);
|
||||
sdkClient.close = async () => {
|
||||
baseTransport.onclose?.();
|
||||
await realClose();
|
||||
};
|
||||
|
||||
// Listener attached AFTER the crash so it counts only disconnect()'s events.
|
||||
let disconnects = 0;
|
||||
client.addEventListener("disconnect", () => {
|
||||
disconnects++;
|
||||
});
|
||||
await client.disconnect();
|
||||
|
||||
expect(disconnects).toBe(1);
|
||||
expect(client.getStatus()).toBe("disconnected");
|
||||
});
|
||||
|
||||
it("does not emit an error event when the handshake rejects connect()", async () => {
|
||||
// A transport whose start() rejects makes connect() reject (handshake
|
||||
// failure). The caller gets the reason via the rejected promise, so the
|
||||
|
|
|
|||
Loading…
Reference in a new issue