mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-10-14 21:20:48 +08:00
* 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
1168 lines
32 KiB
JavaScript
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)
|
|
}
|
|
})
|