Merge remote-tracking branch 'origin/worktree-process-service-seam' into subprocess-simpl/c-one-termination-verb
# Conflicts: # .agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.i18n.yaml # .agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.md # .agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.zh.md # docs/cordis-catalog/services.md # docs/core-data-structures/subprocess.i18n.yaml # docs/core-data-structures/subprocess.md # docs/core-data-structures/subprocess.zh.md # packages/cordis/tool-cordis/src/api-catalog.ts # packages/subprocess/subprocess-local/README.i18n.yaml # packages/subprocess/subprocess-local/README.md # packages/subprocess/subprocess-local/README.zh.md # packages/subprocess/subprocess/README.i18n.yaml # packages/subprocess/subprocess/README.md # packages/subprocess/subprocess/README.zh.md # packages/subprocess/subprocess/src/index.ts
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
README.md: b16d5e9a7eabb39db549b9fd6452e8fecee73022
|
||||
README.zh.md: 81b3f9ec8341a73732e2cf342bb0f9fe5c290357
|
||||
README.md: b6f0738e87e5ed62966604bced79159c38f7a47b
|
||||
README.zh.md: 3a8e80deb248d66dc76bc0f27b1256b2d3fca804
|
||||
|
||||
@@ -6,7 +6,7 @@ Local implementation of the [`@deepseek-ai/dsh-subprocess`](../subprocess/README
|
||||
|
||||
## Behavior (and where it came from)
|
||||
|
||||
- **Detached process trees with platform-correct signalling** — POSIX children are spawned `detached` (own process group) and signalled by negative pgid with a direct-child fallback; Windows terminates the tree via `taskkill /PID <pid> /T /F` (injectable for tests). `terminate()` — the handle's only termination verb — sends SIGTERM then SIGKILL after the spec's grace (OpenCode's escalation; pipelines and subshells die with the parent) and is a no-op once the tree is gone; `dispose(graces)` runs stdin-EOF → SIGTERM → SIGKILL with caller-supplied windows and one memoized disposal per handle. After the leader exits, still-open pipes receive the same bounded drain grace so a surviving descendant cannot hold the outcome open indefinitely. ESRCH is tolerated; daemons that re-parent away from the group can still survive — the same caveat as the surveyed tools.
|
||||
- **Detached process trees with platform-correct signalling** — POSIX children are spawned `detached` (own process group) and signalled by negative pgid with a direct-child fallback; Windows terminates the tree via `taskkill /PID <pid> /T /F` (injectable for tests). `terminate()` — the handle's only termination verb — sends SIGTERM then SIGKILL after the spec's grace (OpenCode's escalation; pipelines and subshells die with the parent) and is a no-op once the tree is gone; `waitForExit()` polls whole-tree liveness so consumer teardown confirms real quiescence. After the leader exits, still-open pipes receive the same bounded drain grace so a surviving descendant cannot hold the outcome open indefinitely. ESRCH is tolerated; daemons that re-parent away from the group can still survive — the same caveat as the surveyed tools.
|
||||
- **Per-stream dispositions** — `'pipe'` hands the raw stream to the caller untouched (protocol framing stays consumer-owned); `'inherit'` passes the parent descriptor through; collect mode keeps the in-memory TAIL beyond its cap (errors and results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a private temp file when a spill cap is configured — omitting `spill` keeps only the tail, the diagnostic shape. A stream larger than the spill cap discards its now-incomplete spill and returns only the marked truncated tail; spill fds are sealed at settlement, and a failed final close withholds the path rather than advertising an incomplete file. Spill files are `0600` with random names under a lazily-created `0700` per-process directory.
|
||||
- **Credential scrub + managed `DSH_*` merge** — `process.env` minus credential-shaped vars (`*KEY*`/`*SECRET*`/`*TOKEN*`) and all ambient `DSH_*` names; a spec's ordinary `env` merges after the scrub but rejects `DSH_*`; managed `dshEnv` rejects ordinary names and merges last, preventing stale nested-harness identity. Supplied stdin is written and closed; otherwise fd 0 is `/dev/null`. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md).
|
||||
- **Offset-based reads** — collect-mode readers return deltas in whole-stream byte coordinates; the service never holds a cursor, so consumer-owned cursors (the bash background read path) and full-stream re-reads coexist, before and after settlement.
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
## 行为(以及设计来源)
|
||||
|
||||
- **带平台正确信号发送的 detached 进程树**:POSIX 子进程使用 `detached` spawn(拥有独立进程组),信号以负 pgid 发送并以直接子进程作为回退;Windows 通过 `taskkill /PID <pid> /T /F` 终止进程树(可为测试注入)。`terminate()`(句柄唯一的终止动词)先发送 SIGTERM,经过 spec 的宽限期后再发送 SIGKILL(沿用 OpenCode 的升级策略;管道与子 shell 会随父进程一起结束),进程树消亡后为空操作;`dispose(graces)` 以调用方提供的时间窗运行 stdin EOF→SIGTERM→SIGKILL 阶梯,dispose(资源释放)按句柄 memoize 化、只执行一次。组长进程退出后,仍然打开的管道也只获得同样有界的排空宽限期,因此存活的后代进程无法无限期地拖住结果不结算。系统会容忍 ESRCH;脱离该组重新挂载的 daemon 仍可能存活,这与调研工具的局限相同。
|
||||
- **带平台正确信号发送的 detached 进程树**:POSIX 子进程使用 `detached` spawn(拥有独立进程组),信号以负 pgid 发送并以直接子进程作为回退;Windows 通过 `taskkill /PID <pid> /T /F` 终止进程树(可为测试注入)。`terminate()`(句柄唯一的终止动词)先发送 SIGTERM,经过 spec 的宽限期后再发送 SIGKILL(沿用 OpenCode 的升级策略;管道与子 shell 会随父进程一起结束),进程树消亡后为空操作;`waitForExit()` 轮询整棵进程树的存活状态,使消费方的拆卸能确认真正的完全停稳。组长进程退出后,仍然打开的管道也只获得同样有界的排空宽限期,因此存活的后代进程无法无限期地拖住结果不结算。系统会容忍 ESRCH;脱离该组重新挂载的 daemon 仍可能存活,这与调研工具的局限相同。
|
||||
- **按流划分的处置方式**:`'pipe'` 把原始流原样交给调用方(协议分帧仍归消费方所有);`'inherit'` 直通父进程的描述符;收集模式(collect)在输出超过上限后于内存中保留尾部(错误与结果通常聚集在末尾,沿用 pi/OpenCode 的理由),并在配置了 spill 上限时把完整流追加到一个私有临时文件;省略 `spill` 则只保留尾部,即诊断尾部的形状。某条流大于 spill 上限时,会丢弃已不完整的 spill,仅返回带截断标记的尾部;spill 文件描述符在结算时封存,最终关闭失败时则不公布路径,以免声称存在不完整的文件。spill 文件权限为 `0600`、名称随机,位于按需延迟创建的 `0700` 每进程目录之下。
|
||||
- **凭据清除 + 受管 `DSH_*` 合并**:以 `process.env` 为基础,移除形似凭据的变量(`*KEY*`/`*SECRET*`/`*TOKEN*`)和所有环境中已有的 `DSH_*` 名称;spec 的普通 `env` 在清除后合并,但会拒绝 `DSH_*`;受管 `dshEnv` 会拒绝普通名称并最后合并,防止陈旧的嵌套 harness 身份。提供的 stdin 会被写入后关闭;否则 fd 0 指向 `/dev/null`。参见 [stdin/env Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md)与[受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。
|
||||
- **基于偏移量的读取**:收集模式的读取器以全流字节坐标返回增量;服务自身从不持有游标,因此消费方自有的游标(bash 的后台读取路径)与完整流重读可以共存,结算前后皆然。
|
||||
|
||||
@@ -29,13 +29,11 @@
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-subprocess": "^0.0.1",
|
||||
"@deepseek-ai/dsh-timeout": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess": "workspace:^",
|
||||
"@deepseek-ai/dsh-timeout": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,8 +16,8 @@ import type { SpawnInternals } from './spawn.ts'
|
||||
/**
|
||||
* Local subprocess service: detached process trees, Node-shaped stdio
|
||||
* dispositions (raw pipes, inherit, bounded tail-keep collection with spill
|
||||
* files), credential-scrubbed environment, tree-scoped signalling with
|
||||
* SIGTERM→grace→SIGKILL escalation, and the cooperative dispose ladder.
|
||||
* files), credential-scrubbed environment, and tree-scoped signalling with
|
||||
* SIGTERM→grace→SIGKILL escalation.
|
||||
*/
|
||||
export class LocalSubprocessService extends SubprocessService {
|
||||
/** Live handles retained only so disposal can terminate and join them. */
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
/**
|
||||
* Process plumbing for the local subprocess service: detached process-tree
|
||||
* spawn with per-stream stdio dispositions, tail-keep collection with spill
|
||||
* files, tree-scoped signalling (POSIX groups; Windows taskkill), the
|
||||
* SIGTERM→SIGKILL escalation, and the cooperative EOF-first dispose ladder.
|
||||
* This layer reacts to an abort signal; callers own deadlines and classify
|
||||
* causes.
|
||||
* files, tree-scoped signalling (POSIX groups; Windows taskkill), and the
|
||||
* SIGTERM→SIGKILL escalation. This layer reacts to an abort signal; callers
|
||||
* own deadlines, teardown ladders, and cause classification.
|
||||
* @module dsh-subprocess-local/spawn
|
||||
*/
|
||||
|
||||
@@ -15,13 +14,11 @@ import { closeSync, mkdtempSync, openSync, unlinkSync, writeSync } from 'node:fs
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { setTimeout as sleepMs } from 'node:timers/promises'
|
||||
import { deadline } from '@deepseek-ai/dsh-timeout'
|
||||
import { DSH_ENV_PREFIX, scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess'
|
||||
import type {
|
||||
CollectedOutput,
|
||||
DshEnvironment,
|
||||
SubprocessCollect,
|
||||
SubprocessDisposeGraces,
|
||||
SubprocessHandle,
|
||||
SubprocessOutcome,
|
||||
SubprocessOutputMode,
|
||||
@@ -63,9 +60,6 @@ export interface SpawnInternals {
|
||||
platform?: NodeJS.Platform
|
||||
}
|
||||
|
||||
/** Timeout code marking a dispose-ladder tier bound (vs an external abort). */
|
||||
const DISPOSE_TIER_TIMEOUT = 'SUBPROCESS_DISPOSE_TIER'
|
||||
|
||||
/**
|
||||
* Liveness-poll cadence for tree-exit waits. The timer stays ref'd: an
|
||||
* awaited teardown must keep the event loop alive until the tree really
|
||||
@@ -392,8 +386,8 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter
|
||||
}
|
||||
}
|
||||
|
||||
// The dispose ladder's tier primitive (not on the handle — terminate() is
|
||||
// the only consumer-facing termination verb). Guards on TREE liveness, not
|
||||
// The escalation's tier primitive (not on the handle — terminate() is the
|
||||
// only consumer-facing termination verb). Guards on TREE liveness, not
|
||||
// outcome settlement: a TERM-trapping helper can outlive the settled direct
|
||||
// child and must stay signalable, while a fully-dead tree (possible pid
|
||||
// reuse) must not be re-signalled by a later tier.
|
||||
@@ -468,39 +462,6 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait, bounded, for whole-tree exit — the dispose ladder's quiescence test.
|
||||
* Tree liveness, not direct-child settlement: a TERM-trapping helper that
|
||||
* outlives the leader must hold the ladder on its tier until it exits.
|
||||
*/
|
||||
const treeExitsWithin = async (ms: number): Promise<boolean> => {
|
||||
using bound = deadline(undefined, ms, DISPOSE_TIER_TIMEOUT)
|
||||
return await waitForExit(bound.signal)
|
||||
}
|
||||
|
||||
let disposal: Promise<void> | undefined
|
||||
const dispose = (graces: SubprocessDisposeGraces): Promise<void> => (disposal ??= (async () => {
|
||||
// A spawn failure has no process to tear down; observe the rejection so
|
||||
// disposal in a finally block cannot surface it as unhandled.
|
||||
if (pid <= 0) {
|
||||
await done.catch(() => {})
|
||||
return
|
||||
}
|
||||
// 1. Close a piped stdin and allow cooperative teardown and flush.
|
||||
if (stdinMode === 'pipe') child.stdin?.end()
|
||||
if (await treeExitsWithin(graces.eofGraceMs)) return
|
||||
// 2. POSIX gets a catchable graceful signal; Windows taskkill force-terminates.
|
||||
if (platform !== 'win32') {
|
||||
kill('SIGTERM')
|
||||
if (await treeExitsWithin(graces.graceMs)) return
|
||||
}
|
||||
// 3. Force-kill the tree and await a bounded exit edge.
|
||||
kill('SIGKILL')
|
||||
if (!(await treeExitsWithin(graces.graceMs))) {
|
||||
throw new Error(`child process tree did not exit within ${graces.graceMs}ms after forced termination`)
|
||||
}
|
||||
})())
|
||||
|
||||
return {
|
||||
pid,
|
||||
/* v8 ignore start -- pipe-mode fds exist on every spawn Node returns; the null-coalesces guard a nonconforming ChildProcess only. */
|
||||
@@ -515,6 +476,5 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter
|
||||
done,
|
||||
terminate,
|
||||
waitForExit,
|
||||
dispose,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -479,40 +479,6 @@ describe('stdio dispositions', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('dispose ladder', () => {
|
||||
it('tier 1: a cooperative child exits on stdin EOF without any signal', async () => {
|
||||
const running = spawnSubprocess({
|
||||
...spec('read -r line; exit 0'),
|
||||
stdio: { stdin: 'pipe', stdout: { maxBytes: 1000 }, stderr: { maxBytes: 1000 } },
|
||||
})
|
||||
await running.dispose({ eofGraceMs: 5_000, graceMs: 200 })
|
||||
const outcome = await running.done
|
||||
expect(outcome.exitCode).toBe(0)
|
||||
expect(outcome.signal).toBeNull()
|
||||
})
|
||||
|
||||
it('tier 2: an EOF-deaf child dies by SIGTERM', async () => {
|
||||
const running = spawnSubprocess({
|
||||
...spec('sleep 60'),
|
||||
stdio: { stdin: 'pipe', stdout: { maxBytes: 1000 }, stderr: { maxBytes: 1000 } },
|
||||
})
|
||||
await running.dispose({ eofGraceMs: 100, graceMs: 5_000 })
|
||||
const outcome = await running.done
|
||||
expect(outcome.signal).toBe('SIGTERM')
|
||||
})
|
||||
|
||||
it('tier 3: a TERM-trapping child dies by SIGKILL, and dispose() is idempotent', async () => {
|
||||
const running = spawnSubprocess(spec('trap \'\' TERM; echo armed; sleep 60'))
|
||||
await waitForStdout(running, 'armed\n')
|
||||
const first = running.dispose({ eofGraceMs: 50, graceMs: 200 })
|
||||
const second = running.dispose({ eofGraceMs: 50, graceMs: 200 })
|
||||
expect(second).toBe(first)
|
||||
await first
|
||||
const outcome = await running.done
|
||||
expect(outcome.signal).toBe('SIGKILL')
|
||||
})
|
||||
})
|
||||
|
||||
describe('windows tree semantics (injected platform)', () => {
|
||||
it('terminate routes through taskkill by root pid', async () => {
|
||||
const killed: number[] = []
|
||||
@@ -563,7 +529,7 @@ describe('waitForExit', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('tree-survivor escalation (terminate/dispose reach helpers the leader left behind)', () => {
|
||||
describe('tree-survivor escalation (terminate and bounded waits reach helpers the leader left behind)', () => {
|
||||
it('terminate() SIGKILLs a TERM-trapping descendant after the direct child settles', async () => {
|
||||
// The leader spawns a TERM-trapping helper with all stdio detached from
|
||||
// the collected pipes, then exits: the helper holds the GROUP alive while
|
||||
@@ -582,18 +548,21 @@ describe('tree-survivor escalation (terminate/dispose reach helpers the leader l
|
||||
await waitGone(helper)
|
||||
})
|
||||
|
||||
it('dispose() holds each tier on whole-tree exit, not direct-child settlement', async () => {
|
||||
const pidFile = join(spillDir, `survivor-dispose-${Date.now()}.pid`)
|
||||
it('a bounded waitForExit reports false while a survivor lives, true after escalation', async () => {
|
||||
const pidFile = join(spillDir, `survivor-wait-${Date.now()}.pid`)
|
||||
const running = spawnSubprocess(spec(
|
||||
`bash -c 'trap "" TERM; echo $$ > ${pidFile}; sleep 60' >/dev/null 2>&1 & disown; exit 0`,
|
||||
{ graceMs: 200 },
|
||||
))
|
||||
const helper = await waitForPidFile(pidFile)
|
||||
await running.done
|
||||
expect(() => process.kill(helper, 0)).not.toThrow()
|
||||
|
||||
await running.dispose({ eofGraceMs: 100, graceMs: 300 })
|
||||
// The ladder only returns once the WHOLE tree is gone.
|
||||
// A consumer-owned teardown tier bounds its wait and reads the verdict.
|
||||
const bound = new AbortController()
|
||||
const timer = setTimeout(() => { bound.abort() }, 100)
|
||||
await expect(running.waitForExit(bound.signal)).resolves.toBe(false)
|
||||
clearTimeout(timer)
|
||||
running.terminate()
|
||||
await expect(running.waitForExit()).resolves.toBe(true)
|
||||
expect(() => process.kill(helper, 0)).toThrow()
|
||||
})
|
||||
|
||||
@@ -626,11 +595,10 @@ describe('coverage seams', () => {
|
||||
expect(() => { taskkillProcessTree(2 ** 30) }).not.toThrow()
|
||||
})
|
||||
|
||||
it('dispose on a spawn-failed handle observes the rejection and returns', async () => {
|
||||
it('a spawn-failed handle rejects done while waitForExit reports gone', async () => {
|
||||
const running = spawnSubprocess(spec('true', { cwd: '/nonexistent-dir-dsh-dispose-test' }))
|
||||
const disposal = running.dispose({ eofGraceMs: 1_000, graceMs: 1_000 })
|
||||
await expect(running.done).rejects.toThrow()
|
||||
await expect(disposal).resolves.toBeUndefined()
|
||||
await expect(running.waitForExit()).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it("an 'inherit' stdout with collected stderr wires only the requested collector", async () => {
|
||||
@@ -677,25 +645,10 @@ describe('coverage seams', () => {
|
||||
await expect(running.waitForExit()).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('dispose() on an already-exited tree returns without delivering a signal', async () => {
|
||||
const running = spawnSubprocess(spec('true'))
|
||||
await running.done
|
||||
await running.waitForExit()
|
||||
const spy = vi.spyOn(process, 'kill')
|
||||
try {
|
||||
await running.dispose({ eofGraceMs: 50, graceMs: 50 })
|
||||
const delivered = spy.mock.calls.filter(([, sig]) => sig !== 0)
|
||||
expect(delivered).toEqual([])
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('a batch-stdin handle exposes no stdin and dispose skips the EOF tier', async () => {
|
||||
it('a batch-stdin handle exposes no stdin surface', async () => {
|
||||
const running = spawnSubprocess(spec('cat', { stdin: 'batch\n' }))
|
||||
expect(running.stdin).toBeUndefined()
|
||||
await running.done
|
||||
await running.dispose({ eofGraceMs: 50, graceMs: 50 })
|
||||
expect(running.collected.stdout!.readFrom(0).text).toBe('batch\n')
|
||||
})
|
||||
})
|
||||
@@ -724,33 +677,15 @@ describe('coverage seams 2', () => {
|
||||
await expect(running.waitForExit()).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('the win32 dispose ladder skips the POSIX SIGTERM tier and force-terminates', async () => {
|
||||
const kills: number[] = []
|
||||
const running = spawnSubprocess({
|
||||
...spec('sleep 60'),
|
||||
stdio: { stdin: 'pipe', stdout: { maxBytes: 1000 }, stderr: { maxBytes: 1000 } },
|
||||
}, {
|
||||
spillDir,
|
||||
platform: 'win32',
|
||||
taskkill: (pid) => {
|
||||
kills.push(pid)
|
||||
try {
|
||||
process.kill(pid, 'SIGKILL')
|
||||
} catch {
|
||||
// Already gone.
|
||||
}
|
||||
},
|
||||
})
|
||||
await running.dispose({ eofGraceMs: 50, graceMs: 5_000 })
|
||||
// Exactly one forced tree termination: no POSIX SIGTERM tier ran.
|
||||
expect(kills).toEqual([running.pid])
|
||||
})
|
||||
|
||||
it('dispose throws when even SIGKILL produces no exit within the grace', async () => {
|
||||
// An inert taskkill simulates a tree that never reports exit.
|
||||
it('an inert win32 taskkill leaves the tree alive for a bounded wait to report', async () => {
|
||||
// An inert taskkill simulates a tree that never reports exit: terminate()
|
||||
// delivers nothing, so a bounded consumer wait must come back false.
|
||||
const running = spawnSubprocess(spec('sleep 60'), { spillDir, platform: 'win32', taskkill: () => {} })
|
||||
await expect(running.dispose({ eofGraceMs: 20, graceMs: 40 }))
|
||||
.rejects.toThrow(/did not exit within 40ms after forced termination/)
|
||||
running.terminate()
|
||||
const bound = new AbortController()
|
||||
const timer = setTimeout(() => { bound.abort() }, 60)
|
||||
await expect(running.waitForExit(bound.signal)).resolves.toBe(false)
|
||||
clearTimeout(timer)
|
||||
// Real cleanup: the injected platform spawned without detachment, so the
|
||||
// child is a plain (group-less) POSIX process — kill it directly.
|
||||
process.kill(running.pid, 'SIGKILL')
|
||||
|
||||
@@ -17,9 +17,6 @@
|
||||
{
|
||||
"path": "../subprocess"
|
||||
},
|
||||
{
|
||||
"path": "../../util/timeout"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user