Files
clash-verge-rev/scripts/dev-control.test.mjs
T
oomeow b71a1e4ec5 refactor: mihomo ipc (#7601)
* refactor: mihomo ipc request

* refactor: remove redundant write locks and await on mihomo handle

* fix(rule): handle non-string types in ProviderButton labels

* refactor: handle non-string value types in RuleItem component

* refactor(tauri): remove unnecessary await in get_base_config call

* chore: update tauri-plugin-mihomo deps

* refactor: rename ConnectionId to WsConnectionId

* chore: update tauri-plugin-mihomo-api dependency reference

* chore(config): use Proxy::default in profile test initialization
2026-07-28 15:16:01 +00:00

1168 lines
32 KiB
JavaScript

import assert from 'node:assert/strict'
import { EventEmitter } from 'node:events'
import { chmod, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import test from 'node:test'
import { inspect } from 'node:util'
import {
createMacosShutdownController,
readPrivateInstanceRecord,
requestDevQuit,
waitForPortClosed,
} from './dev-control.mjs'
import { windowsElevationInvocation } from './dev-service.mjs'
import {
buildTauriInvocation,
mirrorChildExit,
prepareDevRuntime,
runDevAnchor,
runDevCommand,
terminateDetachedGroup,
} from './dev.mjs'
const token = 'ab'.repeat(32)
const posixOnly = {
skip: process.platform === 'win32' ? 'requires POSIX file metadata' : false,
}
test('development startup preserves the installed service state unless service mode is explicit', async () => {
for (const mode of ['dev', 'trace', 'sidecar', 'service']) {
const calls = []
const result = await prepareDevRuntime(mode, {
ensureService: async () => {
calls.push('ensure')
return '/service'
},
prepareService: async () => {
calls.push('prepare')
return '/service'
},
})
assert.deepEqual(calls, [mode === 'service' ? 'ensure' : 'prepare'])
assert.equal(result.mode, mode === 'service' ? 'dev' : mode)
assert.equal(result.serviceDirectory, '/service')
}
})
test('Windows elevation passes the installer path outside PowerShell source', () => {
const installer = String.raw`C:\ssp\path with spaces\clash-verge-service-install.exe`
const baseEnvironment = { SYSTEMROOT: String.raw`C:\Windows` }
const invocation = windowsElevationInvocation(installer, baseEnvironment)
assert.equal(invocation.command, 'powershell.exe')
assert.equal(invocation.args.at(-2), '-Command')
assert.equal(invocation.args.includes(installer), false)
assert.equal(invocation.env.CLASH_VERGE_DEV_SERVICE_INSTALLER, installer)
assert.equal(baseEnvironment.CLASH_VERGE_DEV_SERVICE_INSTALLER, undefined)
})
async function withTemporaryDirectory(run) {
const root = await mkdtemp(join(tmpdir(), 'clash-verge-dev-control-'))
try {
await run(root)
} finally {
await rm(root, { recursive: true, force: true })
}
}
function requestSequence(statuses, requests) {
return (options, onResponse) => {
const outgoing = new EventEmitter()
outgoing.setTimeout = () => outgoing
outgoing.destroy = (error) =>
queueMicrotask(() => outgoing.emit('error', error))
outgoing.end = () => {
requests.push(options)
const outcome = statuses.shift()
if (outcome instanceof Error) {
queueMicrotask(() => outgoing.emit('error', outcome))
return
}
const response = new EventEmitter()
response.statusCode = outcome
response.resume = () => {}
response.destroy = () => {}
queueMicrotask(() => {
onResponse(response)
queueMicrotask(() => response.emit('end'))
})
}
return outgoing
}
}
function responseLifecycleRequest(status, events) {
return (_options, onResponse) => {
const outgoing = new EventEmitter()
outgoing.setTimeout = () => outgoing
outgoing.destroy = (error) =>
queueMicrotask(() => outgoing.emit('error', error))
outgoing.end = () => {
const response = new EventEmitter()
response.statusCode = status
response.resume = () => {}
response.destroy = () => {}
queueMicrotask(() => {
onResponse(response)
for (const event of events) {
response.emit(
event,
event === 'error' ? new Error('response failed') : undefined,
)
}
})
}
return outgoing
}
}
function releasedResponseSequence(statuses, releases) {
return (_options, onResponse) => {
const outgoing = new EventEmitter()
outgoing.setTimeout = () => outgoing
outgoing.destroy = (error) =>
queueMicrotask(() => outgoing.emit('error', error))
outgoing.end = () => {
const response = new EventEmitter()
response.statusCode = statuses.shift()
response.resume = () => {}
response.destroy = () => {
releases.push(response.statusCode)
response.emit('aborted')
response.emit('error', new Error('destroyed response'))
response.emit('close')
}
queueMicrotask(() => onResponse(response))
}
return outgoing
}
}
function createChild(pid) {
const child = new EventEmitter()
child.pid = pid
return child
}
function flushTasks() {
return new Promise((resolve) => setImmediate(resolve))
}
test(
'private instance record must be an owned 0600 ordinary file',
posixOnly,
async () => {
await withTemporaryDirectory(async (root) => {
const recordPath = join(root, 'singleton-instance.json')
await writeFile(recordPath, JSON.stringify({ port: 42123, token }), {
mode: 0o600,
})
assert.deepEqual(
await readPrivateInstanceRecord(recordPath, process.getuid()),
{
port: 42123,
token,
},
)
await chmod(recordPath, 0o644)
await assert.rejects(
readPrivateInstanceRecord(recordPath, process.getuid()),
/0600/,
)
})
},
)
test('private instance record rejects a symbolic link', posixOnly, async () => {
await withTemporaryDirectory(async (root) => {
const targetPath = join(root, 'target.json')
const recordPath = join(root, 'singleton-instance.json')
await writeFile(targetPath, JSON.stringify({ port: 42123, token }), {
mode: 0o600,
})
await symlink(targetPath, recordPath)
await assert.rejects(
readPrivateInstanceRecord(recordPath, process.getuid()),
)
})
})
test('private instance record rejects the wrong owner', posixOnly, async () => {
await withTemporaryDirectory(async (root) => {
const recordPath = join(root, 'singleton-instance.json')
await writeFile(recordPath, JSON.stringify({ port: 42123, token }), {
mode: 0o600,
})
await assert.rejects(
readPrivateInstanceRecord(recordPath, process.getuid() + 1),
/owner/,
)
})
})
test('private instance record rejects invalid ports', posixOnly, async (t) => {
for (const port of [0, 65536, 1.5, '42123']) {
await t.test(String(port), async () => {
await withTemporaryDirectory(async (root) => {
const recordPath = join(root, 'singleton-instance.json')
await writeFile(recordPath, JSON.stringify({ port, token }), {
mode: 0o600,
})
await assert.rejects(
readPrivateInstanceRecord(recordPath, process.getuid()),
/port/,
)
})
})
}
})
test(
'private instance record rejects tokens outside the lowercase 64-character format',
posixOnly,
async (t) => {
for (const invalidToken of [
'ab'.repeat(31),
'AB'.repeat(32),
'z'.repeat(64),
]) {
await t.test(
invalidToken.length === 64 ? invalidToken.slice(0, 2) : 'short',
async () => {
await withTemporaryDirectory(async (root) => {
const recordPath = join(root, 'singleton-instance.json')
await writeFile(
recordPath,
JSON.stringify({ port: 42123, token: invalidToken }),
{ mode: 0o600 },
)
await assert.rejects(
readPrivateInstanceRecord(recordPath, process.getuid()),
/token/,
)
})
},
)
}
},
)
test('requestDevQuit posts only to loopback and retries 503', async () => {
const requests = []
const record = { port: 42123, token }
assert.deepEqual(
await requestDevQuit(record, {
requestImpl: requestSequence([503, 202], requests),
retryDelayMs: 1,
timeoutMs: 1_000,
}),
record,
)
assert.equal(requests.length, 2)
for (const options of requests) {
assert.equal(options.host, '127.0.0.1')
assert.equal(options.port, 42123)
assert.equal(options.method, 'POST')
assert.equal(options.path, '/commands/dev/quit')
assert.equal(options.headers['X-Instance-Token'], token)
}
})
test('requestDevQuit retries a missing record source and returns the accepted record', async () => {
let reads = 0
const record = await requestDevQuit(
async () => {
reads += 1
if (reads === 1) {
const error = new Error('missing')
error.code = 'ENOENT'
throw error
}
return { port: 42123, token }
},
{
requestImpl: requestSequence([202], []),
retryDelayMs: 1,
timeoutMs: 1_000,
},
)
assert.deepEqual(record, { port: 42123, token })
assert.equal(reads, 2)
})
test('requestDevQuit errors never disclose the instance token', async () => {
await assert.rejects(
requestDevQuit(
{ port: 42123, token },
{ requestImpl: requestSequence([500], []) },
),
(error) => {
assert.equal(error.message.includes(token), false)
assert.match(error.message, /500/)
return true
},
)
})
test('non-transient request errors preserve only a token-safe error cause', async () => {
const unsafe = new Error(`permission denied for token ${token}`)
unsafe.code = 'EACCES'
unsafe.options = { headers: { 'X-Instance-Token': token } }
let resultError
await assert.rejects(
requestDevQuit(
{ port: 42123, token },
{ requestImpl: requestSequence([unsafe], []) },
),
(error) => {
resultError = error
return true
},
)
const rendered = inspect(resultError, { depth: null })
assert.equal(rendered.includes(token), false)
assert.match(resultError.cause.message, /EACCES/)
assert.notEqual(resultError.cause, unsafe)
})
test('requestDevQuit settles from response headers and consumes shutdown lifecycle events', async () => {
const record = { port: 42123, token }
for (const events of [
['aborted', 'close'],
['error', 'close'],
]) {
assert.deepEqual(
await requestDevQuit(record, {
requestImpl: responseLifecycleRequest(202, events),
}),
record,
)
}
})
test('requestDevQuit actively releases 503 and 202 responses after headers', async () => {
const releases = []
assert.deepEqual(
await requestDevQuit(
{ port: 42123, token },
{
requestImpl: releasedResponseSequence([503, 202], releases),
retryDelayMs: 1,
timeoutMs: 100,
},
),
{ port: 42123, token },
)
assert.deepEqual(releases, [503, 202])
})
test('requestDevQuit retries transient connection errors without disclosing the token', async () => {
const transient = new Error(`connect failed with secret ${token}`)
transient.code = 'ECONNREFUSED'
const requests = []
assert.deepEqual(
await requestDevQuit(
{ port: 42123, token },
{
requestImpl: requestSequence([transient, 202], requests),
retryDelayMs: 1,
timeoutMs: 100,
},
),
{ port: 42123, token },
)
assert.equal(requests.length, 2)
})
test('requestDevQuit bounds ENOENT, 503, and transient-error retries', async (t) => {
const cases = [
{
name: 'ENOENT',
source: async () => {
const error = new Error('missing')
error.code = 'ENOENT'
throw error
},
requestImpl: requestSequence([], []),
},
{
name: '503',
source: { port: 42123, token },
requestImpl: requestSequence(Array(20).fill(503), []),
},
{
name: 'transient error',
source: { port: 42123, token },
requestImpl: requestSequence(
Array.from({ length: 20 }, () => {
const error = new Error(`reset ${token}`)
error.code = 'ECONNRESET'
return error
}),
[],
),
},
]
for (const entry of cases) {
await t.test(entry.name, async () => {
await assert.rejects(
requestDevQuit(entry.source, {
requestImpl: entry.requestImpl,
retryDelayMs: 1,
timeoutMs: 5,
}),
(error) => {
assert.match(error.message, /timed out/)
assert.equal(error.message.includes(token), false)
return true
},
)
})
}
})
test('waitForPortClosed resolves only after the loopback connection probe fails', async () => {
let probes = 0
await waitForPortClosed(42123, {
probePort: async () => {
probes += 1
return probes < 3
},
retryDelayMs: 1,
timeoutMs: 1_000,
})
assert.equal(probes, 3)
})
test('waitForPortClosed gives each probe only the remaining absolute budget', async () => {
let now = 1_000
const budgets = []
await assert.rejects(
waitForPortClosed(42123, {
now: () => now,
probePort: async (_port, budget) => {
budgets.push(budget)
return true
},
retryDelayMs: 3,
sleep: async (milliseconds) => {
now += milliseconds
},
timeoutMs: 10,
}),
/timed out/,
)
assert.deepEqual(budgets, [10, 7, 4, 1])
})
test('first SIGINT finalizes the group before exiting 130', async () => {
const calls = []
const controller = createMacosShutdownController({
requestQuit: async () => {
calls.push('request')
return { port: 42123 }
},
waitForPortClosed: async () => {
calls.push('closed')
},
waitForChildExit: async () => {
calls.push('child')
},
terminateGroup: () => calls.push('terminate'),
exit: (code) => calls.push(`exit:${code}`),
})
await controller.handleSigint()
assert.deepEqual(calls, [
'request',
'closed',
'child',
'terminate',
'exit:130',
])
})
test('first timeout preserves the group and second SIGINT forces it exactly once', async () => {
const calls = []
const controller = createMacosShutdownController({
requestQuit: async () => {
throw new Error('timed out')
},
waitForPortClosed: async () => {},
waitForChildExit: async () => {},
terminateGroup: () => calls.push('terminate'),
exit: (code) => calls.push(`exit:${code}`),
report: (message) => calls.push(message),
})
await controller.handleSigint()
assert.equal(calls.includes('terminate'), false)
assert.equal(
calls.some((call) => String(call).includes('timed out')),
true,
)
await controller.handleSigint()
await controller.handleSigint()
assert.equal(calls.filter((call) => call === 'terminate').length, 1)
})
test('second SIGINT terminates the group while the first request is still pending', async () => {
let releaseRequest
const requestPending = new Promise((resolve) => {
releaseRequest = resolve
})
const calls = []
const controller = createMacosShutdownController({
requestQuit: async () => requestPending,
waitForPortClosed: async () => {},
waitForChildExit: async () => {},
terminateGroup: () => calls.push('terminate'),
exit: (code) => calls.push(`exit:${code}`),
})
const graceful = controller.handleSigint()
await controller.handleSigint()
assert.deepEqual(calls, ['terminate', 'exit:130'])
releaseRequest({ port: 42123 })
await graceful
})
test('third SIGINT shares the in-progress forced termination', async () => {
let finishTermination
const terminating = new Promise((resolve) => {
finishTermination = resolve
})
const calls = []
const controller = createMacosShutdownController({
requestQuit: async () => new Promise(() => {}),
waitForPortClosed: async () => {},
waitForChildExit: async () => {},
terminateGroup: async () => {
calls.push('terminate')
await terminating
},
exit: (code) => calls.push(`exit:${code}`),
})
void controller.handleSigint()
const forced = controller.handleSigint()
const repeated = controller.handleSigint()
await flushTasks()
assert.deepEqual(calls, ['terminate'])
finishTermination()
assert.equal(await forced, true)
assert.equal(await repeated, true)
assert.deepEqual(calls, ['terminate', 'exit:130'])
})
test('trace invocation preserves the existing Rust flags and Tauri arguments', () => {
const invocation = buildTauriInvocation('trace', { PATH: '/bin' }, 'darwin')
assert.deepEqual(invocation.args, [
'exec',
'tauri',
'dev',
'-f',
'verge-dev',
'tokio-trace',
])
assert.equal(invocation.env.RUST_BACKTRACE, 'full')
assert.equal(invocation.env.RUSTFLAGS, '--cfg tokio_unstable')
assert.equal(invocation.detached, true)
})
test('normal dev invocation preserves arguments and remains direct off macOS', () => {
const invocation = buildTauriInvocation('dev', { PATH: '/bin' }, 'linux')
assert.equal(invocation.command, 'pnpm')
assert.deepEqual(invocation.args, ['exec', 'tauri', 'dev', '-f', 'verge-dev'])
assert.deepEqual(invocation.env, { PATH: '/bin', RUST_BACKTRACE: 'full' })
assert.equal(invocation.detached, false)
})
test('sidecar invocation explicitly enables only the development sidecar feature', () => {
const invocation = buildTauriInvocation('sidecar', { PATH: '/bin' }, 'win32')
assert.deepEqual(invocation.args, [
'exec',
'tauri',
'dev',
'-f',
'verge-dev,dev-sidecar',
])
assert.equal(invocation.detached, false)
})
test('macOS parent launches a detached Node anchor with inherited stdio and IPC', async () => {
const anchor = createChild(4321)
let spawned
const running = runDevCommand('trace', {
environment: { HOME: '/tmp/home', PATH: '/bin' },
installSigint: () => () => {},
platform: 'darwin',
spawnChild: (command, args, options) => {
spawned = { command, args, options }
queueMicrotask(() => {
anchor.emit('spawn')
anchor.emit('message', { type: 'ready' })
})
return anchor
},
})
await running
assert.equal(spawned.command, process.execPath)
assert.deepEqual(spawned.args.slice(-2), ['--anchor', 'trace'])
assert.equal(spawned.options.detached, true)
assert.deepEqual(spawned.options.stdio, [
'inherit',
'inherit',
'inherit',
'ipc',
])
})
test('anchor launches the exact inner command non-detached and stays for release', async () => {
const inner = createChild(8765)
const sent = []
const exits = []
let handleMessage
let handleSigterm
let spawned
const starting = runDevAnchor('trace', {
environment: { PATH: '/bin' },
exit: (code) => exits.push(code),
installDisconnect: () => () => {},
installMessage: (handler) => {
handleMessage = handler
return () => {}
},
installSigterm: (handler) => {
handleSigterm = handler
return () => {}
},
send: (message) => sent.push(message),
spawnChild: (command, args, options) => {
spawned = { command, args, options }
queueMicrotask(() => inner.emit('spawn'))
return inner
},
})
await starting
assert.equal(spawned.command, 'pnpm')
assert.deepEqual(spawned.args, [
'exec',
'tauri',
'dev',
'-f',
'verge-dev',
'tokio-trace',
])
assert.equal(spawned.options.detached, false)
assert.equal(spawned.options.stdio, 'inherit')
assert.deepEqual(sent, [{ type: 'ready' }])
handleSigterm()
assert.deepEqual(exits, [])
inner.emit('exit', 0, null)
await flushTasks()
assert.deepEqual(sent.at(-1), {
type: 'inner-result',
code: 0,
signal: null,
})
assert.deepEqual(exits, [])
handleMessage({ type: 'release' })
assert.deepEqual(exits, [0])
})
test('anchor cleans its own group after parent IPC disconnect', async () => {
const inner = createChild(8765)
const calls = []
let handleDisconnect
const starting = runDevAnchor('dev', {
environment: { PATH: '/bin' },
exit: (code) => calls.push(`exit:${code}`),
installDisconnect: (handler) => {
handleDisconnect = handler
return () => {}
},
installMessage: () => () => {},
installSigterm: () => () => {},
ownPid: 4321,
send: () => {},
spawnChild: () => {
queueMicrotask(() => inner.emit('spawn'))
return inner
},
terminateGroup: async (pid) => calls.push(`terminate:${pid}`),
})
await starting
handleDisconnect()
await flushTasks()
assert.deepEqual(calls, ['terminate:4321', 'exit:1'])
})
test('first finalize and second SIGINT share one group finalization', async () => {
let finishFinalize
const finalizing = new Promise((resolve) => {
finishFinalize = resolve
})
const calls = []
const controller = createMacosShutdownController({
exit: (code) => calls.push(`exit:${code}`),
finalizeGroup: async () => {
calls.push('finalize')
await finalizing
},
requestQuit: async () => ({ port: 42123 }),
terminateGroup: async () => calls.push('force'),
waitForChildExit: async () => {},
waitForPortClosed: async () => {},
})
const graceful = controller.handleSigint()
await flushTasks()
const forced = controller.handleSigint()
const repeated = controller.handleSigint()
assert.deepEqual(calls, ['finalize'])
finishFinalize()
await Promise.all([graceful, forced, repeated])
assert.equal(calls.filter((call) => call === 'finalize').length, 1)
assert.equal(calls.includes('force'), false)
assert.deepEqual(
calls.filter((call) => call.startsWith('exit:')),
['exit:130'],
)
})
test('graceful shutdown shares one absolute ten-second budget', async () => {
let now = 1_000
const budgets = []
const controller = createMacosShutdownController({
exit: () => {},
finalizeGroup: async (budget) => {
budgets.push(['finalize', budget])
now += 500
},
now: () => now,
requestQuit: async (budget) => {
budgets.push(['request', budget])
now += 3_000
return { port: 42123 }
},
terminateGroup: async () => {},
timeoutMs: 10_000,
waitForChildExit: async (budget) => {
budgets.push(['inner', budget])
now += 4_000
},
waitForPortClosed: async (_port, budget) => {
budgets.push(['endpoint', budget])
now += 2_000
},
})
await controller.handleSigint()
assert.deepEqual(budgets, [
['request', 10_000],
['endpoint', 7_000],
['inner', 5_000],
['finalize', 1_000],
])
})
test('macOS validates HOME before spawning', async () => {
let spawned = false
await assert.rejects(
runDevCommand('dev', {
environment: { PATH: '/bin' },
platform: 'darwin',
spawnChild: () => {
spawned = true
},
}),
/HOME/,
)
assert.equal(spawned, false)
})
test('macOS rejects spawn failure and unavailable PID without group signals', async (t) => {
for (const scenario of ['error', 'missing pid']) {
await t.test(scenario, async () => {
const child = createChild(undefined)
const groupSignals = []
const start = runDevCommand('dev', {
environment: { HOME: '/tmp/home', PATH: '/bin' },
installSigint: () => () => {},
platform: 'darwin',
signalGroup: (...args) => groupSignals.push(args),
spawnChild: () => {
queueMicrotask(() => {
if (scenario === 'error')
child.emit('error', new Error('spawn failed'))
else child.emit('spawn')
})
return child
},
})
await assert.rejects(start, scenario === 'error' ? /spawn failed/ : /PID/)
assert.deepEqual(groupSignals, [])
})
}
})
test('SIGINT received during macOS spawn setup waits for a validated child PID', async () => {
const child = createChild(4321)
let handleSigint
const calls = []
const starting = runDevCommand('dev', {
environment: { HOME: '/tmp/home', PATH: '/bin' },
exit: (code) => calls.push(`exit:${code}`),
installSigint: (handler) => {
handleSigint = handler
return () => {}
},
platform: 'darwin',
requestQuit: async () => new Promise(() => {}),
spawnChild: () => child,
terminateGroup: async (pid) => calls.push(`terminate:${pid}`),
})
await flushTasks()
handleSigint()
handleSigint()
assert.deepEqual(calls, [])
child.emit('spawn')
child.emit('message', { type: 'ready' })
await starting
await flushTasks()
assert.deepEqual(calls, ['terminate:4321', 'exit:130'])
})
test('inner leader exit leaves the anchor available for lingering forced group cleanup', async () => {
const child = createChild(4321)
let handleSigint
let finishTermination
const terminating = new Promise((resolve) => {
finishTermination = resolve
})
const calls = []
const started = runDevCommand('dev', {
environment: { HOME: '/tmp/home', PATH: '/bin' },
exit: (code) => calls.push(`exit:${code}`),
installSigint: (handler) => {
handleSigint = handler
return () => {}
},
mirrorResult: (result) =>
calls.push(`mirror:${result.code}:${result.signal}`),
platform: 'darwin',
report: (message) => calls.push(message),
requestQuit: async () => {
throw new Error('timed out')
},
spawnChild: () => {
queueMicrotask(() => {
child.emit('spawn')
child.emit('message', { type: 'ready' })
})
return child
},
terminateGroup: async () => {
calls.push('terminate')
await terminating
},
})
await started
handleSigint()
await flushTasks()
child.emit('message', {
type: 'inner-result',
code: 0,
signal: null,
})
await flushTasks()
assert.equal(
calls.some((call) => call.startsWith('mirror:')),
false,
)
handleSigint()
handleSigint()
await flushTasks()
assert.equal(calls.filter((call) => call === 'terminate').length, 1)
assert.equal(calls.includes('exit:130'), false)
finishTermination()
await flushTasks()
assert.equal(calls.filter((call) => call === 'exit:130').length, 1)
})
test('unexpected anchor exit makes delayed force cleanup fail closed', async () => {
const anchor = createChild(4321)
let handleSigint
const calls = []
await runDevCommand('dev', {
environment: { HOME: '/tmp/home', PATH: '/bin' },
exit: (code) => calls.push(`exit:${code}`),
installSigint: (handler) => {
handleSigint = handler
return () => {}
},
platform: 'darwin',
report: (message) => calls.push(`report:${message}`),
requestQuit: async () => {
throw new Error('timed out')
},
signalGroup: (pid, signal) => calls.push(`signal:${pid}:${signal}`),
spawnChild: () => {
queueMicrotask(() => {
anchor.emit('spawn')
anchor.emit('message', { type: 'ready' })
})
return anchor
},
})
handleSigint()
await flushTasks()
anchor.emit('exit', 1, null)
await flushTasks()
handleSigint()
await flushTasks()
assert.equal(
calls.some((call) => call.startsWith('signal:')),
false,
)
assert.equal(
calls.some((call) => call.includes('unsafe')),
true,
)
assert.deepEqual(
calls.filter((call) => call.startsWith('exit:')),
['exit:1'],
)
})
test('normal inner completion releases the anchor before mirroring the result', async () => {
const anchor = createChild(4321)
const calls = []
anchor.send = (message, callback) => {
calls.push(message.type)
queueMicrotask(() => {
callback?.()
anchor.emit('exit', 0, null)
})
}
await runDevCommand('dev', {
environment: { HOME: '/tmp/home', PATH: '/bin' },
installSigint: () => () => {},
mirrorResult: ({ code, signal }) => calls.push(`mirror:${code}:${signal}`),
platform: 'darwin',
signalGroup: (pid, signal) => {
calls.push(`probe:${pid}:${signal}`)
const error = new Error('group absent')
error.code = 'ESRCH'
throw error
},
spawnChild: () => {
queueMicrotask(() => {
anchor.emit('spawn')
anchor.emit('message', { type: 'ready' })
})
return anchor
},
})
anchor.emit('message', {
type: 'inner-result',
code: 0,
signal: null,
})
await flushTasks()
await flushTasks()
assert.deepEqual(calls, ['release', 'probe:-4321:0', 'mirror:0:null'])
})
test('normal release fails closed while the original group still exists', async () => {
const anchor = createChild(4321)
const calls = []
anchor.send = (message, callback) => {
calls.push(message.type)
queueMicrotask(() => {
callback?.()
anchor.emit('exit', 0, null)
})
}
await runDevCommand('dev', {
environment: { HOME: '/tmp/home', PATH: '/bin' },
exit: (code) => calls.push(`exit:${code}`),
installSigint: () => () => {},
mirrorResult: () => calls.push('mirror'),
platform: 'darwin',
report: (message) => calls.push(`report:${message}`),
signalGroup: (pid, signal) => calls.push(`probe:${pid}:${signal}`),
spawnChild: () => {
queueMicrotask(() => {
anchor.emit('spawn')
anchor.emit('message', { type: 'ready' })
})
return anchor
},
})
anchor.emit('message', {
type: 'inner-result',
code: 0,
signal: null,
})
await flushTasks()
await flushTasks()
assert.deepEqual(calls, [
'release',
'probe:-4321:0',
'report:Dev command failed: dev process group still exists after anchor exit',
'exit:1',
])
})
test('failed forced cleanup is shared, reported once, and never mirrors child exit', async () => {
const child = createChild(4321)
let handleSigint
const calls = []
await runDevCommand('dev', {
environment: { HOME: '/tmp/home', PATH: '/bin' },
exit: (code) => calls.push(`exit:${code}`),
installSigint: (handler) => {
handleSigint = handler
return () => {}
},
mirrorResult: () => calls.push('mirror'),
platform: 'darwin',
report: (message) => calls.push(`report:${message}`),
requestQuit: async () => new Promise(() => {}),
spawnChild: () => {
queueMicrotask(() => {
child.emit('spawn')
child.emit('message', { type: 'ready' })
})
return child
},
terminateGroup: async () => {
calls.push('terminate')
throw new Error('forced cleanup failed')
},
})
handleSigint()
handleSigint()
handleSigint()
child.emit('exit', 0, null)
await flushTasks()
await flushTasks()
assert.equal(calls.filter((call) => call === 'terminate').length, 1)
assert.equal(calls.filter((call) => call.startsWith('report:')).length, 1)
assert.deepEqual(
calls.filter((call) => call.startsWith('exit:')),
['exit:1'],
)
assert.equal(calls.includes('mirror'), false)
})
test('detached group termination waits after TERM, then treats KILL ESRCH as success', async () => {
let now = 0
const calls = []
await terminateDetachedGroup(4321, {
graceMs: 100,
now: () => now,
signalGroup: (pid, signal) => {
calls.push(`${pid}:${signal}`)
if (signal === 'SIGKILL') {
const error = new Error('gone')
error.code = 'ESRCH'
throw error
}
},
sleep: async (milliseconds) => {
calls.push(`sleep:${milliseconds}`)
now += milliseconds
},
})
assert.deepEqual(calls, [
'-4321:SIGTERM',
'sleep:50',
'-4321:0',
'sleep:50',
'-4321:SIGKILL',
])
})
test('non-macOS wiring mirrors child exit codes and signals without installing SIGINT', async (t) => {
for (const result of [
{ code: 7, signal: null },
{ code: null, signal: 'SIGTERM' },
]) {
await t.test(
result.code === null ? result.signal : String(result.code),
async () => {
const child = createChild(4321)
const calls = []
const running = runDevCommand('dev', {
environment: { PATH: '/bin' },
installSigint: () => {
throw new Error('must not install SIGINT')
},
mirrorResult: (childResult) =>
mirrorChildExit(childResult, {
exit: (code) => calls.push(`exit:${code}`),
signalSelf: (signal) => calls.push(`signal:${signal}`),
}),
platform: 'linux',
spawnChild: () => {
queueMicrotask(() => child.emit('exit', result.code, result.signal))
return child
},
})
await running
assert.deepEqual(calls, [
result.code === null ? 'signal:SIGTERM' : 'exit:7',
])
},
)
}
})
test('non-macOS spawn errors consume every lifecycle rejection', async () => {
const child = createChild(undefined)
const unhandled = []
const captureUnhandled = (error) => unhandled.push(error)
process.prependListener('unhandledRejection', captureUnhandled)
try {
const running = runDevCommand('dev', {
environment: { PATH: '/bin' },
platform: 'linux',
spawnChild: () => {
queueMicrotask(() => child.emit('error', new Error('spawn failed')))
return child
},
})
await assert.rejects(running, /spawn failed/)
await flushTasks()
assert.deepEqual(unhandled, [])
} finally {
process.removeListener('unhandledRejection', captureUnhandled)
}
})