dsh-ris-yds 1.0.0 → 1.1.0
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/README.md +20 -1
- package/lib/cli.js +158 -31
- package/lib/ide-proxy.js +296 -72
- package/lib/ide.js +31 -5
- package/lib/proc.js +270 -0
- package/lib/risd-peer.js +353 -0
- package/lib/rules.js +193 -0
- package/lib/socks.js +51 -32
- package/lib/yms.js +59 -25
- package/package.json +1 -1
- package/test/cli-stop.test.mjs +40 -0
- package/test/hints.test.mjs +13 -0
- package/test/ide-config.test.mjs +4 -0
- package/test/ide-proxy.test.mjs +80 -18
- package/test/lease-release.test.mjs +76 -0
- package/test/pidfile.test.mjs +105 -0
- package/test/proc.test.mjs +172 -0
- package/test/proxy-standalone.test.mjs +3 -0
- package/test/risd-leases.test.mjs +111 -0
- package/test/risd-tier.test.mjs +361 -0
- package/test/rules-e2e.test.mjs +122 -0
- package/test/rules.test.mjs +154 -0
package/lib/rules.js
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// The proxy rule table is machine-wide AND global: there is exactly one table for the
|
|
2
|
+
// whole box, and the panel saves it wholesale ("全局一张表、保存即整表覆盖"). The daemon's
|
|
3
|
+
// `POST /v1/proxy` mirrors that contract, so yds owns its share outright:
|
|
4
|
+
//
|
|
5
|
+
// * `yds start` writes the COMPLETE set of yds rows (whatever yds rows were there
|
|
6
|
+
// before — from any workspace — are replaced),
|
|
7
|
+
// * `yds stop` removes EVERY `source: 'yds'` row.
|
|
8
|
+
//
|
|
9
|
+
// There is no cross-workspace bookkeeping any more: no `owners` map, no per-workspace
|
|
10
|
+
// subtraction. Two projects that both run `yds start` do not merge their endpoints;
|
|
11
|
+
// the last save simply wins, which is exactly what "没有交叉配置" means.
|
|
12
|
+
//
|
|
13
|
+
// The only identity that must stay distinct is the program: `java.exe` is the Windows
|
|
14
|
+
// image and `java` is the process name a WSL/Linux side matches, so the group key is
|
|
15
|
+
// `side|program` and the two live side by side instead of collapsing into one row.
|
|
16
|
+
export const YDS_RULE_SOURCE = 'yds'
|
|
17
|
+
|
|
18
|
+
/** Program identity for duplicate detection: basename, lower-case, no script suffix. */
|
|
19
|
+
export function programKeyOf(program) {
|
|
20
|
+
if (typeof program !== 'string') return ''
|
|
21
|
+
const text = program.trim()
|
|
22
|
+
if (text === '') return ''
|
|
23
|
+
// A wildcard program (`C:\devkit\jdk*\bin\*.exe`) has no basename identity: its
|
|
24
|
+
// basename is `*.exe`, which would collapse EVERY wildcard program into one row.
|
|
25
|
+
// Keep the whole pattern as the key instead.
|
|
26
|
+
if (/[*?]/.test(text)) return `raw:${text.toLowerCase()}`
|
|
27
|
+
const base = text.split(/[\\/]/).pop() ?? ''
|
|
28
|
+
return base.toLowerCase().replace(/\.(exe|cmd|bat|sh)$/, '')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Identity of one rule inside the yds share: the side it applies to plus the program.
|
|
33
|
+
* `windows|java` and `local|java` are different rows on purpose (the Windows engine
|
|
34
|
+
* matches `java.exe`, the WSL/Linux side matches `java`).
|
|
35
|
+
* @param {{side?: string, program?: string}} rule
|
|
36
|
+
* @returns {string}
|
|
37
|
+
*/
|
|
38
|
+
export function ruleGroupKey(rule) {
|
|
39
|
+
const side = typeof rule?.side === 'string' ? rule.side.trim().toLowerCase() : ''
|
|
40
|
+
return `${side}|${programKeyOf(rule?.program)}`
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function hostList(value) {
|
|
44
|
+
return String(value ?? '')
|
|
45
|
+
.split(',')
|
|
46
|
+
.map((item) => item.trim())
|
|
47
|
+
.filter((item) => item !== '')
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function portList(value) {
|
|
51
|
+
return String(value ?? '')
|
|
52
|
+
.split(',')
|
|
53
|
+
.map((item) => item.trim())
|
|
54
|
+
.filter((item) => item !== '')
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function uniquePush(target, values) {
|
|
58
|
+
for (const value of values) {
|
|
59
|
+
if (!target.includes(value)) target.push(value)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function hostText(hosts) {
|
|
64
|
+
return hosts.join(',')
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** A rule allows every port when any caller says `*`: keep the wildcard form. */
|
|
68
|
+
function portText(ports) {
|
|
69
|
+
return ports.includes('*') ? '*' : ports.join(',')
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Validate one fresh row and canonicalize the fields the engine reads. Unknown fields
|
|
74
|
+
* (id, label, side, ...) are kept as they came; only the list/boolean fields are
|
|
75
|
+
* normalized so a hand-written value cannot reach the engine half-parsed.
|
|
76
|
+
* @returns {object|null} null when the row cannot be a rule (logged).
|
|
77
|
+
*/
|
|
78
|
+
function normalizeFreshRow(row, { log } = {}) {
|
|
79
|
+
if (row === null || typeof row !== 'object' || Array.isArray(row)) {
|
|
80
|
+
log?.('代理规则:忽略一条不是对象的规则')
|
|
81
|
+
return null
|
|
82
|
+
}
|
|
83
|
+
const program = typeof row.program === 'string' ? row.program.trim() : ''
|
|
84
|
+
if (program === '') {
|
|
85
|
+
log?.(`代理规则:忽略一条没有 program 的规则(id=${String(row.id ?? '?')})`)
|
|
86
|
+
return null
|
|
87
|
+
}
|
|
88
|
+
const hosts = hostList(row.hosts)
|
|
89
|
+
const ports = portList(row.ports)
|
|
90
|
+
if (hosts.length === 0 && ports.length === 0) {
|
|
91
|
+
log?.(`代理规则:忽略一条既没有 host 也没有 port 的规则(id=${String(row.id ?? '?')})`)
|
|
92
|
+
return null
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
...row,
|
|
96
|
+
program,
|
|
97
|
+
hosts: hostText(hosts),
|
|
98
|
+
ports: portText(ports),
|
|
99
|
+
enabled: row.enabled !== false,
|
|
100
|
+
shadow: row.shadow === true,
|
|
101
|
+
direct: row.direct === true,
|
|
102
|
+
source: YDS_RULE_SOURCE,
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Union two fresh rows that name the same side+program (a caller bug, not the norm). */
|
|
107
|
+
function mergeFreshRows(into, extra) {
|
|
108
|
+
const hosts = hostList(into.hosts)
|
|
109
|
+
uniquePush(hosts, hostList(extra.hosts))
|
|
110
|
+
const ports = portList(into.ports)
|
|
111
|
+
uniquePush(ports, portList(extra.ports))
|
|
112
|
+
return {
|
|
113
|
+
...into,
|
|
114
|
+
hosts: hostText(hosts),
|
|
115
|
+
ports: portText(ports),
|
|
116
|
+
// Lists union; `enabled` is satisfied by either row; the opt-ins only survive if
|
|
117
|
+
// every copy asked for them (a stray duplicate must not turn interception off).
|
|
118
|
+
enabled: into.enabled === true || extra.enabled === true,
|
|
119
|
+
shadow: into.shadow === true && extra.shadow === true,
|
|
120
|
+
direct: into.direct === true && extra.direct === true,
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Build the table to save: every non-yds row is passed through untouched, then the
|
|
126
|
+
* fresh yds rows are appended (one row per side+program, ids preserved).
|
|
127
|
+
*
|
|
128
|
+
* This is a WHOLE-TABLE function: the return value is the complete rule list, so the
|
|
129
|
+
* caller can PUT it to the daemon as-is. An empty `freshRules` array therefore removes
|
|
130
|
+
* every yds row (that is `yds stop`) and leaves the non-yds rows alone.
|
|
131
|
+
* @param {object[]} existingRules - rows currently stored (any source).
|
|
132
|
+
* @param {object[]} freshRules - the yds rows to own; empty = remove them all.
|
|
133
|
+
* @param {{log?: (line: string) => void}} [options]
|
|
134
|
+
* @returns {object[]} the complete table, non-yds rows first.
|
|
135
|
+
*/
|
|
136
|
+
export function mergeYdsRules(existingRules, freshRules, { log } = {}) {
|
|
137
|
+
const rows = []
|
|
138
|
+
for (const row of Array.isArray(existingRules) ? existingRules : []) {
|
|
139
|
+
if (row === null || typeof row !== 'object' || Array.isArray(row)) continue
|
|
140
|
+
// The yds share is rebuilt from `freshRules` below, never inherited: a row that
|
|
141
|
+
// is not regenerated must disappear (an endpoint that moved, or another
|
|
142
|
+
// workspace's rows, which the global table replaces wholesale).
|
|
143
|
+
if (row.source === YDS_RULE_SOURCE) continue
|
|
144
|
+
rows.push(row)
|
|
145
|
+
}
|
|
146
|
+
const fresh = new Map()
|
|
147
|
+
const order = []
|
|
148
|
+
for (const row of Array.isArray(freshRules) ? freshRules : []) {
|
|
149
|
+
const normalized = normalizeFreshRow(row, { log })
|
|
150
|
+
if (normalized === null) continue
|
|
151
|
+
const key = ruleGroupKey(normalized)
|
|
152
|
+
const existing = fresh.get(key)
|
|
153
|
+
if (existing === undefined) {
|
|
154
|
+
fresh.set(key, normalized)
|
|
155
|
+
order.push(key)
|
|
156
|
+
continue
|
|
157
|
+
}
|
|
158
|
+
fresh.set(key, mergeFreshRows(existing, normalized))
|
|
159
|
+
}
|
|
160
|
+
for (const key of order) rows.push(fresh.get(key))
|
|
161
|
+
return rows
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Just the yds rows of a stored table (for counts and withdrawal math). */
|
|
165
|
+
export function ydsRowsOf(existingRules) {
|
|
166
|
+
return (Array.isArray(existingRules) ? existingRules : [])
|
|
167
|
+
.filter((row) => row !== null && typeof row === 'object' && row.source === YDS_RULE_SOURCE)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* How many yds rows and distinct hosts disappear between two table snapshots, for the
|
|
172
|
+
* CLI text. Counts yds rows only: the non-yds rows are never ours to report on.
|
|
173
|
+
* @returns {{rules: number, hosts: number}}
|
|
174
|
+
*/
|
|
175
|
+
export function withdrawnBetween(beforeRules, afterRules) {
|
|
176
|
+
const hosts = (rows) => {
|
|
177
|
+
const out = new Set()
|
|
178
|
+
for (const row of ydsRowsOf(rows)) {
|
|
179
|
+
for (const host of hostList(row.hosts)) out.add(host)
|
|
180
|
+
}
|
|
181
|
+
return out
|
|
182
|
+
}
|
|
183
|
+
const before = hosts(beforeRules)
|
|
184
|
+
const after = hosts(afterRules)
|
|
185
|
+
let removed = 0
|
|
186
|
+
for (const host of before) {
|
|
187
|
+
if (!after.has(host)) removed += 1
|
|
188
|
+
}
|
|
189
|
+
return {
|
|
190
|
+
rules: Math.max(0, ydsRowsOf(beforeRules).length - ydsRowsOf(afterRules).length),
|
|
191
|
+
hosts: removed,
|
|
192
|
+
}
|
|
193
|
+
}
|
package/lib/socks.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { spawn } from 'node:child_process'
|
|
9
9
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs'
|
|
10
|
+
import { pidAlive, pidIdentity, stopPid } from './proc.js'
|
|
10
11
|
import { join } from 'node:path'
|
|
11
12
|
import net from 'node:net'
|
|
12
13
|
import { fileURLToPath } from 'node:url'
|
|
@@ -18,32 +19,48 @@ export function socksPidFile(cfg) {
|
|
|
18
19
|
/**
|
|
19
20
|
* Verify a pid is really our process (guards against pid reuse after a
|
|
20
21
|
* crash/reboot: a bare pid from a stale pidfile could be an unrelated
|
|
21
|
-
* process — never signal it). Identity =
|
|
22
|
-
* marker
|
|
23
|
-
*
|
|
22
|
+
* process — never signal it). Identity = the pid's command line contains our
|
|
23
|
+
* marker, from /proc on Linux/WSL or the Win32 process table on Windows.
|
|
24
|
+
* `unknown` (probe failed) is NOT "not ours": see pidIdentity (review D-P1-6).
|
|
24
25
|
*/
|
|
25
26
|
export function pidIsOurs(pid, marker) {
|
|
26
27
|
if (!Number.isInteger(pid) || pid <= 0) return false
|
|
27
|
-
|
|
28
|
-
const cmd = readFileSync(`/proc/${pid}/cmdline`, 'utf8')
|
|
29
|
-
return cmd.includes(marker)
|
|
30
|
-
} catch { return false }
|
|
28
|
+
return pidIdentity(pid, [marker]).state === 'ours'
|
|
31
29
|
}
|
|
32
30
|
|
|
33
|
-
|
|
34
|
-
|
|
31
|
+
const FORWARDER_MARKER = 'socks-fwd'
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Read + verify the forwarder pidfile.
|
|
35
|
+
* @returns {{pid: number|null, state: 'absent'|'verified'|'stale'|'unknown', reason: string}}
|
|
36
|
+
* Only `verified` yields a pid. `unknown` keeps the pidfile untouched so a
|
|
37
|
+
* transient probe failure cannot cause a duplicate forwarder.
|
|
38
|
+
*/
|
|
39
|
+
export function probeForwarder(cfg, options = {}) {
|
|
35
40
|
const pidFile = socksPidFile(cfg)
|
|
36
|
-
if (!existsSync(pidFile)) return null
|
|
41
|
+
if (!existsSync(pidFile)) return { pid: null, state: 'absent', reason: 'pidfile 不存在' }
|
|
37
42
|
const pid = Number(readFileSync(pidFile, 'utf8').trim())
|
|
38
|
-
if (!Number.isInteger(pid) || pid <= 0
|
|
39
|
-
try {
|
|
40
|
-
return null
|
|
43
|
+
if (!Number.isInteger(pid) || pid <= 0) {
|
|
44
|
+
try { unlinkSync(pidFile) } catch { /* ignore */ }
|
|
45
|
+
return { pid: null, state: 'stale', reason: 'pidfile 内容无效(已清理)' }
|
|
41
46
|
}
|
|
42
|
-
|
|
47
|
+
const identity = pidIdentity(pid, [FORWARDER_MARKER], options)
|
|
48
|
+
if (identity.state === 'ours') return { pid, state: 'verified', reason: '' }
|
|
49
|
+
if (identity.state === 'dead') {
|
|
50
|
+
try { unlinkSync(pidFile) } catch { /* ignore */ }
|
|
51
|
+
return { pid: null, state: 'stale', reason: '转发器进程已退出(已清理 pidfile)' }
|
|
52
|
+
}
|
|
53
|
+
if (identity.state === 'foreign') {
|
|
54
|
+
// pid reused by an unrelated process after a crash/reboot: the pidfile is stale.
|
|
55
|
+
try { unlinkSync(pidFile) } catch { /* ignore */ }
|
|
56
|
+
return { pid: null, state: 'stale', reason: 'pid 已被其它进程复用(已清理 pidfile)' }
|
|
57
|
+
}
|
|
58
|
+
return { pid: null, state: 'unknown', reason: '无法确认 pidfile 进程身份(探测失败),已保留 pidfile 未动' }
|
|
43
59
|
}
|
|
44
60
|
|
|
45
|
-
|
|
46
|
-
|
|
61
|
+
/** Verified forwarder pid, or null (absent/stale/foreign/unknown). */
|
|
62
|
+
export function forwarderPid(cfg, options = {}) {
|
|
63
|
+
return probeForwarder(cfg, options).pid
|
|
47
64
|
}
|
|
48
65
|
|
|
49
66
|
export function portOpen(host, port, timeout = 1500) {
|
|
@@ -61,10 +78,15 @@ export function portOpen(host, port, timeout = 1500) {
|
|
|
61
78
|
* the port answers. A port answering without our pid = some other local
|
|
62
79
|
* service (e.g. a local Oracle on 1521) — NOT the tunnel.
|
|
63
80
|
*/
|
|
64
|
-
export function isForwarderUp(cfg) {
|
|
81
|
+
export function isForwarderUp(cfg, options = {}) {
|
|
65
82
|
return (async () => {
|
|
66
|
-
const
|
|
67
|
-
|
|
83
|
+
const probe = probeForwarder(cfg, options)
|
|
84
|
+
// Identity unreadable (`unknown`): fall back to the listeners, because starting a
|
|
85
|
+
// second forwarder next to a live one is worse than a possible false positive.
|
|
86
|
+
if (probe.state === 'unknown') {
|
|
87
|
+
return (await portOpen('127.0.0.1', cfg.dbForwardPort, 400)) || (await portOpen('127.0.0.1', cfg.redisForwardPort, 400))
|
|
88
|
+
}
|
|
89
|
+
if (probe.pid === null) return false
|
|
68
90
|
return (await portOpen('127.0.0.1', cfg.dbForwardPort, 400)) || (await portOpen('127.0.0.1', cfg.redisForwardPort, 400))
|
|
69
91
|
})()
|
|
70
92
|
}
|
|
@@ -120,7 +142,7 @@ export async function startForwarder(cfg, targets, { logPath } = {}) {
|
|
|
120
142
|
// the wait loop above can exhaust without the port ever answering (child
|
|
121
143
|
// crashed instantly, e.g. EADDRINUSE) — verify before reporting success.
|
|
122
144
|
const down = await portsUp()
|
|
123
|
-
const alive =
|
|
145
|
+
const alive = pidAlive(child.pid)
|
|
124
146
|
if (down !== null || !alive) {
|
|
125
147
|
try { unlinkSync(socksPidFile(cfg)) } catch { /* ignore */ }
|
|
126
148
|
throw new Error(`socks 转发器启动失败(端口 ${down ?? '?'} 未监听 / 进程存活=${alive})——详情见 ${out}(端口被占或其他本地服务在 127.0.0.1:${down}?)`)
|
|
@@ -129,21 +151,18 @@ export async function startForwarder(cfg, targets, { logPath } = {}) {
|
|
|
129
151
|
}
|
|
130
152
|
|
|
131
153
|
/** Stop the detached forwarder if its pidfile pid is ours (never kill a reused foreign pid). */
|
|
132
|
-
export async function stopForwarder(cfg) {
|
|
133
|
-
const
|
|
134
|
-
if (pid === null) return { stopped: false, reason:
|
|
154
|
+
export async function stopForwarder(cfg, options = {}) {
|
|
155
|
+
const probe = probeForwarder(cfg, options)
|
|
156
|
+
if (probe.pid === null) return { stopped: false, reason: probe.reason }
|
|
135
157
|
const pidFile = socksPidFile(cfg)
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
await new Promise((r) => setTimeout(r, 200))
|
|
142
|
-
} catch { break }
|
|
158
|
+
const killed = await stopPid(probe.pid, { graceMs: 5000, ...options })
|
|
159
|
+
if (killed.ok !== true) {
|
|
160
|
+
// Keep the pidfile: the process is still there and `yds status` must keep seeing it
|
|
161
|
+
// (review D-P1-7).
|
|
162
|
+
return { stopped: false, pid: probe.pid, reason: `停止失败:${killed.error ?? '未知原因'}(已保留 pidfile)` }
|
|
143
163
|
}
|
|
144
|
-
try { process.kill(pid, 'SIGKILL') } catch { /* gone */ }
|
|
145
164
|
try { unlinkSync(pidFile) } catch { /* ignore */ }
|
|
146
|
-
return { stopped: true, pid }
|
|
165
|
+
return { stopped: true, pid: probe.pid }
|
|
147
166
|
}
|
|
148
167
|
|
|
149
168
|
/** SOCKS5 no-auth handshake + CONNECT, promise-based (proven implementation). */
|
package/lib/yms.js
CHANGED
|
@@ -7,6 +7,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, openSyn
|
|
|
7
7
|
import { join } from 'node:path'
|
|
8
8
|
import http from 'node:http'
|
|
9
9
|
import { portOpen } from './socks.js'
|
|
10
|
+
import { pidIdentity, stopPid } from './proc.js'
|
|
10
11
|
import { ymsHomeIsBuilt } from './build.js'
|
|
11
12
|
|
|
12
13
|
export function ymsPidFile(cfg) {
|
|
@@ -140,7 +141,12 @@ function rememberPort(cfg) {
|
|
|
140
141
|
export async function startDetached(cfg, { jvmArgs = [] } = {}) {
|
|
141
142
|
const pidFile = ymsPidFile(cfg)
|
|
142
143
|
const running = await readRunning(cfg)
|
|
143
|
-
if (running !== null)
|
|
144
|
+
if (running !== null) {
|
|
145
|
+
if (running.state === 'unknown') {
|
|
146
|
+
return { started: false, alreadyRunning: false, unknown: true, pid: running.pid, reason: running.reason }
|
|
147
|
+
}
|
|
148
|
+
return { started: false, alreadyRunning: true, pid: running.pid }
|
|
149
|
+
}
|
|
144
150
|
if (!ymsHomeIsBuilt(cfg)) throw new Error('yms-home 未构建(先执行 yds build)')
|
|
145
151
|
if (cfg.java == null) throw new Error('未找到 JDK——用 .env 的 java.path 或 JAVA_HOME 指定')
|
|
146
152
|
if (cfg.module == null) throw new Error('未识别模块名——检查项目 yonbuilder.json / module.xml')
|
|
@@ -180,8 +186,11 @@ export async function startDetached(cfg, { jvmArgs = [] } = {}) {
|
|
|
180
186
|
}
|
|
181
187
|
}
|
|
182
188
|
if (!up) {
|
|
189
|
+
// TERM → grace → KILL (Windows: taskkill /T /F) so a half-started java tree cannot
|
|
190
|
+
// survive the failure and keep the port (review D-P2-5); the pidfile only goes
|
|
191
|
+
// away once the process is actually gone.
|
|
192
|
+
try { await stopPid(child.pid, { graceMs: 5000, group: true }) } catch { /* best effort */ }
|
|
183
193
|
try { unlinkSync(pidFile) } catch { /* ignore */ }
|
|
184
|
-
try { process.kill(child.pid, 'SIGTERM') } catch { /* ignore */ }
|
|
185
194
|
throw new Error(`YMS 端口 ${cfg.ymsPort} 在 120 秒内未就绪——查看 ${log}`)
|
|
186
195
|
}
|
|
187
196
|
return { started: true, pid: child.pid, port: cfg.ymsPort, consoleLog: log }
|
|
@@ -216,41 +225,65 @@ export function startForeground(cfg, { jvmArgs = [], onStdout, signal } = {}) {
|
|
|
216
225
|
return child
|
|
217
226
|
}
|
|
218
227
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
228
|
+
const YMS_MARKERS = ['yms.jar', 'YMSLauncher']
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Identity of a pidfile owner: `ours` / `foreign` / `dead` / `unknown`. The four-way
|
|
232
|
+
* answer matters: a probe that could not read the command line must never be treated
|
|
233
|
+
* as "not our process" (that deletes the pidfile and starts a second YMS — review
|
|
234
|
+
* D-P1-6).
|
|
235
|
+
* @returns {{state: 'ours'|'foreign'|'dead'|'unknown', commandLine: string}}
|
|
236
|
+
*/
|
|
237
|
+
export function probeYmsPid(pid, options = {}) {
|
|
238
|
+
return pidIdentity(pid, YMS_MARKERS, options)
|
|
225
239
|
}
|
|
226
240
|
|
|
227
|
-
/**
|
|
228
|
-
|
|
241
|
+
/**
|
|
242
|
+
* Read the pidfile; verify the process is alive AND ours.
|
|
243
|
+
* @returns {null | {pid: number, state?: 'unknown', reason?: string}}
|
|
244
|
+
* `null` when absent/stale/foreign (the stale pidfile is unlinked). An `unknown`
|
|
245
|
+
* entry keeps the pidfile and must be treated as "do not start a second one, do not
|
|
246
|
+
* kill it blindly".
|
|
247
|
+
*/
|
|
248
|
+
export async function readRunning(cfg, options = {}) {
|
|
229
249
|
const pidFile = ymsPidFile(cfg)
|
|
230
250
|
if (!existsSync(pidFile)) return null
|
|
231
251
|
const pid = Number(readFileSync(pidFile, 'utf8').trim())
|
|
232
|
-
if (!Number.isInteger(pid) || pid <= 0)
|
|
233
|
-
try { process.kill(pid, 0) } catch { try { unlinkSync(pidFile) } catch { /* ignore */ } return null }
|
|
234
|
-
if (!pidIsYms(pid)) {
|
|
235
|
-
// pid reused by an unrelated process after a crash/reboot — stale marker
|
|
252
|
+
if (!Number.isInteger(pid) || pid <= 0) {
|
|
236
253
|
try { unlinkSync(pidFile) } catch { /* ignore */ }
|
|
237
254
|
return null
|
|
238
255
|
}
|
|
239
|
-
|
|
256
|
+
const identity = probeYmsPid(pid, options)
|
|
257
|
+
if (identity.state === 'ours') return { pid }
|
|
258
|
+
if (identity.state === 'dead' || identity.state === 'foreign') {
|
|
259
|
+
// Gone, or the pid was reused by an unrelated process after a crash/reboot.
|
|
260
|
+
try { unlinkSync(pidFile) } catch { /* ignore */ }
|
|
261
|
+
return null
|
|
262
|
+
}
|
|
263
|
+
return {
|
|
264
|
+
pid,
|
|
265
|
+
state: 'unknown',
|
|
266
|
+
reason: '无法确认 pidfile 进程身份(命令行为空或探测失败),已保留 pidfile 未动',
|
|
267
|
+
}
|
|
240
268
|
}
|
|
241
269
|
|
|
242
|
-
/** Stop the detached YMS: TERM, grace (YMS is slow), KILL. */
|
|
243
|
-
export async function stopYms(cfg, { graceMs = 30000 } = {}) {
|
|
244
|
-
const running = await readRunning(cfg) // identity-verified: never kills a reused pid
|
|
270
|
+
/** Stop the detached YMS: TERM, grace (YMS is slow), KILL; Windows uses taskkill /T /F. */
|
|
271
|
+
export async function stopYms(cfg, { graceMs = 30000, ...options } = {}) {
|
|
272
|
+
const running = await readRunning(cfg, options) // identity-verified: never kills a reused pid
|
|
245
273
|
if (running === null) return { stopped: false, reason: '未在运行' }
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
274
|
+
if (running.state === 'unknown') {
|
|
275
|
+
return {
|
|
276
|
+
stopped: false,
|
|
277
|
+
pid: running.pid,
|
|
278
|
+
reason: `${running.reason};为避免误杀,未发送停止信号(请手动确认该 pid)`,
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
// Windows has no graceful signal path: taskkill the tree; POSIX TERM→grace→KILL.
|
|
282
|
+
const killed = await stopPid(running.pid, { graceMs, ...options })
|
|
283
|
+
if (killed.ok !== true) {
|
|
284
|
+
// Keep the pidfile so `yds status` still shows the process (review D-P1-7).
|
|
285
|
+
return { stopped: false, pid: running.pid, reason: `停止失败:${killed.error ?? '未知原因'}(已保留 pidfile)` }
|
|
252
286
|
}
|
|
253
|
-
try { process.kill(running.pid, 0); process.kill(running.pid, 'SIGKILL') } catch { /* gone */ }
|
|
254
287
|
try { unlinkSync(ymsPidFile(cfg)) } catch { /* ignore */ }
|
|
255
288
|
return { stopped: true, pid: running.pid }
|
|
256
289
|
}
|
|
@@ -265,6 +298,7 @@ export async function status(cfg) {
|
|
|
265
298
|
}
|
|
266
299
|
return {
|
|
267
300
|
process: running,
|
|
301
|
+
processUnknown: running !== null && running.state === 'unknown',
|
|
268
302
|
port,
|
|
269
303
|
ready: ready !== null ? ready.status : null,
|
|
270
304
|
ymsHomeBuilt: ymsHomeIsBuilt(cfg),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-ris-yds",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "YonBIP YDS local runtime for the DSH agent: config resolution (project settings.json + optional .env), YYCToken, confcenter sync, SOCKS forward, yms-home build/start/stop with JDWP, and dsh-ris-ide run-config integration. Cross-OS (linux/darwin/win32, WSL aware). · YonBIP YDS 本地运行时:项目配置解析(settings.json / .env)、YYCToken、confcenter 拉取、SOCKS 转发、yms-home 构建与启停(含 JDWP 调试)、dsh-ris-ide 启动器集成;跨平台(linux/darwin/win32,识别 WSL)。",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// `yds stop` must not tear the tunnel down under a YMS it could not stop, and its
|
|
2
|
+
// summary must say how much of the shared rule table actually changed (review
|
|
3
|
+
// D-P1-2 / D-P2-7).
|
|
4
|
+
import assert from 'node:assert/strict'
|
|
5
|
+
import test from 'node:test'
|
|
6
|
+
|
|
7
|
+
import { stopAbortReason, withdrawnText } from '../lib/cli.js'
|
|
8
|
+
|
|
9
|
+
test('stop aborts when a live YMS pid could not be stopped', () => {
|
|
10
|
+
const reason = stopAbortReason({ stopped: false, pid: 4242, reason: '停止失败:Access is denied(已保留 pidfile)' })
|
|
11
|
+
assert.match(reason, /4242/)
|
|
12
|
+
assert.match(reason, /已中止/)
|
|
13
|
+
assert.match(reason, /--force/)
|
|
14
|
+
assert.match(reason, /Access is denied/, 'the original failure is surfaced')
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
test('stop continues when nothing is running or the stop succeeded', () => {
|
|
18
|
+
assert.equal(stopAbortReason({ stopped: false, reason: '未在运行' }), '', 'no pid => nothing to protect')
|
|
19
|
+
assert.equal(stopAbortReason({ stopped: false, pid: 0 }), '')
|
|
20
|
+
assert.equal(stopAbortReason({ stopped: true, pid: 4242 }), '')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test('--force overrides the guard', () => {
|
|
24
|
+
assert.equal(stopAbortReason({ stopped: false, pid: 4242, reason: 'unknown' }, { force: true }), '')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('the summary reports what was withdrawn instead of a blanket claim', () => {
|
|
28
|
+
assert.match(withdrawnText({ rules: 2, hosts: 3 }), /2 条规则/)
|
|
29
|
+
assert.match(withdrawnText({ rules: 2, hosts: 3 }), /3 个 host/)
|
|
30
|
+
assert.match(withdrawnText({ rules: 2, hosts: 3 }), /全局一张表,不区分 workspace/)
|
|
31
|
+
assert.doesNotMatch(withdrawnText({ rules: 2, hosts: 3 }), /其它 workspace 的规则保留/)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('a stop that changed nothing says so', () => {
|
|
35
|
+
for (const withdrawn of [{ rules: 0, hosts: 0 }, undefined, {}]) {
|
|
36
|
+
const text = withdrawnText(withdrawn)
|
|
37
|
+
assert.match(text, /共享规则表里没有 yds 规则(未改动)/)
|
|
38
|
+
assert.doesNotMatch(text, /已撤掉/)
|
|
39
|
+
}
|
|
40
|
+
})
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Failures an agent reads must carry the command that fixes them: yds can start the
|
|
2
|
+
// local runtime (and the forwarded tunnel) itself, so the message says so.
|
|
3
|
+
import assert from 'node:assert/strict'
|
|
4
|
+
import { readFileSync } from 'node:fs'
|
|
5
|
+
import test from 'node:test'
|
|
6
|
+
|
|
7
|
+
const cli = readFileSync(new URL('../lib/cli.js', import.meta.url), 'utf8')
|
|
8
|
+
|
|
9
|
+
test('status and doctor point at `yds start` when the runtime is not running', () => {
|
|
10
|
+
assert.match(cli, /提示: 运行 yds start 可拉起本地运行时(YMS)/, 'doctor hints the start command')
|
|
11
|
+
assert.match(cli, /YMS 进程: .*提示: 运行 yds start 可拉起本地运行时(YMS)/, 'status does too')
|
|
12
|
+
assert.match(cli, /未运行(yds start \/ yds proxy start 会拉起)/, 'the tunnel path keeps its hint')
|
|
13
|
+
})
|
package/test/ide-config.test.mjs
CHANGED
|
@@ -141,6 +141,10 @@ test('the discovered backend row is disabled and a hijacked startCommand is remo
|
|
|
141
141
|
]
|
|
142
142
|
const result = repairYdsRows(cfg, programs)
|
|
143
143
|
assert.deepEqual(result.renamed.map((item) => item.to), ['c-ass-kk-hans-fe'])
|
|
144
|
+
// The renamed frontend also takes its own log name: the old "<project>-<name>"
|
|
145
|
+
// value collided with the backend row and both wrote the same log/pidfile.
|
|
146
|
+
assert.equal(result.programs[2].logName, 'c-ass-kk-hans-fe')
|
|
147
|
+
assert.deepEqual(result.renamedLogs, [{ name: 'c-ass-kk-hans-fe', from: undefined, to: 'c-ass-kk-hans-fe' }])
|
|
144
148
|
assert.deepEqual(result.disabled.map((item) => item.name), ['dev-c-ass-kk-hans-bootstrap'])
|
|
145
149
|
assert.deepEqual(result.cleaned.map((item) => item.name), ['c-ass-kk-hans-fe'])
|
|
146
150
|
assert.equal(result.programs[0].disabled, undefined, 'our row stays enabled')
|