mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-04 10:48:20 +00:00
fix(daemon): name the conflicting daemon and the way out on a build conflict (#2277)
A client refused because another CBM build owns the account daemon printed only "Close all CBM sessions and commands, then retry." The reporter had a 0.10.3 daemon kept alive by a running Codex session while starting 0.11.0 from a terminal, and nothing in the message said which process that was, that an agent session held it, or that `daemon stop` exists. The refusal itself is correct and stays unchanged: session-managed daemons serving live sessions must never be killed by a newer client, and cbm_daemon_conflict_format() is validated byte-for-byte across generations on the HELLO wire, so its text cannot change without breaking old/new daemon pairs. The fix adds a separate, client-side remedy sentence built from the cross-build STATUS probe (cbm_daemon_conflict_remedy_format): active daemon pid, version, permanent vs session-managed, "already stopping", the pids of the CBM sessions holding it, the likely cause (another editor or agent launched a different install; compare `which -a codebase-memory-mcp` with each client's MCP config) and `daemon stop` once no session uses it. When STATUS does not answer, the remedy points at `daemon status`. It is appended on every user-facing conflict exit: the MCP/CLI bootstrap path (after the older-permanent auto-drain policy declines), the one-shot CLI exact-build cohort refusal and the MCP client cohort refusal (the path the reporter hit). Hooks skip the extra probe to keep their fail-open budget. The probe uses the `daemon status` budget (3 s): the daemon hashes the ~300 MB peer image before answering, which overran the 1 s connect budget in the real-binary proof. No identity or admission check changes; STATUS is already the authenticated, cross-build observability op behind `daemon status`. Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
1 parent
9d65f4cb62
commit
19d144477b
5 files changed
+237
-13
No files matched your search
@@ -93,6 +93,9 @@ typedef struct {
|
||||
cbm_daemon_runtime_connect_result_t connect_result;
|
||||
bool daemon_spawned;
|
||||
char message[CBM_DAEMON_CONFLICT_MESSAGE_SIZE];
|
||||
/* #2277: filled by the client on a CONFLICT only — who holds the
|
||||
* endpoint and how to clear it (cbm_daemon_conflict_remedy_format). */
|
||||
char remedy[CBM_DAEMON_CONFLICT_REMEDY_SIZE];
|
||||
} cbm_daemon_bootstrap_result_t;
|
||||
|
||||
/* A probe distinguishes an absent endpoint from a reserved endpoint whose
|
||||
|
||||
@@ -2857,6 +2857,73 @@ bool cbm_daemon_runtime_request_status(const cbm_daemon_ipc_endpoint_t *endpoint
|
||||
return true;
|
||||
}
|
||||
|
||||
/* "; CBM sessions using it: pids 1, 2 and 3 more" — empty when none are known. */
|
||||
static bool remedy_sessions_format(const cbm_daemon_runtime_status_t *active, char *out,
|
||||
size_t out_size) {
|
||||
out[0] = '\0';
|
||||
size_t listed = active->client_count;
|
||||
if (listed > CBM_DAEMON_CONTROL_CLIENT_CAP) {
|
||||
listed = CBM_DAEMON_CONTROL_CLIENT_CAP;
|
||||
}
|
||||
size_t used = 0;
|
||||
for (size_t i = 0; i < listed; i++) {
|
||||
int written = snprintf(out + used, out_size - used, "%s%lu",
|
||||
i == 0 ? "; CBM sessions using it: pids " : ", ",
|
||||
(unsigned long)active->client_pids[i]);
|
||||
if (written < 0 || (size_t)written >= out_size - used) {
|
||||
return false;
|
||||
}
|
||||
used += (size_t)written;
|
||||
}
|
||||
if (listed > 0 && active->committed_clients > listed) {
|
||||
int written = snprintf(out + used, out_size - used, " and %lu more",
|
||||
(unsigned long)(active->committed_clients - listed));
|
||||
if (written < 0 || (size_t)written >= out_size - used) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool cbm_daemon_conflict_remedy_format(const cbm_daemon_runtime_status_t *active, char *out,
|
||||
size_t out_size) {
|
||||
if (!out || out_size == 0) {
|
||||
return false;
|
||||
}
|
||||
out[0] = '\0';
|
||||
static const char install_hint[] =
|
||||
"Usually another editor or agent session (for example Codex or Claude Code) "
|
||||
"launched a different codebase-memory-mcp install: close it, or point every client "
|
||||
"at one binary (compare `which -a codebase-memory-mcp` with each client's MCP "
|
||||
"config).";
|
||||
int written;
|
||||
if (!active) {
|
||||
written = snprintf(out, out_size,
|
||||
"Run `codebase-memory-mcp daemon status` to see the active daemon and "
|
||||
"the sessions using it. %s `codebase-memory-mcp daemon stop` retires "
|
||||
"the daemon once no session uses it.",
|
||||
install_hint);
|
||||
} else {
|
||||
char sessions[160];
|
||||
if (!remedy_sessions_format(active, sessions, sizeof(sessions))) {
|
||||
return false;
|
||||
}
|
||||
const char *lifetime =
|
||||
active->permanent ? "permanent" : "session-managed, exits when its last session closes";
|
||||
written = snprintf(out, out_size,
|
||||
"Active daemon: pid %lu, version %s, %s%s%s. %s "
|
||||
"`codebase-memory-mcp daemon stop` retires the daemon once no session "
|
||||
"uses it.",
|
||||
(unsigned long)active->daemon_pid, active->semantic_version, lifetime,
|
||||
active->stopping ? ", already stopping" : "", sessions, install_hint);
|
||||
}
|
||||
if (written < 0 || (size_t)written >= out_size) {
|
||||
out[0] = '\0';
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool cbm_daemon_runtime_request_stop(const cbm_daemon_ipc_endpoint_t *endpoint,
|
||||
const cbm_daemon_build_identity_t *identity,
|
||||
uint32_t timeout_ms,
|
||||
|
||||
@@ -301,6 +301,17 @@ bool cbm_daemon_runtime_request_stop(const cbm_daemon_ipc_endpoint_t *endpoint,
|
||||
uint32_t timeout_ms,
|
||||
cbm_daemon_runtime_stop_result_t *result_out);
|
||||
|
||||
/* #2277: the actionable half of a version/build conflict refusal. The
|
||||
* conflict text itself (cbm_daemon_conflict_format) is wire-validated
|
||||
* byte-for-byte across generations and must not change, so the remedy is a
|
||||
* separate, client-side sentence built from the cross-build STATUS probe:
|
||||
* which daemon holds the endpoint (pid, version, lifetime), which CBM
|
||||
* sessions keep it alive, and how to clear it. active may be NULL when the
|
||||
* status probe did not answer. Returns false (out empty) on truncation. */
|
||||
#define CBM_DAEMON_CONFLICT_REMEDY_SIZE 640U
|
||||
bool cbm_daemon_conflict_remedy_format(const cbm_daemon_runtime_status_t *active, char *out,
|
||||
size_t out_size);
|
||||
|
||||
/* Performs the complete guarded first-participant handoff, starts listening
|
||||
* synchronously, then owns both that participant claim and its
|
||||
* accept/connection threads. All config scalar/text data is copied. endpoint
|
||||
|
||||
+69
-13
@@ -45,6 +45,10 @@ enum {
|
||||
MAIN_MAX_PORT = 65536,
|
||||
MAIN_PATH_CAP = 4096,
|
||||
MAIN_CONNECT_TIMEOUT_MS = 1000,
|
||||
/* #2277: the conflict-remedy STATUS probe runs only after a refusal, so it
|
||||
* gets the same budget as `daemon status`: the daemon hashes the peer's
|
||||
* (~300 MB) image before answering, which can exceed a 1 s connect budget. */
|
||||
MAIN_CONFLICT_STATUS_PROBE_TIMEOUT_MS = 3000,
|
||||
MAIN_STARTUP_TIMEOUT_MS = 10000,
|
||||
/* Backstop for waiting out a held startup transition — see
|
||||
* main_local_transition_acquire. Not a budget for healthy contention: a busy
|
||||
@@ -1698,6 +1702,10 @@ static bool main_semver_newer(const char *candidate, const char *active) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/* A conflict refusal plus its remedy (#2277), joined by one space. */
|
||||
#define MAIN_CLIENT_FAILURE_DETAIL_CAP \
|
||||
(CBM_DAEMON_CONFLICT_MESSAGE_SIZE + CBM_DAEMON_CONFLICT_REMEDY_SIZE + 1U)
|
||||
|
||||
/* A client that cannot reach the daemon must SAY SO, in the caller's own
|
||||
* protocol. An MCP client speaks JSON-RPC over stdout, and the old path
|
||||
* returned EXIT_FAILURE having written nothing at all: agents saw a transport
|
||||
@@ -1721,7 +1729,7 @@ static bool main_semver_newer(const char *candidate, const char *active) {
|
||||
* just the one that happened to be fixed first. */
|
||||
static void main_report_client_failure(cbm_daemon_process_role_t role, const char *detail) {
|
||||
if (cbm_daemon_process_role_requires_client(role)) {
|
||||
char escaped[CBM_DAEMON_CONFLICT_MESSAGE_SIZE * 2];
|
||||
char escaped[MAIN_CLIENT_FAILURE_DETAIL_CAP * 2];
|
||||
size_t out = 0;
|
||||
for (size_t i = 0; detail[i] && out + 2 < sizeof(escaped); i++) {
|
||||
unsigned char c = (unsigned char)detail[i];
|
||||
@@ -1746,10 +1754,35 @@ static void main_report_client_failure(cbm_daemon_process_role_t role, const cha
|
||||
|
||||
static void main_report_client_bootstrap_failure(cbm_daemon_process_role_t role,
|
||||
const cbm_daemon_bootstrap_result_t *result) {
|
||||
main_report_client_failure(role, (result && result->message[0])
|
||||
? result->message
|
||||
: "CBM daemon connection failed before the session was "
|
||||
"established");
|
||||
const char *message = (result && result->message[0])
|
||||
? result->message
|
||||
: "CBM daemon connection failed before the session was "
|
||||
"established";
|
||||
char detail[MAIN_CLIENT_FAILURE_DETAIL_CAP];
|
||||
(void)snprintf(detail, sizeof(detail), "%s%s%s", message,
|
||||
(result && result->remedy[0]) ? " " : "", result ? result->remedy : "");
|
||||
main_report_client_failure(role, detail);
|
||||
}
|
||||
|
||||
/* #2277: a conflict is a legitimate refusal (another build owns the account
|
||||
* daemon), but it must name that daemon and the way out. known_active is a
|
||||
* STATUS answer the caller already holds; without one this probes STATUS
|
||||
* (cross-build by design) itself. When nothing answers, the remedy falls
|
||||
* back to pointing at `daemon status`. */
|
||||
static void main_conflict_remedy_probe(const cbm_daemon_ipc_endpoint_t *endpoint,
|
||||
const cbm_daemon_build_identity_t *identity,
|
||||
const cbm_daemon_runtime_status_t *known_active, char *out,
|
||||
size_t out_size) {
|
||||
cbm_daemon_runtime_status_t probed;
|
||||
const cbm_daemon_runtime_status_t *active = known_active;
|
||||
if (!active && endpoint && identity &&
|
||||
cbm_daemon_runtime_request_status(endpoint, identity, MAIN_CONFLICT_STATUS_PROBE_TIMEOUT_MS,
|
||||
&probed)) {
|
||||
active = &probed;
|
||||
}
|
||||
if (!cbm_daemon_conflict_remedy_format(active, out, out_size)) {
|
||||
out[0] = '\0';
|
||||
}
|
||||
}
|
||||
|
||||
/* Client bootstrap with the upgrade policy: a CONFLICT against a PERMANENT
|
||||
@@ -1766,10 +1799,13 @@ static cbm_daemon_bootstrap_status_t main_client_bootstrap_with_upgrade(
|
||||
return status;
|
||||
}
|
||||
cbm_daemon_runtime_status_t active;
|
||||
if (!cbm_daemon_runtime_request_status(config->endpoint, config->identity,
|
||||
MAIN_CONNECT_TIMEOUT_MS, &active) ||
|
||||
!active.permanent ||
|
||||
bool active_known = cbm_daemon_runtime_request_status(config->endpoint, config->identity,
|
||||
MAIN_CONNECT_TIMEOUT_MS, &active);
|
||||
if (!active_known || !active.permanent ||
|
||||
!main_semver_newer(config->identity->semantic_version, active.semantic_version)) {
|
||||
main_conflict_remedy_probe(config->endpoint, config->identity,
|
||||
active_known ? &active : NULL, result->remedy,
|
||||
sizeof(result->remedy));
|
||||
return status;
|
||||
}
|
||||
if (report_lifecycle) {
|
||||
@@ -1786,9 +1822,16 @@ static cbm_daemon_bootstrap_status_t main_client_bootstrap_with_upgrade(
|
||||
!drain.accepted) {
|
||||
(void)fprintf(stderr, "codebase-memory-mcp: the active daemon did not accept the "
|
||||
"upgrade drain; run `codebase-memory-mcp daemon stop`\n");
|
||||
main_conflict_remedy_probe(config->endpoint, config->identity, &active, result->remedy,
|
||||
sizeof(result->remedy));
|
||||
return status;
|
||||
}
|
||||
return cbm_daemon_bootstrap_execute(config, result);
|
||||
status = cbm_daemon_bootstrap_execute(config, result);
|
||||
if (status == CBM_DAEMON_BOOTSTRAP_CONFLICT) {
|
||||
main_conflict_remedy_probe(config->endpoint, config->identity, NULL, result->remedy,
|
||||
sizeof(result->remedy));
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
/* One-shot CLI commands execute through the shared daemon, exactly like MCP
|
||||
@@ -1825,6 +1868,9 @@ static char *main_local_cli_daemon_execute(const char *tool_name, const char *ar
|
||||
(void)fprintf(stderr, "error: %s\n",
|
||||
bootstrap.message[0] ? bootstrap.message
|
||||
: "no CBM daemon connection for CLI execution");
|
||||
if (bootstrap.remedy[0]) {
|
||||
(void)fprintf(stderr, "hint: %s\n", bootstrap.remedy);
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
if (bootstrap.daemon_spawned && cbm_log_get_level() <= CBM_LOG_INFO) {
|
||||
@@ -2870,13 +2916,17 @@ int main(int argc, char **argv) {
|
||||
char message[CBM_DAEMON_CONFLICT_MESSAGE_SIZE];
|
||||
bool formatted = cohort_status == CBM_VERSION_COHORT_CONFLICT &&
|
||||
cbm_daemon_conflict_format(&cohort_conflict, message, sizeof(message));
|
||||
char remedy[CBM_DAEMON_CONFLICT_REMEDY_SIZE] = "";
|
||||
if (cohort_status == CBM_VERSION_COHORT_CONFLICT) {
|
||||
(void)cbm_version_cohort_log_conflict(&cohort_conflict);
|
||||
main_conflict_remedy_probe(local_endpoint, &local_identity, NULL, remedy,
|
||||
sizeof(remedy));
|
||||
}
|
||||
(void)fprintf(stderr, "codebase-memory-mcp: %s\n",
|
||||
(void)fprintf(stderr, "codebase-memory-mcp: %s%s%s\n",
|
||||
formatted ? message
|
||||
: "CLI exact-build admission could not be verified; retry "
|
||||
"after active CBM operations exit");
|
||||
"after active CBM operations exit",
|
||||
remedy[0] ? " " : "", remedy);
|
||||
goto local_cli_cleanup;
|
||||
}
|
||||
main_local_maintenance_context_init(&maintenance_context);
|
||||
@@ -3198,11 +3248,17 @@ int main(int argc, char **argv) {
|
||||
bool formatted =
|
||||
client_cohort_status == CBM_VERSION_COHORT_CONFLICT &&
|
||||
cbm_daemon_conflict_format(&client_cohort_conflict, message, sizeof(message));
|
||||
char remedy[CBM_DAEMON_CONFLICT_REMEDY_SIZE] = "";
|
||||
if (client_cohort_status == CBM_VERSION_COHORT_CONFLICT) {
|
||||
(void)cbm_version_cohort_log_conflict(&client_cohort_conflict);
|
||||
/* Hooks fail open on a tight budget; they skip the extra probe. */
|
||||
if (role != CBM_DAEMON_PROCESS_HOOK_CLIENT) {
|
||||
main_conflict_remedy_probe(endpoint, &identity, NULL, remedy, sizeof(remedy));
|
||||
}
|
||||
}
|
||||
(void)fprintf(stderr, "codebase-memory-mcp: %s\n",
|
||||
formatted ? message : "client exact-build admission failed");
|
||||
(void)fprintf(stderr, "codebase-memory-mcp: %s%s%s\n",
|
||||
formatted ? message : "client exact-build admission failed",
|
||||
remedy[0] ? " " : "", remedy);
|
||||
if (role == CBM_DAEMON_PROCESS_HOOK_CLIENT &&
|
||||
client_cohort_status == CBM_VERSION_COHORT_CONFLICT) {
|
||||
main_hook_report_conflicted_daemon(hook_dialect);
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#include "test_framework.h"
|
||||
|
||||
#include "daemon/daemon.h"
|
||||
#include "daemon/runtime.h"
|
||||
#include "daemon/service.h"
|
||||
#include "daemon/service_internal.h"
|
||||
#include "foundation/compat.h"
|
||||
@@ -277,6 +278,87 @@ TEST(daemon_hello_version_conflict_exposes_active_and_requested_builds) {
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* #2277: a version/build conflict refusal must say WHICH daemon holds the
|
||||
* endpoint and how to clear it. The reporter saw only "close all CBM sessions
|
||||
* and commands", with no pid, no hint that a live agent session (Codex) had
|
||||
* launched an older install, and no mention of `daemon stop`. */
|
||||
static cbm_daemon_runtime_status_t version_test_active_status(const char *version, bool permanent,
|
||||
uint32_t pid) {
|
||||
cbm_daemon_runtime_status_t status;
|
||||
memset(&status, 0, sizeof(status));
|
||||
status.permanent = permanent;
|
||||
status.daemon_pid = pid;
|
||||
(void)snprintf(status.semantic_version, sizeof(status.semantic_version), "%s", version);
|
||||
(void)snprintf(status.build_fingerprint, sizeof(status.build_fingerprint), "%s", BUILD_A);
|
||||
return status;
|
||||
}
|
||||
|
||||
TEST(daemon_conflict_remedy_names_session_managed_daemon_and_its_sessions) {
|
||||
cbm_daemon_runtime_status_t active = version_test_active_status("0.10.3", false, 4242);
|
||||
active.committed_clients = 2;
|
||||
active.client_count = 2;
|
||||
active.client_pids[0] = 5151;
|
||||
active.client_pids[1] = 6262;
|
||||
char remedy[CBM_DAEMON_CONFLICT_REMEDY_SIZE];
|
||||
|
||||
ASSERT_TRUE(cbm_daemon_conflict_remedy_format(&active, remedy, sizeof(remedy)));
|
||||
ASSERT_NOT_NULL(strstr(remedy, "pid 4242"));
|
||||
ASSERT_NOT_NULL(strstr(remedy, "version 0.10.3"));
|
||||
ASSERT_NOT_NULL(strstr(remedy, "session-managed"));
|
||||
ASSERT_NOT_NULL(strstr(remedy, "5151, 6262"));
|
||||
ASSERT_NOT_NULL(strstr(remedy, "which -a codebase-memory-mcp"));
|
||||
ASSERT_NOT_NULL(strstr(remedy, "codebase-memory-mcp daemon stop"));
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(daemon_conflict_remedy_names_permanent_daemon_and_truncated_session_list) {
|
||||
cbm_daemon_runtime_status_t active = version_test_active_status("0.12.0", true, 77);
|
||||
active.committed_clients = CBM_DAEMON_CONTROL_CLIENT_CAP + 3U;
|
||||
active.client_count = CBM_DAEMON_CONTROL_CLIENT_CAP;
|
||||
for (uint32_t i = 0; i < CBM_DAEMON_CONTROL_CLIENT_CAP; i++) {
|
||||
active.client_pids[i] = 1000U + i;
|
||||
}
|
||||
char remedy[CBM_DAEMON_CONFLICT_REMEDY_SIZE];
|
||||
|
||||
ASSERT_TRUE(cbm_daemon_conflict_remedy_format(&active, remedy, sizeof(remedy)));
|
||||
ASSERT_NOT_NULL(strstr(remedy, "pid 77"));
|
||||
ASSERT_NOT_NULL(strstr(remedy, "permanent"));
|
||||
ASSERT_NOT_NULL(strstr(remedy, "1007 and 3 more"));
|
||||
ASSERT_NOT_NULL(strstr(remedy, "codebase-memory-mcp daemon stop"));
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(daemon_conflict_remedy_reports_a_stopping_daemon) {
|
||||
cbm_daemon_runtime_status_t active = version_test_active_status("0.10.3", false, 99);
|
||||
active.stopping = true;
|
||||
char remedy[CBM_DAEMON_CONFLICT_REMEDY_SIZE];
|
||||
|
||||
ASSERT_TRUE(cbm_daemon_conflict_remedy_format(&active, remedy, sizeof(remedy)));
|
||||
ASSERT_NOT_NULL(strstr(remedy, "pid 99"));
|
||||
ASSERT_NOT_NULL(strstr(remedy, "already stopping"));
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(daemon_conflict_remedy_without_status_points_at_daemon_status) {
|
||||
char remedy[CBM_DAEMON_CONFLICT_REMEDY_SIZE];
|
||||
|
||||
ASSERT_TRUE(cbm_daemon_conflict_remedy_format(NULL, remedy, sizeof(remedy)));
|
||||
ASSERT_NOT_NULL(strstr(remedy, "codebase-memory-mcp daemon status"));
|
||||
ASSERT_NOT_NULL(strstr(remedy, "which -a codebase-memory-mcp"));
|
||||
ASSERT_NOT_NULL(strstr(remedy, "codebase-memory-mcp daemon stop"));
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(daemon_conflict_remedy_fails_closed_when_truncated) {
|
||||
cbm_daemon_runtime_status_t active = version_test_active_status("0.10.3", false, 4242);
|
||||
char tiny[16];
|
||||
|
||||
ASSERT_FALSE(cbm_daemon_conflict_remedy_format(&active, tiny, sizeof(tiny)));
|
||||
ASSERT_STR_EQ(tiny, "");
|
||||
ASSERT_FALSE(cbm_daemon_conflict_remedy_format(&active, NULL, 0));
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(daemon_hello_rejects_each_abi_mismatch) {
|
||||
cbm_daemon_build_identity_t active = version_test_identity("2.4.0", BUILD_A);
|
||||
cbm_daemon_build_identity_t requested = active;
|
||||
@@ -613,6 +695,11 @@ SUITE(daemon_version) {
|
||||
RUN_TEST(daemon_build_fingerprint_hashes_exact_executable_bytes);
|
||||
RUN_TEST(daemon_hello_accepts_only_the_exact_active_build_identity);
|
||||
RUN_TEST(daemon_hello_version_conflict_exposes_active_and_requested_builds);
|
||||
RUN_TEST(daemon_conflict_remedy_names_session_managed_daemon_and_its_sessions);
|
||||
RUN_TEST(daemon_conflict_remedy_names_permanent_daemon_and_truncated_session_list);
|
||||
RUN_TEST(daemon_conflict_remedy_reports_a_stopping_daemon);
|
||||
RUN_TEST(daemon_conflict_remedy_without_status_points_at_daemon_status);
|
||||
RUN_TEST(daemon_conflict_remedy_fails_closed_when_truncated);
|
||||
RUN_TEST(daemon_hello_rejects_each_abi_mismatch);
|
||||
RUN_TEST(daemon_hello_fails_closed_without_an_exact_build_fingerprint);
|
||||
RUN_TEST(daemon_conflict_log_is_durable_private_and_rotates);
|
||||
|
||||
Reference in new issue
Block a user