新增实例槽位隔离与结构化日志,多开不再共用日志与账号
This commit is contained in:
@@ -0,0 +1,236 @@
|
||||
// Package instance 负责多开场景下的实例槽位分配与数据目录隔离。
|
||||
//
|
||||
// 每个进程启动时占用一个「实例槽位」:1 号槽位是默认实例(沿用历史数据目录),
|
||||
// 2 号及以后各自拿到 instances/instance-N 目录,互不共享配置、账号与日志文件。
|
||||
// 槽位通过独立的锁文件互斥,进程退出(或崩溃后文件句柄被系统回收)即可被下一个
|
||||
// 进程复用。
|
||||
package instance
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// appDirName 用户配置目录下的应用目录名。
|
||||
appDirName = "huya-go"
|
||||
// logSubDir 默认的日志子目录名。
|
||||
logSubDir = "logs"
|
||||
// maxSlots 单机允许同时运行的实例数上限。
|
||||
maxSlots = 64
|
||||
)
|
||||
|
||||
// 单次实例分配里允许的「意外错误」重试预算与间隔。
|
||||
// 杀毒软件/索引器可能短暂独占刚创建的锁文件,稍后重试同一个槽位即可,
|
||||
// 不该直接跳到别的槽位,也不该让整个分配失败。
|
||||
const (
|
||||
slotLockRetryBudget = 12
|
||||
slotLockRetryWait = 80 * time.Millisecond
|
||||
)
|
||||
|
||||
// Paths 是某个实例独占的运行路径。
|
||||
type Paths struct {
|
||||
// DataDir 实例数据目录,同时用作 WebView2 用户数据目录的父目录。
|
||||
DataDir string
|
||||
// LogDir 实例日志目录。
|
||||
LogDir string
|
||||
}
|
||||
|
||||
// Lease 代表当前进程持有的实例槽位;必须在程序退出时释放。
|
||||
type Lease struct {
|
||||
paths Paths
|
||||
name string
|
||||
id string
|
||||
lock instanceLock
|
||||
releaseOnce sync.Once
|
||||
}
|
||||
|
||||
// Acquire 按顺序占用空闲实例。第一个进程使用默认实例,后续进程分别使用实例 2、
|
||||
// 实例 3,以隔离数据目录与日志文件。
|
||||
//
|
||||
// 返回的 lease 一定可用:即使所有槽位都拿不到(锁目录不可写、槽位全部异常等),
|
||||
// 也会退回到一个「进程唯一」的临时实例目录,并把原因作为 error 返回供上层记录。
|
||||
// 绝不退回默认目录——那样两个进程会共享 localStorage 数据目录与日志文件,
|
||||
// 导致账号互相覆盖与日志交错。
|
||||
func Acquire() (*Lease, error) {
|
||||
baseDir := baseDataDir()
|
||||
instanceRoot := filepath.Join(baseDir, "instances")
|
||||
lockDir := filepath.Join(instanceRoot, ".locks")
|
||||
if err := osMkdirAll(lockDir); err != nil {
|
||||
return fallbackLease(baseDir, fmt.Errorf("创建实例锁目录失败: %w", err))
|
||||
}
|
||||
|
||||
var scanErr error
|
||||
scanRetries := slotLockRetryBudget
|
||||
for slot := 1; slot <= maxSlots; slot++ {
|
||||
lockPath := filepath.Join(lockDir, instanceLockFileName(slot))
|
||||
lock, acquired, err := tryAcquireInstanceLock(lockPath)
|
||||
for err != nil && scanRetries > 0 {
|
||||
scanRetries--
|
||||
time.Sleep(slotLockRetryWait)
|
||||
lock, acquired, err = tryAcquireInstanceLock(lockPath)
|
||||
}
|
||||
if err != nil {
|
||||
// 单个槽位异常不该让整个分配失败:记录原因后继续试下一个槽位。
|
||||
if scanErr == nil {
|
||||
scanErr = fmt.Errorf("锁定%s失败: %w", instanceName(slot), err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !acquired {
|
||||
continue
|
||||
}
|
||||
|
||||
dataDir := baseDir
|
||||
if slot > 1 {
|
||||
dataDir = filepath.Join(instanceRoot, instanceID(slot))
|
||||
}
|
||||
if err := osMkdirAll(dataDir); err != nil {
|
||||
_ = lock.Close()
|
||||
if scanErr == nil {
|
||||
scanErr = fmt.Errorf("创建%s数据目录失败: %w", instanceName(slot), err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
return &Lease{
|
||||
paths: pathsForDataDir(dataDir),
|
||||
name: instanceName(slot),
|
||||
id: instanceID(slot),
|
||||
lock: lock,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return fallbackLease(baseDir, fmt.Errorf("最多支持同时运行 %d 个实例(%v)", maxSlots, scanErr))
|
||||
}
|
||||
|
||||
// fallbackLease 构造兜底实例:使用进程唯一的临时目录,保证与其它实例互不干扰。
|
||||
// 即使该目录也创建不出来,也只返回这个「本进程独占」的路径(日志会降级为仅内存),
|
||||
// 绝不退回默认目录——退回默认目录会让两个进程共用同一个日志文件与前端数据目录。
|
||||
func fallbackLease(baseDir string, cause error) (*Lease, error) {
|
||||
id := fmt.Sprintf("instance-tmp-%d", os.Getpid())
|
||||
dataDir := filepath.Join(baseDir, "instances", id)
|
||||
_ = osMkdirAll(dataDir)
|
||||
return &Lease{
|
||||
paths: pathsForDataDir(dataDir),
|
||||
name: "临时实例",
|
||||
id: id,
|
||||
}, cause
|
||||
}
|
||||
|
||||
// Paths 返回当前实例独立的数据路径。
|
||||
func (l *Lease) Paths() Paths {
|
||||
if l == nil {
|
||||
return Paths{}
|
||||
}
|
||||
return l.paths
|
||||
}
|
||||
|
||||
// Name 返回面向用户的实例名称。
|
||||
func (l *Lease) Name() string {
|
||||
if l == nil {
|
||||
return ""
|
||||
}
|
||||
return l.name
|
||||
}
|
||||
|
||||
// ID 返回实例的稳定标识,用于给前端本地存储分片(不含空格与中文)。
|
||||
func (l *Lease) ID() string {
|
||||
if l == nil {
|
||||
return ""
|
||||
}
|
||||
return l.id
|
||||
}
|
||||
|
||||
// WebviewUserDataDir 返回当前实例独占的 WebView2 用户数据目录。
|
||||
//
|
||||
// Wails 在 WebviewUserDataPath 为空时会把用户数据目录放在 %APPDATA%\<可执行文件名>,
|
||||
// 所有实例共用同一个目录;而 WebView2 的一个用户数据目录只能有一个浏览器进程,
|
||||
// 第二个实例创建环境时会偶发失败 —— 表现就是「有时候开不出新实例,必须全部关掉重开」。
|
||||
// 这里给每个实例一个独立目录,从根上消除这个竞争。
|
||||
func (l *Lease) WebviewUserDataDir() string {
|
||||
if l == nil || l.paths.DataDir == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(l.paths.DataDir, "webview2")
|
||||
}
|
||||
|
||||
// Release 释放实例锁,允许新启动的程序复用该实例。
|
||||
func (l *Lease) Release() {
|
||||
if l == nil || l.lock == nil {
|
||||
return
|
||||
}
|
||||
l.releaseOnce.Do(func() {
|
||||
_ = l.lock.Close()
|
||||
})
|
||||
}
|
||||
|
||||
func pathsForDataDir(dataDir string) Paths {
|
||||
logDir := filepath.Join(dataDir, logSubDir)
|
||||
if value := os.Getenv("HUYA_LOG_DIR"); value != "" {
|
||||
logDir = resolvePath(value)
|
||||
}
|
||||
return Paths{DataDir: dataDir, LogDir: logDir}
|
||||
}
|
||||
|
||||
// DefaultLogDir 返回不绑定实例槽位的默认日志目录,供 CLI 等单次运行入口使用。
|
||||
func DefaultLogDir() string {
|
||||
return pathsForDataDir(baseDataDir()).LogDir
|
||||
}
|
||||
|
||||
// DefaultDataDir 返回不绑定实例槽位的默认数据目录,供 CLI 等单次运行入口使用。
|
||||
func DefaultDataDir() string {
|
||||
return baseDataDir()
|
||||
}
|
||||
|
||||
// baseDataDir 返回实例目录的根。
|
||||
// 默认放在当前用户的配置目录,而不是工作目录,避免应用包目录无写权限。
|
||||
func baseDataDir() string {
|
||||
if value := os.Getenv("HUYA_DATA_DIR"); value != "" {
|
||||
return resolvePath(value)
|
||||
}
|
||||
return resolvePath(filepath.Join(defaultAppDataDir(), appDirName))
|
||||
}
|
||||
|
||||
func defaultAppDataDir() string {
|
||||
if dir, err := os.UserConfigDir(); err == nil && dir != "" {
|
||||
return dir
|
||||
}
|
||||
if home, err := os.UserHomeDir(); err == nil && home != "" {
|
||||
return filepath.Join(home, ".config")
|
||||
}
|
||||
return "."
|
||||
}
|
||||
|
||||
func resolvePath(path string) string {
|
||||
if filepath.IsAbs(path) {
|
||||
return path
|
||||
}
|
||||
if abs, err := filepath.Abs(path); err == nil {
|
||||
return abs
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func instanceName(slot int) string {
|
||||
if slot <= 1 {
|
||||
return "默认实例"
|
||||
}
|
||||
return fmt.Sprintf("实例 %d", slot)
|
||||
}
|
||||
|
||||
func instanceID(slot int) string {
|
||||
if slot <= 1 {
|
||||
return "default"
|
||||
}
|
||||
return fmt.Sprintf("instance-%d", slot)
|
||||
}
|
||||
|
||||
func instanceLockFileName(slot int) string {
|
||||
if slot <= 1 {
|
||||
return "default.lock"
|
||||
}
|
||||
return fmt.Sprintf("instance-%d.lock", slot)
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package instance
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestAcquireSeparatesConcurrentInstances 验证槽位分配:第一个进程用默认数据目录,
|
||||
// 第二个进程拿到 instance-2,锁释放后默认实例可被复用。
|
||||
func TestAcquireSeparatesConcurrentInstances(t *testing.T) {
|
||||
baseDir := t.TempDir()
|
||||
t.Setenv("HUYA_DATA_DIR", baseDir)
|
||||
t.Setenv("HUYA_LOG_DIR", "")
|
||||
|
||||
first, err := Acquire()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer first.Release()
|
||||
if first.Name() != "默认实例" || first.ID() != "default" {
|
||||
t.Fatalf("first instance = %q/%q", first.Name(), first.ID())
|
||||
}
|
||||
if first.Paths().DataDir != baseDir {
|
||||
t.Fatalf("first dataDir = %q, want %q", first.Paths().DataDir, baseDir)
|
||||
}
|
||||
if first.Paths().LogDir != filepath.Join(baseDir, "logs") {
|
||||
t.Fatalf("first logDir = %q", first.Paths().LogDir)
|
||||
}
|
||||
|
||||
second, err := Acquire()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer second.Release()
|
||||
wantSecondDir := filepath.Join(baseDir, "instances", "instance-2")
|
||||
if second.Name() != "实例 2" || second.ID() != "instance-2" {
|
||||
t.Fatalf("second instance = %q/%q", second.Name(), second.ID())
|
||||
}
|
||||
if second.Paths().DataDir != wantSecondDir {
|
||||
t.Fatalf("second dataDir = %q, want %q", second.Paths().DataDir, wantSecondDir)
|
||||
}
|
||||
// 日志目录必须落在实例自己的目录里,否则多开时日志会交错。
|
||||
if second.Paths().LogDir != filepath.Join(wantSecondDir, "logs") {
|
||||
t.Fatalf("second logDir = %q", second.Paths().LogDir)
|
||||
}
|
||||
|
||||
first.Release()
|
||||
reused, err := Acquire()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer reused.Release()
|
||||
if reused.Name() != "默认实例" || reused.Paths().DataDir != baseDir {
|
||||
t.Fatalf("reused instance = %q/%q", reused.Name(), reused.Paths().DataDir)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebviewDirsAreIsolated WebView2 一个用户数据目录只能有一个浏览器进程,
|
||||
// 每个实例必须是不同目录,否则第二个实例会偶发开不出窗口。
|
||||
func TestWebviewDirsAreIsolated(t *testing.T) {
|
||||
baseDir := t.TempDir()
|
||||
t.Setenv("HUYA_DATA_DIR", baseDir)
|
||||
t.Setenv("HUYA_LOG_DIR", "")
|
||||
|
||||
first, err := Acquire()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer first.Release()
|
||||
second, err := Acquire()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer second.Release()
|
||||
|
||||
firstDir := first.WebviewUserDataDir()
|
||||
secondDir := second.WebviewUserDataDir()
|
||||
if firstDir == "" || secondDir == "" || firstDir == secondDir {
|
||||
t.Fatalf("webview dirs = %q / %q", firstDir, secondDir)
|
||||
}
|
||||
if want := filepath.Join(baseDir, "webview2"); firstDir != want {
|
||||
t.Fatalf("first webview dir = %q, want %q", firstDir, want)
|
||||
}
|
||||
if want := filepath.Join(baseDir, "instances", "instance-2", "webview2"); secondDir != want {
|
||||
t.Fatalf("second webview dir = %q, want %q", secondDir, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAcquireNeverSharesDefaultDir 保证实例占位失败时也不会退回默认目录。
|
||||
// 退回默认目录会让两个进程共享日志文件与前端数据目录。
|
||||
func TestAcquireNeverSharesDefaultDir(t *testing.T) {
|
||||
baseDir := t.TempDir()
|
||||
// 用一个普通文件占住 <dataDir>,让 <dataDir>/instances/.locks 无法创建。
|
||||
blocked := filepath.Join(baseDir, "blocked")
|
||||
if err := os.WriteFile(blocked, []byte("x"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("HUYA_DATA_DIR", blocked)
|
||||
t.Setenv("HUYA_LOG_DIR", "")
|
||||
|
||||
lease, err := Acquire()
|
||||
if lease == nil {
|
||||
t.Fatal("实例占位必须始终返回可用目录")
|
||||
}
|
||||
defer lease.Release()
|
||||
if err == nil {
|
||||
t.Fatal("降级路径应当把原因返回给上层记录")
|
||||
}
|
||||
if lease.Name() != "临时实例" {
|
||||
t.Fatalf("降级实例名 = %q, want 临时实例", lease.Name())
|
||||
}
|
||||
if lease.Paths().DataDir == blocked || !strings.Contains(lease.ID(), "instance-tmp-") {
|
||||
t.Fatalf("降级目录必须与本进程绑定且独立: %q", lease.Paths().DataDir)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReleaseIsIdempotent 验证重复释放不会出错(Shutdown 与 defer 可能都调用一次)。
|
||||
func TestReleaseIsIdempotent(t *testing.T) {
|
||||
t.Setenv("HUYA_DATA_DIR", t.TempDir())
|
||||
t.Setenv("HUYA_LOG_DIR", "")
|
||||
|
||||
lease, err := Acquire()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lease.Release()
|
||||
lease.Release()
|
||||
}
|
||||
|
||||
// TestAcquireIsExclusiveAcrossProcesses 用两个真实进程验证「多开」:第二个进程不会拿到
|
||||
// 与第一个进程相同的数据目录(否则会互相覆盖日志与前端数据)。
|
||||
func TestAcquireIsExclusiveAcrossProcesses(t *testing.T) {
|
||||
if os.Getenv("HUYA_INSTANCE_HELPER") == "1" {
|
||||
runInstanceHelper()
|
||||
return
|
||||
}
|
||||
|
||||
baseDir := t.TempDir()
|
||||
first := startInstanceHelper(t, baseDir)
|
||||
defer first.stop()
|
||||
firstName, firstID, firstDir := first.read(t)
|
||||
|
||||
// 第一个进程已经拿到锁之后再启动第二个,避免出现「同时抢同一槽位」的随机性。
|
||||
second := startInstanceHelper(t, baseDir)
|
||||
defer second.stop()
|
||||
_, secondID, secondDir := second.read(t)
|
||||
|
||||
if firstID != "default" || firstName != "默认实例" {
|
||||
t.Fatalf("第一个进程实例 = %q/%q, want 默认实例/default", firstName, firstID)
|
||||
}
|
||||
if secondID != "instance-2" {
|
||||
t.Fatalf("第二个进程实例 = %q, want instance-2", secondID)
|
||||
}
|
||||
if firstDir == secondDir {
|
||||
t.Fatalf("两个进程的数据目录相同: %s", firstDir)
|
||||
}
|
||||
if !strings.Contains(secondDir, filepath.Join("instances", "instance-2")) {
|
||||
t.Fatalf("第二个进程数据目录 = %q", secondDir)
|
||||
}
|
||||
}
|
||||
|
||||
// runInstanceHelper 是子进程分支:占位后打印实例信息,短暂保持占用再释放。
|
||||
func runInstanceHelper() {
|
||||
lease, err := Acquire()
|
||||
if err != nil {
|
||||
fmt.Println("ERR:" + err.Error())
|
||||
return
|
||||
}
|
||||
fmt.Printf("%s|%s|%s\n", lease.Name(), lease.ID(), lease.Paths().DataDir)
|
||||
time.Sleep(2 * time.Second)
|
||||
lease.Release()
|
||||
}
|
||||
|
||||
type instanceHelper struct {
|
||||
cmd *exec.Cmd
|
||||
lines *bufio.Scanner
|
||||
}
|
||||
|
||||
func startInstanceHelper(t *testing.T, baseDir string) *instanceHelper {
|
||||
t.Helper()
|
||||
cmd := exec.Command(os.Args[0], "-test.run=TestAcquireIsExclusiveAcrossProcesses")
|
||||
cmd.Env = append(os.Environ(),
|
||||
"HUYA_INSTANCE_HELPER=1",
|
||||
"HUYA_DATA_DIR="+baseDir,
|
||||
"HUYA_LOG_DIR=",
|
||||
)
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &instanceHelper{cmd: cmd, lines: bufio.NewScanner(stdout)}
|
||||
}
|
||||
|
||||
// read 读取子进程输出的 "名称|标识|数据目录"。
|
||||
func (h *instanceHelper) read(t *testing.T) (name, id, dir string) {
|
||||
t.Helper()
|
||||
if !h.lines.Scan() {
|
||||
t.Fatalf("子进程未输出实例信息: %v", h.lines.Err())
|
||||
}
|
||||
parts := strings.SplitN(strings.TrimSpace(h.lines.Text()), "|", 3)
|
||||
if len(parts) != 3 {
|
||||
t.Fatalf("子进程输出异常: %q", h.lines.Text())
|
||||
}
|
||||
return parts[0], parts[1], parts[2]
|
||||
}
|
||||
|
||||
func (h *instanceHelper) stop() {
|
||||
if h.cmd.Process != nil {
|
||||
_ = h.cmd.Wait()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package instance
|
||||
|
||||
import "os"
|
||||
|
||||
// instanceLock 抽象「进程独占某个槽位」的能力,由各平台实现。
|
||||
type instanceLock interface {
|
||||
Close() error
|
||||
}
|
||||
|
||||
func osMkdirAll(path string) error {
|
||||
return os.MkdirAll(path, 0o700)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
//go:build !windows
|
||||
|
||||
package instance
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
type unixInstanceLock struct {
|
||||
file *os.File
|
||||
}
|
||||
|
||||
// tryAcquireInstanceLock 用 flock 非阻塞独占锁文件。
|
||||
// 第二个参数表示「锁被别的进程持有」,此时调用方应继续扫描下一个槽位。
|
||||
func tryAcquireInstanceLock(path string) (instanceLock, bool, error) {
|
||||
file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
|
||||
_ = file.Close()
|
||||
if errors.Is(err, syscall.EWOULDBLOCK) || errors.Is(err, syscall.EAGAIN) {
|
||||
return nil, false, nil
|
||||
}
|
||||
return nil, false, err
|
||||
}
|
||||
return &unixInstanceLock{file: file}, true, nil
|
||||
}
|
||||
|
||||
func (l *unixInstanceLock) Close() error {
|
||||
if l == nil || l.file == nil {
|
||||
return nil
|
||||
}
|
||||
_ = syscall.Flock(int(l.file.Fd()), syscall.LOCK_UN)
|
||||
return l.file.Close()
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
//go:build windows
|
||||
|
||||
package instance
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
type windowsInstanceLock struct {
|
||||
handle windows.Handle
|
||||
}
|
||||
|
||||
// tryAcquireInstanceLock 用共享模式为 0 的 CreateFile 独占锁文件:
|
||||
// 已被占用的锁文件会返回 ERROR_SHARING_VIOLATION,此时调用方继续扫描下一个槽位。
|
||||
func tryAcquireInstanceLock(path string) (instanceLock, bool, error) {
|
||||
handle, err := windows.CreateFile(
|
||||
windows.StringToUTF16Ptr(path),
|
||||
windows.GENERIC_READ|windows.GENERIC_WRITE,
|
||||
0,
|
||||
nil,
|
||||
windows.OPEN_ALWAYS,
|
||||
windows.FILE_ATTRIBUTE_NORMAL,
|
||||
0,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, windows.ERROR_SHARING_VIOLATION) || errors.Is(err, windows.ERROR_ACCESS_DENIED) {
|
||||
return nil, false, nil
|
||||
}
|
||||
return nil, false, err
|
||||
}
|
||||
return &windowsInstanceLock{handle: handle}, true, nil
|
||||
}
|
||||
|
||||
func (l *windowsInstanceLock) Close() error {
|
||||
if l == nil || l.handle == windows.InvalidHandle {
|
||||
return nil
|
||||
}
|
||||
err := windows.CloseHandle(l.handle)
|
||||
l.handle = windows.InvalidHandle
|
||||
return err
|
||||
}
|
||||
Reference in New Issue
Block a user