mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
fix(ida): attribute startup failures and retain cleanup ownership
Attribute shared startup failures separately for each accepted operation. Preserve the original stage as a compact diagnostic, with typed timeout budgets, limits, output, recovery and exact cleanup dependencies. Link cancellation wrappers to the originating live cleanup owner, including release before and after wrapping. Remove the redundant typed-error branch. Validated 130 focused tests, static checks, docs and five conformance controls; four source controls rerun after fixture type correction. Public CLI uses a loopback supervisor; no native IDA engine verified. Refs: #4614, #4633
This commit is contained in:
1 parent
3e8236639a
commit
8f65d7f256
5 files changed
+515
-13
No files matched your search
@@ -8,6 +8,8 @@ Choose one lifecycle mode in an upstream MCP JSON registration. Set `REA_IDA_MCP
|
||||
|
||||
Each mode accepts one upstream tool profile, checked against `tools/list` before any analysis runs. The attached profile requires the legacy GUI tool names; upstream 2.0.0 renamed those tools, and REA has not established an equivalent attached-mode lifecycle and ownership contract for that catalog. It therefore fails closed with the missing tool names in the error diagnostics.
|
||||
|
||||
Startup failures identify the accepted REA analysis operation. Missing-profile and typed startup transport failures retain the original stage in `details.diagnostics.startup_operation`; existing diagnostics, timeout budgets and resource limits remain available. Startup remains shared within a session; each caller receives its own operation attribution. Cancellation retains live cleanup ownership, so a successful retry releases the originating cancellation outcome as well.
|
||||
|
||||
The headless profile is defined by the database-supervisor API, not by an upstream version string. The upstream source snapshot [`c133c385`](https://github.com/mrexodia/ida-pro-mcp/tree/c133c3853faa111a9b00ee615c013b720d0c4acd) includes `idb_close` (introduced by [upstream PR #495](https://github.com/mrexodia/ida-pro-mcp/pull/495)) alongside `idb_open` and `idb_list`. Earlier builds may report the same `2.0.0` version while lacking that tool, so REA checks the live catalog rather than treating a version number or branch name as a compatibility guarantee.
|
||||
|
||||
| Mode | Upstream profile | Known working |
|
||||
|
||||
+64
-12
@@ -12,6 +12,8 @@ import {
|
||||
AnalysisProtocolError,
|
||||
AnalysisOutputError,
|
||||
AnalysisInputError,
|
||||
AnalysisTimeoutError,
|
||||
AnalysisResourceConstraintError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
|
||||
@@ -291,7 +293,7 @@ export class IdaSessionClient implements AnalysisClient {
|
||||
parameters,
|
||||
};
|
||||
try {
|
||||
if (!readyOnly) await this.#start();
|
||||
if (!readyOnly) await this.#start(operation);
|
||||
const before = await this.#observe(options);
|
||||
failureContext.metadataBefore = before;
|
||||
if (wasCancelled(options)) throw new AnalysisCancelledError(operation);
|
||||
@@ -385,7 +387,7 @@ export class IdaSessionClient implements AnalysisClient {
|
||||
context: IdaOperationFailureContext,
|
||||
) {
|
||||
let failure: AnalysisError;
|
||||
if (wasCancelled(options))
|
||||
if (wasCancelled(options)) {
|
||||
failure = new AnalysisCancelledError(operation, {
|
||||
cause,
|
||||
...(cause instanceof AnalysisError
|
||||
@@ -404,7 +406,11 @@ export class IdaSessionClient implements AnalysisClient {
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
else if (
|
||||
if (cause instanceof AnalysisError)
|
||||
failure
|
||||
.bindCleanupDependencies([cause])
|
||||
.bindCleanupResourceOwner(cause);
|
||||
} else if (
|
||||
cause instanceof ProviderAdapterError &&
|
||||
cause.operation === "transport"
|
||||
)
|
||||
@@ -419,13 +425,6 @@ export class IdaSessionClient implements AnalysisClient {
|
||||
"Check the configured upstream MCP registration and selected target; see docs/ida-provider.md.",
|
||||
},
|
||||
});
|
||||
else if (
|
||||
cause instanceof AnalysisError &&
|
||||
(cause.partialObservation !== undefined ||
|
||||
cause.cleanupIncomplete ||
|
||||
cause.capturedOutput !== undefined)
|
||||
)
|
||||
failure = cause;
|
||||
else if (cause instanceof AnalysisError) failure = cause;
|
||||
else if (cause instanceof z.ZodError)
|
||||
failure = new AnalysisOutputError(
|
||||
@@ -506,9 +505,62 @@ export class IdaSessionClient implements AnalysisClient {
|
||||
return err(failure);
|
||||
}
|
||||
|
||||
#start(): Promise<void> {
|
||||
async #start(operation: string): Promise<void> {
|
||||
this.#startup ??= this.#initialize();
|
||||
return this.#startup;
|
||||
try {
|
||||
await this.#startup;
|
||||
} catch (cause: unknown) {
|
||||
if (
|
||||
!(cause instanceof AnalysisTimeoutError) &&
|
||||
!(cause instanceof AnalysisResourceConstraintError) &&
|
||||
!(cause instanceof ProviderAdapterError)
|
||||
)
|
||||
throw cause;
|
||||
// Transport adapters already receive caller attribution in #failure.
|
||||
if (
|
||||
cause instanceof ProviderAdapterError &&
|
||||
cause.operation === "transport"
|
||||
)
|
||||
throw cause;
|
||||
const options = {
|
||||
cause,
|
||||
capturedOutput: cause.capturedOutput,
|
||||
partialObservation: cause.partialObservation,
|
||||
cleanup: cause.cleanup,
|
||||
...(cause.userMessage === undefined
|
||||
? {}
|
||||
: { userMessage: cause.userMessage }),
|
||||
...(cause.remediationAction === undefined
|
||||
? {}
|
||||
: { remediationAction: cause.remediationAction }),
|
||||
diagnostics: {
|
||||
...cause.diagnostics,
|
||||
startup_operation: cause.operation,
|
||||
},
|
||||
};
|
||||
// Startup is shared, but each accepted operation owns its attribution.
|
||||
// Keep the original cached failure unchanged for later callers.
|
||||
const failure =
|
||||
cause instanceof AnalysisTimeoutError
|
||||
? new AnalysisTimeoutError(operation, cause.timeoutMs, options)
|
||||
: cause instanceof AnalysisResourceConstraintError
|
||||
? new AnalysisResourceConstraintError(
|
||||
operation,
|
||||
cause.resource,
|
||||
cause.reason,
|
||||
cause.reportedLimits,
|
||||
{
|
||||
...options,
|
||||
...(cause.retryable === undefined
|
||||
? {}
|
||||
: { retryable: cause.retryable }),
|
||||
},
|
||||
)
|
||||
: new ProviderAdapterError(cause.providerId, operation, options);
|
||||
throw failure
|
||||
.bindCleanupDependencies([cause])
|
||||
.bindCleanupResourceOwner(cause);
|
||||
}
|
||||
}
|
||||
|
||||
async #initialize(): Promise<void> {
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import { expect, it, onTestFinished } from "vitest";
|
||||
import { rm } from "node:fs/promises";
|
||||
import {
|
||||
createIdaTarget,
|
||||
RecordingIdaMcp,
|
||||
} from "../../tests/fixtures/idaMcp.js";
|
||||
import {
|
||||
AnalysisTimeoutError,
|
||||
AnalysisResourceConstraintError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js";
|
||||
import { IdaSessionClient } from "./IdaSessionClient.js";
|
||||
|
||||
it.each(["timeout", "resource"] as const)(
|
||||
"attributes a cached startup %s to each caller without losing facts or live cleanup",
|
||||
async (kind) => {
|
||||
const { root, target } = await createIdaTarget();
|
||||
const sourceOptions = {
|
||||
diagnostics: { stage_fact: "original" },
|
||||
capturedOutput: {
|
||||
stdout: "original output",
|
||||
stderr: "original warning",
|
||||
truncated: true,
|
||||
},
|
||||
cleanup: { reason: "release denied", resources: ["owned transport"] },
|
||||
userMessage: "original explanation",
|
||||
remediationAction: "original recovery",
|
||||
};
|
||||
const primary =
|
||||
kind === "timeout"
|
||||
? new AnalysisTimeoutError("ida_mcp_connect", 123, sourceOptions)
|
||||
: new AnalysisResourceConstraintError(
|
||||
"ida_mcp_catalog",
|
||||
"transport",
|
||||
"original byte limit",
|
||||
{ max_response_bytes: 456 },
|
||||
{ ...sourceOptions, retryable: false },
|
||||
);
|
||||
let permitRelease = false;
|
||||
const lease = new AnalysisCleanupLease(async () => {
|
||||
if (!permitRelease) throw new Error("release denied");
|
||||
}, ["owned transport"]);
|
||||
lease.bindCleanupError(primary);
|
||||
const producer = new RecordingIdaMcp(target, "headless");
|
||||
producer.connect = async () => {
|
||||
producer.connects++;
|
||||
throw primary;
|
||||
};
|
||||
const client = new IdaSessionClient(
|
||||
{
|
||||
url: "http://127.0.0.1:12345/mcp",
|
||||
headers: {},
|
||||
mode: "headless",
|
||||
timeoutMs: 1000,
|
||||
workspaceRoot: root,
|
||||
},
|
||||
target,
|
||||
producer,
|
||||
);
|
||||
onTestFinished(async () => {
|
||||
permitRelease = true;
|
||||
await lease.release();
|
||||
await client.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
});
|
||||
const first = await client.execute("procedure_pseudo_code", {
|
||||
procedure: "main",
|
||||
});
|
||||
const second = await client.execute("list_procedures", {});
|
||||
if (first.ok || second.ok) throw new Error("Expected startup failure");
|
||||
expect(producer.connects).toBe(1);
|
||||
expect(producer.calls).toEqual([]);
|
||||
expect(primary.operation).toBe(
|
||||
kind === "timeout" ? "ida_mcp_connect" : "ida_mcp_catalog",
|
||||
);
|
||||
for (const [error, operation] of [
|
||||
[first.error, "procedure_pseudo_code"],
|
||||
[second.error, "list_procedures"],
|
||||
] as const) {
|
||||
expect(error.cause).toBe(primary);
|
||||
expect(projectAnalysisError(error)).toMatchObject({
|
||||
code: "cleanup_incomplete",
|
||||
details: {
|
||||
operation,
|
||||
resources: ["owned transport"],
|
||||
diagnostics: {
|
||||
stage_fact: "original",
|
||||
startup_operation: primary.operation,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
await expect(lease.release()).rejects.toThrow("release denied");
|
||||
expect(projectAnalysisError(first.error).code).toBe("cleanup_incomplete");
|
||||
permitRelease = true;
|
||||
await lease.release();
|
||||
for (const [error, operation] of [
|
||||
[first.error, "procedure_pseudo_code"],
|
||||
[second.error, "list_procedures"],
|
||||
] as const) {
|
||||
expect(projectAnalysisError(error)).toMatchObject({
|
||||
code: kind === "timeout" ? "provider_timeout" : "resource_constraint",
|
||||
message: projectAnalysisError(primary).message,
|
||||
remediation: { action: "original recovery" },
|
||||
details: { operation, captured_output: sourceOptions.capturedOutput },
|
||||
});
|
||||
expect(projectAnalysisError(error).details).not.toHaveProperty(
|
||||
"resources",
|
||||
);
|
||||
}
|
||||
expect(projectAnalysisError(first.error)).toMatchObject(
|
||||
kind === "timeout"
|
||||
? { retryable: true, details: { timeout_ms: 123 } }
|
||||
: {
|
||||
retryable: false,
|
||||
details: {
|
||||
resource: "transport",
|
||||
reported_limits: { max_response_bytes: 456 },
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["pending", "released"] as const)(
|
||||
"keeps cancellation linked to the exact startup cleanup owner when %s",
|
||||
async (state) => {
|
||||
const { root, target } = await createIdaTarget();
|
||||
const controller = new AbortController();
|
||||
const primary = new AnalysisTimeoutError("ida_mcp_connect", 123, {
|
||||
cleanup: { reason: "release denied", resources: ["owned transport"] },
|
||||
capturedOutput: {
|
||||
stdout: "",
|
||||
stderr: "original warning",
|
||||
truncated: false,
|
||||
},
|
||||
});
|
||||
let permitRelease = state === "released";
|
||||
const lease = new AnalysisCleanupLease(async () => {
|
||||
if (!permitRelease) throw new Error("release denied");
|
||||
}, ["owned transport"]);
|
||||
lease.bindCleanupError(primary);
|
||||
const producer = new RecordingIdaMcp(target, "headless");
|
||||
producer.connect = async () => {
|
||||
if (state === "released") await lease.release();
|
||||
controller.abort("caller reason");
|
||||
throw primary;
|
||||
};
|
||||
const client = new IdaSessionClient(
|
||||
{
|
||||
url: "http://127.0.0.1:12345/mcp",
|
||||
headers: {},
|
||||
mode: "headless",
|
||||
timeoutMs: 1000,
|
||||
workspaceRoot: root,
|
||||
},
|
||||
target,
|
||||
producer,
|
||||
);
|
||||
onTestFinished(async () => {
|
||||
permitRelease = true;
|
||||
await lease.release();
|
||||
await client.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
});
|
||||
const result = await client.execute(
|
||||
"procedure_pseudo_code",
|
||||
{ procedure: "main" },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
if (result.ok) throw new Error("Expected cancellation");
|
||||
if (state === "pending") {
|
||||
expect(projectAnalysisError(result.error).code).toBe(
|
||||
"cleanup_incomplete",
|
||||
);
|
||||
await expect(lease.release()).rejects.toThrow("release denied");
|
||||
expect(projectAnalysisError(result.error).code).toBe(
|
||||
"cleanup_incomplete",
|
||||
);
|
||||
permitRelease = true;
|
||||
await lease.release();
|
||||
}
|
||||
expect(projectAnalysisError(result.error)).toMatchObject({
|
||||
code: "cancelled",
|
||||
details: {
|
||||
operation: "procedure_pseudo_code",
|
||||
captured_output: { stderr: "original warning" },
|
||||
},
|
||||
});
|
||||
expect(projectAnalysisError(result.error).details).not.toHaveProperty(
|
||||
"resources",
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -67,7 +67,10 @@ describe("headless IDA timeout through the one-shot CLI", () => {
|
||||
details: {
|
||||
cleanup: "incomplete",
|
||||
execution_failure: "provider_timeout",
|
||||
operation: "idb_open",
|
||||
operation: "procedure_pseudo_code",
|
||||
diagnostics: {
|
||||
startup_operation: "idb_open",
|
||||
},
|
||||
timeout_ms: 500,
|
||||
resources: [expect.stringContaining(workspaceRoot)],
|
||||
cleanup_error: {
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
import { createServer } from "node:http";
|
||||
import { expect } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { workspaceCliTest } from "../../support/cli/workspaceCliFixture.js";
|
||||
import { elf } from "../../../src/domain/binaryTarget.fixture.js";
|
||||
|
||||
const tools = [
|
||||
"idb_open",
|
||||
"idb_list",
|
||||
"idb_close",
|
||||
"server_health",
|
||||
"list_funcs",
|
||||
"lookup_funcs",
|
||||
"find_regex",
|
||||
"decompile",
|
||||
"disasm",
|
||||
"callees",
|
||||
"xrefs_to",
|
||||
];
|
||||
const requestSchema = z.object({
|
||||
id: z.union([z.string(), z.number()]).optional(),
|
||||
method: z.string(),
|
||||
params: z
|
||||
.object({
|
||||
protocolVersion: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
arguments: z.record(z.string(), z.unknown()).optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
type StartupMode =
|
||||
| "missing-profile"
|
||||
| "connect-timeout"
|
||||
| "catalog-timeout"
|
||||
| "catalog-refused"
|
||||
| "success";
|
||||
|
||||
const createStartupSupervisor = async (mode: StartupMode) => {
|
||||
const calls: string[] = [];
|
||||
let database: string | undefined;
|
||||
let input: string | undefined;
|
||||
const server = createServer(async (request, response) => {
|
||||
if (request.method !== "POST") {
|
||||
response.writeHead(405).end();
|
||||
return;
|
||||
}
|
||||
let body = "";
|
||||
for await (const chunk of request) body += String(chunk);
|
||||
const rpc = requestSchema.parse(JSON.parse(body));
|
||||
if (rpc.id === undefined) {
|
||||
response.writeHead(202).end();
|
||||
return;
|
||||
}
|
||||
const send = (result: unknown) =>
|
||||
response
|
||||
.writeHead(200, { "Content-Type": "application/json" })
|
||||
.end(JSON.stringify({ jsonrpc: "2.0", id: rpc.id, result }));
|
||||
if (rpc.method === "initialize") {
|
||||
if (mode === "connect-timeout") return;
|
||||
send({
|
||||
protocolVersion: rpc.params?.protocolVersion,
|
||||
capabilities: { tools: {} },
|
||||
serverInfo: { name: "ida-startup-fixture", version: "1" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (rpc.method === "tools/list") {
|
||||
if (mode === "catalog-timeout") return;
|
||||
if (mode === "catalog-refused") {
|
||||
response.writeHead(200, { "Content-Type": "application/json" }).end(
|
||||
JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: rpc.id,
|
||||
error: { code: -32603, message: "fixture catalog refused" },
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
send({
|
||||
tools: tools
|
||||
.filter((name) => mode !== "missing-profile" || name !== "list_funcs")
|
||||
.map((name) => ({
|
||||
name,
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
})),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const name = rpc.params?.name;
|
||||
const args = rpc.params?.arguments;
|
||||
if (name === undefined || args === undefined)
|
||||
throw new Error("Unexpected RPC");
|
||||
calls.push(name);
|
||||
let result: unknown;
|
||||
if (name === "idb_open") {
|
||||
database = z.string().parse(args.preferred_session_id);
|
||||
input = z.string().parse(args.input_path);
|
||||
result = {
|
||||
success: true,
|
||||
session: { session_id: database, input_path: input },
|
||||
};
|
||||
} else if (name === "idb_list")
|
||||
result = {
|
||||
sessions:
|
||||
database === undefined
|
||||
? []
|
||||
: [
|
||||
{
|
||||
session_id: database,
|
||||
input_path: input,
|
||||
owned: true,
|
||||
backend: "worker",
|
||||
is_active: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
else if (name === "idb_close") {
|
||||
result = {
|
||||
success: true,
|
||||
session_id: database,
|
||||
saved: null,
|
||||
owned: true,
|
||||
backend: "worker",
|
||||
};
|
||||
database = undefined;
|
||||
} else if (name === "server_health")
|
||||
result = {
|
||||
status: "ok",
|
||||
input_path: input,
|
||||
idb_path: input + ".i64",
|
||||
module: "target.elf",
|
||||
imagebase: "0x1000",
|
||||
auto_analysis_ready: true,
|
||||
};
|
||||
else if (name === "lookup_funcs")
|
||||
result = z
|
||||
.array(z.string())
|
||||
.parse(args.queries)
|
||||
.map((query) => ({
|
||||
query,
|
||||
fn: { addr: "0x1000", name: "main", size: "0x20" },
|
||||
error: null,
|
||||
}));
|
||||
else if (name === "decompile")
|
||||
result = {
|
||||
code: "int main(void) { return 42; }",
|
||||
cursor: { done: true },
|
||||
};
|
||||
else throw new Error("Unexpected tool: " + name);
|
||||
send({ content: [{ type: "text", text: JSON.stringify(result) }] });
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
return {
|
||||
server,
|
||||
calls,
|
||||
get database() {
|
||||
return database;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
workspaceCliTest.for([
|
||||
"missing-profile",
|
||||
"connect-timeout",
|
||||
"catalog-timeout",
|
||||
"catalog-refused",
|
||||
"success",
|
||||
] as const)(
|
||||
"attributes selected IDA startup to the public analysis: %s",
|
||||
async (mode, { cli, workspace }) => {
|
||||
const supervisor = await createStartupSupervisor(mode);
|
||||
const { server, calls } = supervisor;
|
||||
try {
|
||||
const address = server.address();
|
||||
if (address === null || typeof address === "string")
|
||||
throw new Error("Missing server address");
|
||||
const workspaceRoot = await workspace.mkdir("ida-workspaces");
|
||||
const registration = await workspace.write(
|
||||
"ida.json",
|
||||
JSON.stringify({
|
||||
url: `http://127.0.0.1:${address.port}/mcp`,
|
||||
mode: "headless",
|
||||
timeoutMs: 500,
|
||||
workspaceRoot,
|
||||
}),
|
||||
);
|
||||
const target = await workspace.write("target.elf", elf(2, 1, 62));
|
||||
const result = await cli.run({
|
||||
arguments: ["decompile", target, "main", "--provider", "ida", "--json"],
|
||||
environment: {
|
||||
...workspace.environment,
|
||||
REA_IDA_MCP_CONFIG: registration,
|
||||
REA_LOG_LEVEL: "silent",
|
||||
},
|
||||
timeoutMs: 15000,
|
||||
});
|
||||
expect(result.stderr).toBe("");
|
||||
expect(supervisor.database).toBeUndefined();
|
||||
if (mode === "success") {
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.json).toMatchObject({
|
||||
operation: "procedure_pseudo_code",
|
||||
normalized_result: "int main(void) { return 42; }",
|
||||
});
|
||||
expect(calls).toContain("decompile");
|
||||
expect(calls).toContain("idb_close");
|
||||
return;
|
||||
}
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(calls).toEqual([]);
|
||||
expect(result.json).toMatchObject({
|
||||
code: mode.endsWith("timeout")
|
||||
? "provider_timeout"
|
||||
: "execution_failure",
|
||||
details: { operation: "procedure_pseudo_code" },
|
||||
});
|
||||
if (mode === "missing-profile")
|
||||
expect(result.json).toMatchObject({
|
||||
details: {
|
||||
diagnostics: {
|
||||
missing_tools: ["list_funcs"],
|
||||
startup_operation: "initialize",
|
||||
},
|
||||
},
|
||||
});
|
||||
if (mode.endsWith("timeout"))
|
||||
expect(result.json).toMatchObject({
|
||||
retryable: true,
|
||||
details: {
|
||||
timeout_ms: 500,
|
||||
captured_output: { stdout: "", stderr: "", truncated: false },
|
||||
diagnostics: {
|
||||
startup_operation:
|
||||
mode === "connect-timeout"
|
||||
? "ida_mcp_connect"
|
||||
: "ida_mcp_catalog",
|
||||
},
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
server.close((error) =>
|
||||
error === undefined ? resolve() : reject(error),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user