Files
clash-verge-rev/scripts/dev-control.test.mjs
T
wonfen 23dda47641 feat: add launch guard and secure multi-user service ownership (#7569)
* feat(service): isolate macOS launch and owner runtime

* ci(windows): run owner security tests

* chore(deps): use kode-bridge v0.4.1 tag

* fix(macos): classify installed service state

* fix(macos): classify marked unavailable service

* fix(macos): gate core startup on service migration

* fix(macos): scope service init to supported platforms

* feat(macos): prompt before migrating installed service

* fix(macos): shorten sidecar IPC path

* build(service): pin helper protocol and artifacts

* fix(core): require confirmed service and sidecar readiness

* fix(macos): harden launch roots and singleton state

* fix(ui): refresh migration state before restart

* fix(macos): linearize service migration lifecycle

* fix(ui): keep service migration state fresh

* feat(service): track authenticated owner sessions

* fix(service): preserve newer owner sessions

* feat(macos): route service proxy changes through helper

* fix(macos): serialize service proxy guard

* fix(macos): close service proxy owner lifecycle

* fix(macos): close remaining proxy stop races

* fix(app): do not cancel controlled service stop

* fix(lifecycle): keep controlled updates cancellation-safe

* fix(macos): stage dev core for service

* test(macos): exercise staged core cleanup

* fix(dev): await controlled macOS exit

* chore: update changelog

* fix(macos): recover sidecar after service install failure

Allow the existing sidecar to continue when service installation leaves the manager in InstallRequired, then settle the session state as SidecarAllowed. Show neutral status text while the migration dialog waits for its final state.

* fix(macos): stage dev service tools before elevation

* fix(macos): localize launch guard prompts

* fix(service): address cross-platform integration issues

* feat: support seamless service migration

* fix: pass Windows service installer path safely

* fix(prebuild): derive service release from Cargo dependency

* fix(dev): preserve service installation state

* fix: resolve clippy warnings in service lifecycle
2026-07-24 09:01:49 +08: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 {
buildTauriInvocation,
mirrorChildExit,
prepareDevRuntime,
runDevAnchor,
runDevCommand,
terminateDetachedGroup,
} from './dev.mjs'
import { windowsElevationInvocation } from './dev-service.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)
}
})