feat(desktop): warn when another install uses the same profile
Use the spawn ledger rather than a last-writer stamp. Record the canonical profile home and require an exact live PID/create-time pair for warnings. Keep the default ledger policy unchanged for process reapers. Expose the advisory message through the existing status response. The desktop shows a dismissible warning on its existing refresh cadence. Do not block startup, redirect HERMES_HOME, or add a timer or lock. Verified with real subprocess ledger tests and the status API. The targeted Python run passed 27 tests. The desktop run passed 12 tests, TypeScript checking, and lint. No full suite or packaged-app test was run.
This commit is contained in:
@@ -1,8 +1,11 @@
|
|||||||
import { act, cleanup, renderHook } from '@testing-library/react'
|
import { act, cleanup, fireEvent, render, renderHook, screen } from '@testing-library/react'
|
||||||
|
import { createElement } from 'react'
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { NotificationStack } from '@/components/notifications'
|
||||||
import { getStatus } from '@/hermes'
|
import { getStatus } from '@/hermes'
|
||||||
import { $setupReadyTick, notifySetupReady } from '@/store/live-sync'
|
import { $setupReadyTick, notifySetupReady } from '@/store/live-sync'
|
||||||
|
import { clearNotifications } from '@/store/notifications'
|
||||||
|
|
||||||
import { deferred } from '../../../test/deferred'
|
import { deferred } from '../../../test/deferred'
|
||||||
|
|
||||||
@@ -27,6 +30,7 @@ beforeEach(() => {
|
|||||||
.mockReset()
|
.mockReset()
|
||||||
.mockResolvedValue({} as never)
|
.mockResolvedValue({} as never)
|
||||||
$setupReadyTick.set(0)
|
$setupReadyTick.set(0)
|
||||||
|
clearNotifications()
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -36,6 +40,45 @@ afterEach(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('useStatusSnapshot', () => {
|
describe('useStatusSnapshot', () => {
|
||||||
|
it('shows a dismissible shared-profile warning on the existing status refresh', async () => {
|
||||||
|
const warning = 'Another installation is using this profile.'
|
||||||
|
const requestGateway = vi.fn().mockResolvedValue({}) as unknown as GatewayRequester
|
||||||
|
|
||||||
|
render(createElement(NotificationStack))
|
||||||
|
|
||||||
|
const { rerender } = renderHook(({ scope }) => useStatusSnapshot('open', requestGateway, scope), {
|
||||||
|
initialProps: { scope: 'local-default' }
|
||||||
|
})
|
||||||
|
|
||||||
|
await flushAsync()
|
||||||
|
expect(screen.queryByText(warning)).toBeNull()
|
||||||
|
vi.mocked(getStatus).mockResolvedValue({ shared_profile_warning: warning } as never)
|
||||||
|
await act(async () => {
|
||||||
|
await vi.advanceTimersByTimeAsync(60_000)
|
||||||
|
})
|
||||||
|
expect(screen.getByText(warning)).toBeTruthy()
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /dismiss/i }))
|
||||||
|
expect(screen.queryByText(warning)).toBeNull()
|
||||||
|
await act(async () => {
|
||||||
|
await vi.advanceTimersByTimeAsync(60_000)
|
||||||
|
})
|
||||||
|
expect(screen.queryByText(warning)).toBeNull()
|
||||||
|
|
||||||
|
vi.mocked(getStatus).mockResolvedValue({ shared_profile_warning: '' } as never)
|
||||||
|
await act(async () => {
|
||||||
|
await vi.advanceTimersByTimeAsync(60_000)
|
||||||
|
})
|
||||||
|
vi.mocked(getStatus).mockResolvedValue({ shared_profile_warning: warning } as never)
|
||||||
|
await act(async () => {
|
||||||
|
await vi.advanceTimersByTimeAsync(60_000)
|
||||||
|
})
|
||||||
|
expect(screen.getByText(warning)).toBeTruthy()
|
||||||
|
vi.mocked(getStatus).mockResolvedValue({ shared_profile_warning: '' } as never)
|
||||||
|
rerender({ scope: 'local-work' })
|
||||||
|
await flushAsync()
|
||||||
|
expect(screen.queryByText(warning)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
it('pauses status RPCs while visible but unfocused, then catches up on focus', async () => {
|
it('pauses status RPCs while visible but unfocused, then catches up on focus', async () => {
|
||||||
vi.mocked(document.hasFocus).mockReturnValue(false)
|
vi.mocked(document.hasFocus).mockReturnValue(false)
|
||||||
const requestGateway = vi.fn().mockResolvedValue({}) as unknown as GatewayRequester
|
const requestGateway = vi.fn().mockResolvedValue({}) as unknown as GatewayRequester
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { getStatus } from '@/hermes'
|
|||||||
import { evaluateRuntimeReadiness, type RuntimeReadinessResult } from '@/lib/runtime-readiness'
|
import { evaluateRuntimeReadiness, type RuntimeReadinessResult } from '@/lib/runtime-readiness'
|
||||||
import { refreshFreeTierStatus, setFreeTierRoute } from '@/store/free-tier'
|
import { refreshFreeTierStatus, setFreeTierRoute } from '@/store/free-tier'
|
||||||
import { $setupReadyTick } from '@/store/live-sync'
|
import { $setupReadyTick } from '@/store/live-sync'
|
||||||
|
import { dismissNotification, notify } from '@/store/notifications'
|
||||||
import type { StatusResponse } from '@/types/hermes'
|
import type { StatusResponse } from '@/types/hermes'
|
||||||
|
|
||||||
// Statusbar health is ambient chrome, not live data — nothing the user acts on
|
// Statusbar health is ambient chrome, not live data — nothing the user acts on
|
||||||
@@ -24,6 +25,8 @@ export function useStatusSnapshot(
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
let timer: number | undefined
|
let timer: number | undefined
|
||||||
|
let sharedProfileWarning = ''
|
||||||
|
let sharedProfileNoticeId: string | undefined
|
||||||
|
|
||||||
// Status and inference readiness belong to one backend. A source switch
|
// Status and inference readiness belong to one backend. A source switch
|
||||||
// can keep gatewayState="open" throughout, so clear the previous source's
|
// can keep gatewayState="open" throughout, so clear the previous source's
|
||||||
@@ -107,6 +110,17 @@ export function useStatusSnapshot(
|
|||||||
|
|
||||||
if (statusResult.status === 'fulfilled') {
|
if (statusResult.status === 'fulfilled') {
|
||||||
setStatusSnapshot(statusResult.value)
|
setStatusSnapshot(statusResult.value)
|
||||||
|
const warning = statusResult.value.shared_profile_warning || ''
|
||||||
|
|
||||||
|
// Keep dismissal until the conflict clears. A new overlap can warn again.
|
||||||
|
if (warning !== sharedProfileWarning) {
|
||||||
|
if (sharedProfileNoticeId) {
|
||||||
|
dismissNotification(sharedProfileNoticeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
sharedProfileWarning = warning
|
||||||
|
sharedProfileNoticeId = warning ? notify({ kind: 'warning', message: warning }) : undefined
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
scheduleRefresh()
|
scheduleRefresh()
|
||||||
@@ -139,6 +153,10 @@ export function useStatusSnapshot(
|
|||||||
document.removeEventListener('visibilitychange', onReturn)
|
document.removeEventListener('visibilitychange', onReturn)
|
||||||
window.removeEventListener('focus', onReturn)
|
window.removeEventListener('focus', onReturn)
|
||||||
|
|
||||||
|
if (sharedProfileNoticeId) {
|
||||||
|
dismissNotification(sharedProfileNoticeId)
|
||||||
|
}
|
||||||
|
|
||||||
if (timer !== undefined) {
|
if (timer !== undefined) {
|
||||||
window.clearTimeout(timer)
|
window.clearTimeout(timer)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1328,6 +1328,7 @@ export interface PlatformStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface StatusResponse {
|
export interface StatusResponse {
|
||||||
|
shared_profile_warning?: string
|
||||||
active_sessions: number
|
active_sessions: number
|
||||||
config_path: string
|
config_path: string
|
||||||
config_version: number
|
config_version: number
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ class LedgerEntry:
|
|||||||
host: str = ""
|
host: str = ""
|
||||||
port: Optional[int] = None
|
port: Optional[int] = None
|
||||||
profile: str = ""
|
profile: str = ""
|
||||||
|
hermes_home: str = ""
|
||||||
|
|
||||||
|
|
||||||
def _ledger_path() -> Path:
|
def _ledger_path() -> Path:
|
||||||
@@ -174,14 +175,18 @@ def _same_incarnation(proc, create_time: Optional[float]) -> bool:
|
|||||||
return create_time is None or abs(float(proc.create_time()) - float(create_time)) < 2.0
|
return create_time is None or abs(float(proc.create_time()) - float(create_time)) < 2.0
|
||||||
|
|
||||||
|
|
||||||
def _pid_alive_matches(pid: int, create_time: Optional[float]) -> Optional[bool]:
|
def _pid_alive_matches(pid: int, create_time: Optional[float], *, strict: bool = False) -> Optional[bool]:
|
||||||
"""True/False when provable; ``None`` when psutil can't say."""
|
"""True/False when provable; ``None`` when psutil can't say."""
|
||||||
try:
|
try:
|
||||||
import psutil
|
import psutil
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
return _same_incarnation(psutil.Process(int(pid)), create_time)
|
proc = psutil.Process(int(pid))
|
||||||
|
if strict:
|
||||||
|
return (create_time is not None and proc.create_time() == create_time
|
||||||
|
and proc.is_running() and proc.status() != psutil.STATUS_ZOMBIE)
|
||||||
|
return _same_incarnation(proc, create_time)
|
||||||
except psutil.NoSuchProcess:
|
except psutil.NoSuchProcess:
|
||||||
return False
|
return False
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -195,9 +200,12 @@ def register_self(purpose: str, *, project_root: Optional[Path] = None, detail:
|
|||||||
pruned on every write. ``detail`` may carry ``host``/``port``/``profile`` so the update
|
pruned on every write. ``detail`` may carry ``host``/``port``/``profile`` so the update
|
||||||
pipeline can relaunch a manually-started serve with its real bind address.
|
pipeline can relaunch a manually-started serve with its real bind address.
|
||||||
"""
|
"""
|
||||||
|
from hermes_constants import hermes_home_key
|
||||||
|
|
||||||
tag = parse_spawn_tag(os.environ.get(SPAWN_ENV_VAR))
|
tag = parse_spawn_tag(os.environ.get(SPAWN_ENV_VAR))
|
||||||
spawner_pid, spawner_create = (tag.spawner_pid, tag.spawner_create) if tag else _desktop_spawner_identity()
|
spawner_pid, spawner_create = (tag.spawner_pid, tag.spawner_create) if tag else _desktop_spawner_identity()
|
||||||
entry = _new_entry(os.getpid(), _process_create_time(), purpose, project_root, spawner_pid, spawner_create)
|
entry = _new_entry(os.getpid(), _process_create_time(), purpose, project_root, spawner_pid, spawner_create)
|
||||||
|
entry.hermes_home = hermes_home_key()
|
||||||
if detail:
|
if detail:
|
||||||
try:
|
try:
|
||||||
entry.host = str(detail.get("host") or "")
|
entry.host = str(detail.get("host") or "")
|
||||||
@@ -299,8 +307,13 @@ def register_child(pid: int, purpose: str, *, project_root: Optional[Path] = Non
|
|||||||
return _append_entry(entry)
|
return _append_entry(entry)
|
||||||
|
|
||||||
|
|
||||||
def ledger_entries(*, project_root: Optional[Path] = None) -> list[dict]:
|
def ledger_entries(
|
||||||
"""Live-verified ledger entries for THIS install (a corrupt ledger is quarantined, read as empty).
|
*, project_root: Optional[Path] = None, all_installs: bool = False, verified_only: bool = False,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Ledger entries for this install, or all installs when explicitly requested.
|
||||||
|
|
||||||
|
``verified_only`` requires an exact live PID/create-time pair. The default preserves
|
||||||
|
unknown processes for reapers, which must not mistake missing proof for a dead process.
|
||||||
|
|
||||||
Entries whose ``(pid, create_time)`` no longer matches a live process are excluded (PID reuse reads as
|
Entries whose ``(pid, create_time)`` no longer matches a live process are excluded (PID reuse reads as
|
||||||
dead, thanks to the create-time pair). A corrupt ledger is quarantined and read as empty — identical
|
dead, thanks to the create-time pair). A corrupt ledger is quarantined and read as empty — identical
|
||||||
@@ -314,9 +327,10 @@ def ledger_entries(*, project_root: Optional[Path] = None) -> list[dict]:
|
|||||||
return []
|
return []
|
||||||
return [
|
return [
|
||||||
e for e in entries
|
e for e in entries
|
||||||
if e.get("install") == want_install
|
if (all_installs or e.get("install") == want_install)
|
||||||
and isinstance(e.get("pid"), int)
|
and isinstance(e.get("pid"), int)
|
||||||
and _pid_alive_matches(e["pid"], e.get("create_time")) is not False
|
and (_pid_alive_matches(e["pid"], e.get("create_time"), strict=True) is True
|
||||||
|
if verified_only else _pid_alive_matches(e["pid"], e.get("create_time")) is not False)
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
24
hermes_cli/shared_profile_warning.py
Normal file
24
hermes_cli/shared_profile_warning.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"""Advisory only: simultaneous installs share profile data, not ownership."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from hermes_cli.process_identity import install_id, ledger_entries
|
||||||
|
from hermes_constants import hermes_home_key
|
||||||
|
|
||||||
|
|
||||||
|
def shared_profile_warning(*, home: Path | None = None, project_root: Path | None = None) -> str:
|
||||||
|
"""An old stamp or a profile name alone cannot prove concurrent use."""
|
||||||
|
own_install = install_id(project_root)
|
||||||
|
home_key = hermes_home_key(home)
|
||||||
|
if any(
|
||||||
|
entry.get("install") and entry["install"] != own_install
|
||||||
|
and entry.get("hermes_home") == home_key
|
||||||
|
for entry in ledger_entries(all_installs=True, verified_only=True)
|
||||||
|
):
|
||||||
|
return (
|
||||||
|
"Another Hermes installation is using this profile. Both installations share "
|
||||||
|
"its settings and data, so changes can conflict. You can continue, or close "
|
||||||
|
"the other installation before making changes."
|
||||||
|
)
|
||||||
|
return ""
|
||||||
@@ -439,6 +439,10 @@ async def get_status(profile: Optional[str] = None):
|
|||||||
if install_id:
|
if install_id:
|
||||||
status["install_id"] = install_id
|
status["install_id"] = install_id
|
||||||
|
|
||||||
|
# Advisory only. The message exposes no paths or process identities on this public probe.
|
||||||
|
from hermes_cli.shared_profile_warning import shared_profile_warning
|
||||||
|
status["shared_profile_warning"] = await run_in_threadpool(shared_profile_warning)
|
||||||
|
|
||||||
components = await _component_health(gateway)
|
components = await _component_health(gateway)
|
||||||
status["components"] = components
|
status["components"] = components
|
||||||
status["overall"] = ("ok" if all(item.get("status") == "ok" for item in components.values())
|
status["overall"] = ("ok" if all(item.get("status") == "ok" for item in components.values())
|
||||||
|
|||||||
138
tests/hermes_cli/test_shared_profile_warning.py
Normal file
138
tests/hermes_cli/test_shared_profile_warning.py
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
"""Live subprocesses, real ledger I/O, and isolated profile homes."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from hermes_cli import process_identity
|
||||||
|
from hermes_constants import hermes_home_key
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def homes(tmp_path, monkeypatch):
|
||||||
|
root = tmp_path / "home"
|
||||||
|
root.mkdir()
|
||||||
|
monkeypatch.setenv("HERMES_HOME", str(root))
|
||||||
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def running_install(home: Path, install: Path):
|
||||||
|
env = dict(os.environ, HERMES_HOME=str(home))
|
||||||
|
for key in ("HERMES_SPAWN", "HERMES_PARENT_PID", "HERMES_PARENT_START_MARKER"):
|
||||||
|
env.pop(key, None)
|
||||||
|
script = """
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from hermes_cli.process_identity import register_self
|
||||||
|
assert register_self('serve', project_root=Path(sys.argv[1]))
|
||||||
|
print('ready', flush=True)
|
||||||
|
sys.stdin.readline()
|
||||||
|
"""
|
||||||
|
child = subprocess.Popen(
|
||||||
|
[sys.executable, "-u", "-c", script, str(install)],
|
||||||
|
env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
assert child.stdout.readline().strip() == "ready"
|
||||||
|
yield child
|
||||||
|
finally:
|
||||||
|
child.communicate("exit\n", timeout=15)
|
||||||
|
assert child.returncode == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_warning_tracks_live_other_install_in_same_home(homes, tmp_path):
|
||||||
|
from hermes_cli.shared_profile_warning import shared_profile_warning
|
||||||
|
|
||||||
|
current = tmp_path / "stable"
|
||||||
|
other = tmp_path / "canary"
|
||||||
|
assert shared_profile_warning(project_root=current) == ""
|
||||||
|
with running_install(homes, other) as child:
|
||||||
|
warning = shared_profile_warning(project_root=current)
|
||||||
|
assert warning and "profile" in warning.lower()
|
||||||
|
assert shared_profile_warning(project_root=other) == ""
|
||||||
|
entry = next(e for e in process_identity.ledger_entries(project_root=other) if e["pid"] == child.pid)
|
||||||
|
assert entry["hermes_home"] == hermes_home_key(homes)
|
||||||
|
assert shared_profile_warning(home=homes / "profiles" / "work", project_root=current) == ""
|
||||||
|
# A stale file is not proof of concurrent use.
|
||||||
|
assert process_identity._ledger_path().exists()
|
||||||
|
assert shared_profile_warning(project_root=current) == ""
|
||||||
|
work = homes / "profiles" / "work"
|
||||||
|
work.mkdir(parents=True)
|
||||||
|
with running_install(work, other):
|
||||||
|
assert shared_profile_warning(project_root=current) == ""
|
||||||
|
assert shared_profile_warning(home=work, project_root=current)
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_surfaces_live_warning_without_host_details(homes, tmp_path, monkeypatch):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from hermes_cli import web_server
|
||||||
|
|
||||||
|
monkeypatch.setattr(web_server.app.state, "auth_required", True, raising=False)
|
||||||
|
client = TestClient(web_server.app)
|
||||||
|
assert client.get("/api/status").json().get("shared_profile_warning", "") == ""
|
||||||
|
with running_install(homes, tmp_path / "canary"):
|
||||||
|
response = client.get("/api/status")
|
||||||
|
assert response.status_code == 200
|
||||||
|
warning = response.json().get("shared_profile_warning", "")
|
||||||
|
assert warning
|
||||||
|
assert str(homes) not in warning
|
||||||
|
assert str(tmp_path / "canary") not in warning
|
||||||
|
assert client.get("/api/status").json().get("shared_profile_warning", "") == ""
|
||||||
|
work = homes / "profiles" / "work"
|
||||||
|
work.mkdir(parents=True)
|
||||||
|
(work / "config.yaml").write_text("{}")
|
||||||
|
with running_install(work, tmp_path / "canary"):
|
||||||
|
assert client.get("/api/status").json().get("shared_profile_warning", "") == ""
|
||||||
|
response = client.get("/api/status?profile=work")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json().get("shared_profile_warning")
|
||||||
|
|
||||||
|
|
||||||
|
def test_warning_rejects_reused_or_unverifiable_process_identity(homes, tmp_path):
|
||||||
|
from hermes_cli.shared_profile_warning import shared_profile_warning
|
||||||
|
|
||||||
|
current = tmp_path / "stable"
|
||||||
|
with running_install(homes, tmp_path / "canary"):
|
||||||
|
ledger = process_identity._ledger_path()
|
||||||
|
entries = json.loads(ledger.read_text())
|
||||||
|
original = entries[0]["create_time"]
|
||||||
|
assert shared_profile_warning(project_root=current)
|
||||||
|
for bad_create in (original - 60, original - 0.5, None):
|
||||||
|
entries[0]["create_time"] = bad_create
|
||||||
|
ledger.write_text(json.dumps(entries))
|
||||||
|
assert shared_profile_warning(project_root=current) == ""
|
||||||
|
entries[0]["create_time"] = original
|
||||||
|
entries[0].pop("hermes_home", None)
|
||||||
|
ledger.write_text(json.dumps(entries))
|
||||||
|
assert shared_profile_warning(project_root=current) == ""
|
||||||
|
entries[0]["hermes_home"] = hermes_home_key(homes / "profiles" / "other")
|
||||||
|
ledger.write_text(json.dumps(entries))
|
||||||
|
assert shared_profile_warning(project_root=current) == ""
|
||||||
|
entries[0]["hermes_home"] = hermes_home_key(homes)
|
||||||
|
ledger.write_text(json.dumps(entries))
|
||||||
|
assert shared_profile_warning(project_root=current)
|
||||||
|
|
||||||
|
# Even a valid record is not live proof if the process probe is unavailable.
|
||||||
|
from unittest.mock import patch
|
||||||
|
with patch.object(process_identity, "_pid_alive_matches", return_value=None):
|
||||||
|
assert shared_profile_warning(project_root=current) == ""
|
||||||
|
assert shared_profile_warning(project_root=current)
|
||||||
|
ledger.write_text("{broken")
|
||||||
|
assert shared_profile_warning(project_root=current) == ""
|
||||||
|
assert ledger.with_suffix(".json.corrupt").exists()
|
||||||
|
ledger.write_text(json.dumps(entries))
|
||||||
|
assert shared_profile_warning(project_root=current)
|
||||||
|
ledger.unlink()
|
||||||
|
assert shared_profile_warning(project_root=current) == ""
|
||||||
|
assert not ledger.exists()
|
||||||
|
assert not (homes / "config.yaml").exists()
|
||||||
|
assert not (homes / "state.db").exists()
|
||||||
Reference in New Issue
Block a user