@twinsearth/dsh-2xray 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +35 -0
- package/LICENSE +21 -0
- package/README.md +328 -2
- package/bin/README.md +27 -0
- package/cordis.patch.yml +37 -0
- package/docs/AC-0.md +60 -0
- package/docs/EVIDENCE.md +135 -0
- package/examples/config.downgrade.json +20 -0
- package/examples/config.example.json +22 -0
- package/examples/config.multi.downgrade.json +39 -0
- package/examples/config.multi.json +46 -0
- package/icon.svg +9 -0
- package/index.js +89 -0
- package/lib/binary.js +133 -0
- package/lib/config.js +401 -0
- package/lib/control/client.js +100 -0
- package/lib/control/grpc.js +121 -0
- package/lib/control/protobuf.js +143 -0
- package/lib/health.js +109 -0
- package/lib/log.js +20 -0
- package/lib/paths.js +65 -0
- package/lib/process.js +197 -0
- package/lib/route.js +299 -0
- package/lib/share-link.js +132 -0
- package/lib/supervisor.js +687 -0
- package/lib/tools.js +106 -0
- package/lib/xray-config.js +225 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +42 -4
- package/scripts/check-config.mjs +56 -0
- package/scripts/parse-share-link.mjs +23 -0
- package/scripts/probe-routing.mjs +66 -0
- package/scripts/release-check.mjs +104 -0
- package/test/binary.test.js +103 -0
- package/test/config.test.js +106 -0
- package/test/control.test.js +166 -0
- package/test/health.test.js +75 -0
- package/test/index.test.js +52 -0
- package/test/multi-node.test.js +111 -0
- package/test/process.test.js +49 -0
- package/test/route.test.js +158 -0
- package/test/share-link.test.js +113 -0
- package/test/supervisor.test.js +525 -0
- package/test/tools.test.js +88 -0
- package/test/xray-config.test.js +132 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal protobuf wire-format codec for the three Xray control-plane messages
|
|
3
|
+
* this plugin needs. Zero dependencies: shipping @grpc/grpc-js + proto-loader
|
|
4
|
+
* would add a second copy of a large dependency tree to every profile for a
|
|
5
|
+
* handful of string fields.
|
|
6
|
+
*
|
|
7
|
+
* Covered:
|
|
8
|
+
* xray.app.router.command.OverrideBalancerTargetRequest { string balancerTag = 1; string target = 2 }
|
|
9
|
+
* xray.app.router.command.GetBalancerInfoRequest { string tag = 1 }
|
|
10
|
+
* xray.app.router.command.GetBalancerInfoResponse { BalancerMsg balancer = 1 }
|
|
11
|
+
* xray.app.stats.command.QueryStatsRequest { repeated string patterns = 1; bool reset = 2 }
|
|
12
|
+
* xray.app.stats.command.QueryStatsResponse { repeated Stat stat = 1 }
|
|
13
|
+
* xray.app.stats.command.Stat { string name = 1; int64 value = 2 }
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export function encodeVarint(value) {
|
|
17
|
+
let n = BigInt(value)
|
|
18
|
+
if (n < 0n) throw new RangeError('protobuf varint must be non-negative')
|
|
19
|
+
const bytes = []
|
|
20
|
+
while (n > 0x7fn) {
|
|
21
|
+
bytes.push(Number(n & 0x7fn) | 0x80)
|
|
22
|
+
n >>= 7n
|
|
23
|
+
}
|
|
24
|
+
bytes.push(Number(n))
|
|
25
|
+
return Buffer.from(bytes)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function readVarint(buffer, start) {
|
|
29
|
+
let result = 0n
|
|
30
|
+
let shift = 0n
|
|
31
|
+
let position = start
|
|
32
|
+
for (;;) {
|
|
33
|
+
if (position >= buffer.length) throw new Error('protobuf: truncated varint')
|
|
34
|
+
const byte = buffer[position++]
|
|
35
|
+
result |= BigInt(byte & 0x7f) << shift
|
|
36
|
+
if ((byte & 0x80) === 0) break
|
|
37
|
+
shift += 7n
|
|
38
|
+
if (shift > 70n) throw new Error('protobuf: varint too long')
|
|
39
|
+
}
|
|
40
|
+
return [result, position]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function fieldKey(field, wireType) {
|
|
44
|
+
return encodeVarint((BigInt(field) << 3n) | BigInt(wireType))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function encodeStringField(field, value) {
|
|
48
|
+
const bytes = Buffer.from(String(value), 'utf8')
|
|
49
|
+
return Buffer.concat([fieldKey(field, 2), encodeVarint(bytes.length), bytes])
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function encodeMessageField(field, bytes) {
|
|
53
|
+
return Buffer.concat([fieldKey(field, 2), encodeVarint(bytes.length), bytes])
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function encodeBoolField(field, value) {
|
|
57
|
+
return Buffer.concat([fieldKey(field, 0), encodeVarint(value ? 1 : 0)])
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Decode one message into `{ field, wire, value }` entries (value is BigInt for varints). */
|
|
61
|
+
export function decodeFields(buffer) {
|
|
62
|
+
const fields = []
|
|
63
|
+
let position = 0
|
|
64
|
+
while (position < buffer.length) {
|
|
65
|
+
let tag
|
|
66
|
+
;[tag, position] = readVarint(buffer, position)
|
|
67
|
+
const field = Number(tag >> 3n)
|
|
68
|
+
const wire = Number(tag & 7n)
|
|
69
|
+
if (wire === 0) {
|
|
70
|
+
let value
|
|
71
|
+
;[value, position] = readVarint(buffer, position)
|
|
72
|
+
fields.push({ field, wire, value })
|
|
73
|
+
} else if (wire === 2) {
|
|
74
|
+
let length
|
|
75
|
+
;[length, position] = readVarint(buffer, position)
|
|
76
|
+
const end = position + Number(length)
|
|
77
|
+
if (end > buffer.length) throw new Error('protobuf: truncated length-delimited field')
|
|
78
|
+
fields.push({ field, wire, value: buffer.subarray(position, end) })
|
|
79
|
+
position = end
|
|
80
|
+
} else if (wire === 1) {
|
|
81
|
+
position += 8
|
|
82
|
+
} else if (wire === 5) {
|
|
83
|
+
position += 4
|
|
84
|
+
} else {
|
|
85
|
+
throw new Error(`protobuf: unsupported wire type ${wire}`)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return fields
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function stringOf(fields, field) {
|
|
92
|
+
const entry = fields.find((item) => item.field === field && item.wire === 2)
|
|
93
|
+
return entry === undefined ? undefined : entry.value.toString('utf8')
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function messageOf(fields, field) {
|
|
97
|
+
const entry = fields.find((item) => item.field === field && item.wire === 2)
|
|
98
|
+
return entry === undefined ? undefined : entry.value
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function encodeOverrideBalancer({ balancerTag, target }) {
|
|
102
|
+
return Buffer.concat([encodeStringField(1, balancerTag), encodeStringField(2, target)])
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function encodeGetBalancerInfo({ tag }) {
|
|
106
|
+
return encodeStringField(1, tag)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function decodeBalancerInfo(buffer) {
|
|
110
|
+
const balancer = messageOf(decodeFields(buffer), 1)
|
|
111
|
+
if (balancer === undefined) return { overrideTarget: undefined, principleTargets: [] }
|
|
112
|
+
const fields = decodeFields(balancer)
|
|
113
|
+
const override = messageOf(fields, 5)
|
|
114
|
+
const principle = messageOf(fields, 6)
|
|
115
|
+
return {
|
|
116
|
+
overrideTarget: override === undefined ? undefined : stringOf(decodeFields(override), 2),
|
|
117
|
+
principleTargets:
|
|
118
|
+
principle === undefined
|
|
119
|
+
? []
|
|
120
|
+
: decodeFields(principle)
|
|
121
|
+
.filter((item) => item.field === 1 && item.wire === 2)
|
|
122
|
+
.map((item) => item.value.toString('utf8')),
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function encodeQueryStats({ patterns = [], reset = false }) {
|
|
127
|
+
const parts = patterns.map((pattern) => encodeStringField(1, pattern))
|
|
128
|
+
if (reset) parts.push(encodeBoolField(2, true))
|
|
129
|
+
return Buffer.concat(parts)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function decodeQueryStats(buffer) {
|
|
133
|
+
const stats = []
|
|
134
|
+
for (const entry of decodeFields(buffer)) {
|
|
135
|
+
if (entry.field !== 1 || entry.wire !== 2) continue
|
|
136
|
+
const fields = decodeFields(entry.value)
|
|
137
|
+
const name = stringOf(fields, 1)
|
|
138
|
+
const value = fields.find((item) => item.field === 2 && item.wire === 0)?.value ?? 0n
|
|
139
|
+
if (name === undefined) continue
|
|
140
|
+
stats.push({ name, value: Number(value) })
|
|
141
|
+
}
|
|
142
|
+
return stats
|
|
143
|
+
}
|
package/lib/health.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import net from 'node:net'
|
|
2
|
+
import http from 'node:http'
|
|
3
|
+
import { setTimeout as delay } from 'node:timers/promises'
|
|
4
|
+
|
|
5
|
+
/** One TCP connect attempt; resolves false on error or timeout, never throws. */
|
|
6
|
+
export function tcpConnect(host, port, timeoutMs) {
|
|
7
|
+
return new Promise((resolve) => {
|
|
8
|
+
let settled = false
|
|
9
|
+
const socket = net.connect({ host, port })
|
|
10
|
+
const finish = (ok) => {
|
|
11
|
+
if (settled) return
|
|
12
|
+
settled = true
|
|
13
|
+
socket.destroy()
|
|
14
|
+
resolve(ok)
|
|
15
|
+
}
|
|
16
|
+
socket.setTimeout(Math.max(50, timeoutMs), () => finish(false))
|
|
17
|
+
socket.once('connect', () => finish(true))
|
|
18
|
+
socket.once('error', () => finish(false))
|
|
19
|
+
})
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Sleep that returns early when the signal aborts. */
|
|
23
|
+
export async function cancellableDelay(ms, signal) {
|
|
24
|
+
if (!signal) return delay(ms)
|
|
25
|
+
if (signal.aborted) return
|
|
26
|
+
await Promise.race([
|
|
27
|
+
delay(ms),
|
|
28
|
+
new Promise((resolve) => {
|
|
29
|
+
signal.addEventListener('abort', resolve, { once: true })
|
|
30
|
+
}),
|
|
31
|
+
])
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Poll the inbound port until it accepts a connection or the budget runs out.
|
|
36
|
+
* This is the hard readiness gate: the global dispatcher is never pointed at a
|
|
37
|
+
* port that has not answered a real TCP connect.
|
|
38
|
+
*/
|
|
39
|
+
export async function waitForPort({ host, port, timeoutMs, intervalMs = 100, signal }) {
|
|
40
|
+
const deadline = Date.now() + timeoutMs
|
|
41
|
+
while (Date.now() < deadline) {
|
|
42
|
+
if (signal?.aborted) return false
|
|
43
|
+
if (await tcpConnect(host, port, 500)) return true
|
|
44
|
+
await cancellableDelay(intervalMs, signal)
|
|
45
|
+
}
|
|
46
|
+
return false
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Whether a loopback port can be bound right now. */
|
|
50
|
+
export function portIsFree(host, port) {
|
|
51
|
+
return new Promise((resolve) => {
|
|
52
|
+
const server = net.createServer()
|
|
53
|
+
server.unref()
|
|
54
|
+
server.once('error', () => resolve(false))
|
|
55
|
+
server.listen({ host, port, exclusive: true }, () => {
|
|
56
|
+
server.close(() => resolve(true))
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* End-to-end probe: ask the local inbound for a small URL through the proxy
|
|
63
|
+
* and require a non-error status. This is the only check that proves the
|
|
64
|
+
* upstream (uuid / flow / SNI / shortId / key) actually works; a TCP connect
|
|
65
|
+
* alone only proves the inbound is listening.
|
|
66
|
+
*
|
|
67
|
+
* @returns {Promise<{ ok: boolean, status?: number, error?: string }>}
|
|
68
|
+
*/
|
|
69
|
+
export function httpProxyProbe({ host, port, url, timeoutMs }) {
|
|
70
|
+
return new Promise((resolve) => {
|
|
71
|
+
let target
|
|
72
|
+
try {
|
|
73
|
+
target = new URL(url)
|
|
74
|
+
} catch (error) {
|
|
75
|
+
resolve({ ok: false, error: `invalid probe url: ${String(error)}` })
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
let settled = false
|
|
79
|
+
const finish = (value) => {
|
|
80
|
+
if (settled) return
|
|
81
|
+
settled = true
|
|
82
|
+
resolve(value)
|
|
83
|
+
}
|
|
84
|
+
const request = http.request(
|
|
85
|
+
{
|
|
86
|
+
host,
|
|
87
|
+
port,
|
|
88
|
+
method: 'GET',
|
|
89
|
+
// Absolute-form request line: the HTTP inbound proxies this.
|
|
90
|
+
path: target.href,
|
|
91
|
+
headers: { host: target.host, connection: 'close' },
|
|
92
|
+
timeout: timeoutMs,
|
|
93
|
+
},
|
|
94
|
+
(response) => {
|
|
95
|
+
response.resume()
|
|
96
|
+
const status = response.statusCode ?? 0
|
|
97
|
+
finish({ ok: status >= 200 && status < 400, status })
|
|
98
|
+
},
|
|
99
|
+
)
|
|
100
|
+
request.on('timeout', () => {
|
|
101
|
+
request.destroy(new Error('probe timeout'))
|
|
102
|
+
finish({ ok: false, error: 'timeout' })
|
|
103
|
+
})
|
|
104
|
+
request.on('error', (error) => {
|
|
105
|
+
finish({ ok: false, error: error?.code ?? error?.message ?? 'request failed' })
|
|
106
|
+
})
|
|
107
|
+
request.end()
|
|
108
|
+
})
|
|
109
|
+
}
|
package/lib/log.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Thin adapter over the cordis logger so tests can run without a context. */
|
|
2
|
+
export function createLogger(ctx) {
|
|
3
|
+
const base = ctx?.logger
|
|
4
|
+
const emit = (level, args) => {
|
|
5
|
+
try {
|
|
6
|
+
if (base && typeof base[level] === 'function') base[level](...args)
|
|
7
|
+
else if (level === 'debug') console.log(...args)
|
|
8
|
+
else console[level](...args)
|
|
9
|
+
} catch {
|
|
10
|
+
// a logger failure must never break the plugin
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
return {
|
|
14
|
+
info: (...args) => emit('info', args),
|
|
15
|
+
warn: (...args) => emit('warn', args),
|
|
16
|
+
error: (...args) => emit('error', args),
|
|
17
|
+
debug: (...args) => emit('debug', args),
|
|
18
|
+
raw: base,
|
|
19
|
+
}
|
|
20
|
+
}
|
package/lib/paths.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { chmodSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { homedir } from 'node:os'
|
|
4
|
+
import { dirname, join } from 'node:path'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The harness home. `DSH_HOME` is the documented override; the desktop app
|
|
8
|
+
* keeps everything under `~/.dsh`.
|
|
9
|
+
*/
|
|
10
|
+
export function resolveDshHome() {
|
|
11
|
+
const fromEnv = process.env.DSH_HOME?.trim()
|
|
12
|
+
if (fromEnv) return fromEnv
|
|
13
|
+
return join(homedir(), '.dsh')
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Resolve the plugin data directory and create it with restrictive
|
|
18
|
+
* permissions. The directory holds the generated xray config, which carries
|
|
19
|
+
* the VLESS uuid and the REALITY key.
|
|
20
|
+
*
|
|
21
|
+
* @param {object} config - normalized config.
|
|
22
|
+
* @returns {string} absolute data directory.
|
|
23
|
+
*/
|
|
24
|
+
export function resolveDataDir(config) {
|
|
25
|
+
const dir = config.binary.dataDir?.trim()
|
|
26
|
+
? config.binary.dataDir.trim()
|
|
27
|
+
: join(resolveDshHome(), 'xray-proxy')
|
|
28
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
29
|
+
try {
|
|
30
|
+
chmodSync(dir, 0o700)
|
|
31
|
+
} catch {
|
|
32
|
+
// Windows ignores POSIX modes; access is governed by the user's profile.
|
|
33
|
+
}
|
|
34
|
+
return dir
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function atomicWriteFile(file, content, mode = 0o600) {
|
|
38
|
+
const dir = dirname(file)
|
|
39
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
40
|
+
const temp = `${file}.${process.pid}.${Date.now()}.tmp`
|
|
41
|
+
try {
|
|
42
|
+
writeFileSync(temp, content, { mode })
|
|
43
|
+
try {
|
|
44
|
+
chmodSync(temp, mode)
|
|
45
|
+
} catch {
|
|
46
|
+
// see resolveDataDir
|
|
47
|
+
}
|
|
48
|
+
renameSync(temp, file)
|
|
49
|
+
} catch (error) {
|
|
50
|
+
try {
|
|
51
|
+
rmSync(temp, { force: true })
|
|
52
|
+
} catch {
|
|
53
|
+
// best effort
|
|
54
|
+
}
|
|
55
|
+
throw error
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function atomicWriteJson(file, value, mode = 0o600) {
|
|
60
|
+
atomicWriteFile(file, `${JSON.stringify(value, null, 2)}\n`, mode)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function sha256(text) {
|
|
64
|
+
return createHash('sha256').update(text).digest('hex')
|
|
65
|
+
}
|
package/lib/process.js
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { spawn, spawnSync } from 'node:child_process'
|
|
2
|
+
import { setTimeout as delay } from 'node:timers/promises'
|
|
3
|
+
|
|
4
|
+
const COLLECT_BYTES = 64 * 1024
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Resolve the subprocess service without tripping Cordis' inject guard.
|
|
8
|
+
*
|
|
9
|
+
* Reading `ctx.subprocess` directly throws
|
|
10
|
+
* `cannot get property "subprocess" without inject` on a fiber that did not
|
|
11
|
+
* declare it; `ctx.get(name)` is the reflective lookup and returns undefined
|
|
12
|
+
* instead. The service stays optional because a profile without
|
|
13
|
+
* `dsh-subprocess-local` still gets the `child_process` fallback.
|
|
14
|
+
*/
|
|
15
|
+
function resolveSubprocess(ctx) {
|
|
16
|
+
if (!ctx) return undefined
|
|
17
|
+
try {
|
|
18
|
+
if (typeof ctx.get === 'function') {
|
|
19
|
+
const service = ctx.get('subprocess')
|
|
20
|
+
if (service) return service
|
|
21
|
+
}
|
|
22
|
+
} catch {
|
|
23
|
+
// inactive or guarded context: fall through to the fallback launcher
|
|
24
|
+
}
|
|
25
|
+
return undefined
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A launcher hides whether the child runs through `ctx.subprocess` (Windows Job
|
|
30
|
+
* Object, process-tree containment, DSH's environment scrub) or through a bare
|
|
31
|
+
* `child_process` fallback for compositions without the service.
|
|
32
|
+
*
|
|
33
|
+
* Every returned handle has the same small surface:
|
|
34
|
+
* done: Promise<{ exitCode, signal, error? }>
|
|
35
|
+
* stderrTail(): string
|
|
36
|
+
* terminate(): Promise<void>
|
|
37
|
+
* waitForExit(): Promise<void>
|
|
38
|
+
* pid?: number
|
|
39
|
+
*/
|
|
40
|
+
export function createLauncher(ctx, { logger } = {}) {
|
|
41
|
+
const service = resolveSubprocess(ctx)
|
|
42
|
+
if (service && typeof service.spawn === 'function' && typeof service.resolveExecutable === 'function') {
|
|
43
|
+
return createServiceLauncher(service)
|
|
44
|
+
}
|
|
45
|
+
logger?.warn?.('ctx.subprocess 不可用,退化为 child_process 启动;Windows 上将无法保证 Job Object 级别的进程树清理')
|
|
46
|
+
return createNodeLauncher()
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Resolve the launcher lazily at first spawn instead of at activation: the
|
|
51
|
+
* subprocess provider may mount after this plugin's fiber, and a strict
|
|
52
|
+
* `inject` dependency would stop the plugin from activating at all.
|
|
53
|
+
*/
|
|
54
|
+
export function createLazyLauncher(ctx, options) {
|
|
55
|
+
let inner
|
|
56
|
+
const resolve = () => (inner ??= createLauncher(ctx, options))
|
|
57
|
+
return {
|
|
58
|
+
get kind() {
|
|
59
|
+
return resolve().kind
|
|
60
|
+
},
|
|
61
|
+
resolveExecutable(command, env, signal) {
|
|
62
|
+
return resolve().resolveExecutable(command, env, signal)
|
|
63
|
+
},
|
|
64
|
+
spawn(spec) {
|
|
65
|
+
return resolve().spawn(spec)
|
|
66
|
+
},
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function createServiceLauncher(service) {
|
|
71
|
+
return {
|
|
72
|
+
kind: 'ctx.subprocess',
|
|
73
|
+
resolveExecutable(command) {
|
|
74
|
+
return service.resolveExecutable(command, process.env, undefined)
|
|
75
|
+
},
|
|
76
|
+
spawn({ argv, cwd, graceMs, env }) {
|
|
77
|
+
const handle = service.spawn({
|
|
78
|
+
argv,
|
|
79
|
+
cwd,
|
|
80
|
+
stdio: {
|
|
81
|
+
stdin: 'ignore',
|
|
82
|
+
stdout: { maxBytes: COLLECT_BYTES },
|
|
83
|
+
stderr: { maxBytes: COLLECT_BYTES },
|
|
84
|
+
},
|
|
85
|
+
graceMs,
|
|
86
|
+
env,
|
|
87
|
+
})
|
|
88
|
+
const done = Promise.resolve(handle.done).then(
|
|
89
|
+
(outcome) => ({ exitCode: outcome?.exitCode ?? null, signal: outcome?.signal ?? null }),
|
|
90
|
+
(error) => ({ exitCode: null, signal: null, error }),
|
|
91
|
+
)
|
|
92
|
+
const readTail = (which) => {
|
|
93
|
+
try {
|
|
94
|
+
return handle.collected?.[which]?.readFrom(0)?.text ?? ''
|
|
95
|
+
} catch {
|
|
96
|
+
return ''
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
kind: 'ctx.subprocess',
|
|
101
|
+
get pid() {
|
|
102
|
+
return typeof handle.pid === 'number' ? handle.pid : undefined
|
|
103
|
+
},
|
|
104
|
+
done,
|
|
105
|
+
stdoutTail: () => readTail('stdout'),
|
|
106
|
+
stderrTail: () => readTail('stderr'),
|
|
107
|
+
terminate() {
|
|
108
|
+
return Promise.resolve(handle.terminate()).catch(() => {})
|
|
109
|
+
},
|
|
110
|
+
waitForExit() {
|
|
111
|
+
return Promise.resolve(handle.waitForExit()).catch(() => {})
|
|
112
|
+
},
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function createNodeLauncher() {
|
|
119
|
+
return {
|
|
120
|
+
kind: 'child_process',
|
|
121
|
+
async resolveExecutable(command) {
|
|
122
|
+
return command
|
|
123
|
+
},
|
|
124
|
+
spawn({ argv, cwd, graceMs, env }) {
|
|
125
|
+
const child = spawn(argv[0], argv.slice(1), {
|
|
126
|
+
cwd,
|
|
127
|
+
env: env ?? process.env,
|
|
128
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
129
|
+
windowsHide: true,
|
|
130
|
+
})
|
|
131
|
+
let stderr = ''
|
|
132
|
+
let stdout = ''
|
|
133
|
+
child.stderr?.on('data', (chunk) => {
|
|
134
|
+
stderr += chunk.toString('utf8')
|
|
135
|
+
if (stderr.length > COLLECT_BYTES) stderr = stderr.slice(-COLLECT_BYTES)
|
|
136
|
+
})
|
|
137
|
+
child.stdout?.on('data', (chunk) => {
|
|
138
|
+
stdout += chunk.toString('utf8')
|
|
139
|
+
if (stdout.length > COLLECT_BYTES) stdout = stdout.slice(-COLLECT_BYTES)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
let settled = false
|
|
143
|
+
let resolveExit
|
|
144
|
+
const exited = new Promise((resolve) => {
|
|
145
|
+
resolveExit = resolve
|
|
146
|
+
})
|
|
147
|
+
const settle = (value) => {
|
|
148
|
+
if (settled) return
|
|
149
|
+
settled = true
|
|
150
|
+
resolveExit(value)
|
|
151
|
+
}
|
|
152
|
+
child.once('error', (error) => settle({ exitCode: null, signal: null, error }))
|
|
153
|
+
child.once('exit', (code, signal) => settle({ exitCode: code, signal }))
|
|
154
|
+
|
|
155
|
+
let terminating
|
|
156
|
+
return {
|
|
157
|
+
kind: 'child_process',
|
|
158
|
+
get pid() {
|
|
159
|
+
return child.pid
|
|
160
|
+
},
|
|
161
|
+
done: exited,
|
|
162
|
+
stdoutTail: () => stdout,
|
|
163
|
+
stderrTail: () => stderr,
|
|
164
|
+
terminate() {
|
|
165
|
+
if (terminating) return terminating
|
|
166
|
+
terminating = (async () => {
|
|
167
|
+
if (child.exitCode !== null || child.signalCode !== null) return
|
|
168
|
+
if (process.platform === 'win32') {
|
|
169
|
+
if (typeof child.pid === 'number') {
|
|
170
|
+
spawnSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], { windowsHide: true })
|
|
171
|
+
}
|
|
172
|
+
await Promise.race([exited, delay(1500).then(() => {})])
|
|
173
|
+
return
|
|
174
|
+
}
|
|
175
|
+
try {
|
|
176
|
+
child.kill('SIGTERM')
|
|
177
|
+
} catch {
|
|
178
|
+
// already gone
|
|
179
|
+
}
|
|
180
|
+
const exitedInTime = await Promise.race([exited.then(() => true), delay(graceMs).then(() => false)])
|
|
181
|
+
if (!exitedInTime) {
|
|
182
|
+
try {
|
|
183
|
+
child.kill('SIGKILL')
|
|
184
|
+
} catch {
|
|
185
|
+
// already gone
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
})()
|
|
189
|
+
return terminating
|
|
190
|
+
},
|
|
191
|
+
waitForExit() {
|
|
192
|
+
return exited.then(() => {})
|
|
193
|
+
},
|
|
194
|
+
}
|
|
195
|
+
},
|
|
196
|
+
}
|
|
197
|
+
}
|