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/test/ide-proxy.test.mjs
CHANGED
|
@@ -10,8 +10,9 @@ import {
|
|
|
10
10
|
YDS_RULE_SOURCE, applyGitProxy, ensureBorrowedProxy, gitHostOf, ideProxyCandidates,
|
|
11
11
|
isIpv4Literal, isPrivateIpv4, loadIdeProxy, privateSegmentsOf, proxyRulesFromTargets,
|
|
12
12
|
shadowTargetsFromTargets,
|
|
13
|
-
toProcessPattern, yoncodeRules,
|
|
13
|
+
toProcessPattern, yoncodeRules, programKeyOf, ydsRulesFor,
|
|
14
14
|
} from '../lib/ide-proxy.js'
|
|
15
|
+
import { mergeYdsRules } from '../lib/rules.js'
|
|
15
16
|
|
|
16
17
|
test('only a dotted-quad IPv4 literal counts as bindable', () => {
|
|
17
18
|
assert.equal(isIpv4Literal('172.20.235.80'), true)
|
|
@@ -20,32 +21,41 @@ test('only a dotted-quad IPv4 literal counts as bindable', () => {
|
|
|
20
21
|
assert.equal(isIpv4Literal(''), false)
|
|
21
22
|
})
|
|
22
23
|
|
|
23
|
-
test('rules name real programs
|
|
24
|
-
const
|
|
24
|
+
test('rules name real programs on both sides: java/git and their WSL-side twins', () => {
|
|
25
|
+
const targets = [
|
|
25
26
|
{ host: '172.20.235.80', port: 1521 },
|
|
26
27
|
{ host: '172.20.235.81', port: 6890, isRedis: true },
|
|
27
28
|
{ host: '172.20.235.81', port: 6880 },
|
|
29
|
+
// a second internal /24 must fold into the SAME java row
|
|
30
|
+
{ host: '172.20.63.131', port: 31010 },
|
|
28
31
|
// duplicates collapse, garbage is dropped
|
|
29
32
|
{ host: '172.20.235.81', port: 6880 },
|
|
30
33
|
{ host: 'srmdev.hanslaser.com', port: 443 },
|
|
31
34
|
{ host: '', port: 1 },
|
|
32
35
|
{ host: '10.0.0.1', port: 0 },
|
|
33
36
|
{ host: '10.0.0.1', port: 70000 },
|
|
34
|
-
]
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
'
|
|
37
|
+
]
|
|
38
|
+
// On a WSL/Linux host the WSL side is the one that matters: `java.exe` is the Windows
|
|
39
|
+
// image, `java` is the process name ProxyBridge matches on the Linux side, and the
|
|
40
|
+
// missing local rows were why a WSL JVM was never intercepted.
|
|
41
|
+
const rules = proxyRulesFromTargets(targets, { gitHost: 'gitlab.hanslaser.com', java: true, platform: 'linux' })
|
|
42
|
+
assert.deepEqual(rules.map((rule) => `${rule.id}|${rule.side}|${rule.program}|${rule.hosts}|${rule.ports}`), [
|
|
43
|
+
'yds-java|windows|java.exe|172.20.235.*,172.20.63.*|*',
|
|
44
|
+
'yds-git|windows|git.exe|gitlab.hanslaser.com|*',
|
|
45
|
+
'yds-java-local|local|java|172.20.235.*,172.20.63.*|*',
|
|
46
|
+
'yds-git-local|local|git|gitlab.hanslaser.com|*',
|
|
41
47
|
])
|
|
42
48
|
assert.equal(rules.every((rule) => rule.source === YDS_RULE_SOURCE), true)
|
|
43
49
|
assert.equal(rules.every((rule) => rule.program !== 'jdbc'), true, 'jdbc is not a process name')
|
|
50
|
+
assert.equal(programKeyOf(rules[0].program), 'java')
|
|
51
|
+
assert.equal(programKeyOf(rules[2].program), 'java', 'the local row names the same program on its side')
|
|
44
52
|
assert.equal(rules.every((rule) => rule.shadow === false), true, 'the table holds intent only; no mapping rows')
|
|
45
|
-
assert.equal(rules
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
53
|
+
assert.equal(rules.every((rule) => rule.workspace === undefined), true, 'the table is global: workspace is not part of a row')
|
|
54
|
+
// Windows has no Linux side: only the `.exe` rows are generated there.
|
|
55
|
+
const windows = proxyRulesFromTargets(targets, { gitHost: 'gitlab.hanslaser.com', java: true, platform: 'win32' })
|
|
56
|
+
assert.deepEqual(windows.map((rule) => rule.id), ['yds-java', 'yds-git'])
|
|
57
|
+
// Without a git URL there is no git rule to write (on either side).
|
|
58
|
+
const noGit = proxyRulesFromTargets([{ host: '172.20.235.80', port: 1521 }], { gitHost: '', java: true, platform: 'linux' })
|
|
49
59
|
assert.equal(noGit.some((rule) => rule.program === 'git'), false)
|
|
50
60
|
})
|
|
51
61
|
|
|
@@ -160,14 +170,14 @@ test('YonCode own executables get their own labelled rules', async () => {
|
|
|
160
170
|
})
|
|
161
171
|
assert.deepEqual(rules.map((rule) => rule.id), ['yds-yoncode-java', 'yds-yoncode-git'])
|
|
162
172
|
assert.deepEqual(rules.map((rule) => rule.label), ['YonCode java', 'YonCode git'])
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
assert.equal(rules[0].program,
|
|
173
|
+
// The image name, not a file under the devkit: a JDK upgrade keeps working and
|
|
174
|
+
// every java.exe on the machine is covered.
|
|
175
|
+
assert.equal(rules[0].program, 'java.exe')
|
|
166
176
|
// The range comes from the endpoints we resolved, never from a hardcoded vendor range.
|
|
167
177
|
assert.equal(rules[0].hosts, '172.20.235.*,172.20.63.*')
|
|
168
178
|
assert.equal(rules[0].ports, '*')
|
|
169
179
|
assert.equal(rules[0].shadow, false)
|
|
170
|
-
assert.equal(rules[1].program,
|
|
180
|
+
assert.equal(rules[1].program, 'git.exe')
|
|
171
181
|
assert.equal(rules[1].hosts, 'gitlab.example.com')
|
|
172
182
|
assert.equal(rules.every((rule) => rule.source === 'yds'), true)
|
|
173
183
|
// No YonCode install and no git: no rows invented.
|
|
@@ -187,8 +197,60 @@ test('executable rows inherit the private ranges of the resolved endpoints', ()
|
|
|
187
197
|
assert.deepEqual(privateSegmentsOf([], []), [])
|
|
188
198
|
})
|
|
189
199
|
|
|
200
|
+
test('the yds rows are a whole-table write: the global set replaces the old yds rows', () => {
|
|
201
|
+
// The table is one global list. A save carries the complete table: the user's rows
|
|
202
|
+
// pass through, the previous yds rows are replaced by this run's set (never unioned
|
|
203
|
+
// with another project's, because there is no other project's share any more).
|
|
204
|
+
const merged = mergeYdsRules(
|
|
205
|
+
[
|
|
206
|
+
{ id: 'user-1', program: 'navicat.exe', hosts: '10.9.9.*', source: 'user' },
|
|
207
|
+
{ id: 'yds-java-old', program: 'java.exe', hosts: '172.20.235.*', ports: '*', enabled: true, source: 'yds', workspace: '/ws-a' },
|
|
208
|
+
{ id: 'yds-git-old', program: 'git.exe', hosts: 'gitlab.example.com', ports: '*', enabled: true, source: 'yds', workspace: '/ws-a' },
|
|
209
|
+
],
|
|
210
|
+
[
|
|
211
|
+
{ id: 'yds-java', program: 'java.exe', side: 'windows', hosts: '172.20.235.*,172.20.63.*', ports: '*', enabled: true, source: 'yds' },
|
|
212
|
+
{ id: 'yds-java-local', program: 'java', side: 'local', hosts: '172.20.235.*,172.20.63.*', ports: '*', enabled: true, source: 'yds' },
|
|
213
|
+
],
|
|
214
|
+
)
|
|
215
|
+
assert.deepEqual(merged.map((row) => row.id), ['user-1', 'yds-java', 'yds-java-local'])
|
|
216
|
+
assert.equal(merged.filter((row) => row.source === 'yds').length, 2, 'one row per side, not per workspace')
|
|
217
|
+
assert.equal(merged[1].hosts, '172.20.235.*,172.20.63.*')
|
|
218
|
+
assert.equal(merged.every((row) => row.workspace === undefined || row.source !== 'yds'), true, 'no workspace column on global rows')
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
test('the rules this project writes are the full side-aware set', () => {
|
|
222
|
+
const rows = ydsRulesFor(
|
|
223
|
+
{ workspace: '/w', proxyJavaRules: true, git: { url: 'https://gitlab.hanslaser.com/x/y.git' } },
|
|
224
|
+
[{ host: '172.20.235.80', port: 1521 }],
|
|
225
|
+
{ platform: 'linux' },
|
|
226
|
+
)
|
|
227
|
+
assert.deepEqual(rows.map((row) => row.id), ['yds-java', 'yds-git', 'yds-java-local', 'yds-git-local'])
|
|
228
|
+
assert.deepEqual(rows.map((row) => row.side), ['windows', 'windows', 'local', 'local'])
|
|
229
|
+
// A second start from another workspace produces the same shape: the last save wins.
|
|
230
|
+
const again = ydsRulesFor(
|
|
231
|
+
{ workspace: '/other', proxyJavaRules: true, git: { url: 'https://gitlab.hanslaser.com/x/y.git' } },
|
|
232
|
+
[{ host: '172.20.63.131', port: 1521 }],
|
|
233
|
+
{ platform: 'linux' },
|
|
234
|
+
)
|
|
235
|
+
assert.equal(again.find((row) => row.id === 'yds-java').hosts, '172.20.63.*', 'the fresh run owns the row')
|
|
236
|
+
})
|
|
237
|
+
|
|
190
238
|
test('a mounted YonCode path becomes a drive-letter pattern', () => {
|
|
191
239
|
assert.equal(toProcessPattern('/mnt/d/Program Files/YonCode/devkit/x/bin/java.exe'), 'D:\\Program Files\\YonCode\\devkit\\x\\bin\\java.exe')
|
|
192
240
|
assert.equal(toProcessPattern('C:\\Program Files\\YonCode\\x\\java.exe'), 'C:\\Program Files\\YonCode\\x\\java.exe')
|
|
193
241
|
assert.equal(toProcessPattern('/opt/YonCode/devkit/x/bin/java'), '/opt/YonCode/devkit/x/bin/java')
|
|
194
242
|
})
|
|
243
|
+
|
|
244
|
+
test('duplicates collapse only within one side+program, never across programs', () => {
|
|
245
|
+
const rows = mergeYdsRules([], [
|
|
246
|
+
{ id: 'a', program: 'java.exe', side: 'windows', hosts: '10.0.0.*' },
|
|
247
|
+
{ id: 'b', program: 'C:\\Program Files\\YonCode\\devkit\\jdk21\\bin\\java.exe', side: 'windows', hosts: '10.0.0.*' },
|
|
248
|
+
{ id: 'c', program: 'git.exe', side: 'windows', hosts: 'gitlab.example.com' },
|
|
249
|
+
{ id: 'd', program: 'git', side: 'local', hosts: 'gitlab.example.com' },
|
|
250
|
+
{ id: 'e', program: '' },
|
|
251
|
+
])
|
|
252
|
+
assert.deepEqual(rows.map((row) => row.id), ['a', 'c', 'd'])
|
|
253
|
+
assert.equal(rows[0].hosts, '10.0.0.*', 'the duplicate spelling contributes no new host')
|
|
254
|
+
assert.equal(programKeyOf('C:\\Program Files\\Git\\bin\\git.exe'), 'git')
|
|
255
|
+
assert.equal(programKeyOf('java'), 'java')
|
|
256
|
+
})
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// A failed lease release must keep the local record: forgetting it turned a
|
|
2
|
+
// transient daemon failure into "nobody owns the engine", resolved only by the
|
|
3
|
+
// daemon's pid sweep (review D-P2-6).
|
|
4
|
+
import assert from 'node:assert/strict'
|
|
5
|
+
import { createServer } from 'node:http'
|
|
6
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
7
|
+
import { tmpdir } from 'node:os'
|
|
8
|
+
import { join } from 'node:path'
|
|
9
|
+
import test from 'node:test'
|
|
10
|
+
|
|
11
|
+
const home = mkdtempSync(join(tmpdir(), 'yds-lease-'))
|
|
12
|
+
const WS = mkdtempSync(join(tmpdir(), 'yds-lease-ws-'))
|
|
13
|
+
let mode = 'ok'
|
|
14
|
+
let server = null
|
|
15
|
+
let port = 0
|
|
16
|
+
|
|
17
|
+
test.before(async () => {
|
|
18
|
+
server = createServer((req, res) => {
|
|
19
|
+
const send = (status, payload) => {
|
|
20
|
+
res.writeHead(status, { 'content-type': 'application/json' })
|
|
21
|
+
res.end(`${JSON.stringify(payload)}\n`)
|
|
22
|
+
}
|
|
23
|
+
if (req.method === 'GET' && req.url === '/v1/health') {
|
|
24
|
+
return send(200, { ok: true, apiVersion: 1, version: 'test' })
|
|
25
|
+
}
|
|
26
|
+
if (req.method === 'POST' && req.url === '/v1/leases') {
|
|
27
|
+
return send(200, { ok: true, lease: { id: 'lease-1' } })
|
|
28
|
+
}
|
|
29
|
+
if (req.method === 'DELETE' && req.url.startsWith('/v1/leases/')) {
|
|
30
|
+
if (mode === 'fail') return send(500, { ok: false, error: 'daemon broke' })
|
|
31
|
+
return send(200, { ok: true, released: true })
|
|
32
|
+
}
|
|
33
|
+
return send(404, { ok: false })
|
|
34
|
+
})
|
|
35
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
|
|
36
|
+
port = server.address().port
|
|
37
|
+
writeFileSync(join(home, 'risd.json'), `${JSON.stringify({
|
|
38
|
+
apiVersion: 1, version: 'test', pid: process.pid, port, token: 'test-token',
|
|
39
|
+
})}\n`, { mode: 0o600 })
|
|
40
|
+
process.env.DSH_RIS_IDE_PROXY_HOME = home
|
|
41
|
+
process.env.YDS_IDE_PROXY = 'none'
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test.after(() => {
|
|
45
|
+
server?.close()
|
|
46
|
+
rmSync(home, { recursive: true, force: true })
|
|
47
|
+
rmSync(join(home, '..', '.dsh-ris-yds'), { recursive: true, force: true })
|
|
48
|
+
rmSync(WS, { recursive: true, force: true })
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
function cfg() {
|
|
52
|
+
return { workspace: WS, socks: { host: '1.2.3.4', port: 1080 }, git: null, proxyJavaRules: true }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
test('a failed release keeps the lease record so a retry can finish the job', async () => {
|
|
56
|
+
const { holdBorrowedLease, releaseBorrowedLease } = await import('../lib/ide-proxy.js')
|
|
57
|
+
const { readLease } = await import('../lib/risd-peer.js')
|
|
58
|
+
const held = await holdBorrowedLease(cfg(), { ownerPid: process.pid })
|
|
59
|
+
assert.equal(held.ok, true)
|
|
60
|
+
assert.equal(readLease(WS)?.id, 'lease-1')
|
|
61
|
+
|
|
62
|
+
mode = 'fail'
|
|
63
|
+
const failed = await releaseBorrowedLease(cfg())
|
|
64
|
+
assert.equal(failed.ok, false)
|
|
65
|
+
assert.match(failed.reason, /daemon broke|释放租约失败/)
|
|
66
|
+
assert.equal(readLease(WS)?.id, 'lease-1', 'the record survives a failed release')
|
|
67
|
+
|
|
68
|
+
mode = 'ok'
|
|
69
|
+
const retried = await releaseBorrowedLease(cfg())
|
|
70
|
+
assert.equal(retried.ok, true)
|
|
71
|
+
assert.equal(retried.released, true)
|
|
72
|
+
assert.equal(readLease(WS), null, 'a successful release forgets the record')
|
|
73
|
+
|
|
74
|
+
const leases = JSON.parse(readFileSync(join(home, '..', '.dsh-ris-yds', 'risd-leases.json'), 'utf8'))
|
|
75
|
+
assert.equal(leases[WS], undefined)
|
|
76
|
+
})
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Pidfile handling when the process identity cannot be read. "Could not tell" used to
|
|
2
|
+
// be treated as "not our process", which deleted the pidfile and either started a
|
|
3
|
+
// second YMS/forwarder on top of a live one or left an orphan unreachable by
|
|
4
|
+
// `yds stop` (review D-P1-6). These tests drive the Windows/timeout path through the
|
|
5
|
+
// injectable probe options so they run on the Linux CI too.
|
|
6
|
+
import assert from 'node:assert/strict'
|
|
7
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
8
|
+
import { tmpdir } from 'node:os'
|
|
9
|
+
import { join } from 'node:path'
|
|
10
|
+
import test from 'node:test'
|
|
11
|
+
|
|
12
|
+
import { probeForwarder, socksPidFile, stopForwarder } from '../lib/socks.js'
|
|
13
|
+
import { readRunning, stopYms, ymsPidFile } from '../lib/yms.js'
|
|
14
|
+
|
|
15
|
+
const WIN = 'win32'
|
|
16
|
+
const unknownExec = () => ({ code: null, stdout: '', stderr: '', error: new Error('powershell timed out') })
|
|
17
|
+
const absentExec = () => ({ code: 0, stdout: 'YDS_PROBE_ABSENT', stderr: '', error: null })
|
|
18
|
+
const aliveExec = (commandLine) => () => ({ code: 0, stdout: `YDS_PROBE_OK:${commandLine}`, stderr: '', error: null })
|
|
19
|
+
|
|
20
|
+
function makeCfg() {
|
|
21
|
+
const stateDir = mkdtempSync(join(tmpdir(), 'yds-pid-'))
|
|
22
|
+
return { stateDir, dbForwardPort: 47431, redisForwardPort: 47432 }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function withCfg(fn) {
|
|
26
|
+
return async () => {
|
|
27
|
+
const cfg = makeCfg()
|
|
28
|
+
try { await fn(cfg) } finally { rmSync(cfg.stateDir, { recursive: true, force: true }) }
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
test('yms: an unreadable identity keeps the pidfile and refuses to signal', withCfg(async (cfg) => {
|
|
33
|
+
writeFileSync(ymsPidFile(cfg), '4242')
|
|
34
|
+
const running = await readRunning(cfg, { platform: WIN, exec: unknownExec })
|
|
35
|
+
assert.equal(running.state, 'unknown')
|
|
36
|
+
assert.equal(running.pid, 4242)
|
|
37
|
+
assert.equal(existsSync(ymsPidFile(cfg)), true, 'pidfile must survive a probe failure')
|
|
38
|
+
assert.match(readFileSync(ymsPidFile(cfg), 'utf8'), /4242/)
|
|
39
|
+
|
|
40
|
+
const stopped = await stopYms(cfg, { platform: WIN, exec: unknownExec, graceMs: 10 })
|
|
41
|
+
assert.equal(stopped.stopped, false)
|
|
42
|
+
assert.match(String(stopped.reason), /未发送停止信号/)
|
|
43
|
+
assert.equal(existsSync(ymsPidFile(cfg)), true, 'still ours as far as we know: keep the pidfile')
|
|
44
|
+
}))
|
|
45
|
+
|
|
46
|
+
test('yms: a dead or reused pid is cleaned from the pidfile', withCfg(async (cfg) => {
|
|
47
|
+
writeFileSync(ymsPidFile(cfg), '4242')
|
|
48
|
+
assert.equal(await readRunning(cfg, { platform: WIN, exec: absentExec }), null)
|
|
49
|
+
assert.equal(existsSync(ymsPidFile(cfg)), false)
|
|
50
|
+
|
|
51
|
+
writeFileSync(ymsPidFile(cfg), '4242')
|
|
52
|
+
// Alive but a completely different command line: the pid was reused after a crash.
|
|
53
|
+
assert.equal(await readRunning(cfg, { platform: WIN, exec: aliveExec('notepad.exe') }), null)
|
|
54
|
+
assert.equal(existsSync(ymsPidFile(cfg)), false)
|
|
55
|
+
}))
|
|
56
|
+
|
|
57
|
+
test('yms: a verified owner is reported, and a failed stop keeps the pidfile', withCfg(async (cfg) => {
|
|
58
|
+
writeFileSync(ymsPidFile(cfg), '4344')
|
|
59
|
+
const owners = aliveExec('java -cp yms.jar iuap.yms.YMSLauncher')
|
|
60
|
+
assert.deepEqual(await readRunning(cfg, { platform: WIN, exec: owners }), { pid: 4344 })
|
|
61
|
+
|
|
62
|
+
// taskkill failed but the process is still alive: report the failure, keep the
|
|
63
|
+
// pidfile so `yds status` keeps seeing it (review D-P1-7).
|
|
64
|
+
const denied = () => ({ code: 5, stdout: '', stderr: 'Access is denied.', error: null })
|
|
65
|
+
const failed = await stopYms(cfg, { platform: WIN, exec: denied, probe: () => ({ state: 'alive', commandLine: '' }), graceMs: 10 })
|
|
66
|
+
assert.equal(failed.stopped, false)
|
|
67
|
+
assert.match(String(failed.reason), /已保留 pidfile/)
|
|
68
|
+
assert.equal(existsSync(ymsPidFile(cfg)), true)
|
|
69
|
+
|
|
70
|
+
// A successful stop is the only path that unlinks the pidfile. (The same fake exec
|
|
71
|
+
// answers the identity probe and taskkill: both exit 0, and the command line still
|
|
72
|
+
// identifies the YMS.)
|
|
73
|
+
assert.equal((await stopYms(cfg, { platform: WIN, exec: owners })).stopped, true)
|
|
74
|
+
assert.equal(existsSync(ymsPidFile(cfg)), false)
|
|
75
|
+
}))
|
|
76
|
+
|
|
77
|
+
test('socks: an unreadable forwarder identity keeps the pidfile', withCfg(async (cfg) => {
|
|
78
|
+
writeFileSync(socksPidFile(cfg), '4343')
|
|
79
|
+
const probe = probeForwarder(cfg, { platform: WIN, exec: unknownExec })
|
|
80
|
+
assert.equal(probe.state, 'unknown')
|
|
81
|
+
assert.equal(probe.pid, null, 'no pid is handed out while the identity is unreadable')
|
|
82
|
+
assert.equal(existsSync(socksPidFile(cfg)), true)
|
|
83
|
+
|
|
84
|
+
const stopped = await stopForwarder(cfg, { platform: WIN, exec: unknownExec })
|
|
85
|
+
assert.equal(stopped.stopped, false)
|
|
86
|
+
assert.equal(existsSync(socksPidFile(cfg)), true)
|
|
87
|
+
}))
|
|
88
|
+
|
|
89
|
+
test('socks: a dead or reused forwarder pid is cleaned', withCfg(async (cfg) => {
|
|
90
|
+
writeFileSync(socksPidFile(cfg), '4343')
|
|
91
|
+
assert.equal(probeForwarder(cfg, { platform: WIN, exec: absentExec }).state, 'stale')
|
|
92
|
+
assert.equal(existsSync(socksPidFile(cfg)), false)
|
|
93
|
+
|
|
94
|
+
writeFileSync(socksPidFile(cfg), '4343')
|
|
95
|
+
const foreign = probeForwarder(cfg, { platform: WIN, exec: aliveExec('some-other-daemon') })
|
|
96
|
+
assert.equal(foreign.state, 'stale')
|
|
97
|
+
assert.equal(existsSync(socksPidFile(cfg)), false)
|
|
98
|
+
}))
|
|
99
|
+
|
|
100
|
+
test('socks: the verified forwarder is recognised as ours', withCfg(async (cfg) => {
|
|
101
|
+
writeFileSync(socksPidFile(cfg), '4343')
|
|
102
|
+
const probe = probeForwarder(cfg, { platform: WIN, exec: aliveExec('node yds.js socks-fwd --socks=1.2.3.4:1080') })
|
|
103
|
+
assert.equal(probe.state, 'verified')
|
|
104
|
+
assert.equal(probe.pid, 4343)
|
|
105
|
+
}))
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { spawn } from 'node:child_process'
|
|
3
|
+
import test from 'node:test'
|
|
4
|
+
|
|
5
|
+
import { pidAlive, processCommandLine, stopPid } from '../lib/proc.js'
|
|
6
|
+
|
|
7
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
8
|
+
|
|
9
|
+
test('processCommandLine reads the current command line on this platform', () => {
|
|
10
|
+
const line = processCommandLine(process.pid)
|
|
11
|
+
assert.match(line, /node/i, 'contains the node executable')
|
|
12
|
+
assert.equal(processCommandLine(0), '')
|
|
13
|
+
assert.equal(processCommandLine(-1), '')
|
|
14
|
+
assert.equal(processCommandLine('nope'), '')
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
test('pidAlive tells live from gone pids', () => {
|
|
18
|
+
assert.equal(pidAlive(process.pid), true)
|
|
19
|
+
assert.equal(pidAlive(2147480000), false)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test('stopPid ends a real child process and tolerates a dead pid', async () => {
|
|
23
|
+
const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore' })
|
|
24
|
+
await sleep(300)
|
|
25
|
+
assert.equal(pidAlive(child.pid), true)
|
|
26
|
+
await stopPid(child.pid, { graceMs: 3000 })
|
|
27
|
+
await sleep(800)
|
|
28
|
+
assert.equal(pidAlive(child.pid), false)
|
|
29
|
+
await stopPid(child.pid, { graceMs: 500 }) // already gone: must not throw
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
// ---- Windows branches, driven through the injectable platform/exec -----------------
|
|
33
|
+
// CI runs on Linux, so the Windows path is only exercised when the platform and the
|
|
34
|
+
// command runner are injected. These tests pin the two behaviours the review found
|
|
35
|
+
// broken: "could not tell" must never become "not ours", and a failed taskkill must
|
|
36
|
+
// never be reported as a successful stop.
|
|
37
|
+
|
|
38
|
+
import { pidIdentity, probePid, signalState, windowsProbeArgs } from '../lib/proc.js'
|
|
39
|
+
|
|
40
|
+
const WIN = 'win32'
|
|
41
|
+
const okOut = (stdout) => ({ code: 0, stdout, stderr: '', error: null })
|
|
42
|
+
|
|
43
|
+
test('windowsProbeArgs uses a single-quoted WQL predicate and only the integer pid', () => {
|
|
44
|
+
const args = windowsProbeArgs(4242)
|
|
45
|
+
assert.deepEqual(args.slice(0, -1), ['-NoProfile', '-NonInteractive', '-Command'])
|
|
46
|
+
const script = args.at(-1)
|
|
47
|
+
assert.match(script, /-Filter 'ProcessId=4242'/)
|
|
48
|
+
assert.ok(!script.includes('"ProcessId'), 'no double quotes embedded in -Command')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('probePid distinguishes absent, readable and unknowable on Windows', () => {
|
|
52
|
+
assert.deepEqual(probePid(11, { platform: WIN, exec: () => okOut('YDS_PROBE_ABSENT\n') }), { state: 'dead', commandLine: '' })
|
|
53
|
+
const alive = probePid(11, { platform: WIN, exec: () => okOut('YDS_PROBE_OK:C:\\jdk\\bin\\java.exe -jar yms.jar') })
|
|
54
|
+
assert.equal(alive.state, 'alive')
|
|
55
|
+
assert.match(alive.commandLine, /yms\.jar/)
|
|
56
|
+
// Empty output / access denied / timeout: cannot tell => unknown (never dead).
|
|
57
|
+
assert.equal(probePid(11, { platform: WIN, exec: () => okOut('') }).state, 'unknown')
|
|
58
|
+
assert.equal(probePid(11, { platform: WIN, exec: () => ({ code: 1, stdout: '', stderr: 'Access is denied.', error: null }) }).state, 'unknown')
|
|
59
|
+
assert.equal(probePid(11, { platform: WIN, exec: () => ({ code: null, stdout: '', stderr: '', error: new Error('ETIMEDOUT') }) }).state, 'unknown')
|
|
60
|
+
// An invalid pid is dead without spawning anything.
|
|
61
|
+
let spawned = 0
|
|
62
|
+
assert.equal(probePid(0, { platform: WIN, exec: () => { spawned += 1; return okOut('') } }).state, 'dead')
|
|
63
|
+
assert.equal(spawned, 0)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('pidIdentity separates ours/foreign/dead/unknown on Windows', () => {
|
|
67
|
+
const markers = ['yms.jar']
|
|
68
|
+
const identity = (stdout) => pidIdentity(7, markers, { platform: WIN, exec: () => okOut(stdout) }).state
|
|
69
|
+
assert.equal(identity('YDS_PROBE_OK:java -jar yms.jar'), 'ours')
|
|
70
|
+
assert.equal(identity('YDS_PROBE_OK:notepad.exe'), 'foreign')
|
|
71
|
+
assert.equal(identity('YDS_PROBE_ABSENT'), 'dead')
|
|
72
|
+
assert.equal(identity('YDS_PROBE_OK:'), 'unknown', 'alive but unreadable command line is NOT foreign')
|
|
73
|
+
assert.equal(identity('garbage'), 'unknown')
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('a readable process with no command line is unknown, not foreign (POSIX too)', () => {
|
|
77
|
+
const base = { platform: 'linux', signal: () => 'alive' }
|
|
78
|
+
assert.equal(pidIdentity(7, ['yms.jar'], { ...base, commandLine: () => '' }).state, 'unknown')
|
|
79
|
+
assert.equal(pidIdentity(7, ['yms.jar'], { ...base, commandLine: () => '/usr/bin/java -jar yms.jar' }).state, 'ours')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('signalState maps EPERM to alive and ESRCH to dead', () => {
|
|
83
|
+
// pid 1 always exists; as an unprivileged user kill(1,0) throws EPERM, which must
|
|
84
|
+
// count as alive (the old code mapped every error to "dead").
|
|
85
|
+
assert.equal(signalState(1), 'alive')
|
|
86
|
+
assert.equal(signalState(2147480000), 'dead')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test('stopPid consumes the taskkill exit code instead of assuming success', async () => {
|
|
90
|
+
const calls = []
|
|
91
|
+
const execOk = (command, args) => { calls.push([command, args]); return { code: 0, stdout: '', stderr: '', error: null } }
|
|
92
|
+
assert.deepEqual(await stopPid(555, { platform: WIN, exec: execOk }), { ok: true, code: 0 })
|
|
93
|
+
assert.deepEqual(calls[0], ['taskkill', ['/PID', '555', '/T', '/F']])
|
|
94
|
+
|
|
95
|
+
// The process exited between probe and taskkill: nonzero exit, but it is gone.
|
|
96
|
+
const execGone = () => ({ code: 128, stdout: '', stderr: 'not found', error: null })
|
|
97
|
+
assert.equal((await stopPid(555, { platform: WIN, exec: execGone, probe: () => ({ state: 'dead' }) })).ok, true)
|
|
98
|
+
|
|
99
|
+
// A real failure must be reported with the reason, and the caller keeps the pidfile.
|
|
100
|
+
const execDenied = () => ({ code: 5, stdout: '', stderr: 'Access is denied.', error: null })
|
|
101
|
+
const failed = await stopPid(555, { platform: WIN, exec: execDenied, probe: () => ({ state: 'alive', commandLine: '' }) })
|
|
102
|
+
assert.equal(failed.ok, false)
|
|
103
|
+
assert.match(String(failed.error), /Access is denied/)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test('the absent marker is matched exactly, never as a substring of a command line', async () => {
|
|
107
|
+
const { probePid } = await import('../lib/proc.js')
|
|
108
|
+
const WIN = 'win32'
|
|
109
|
+
const okOut = (stdout) => ({ code: 0, stdout, stderr: '', error: null })
|
|
110
|
+
// A live java process whose own command line quotes the probe marker must stay alive.
|
|
111
|
+
const alive = probePid(11, { platform: WIN, exec: () => okOut('YDS_PROBE_OK:java -Dtag=YDS_PROBE_ABSENT -jar yms.jar') })
|
|
112
|
+
assert.equal(alive.state, 'alive')
|
|
113
|
+
assert.match(alive.commandLine, /YDS_PROBE_ABSENT/)
|
|
114
|
+
// Extra noise around the absent marker is not proof: only the exact output is dead.
|
|
115
|
+
assert.equal(probePid(11, { platform: WIN, exec: () => okOut('YDS_PROBE_ABSENT\nsome warning') }).state, 'unknown')
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
test('probe results are only cached for the real Windows probe', async () => {
|
|
119
|
+
const { probePid, clearProbeCache } = await import('../lib/proc.js')
|
|
120
|
+
const WIN = 'win32'
|
|
121
|
+
let calls = 0
|
|
122
|
+
const exec = () => { calls += 1; return { code: 0, stdout: 'YDS_PROBE_ABSENT', stderr: '', error: null } }
|
|
123
|
+
probePid(4242, { platform: WIN, exec })
|
|
124
|
+
probePid(4242, { platform: WIN, exec })
|
|
125
|
+
assert.equal(calls, 2, 'an injected executor is never cached (tests must see every probe)')
|
|
126
|
+
clearProbeCache(4242)
|
|
127
|
+
assert.equal(typeof clearProbeCache, 'function')
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
test('a forced stop signals through the injected kill and verifies the result', async () => {
|
|
131
|
+
const { stopPid } = await import('../lib/proc.js')
|
|
132
|
+
const POSIX = 'linux'
|
|
133
|
+
const signals = []
|
|
134
|
+
const kill = (pid, sig) => { signals.push([pid, sig]); return { ok: true } }
|
|
135
|
+
// Gone after SIGKILL: success.
|
|
136
|
+
assert.deepEqual(
|
|
137
|
+
await stopPid(777, { force: true, platform: POSIX, kill, probe: () => ({ state: 'dead' }) }),
|
|
138
|
+
{ ok: true, code: null },
|
|
139
|
+
)
|
|
140
|
+
assert.deepEqual(signals, [[777, 'SIGKILL']], 'the injected kill is used, not process.kill')
|
|
141
|
+
|
|
142
|
+
// SIGKILL did not work (uninterruptible/foreign): report failure instead of claiming it.
|
|
143
|
+
const stubborn = await stopPid(778, { force: true, platform: POSIX, kill, probe: () => ({ state: 'alive', commandLine: '' }) })
|
|
144
|
+
assert.equal(stubborn.ok, false)
|
|
145
|
+
assert.match(String(stubborn.error), /SIGKILL 后进程仍存活/)
|
|
146
|
+
|
|
147
|
+
// A kill that fails outright also reports its reason.
|
|
148
|
+
const denied = await stopPid(779, {
|
|
149
|
+
force: true, platform: POSIX, kill: () => ({ ok: false, error: 'operation not permitted' }), probe: () => ({ state: 'alive' }),
|
|
150
|
+
})
|
|
151
|
+
assert.equal(denied.ok, false)
|
|
152
|
+
assert.match(String(denied.error), /not permitted/)
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
test('a graceful stop skips the kill when the injected TERM already succeeded', async () => {
|
|
156
|
+
const { stopPid } = await import('../lib/proc.js')
|
|
157
|
+
const POSIX = 'linux'
|
|
158
|
+
const signals = []
|
|
159
|
+
const kill = (pid, sig) => { signals.push(sig); return { ok: true } }
|
|
160
|
+
const result = await stopPid(780, { platform: POSIX, graceMs: 300, kill, probe: () => ({ state: 'dead' }) })
|
|
161
|
+
assert.deepEqual(result, { ok: true, code: null })
|
|
162
|
+
assert.deepEqual(signals, ['SIGTERM'])
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
test('a detached child is stopped as a process group', async () => {
|
|
166
|
+
const { stopPid } = await import('../lib/proc.js')
|
|
167
|
+
const signals = []
|
|
168
|
+
const kill = (pid, sig) => { signals.push([pid, sig]); return { ok: true } }
|
|
169
|
+
const result = await stopPid(781, { force: true, group: true, platform: 'linux', kill, probe: () => ({ state: 'dead' }) })
|
|
170
|
+
assert.equal(result.ok, true)
|
|
171
|
+
assert.deepEqual(signals, [[-781, 'SIGKILL']], 'the group leader pid is negated')
|
|
172
|
+
})
|
|
@@ -15,6 +15,9 @@ for (const key of Object.keys(process.env)) {
|
|
|
15
15
|
if (key.startsWith('YDS_')) delete process.env[key]
|
|
16
16
|
}
|
|
17
17
|
process.env.YDS_IDE_PROXY = 'none'
|
|
18
|
+
// The risd tier reads `~/.dsh-ris-ide` by default; a developer's daemon running on
|
|
19
|
+
// the machine must not decide this test's outcome. Point it at a fresh home.
|
|
20
|
+
process.env.DSH_RIS_IDE_PROXY_HOME = mkdtempSync(join(tmpdir(), 'yds-standalone-risd-'))
|
|
18
21
|
|
|
19
22
|
const DB_PORT = 24531
|
|
20
23
|
const REDIS_PORT = 26891
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// The lease record on disk is how a short-lived CLI hands a long-lived tunnel
|
|
2
|
+
// ownership to the YMS process, so it must survive two `yds start` runs in
|
|
3
|
+
// different workspaces and a writer that died between read and write.
|
|
4
|
+
//
|
|
5
|
+
// Review D-P2-6: the file used to be a bare read-modify-write (a lost update
|
|
6
|
+
// dropped the other workspace's lease, leaving the daemon to reap an engine a
|
|
7
|
+
// live YMS still needed) and a leftover `.lock` had no recovery path.
|
|
8
|
+
import assert from 'node:assert/strict'
|
|
9
|
+
import { spawn } from 'node:child_process'
|
|
10
|
+
import {
|
|
11
|
+
existsSync, mkdirSync, mkdtempSync, openSync, closeSync, readFileSync, readdirSync, rmSync, statSync, utimesSync, writeFileSync,
|
|
12
|
+
} from 'node:fs'
|
|
13
|
+
import { tmpdir } from 'node:os'
|
|
14
|
+
import { join } from 'node:path'
|
|
15
|
+
import { pathToFileURL } from 'node:url'
|
|
16
|
+
import { after, before, test } from 'node:test'
|
|
17
|
+
|
|
18
|
+
import { dropDeadLease, forgetLease, readLease, rememberLease } from '../lib/risd-peer.js'
|
|
19
|
+
|
|
20
|
+
const PEER = pathToFileURL(join(import.meta.dirname, '..', 'lib', 'risd-peer.js')).href
|
|
21
|
+
|
|
22
|
+
let root
|
|
23
|
+
let home
|
|
24
|
+
|
|
25
|
+
before(() => {
|
|
26
|
+
root = mkdtempSync(join(tmpdir(), 'yds-risd-leases-'))
|
|
27
|
+
home = join(root, '.dsh-ris-ide')
|
|
28
|
+
mkdirSync(home, { recursive: true })
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
after(() => {
|
|
32
|
+
rmSync(root, { recursive: true, force: true })
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
function leasesFile() {
|
|
36
|
+
return join(root, '.dsh-ris-yds', 'risd-leases.json')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
test('a lease round-trips and is written 0600 without leaving temp files', () => {
|
|
40
|
+
const cwd = '/ws/one'
|
|
41
|
+
assert.equal(rememberLease(cwd, { id: 'lease-1', ownerPid: process.pid, port: 11081 }, { home }), true)
|
|
42
|
+
const entry = readLease(cwd, { home })
|
|
43
|
+
assert.equal(entry.id, 'lease-1')
|
|
44
|
+
assert.equal(entry.ownerPid, process.pid)
|
|
45
|
+
assert.equal(typeof entry.at, 'string', 'timestamp for debugging a stale record')
|
|
46
|
+
assert.equal(statSync(leasesFile()).mode & 0o777, 0o600)
|
|
47
|
+
assert.deepEqual(readdirSync(join(root, '.dsh-ris-yds')).filter((name) => name.endsWith('.tmp')), [])
|
|
48
|
+
forgetLease(cwd, { home })
|
|
49
|
+
assert.equal(readLease(cwd, { home }), null)
|
|
50
|
+
assert.equal(existsSync(join(root, '.dsh-ris-yds', 'risd-leases.json.lock')), false, 'lock released')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('a stale lease is dropped only when its owner is gone', () => {
|
|
54
|
+
const live = '/ws/live'
|
|
55
|
+
const dead = '/ws/dead'
|
|
56
|
+
rememberLease(live, { id: 'lease-live', ownerPid: process.pid, port: 1 }, { home })
|
|
57
|
+
rememberLease(dead, { id: 'lease-dead', ownerPid: 999999, port: 1 }, { home })
|
|
58
|
+
assert.equal(dropDeadLease(live, { home }), false)
|
|
59
|
+
assert.notEqual(readLease(live, { home }), null, 'a live owner keeps its lease')
|
|
60
|
+
assert.equal(dropDeadLease(dead, { home }), true)
|
|
61
|
+
assert.equal(readLease(dead, { home }), null, 'a dead owner does not keep the engine alive')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('an abandoned lock file is stolen instead of blocking every later start', () => {
|
|
65
|
+
const cwd = '/ws/stale-lock'
|
|
66
|
+
const lock = `${leasesFile()}.lock`
|
|
67
|
+
mkdirSync(join(root, '.dsh-ris-yds'), { recursive: true })
|
|
68
|
+
writeFileSync(lock, '12345')
|
|
69
|
+
const old = new Date(Date.now() - 60_000)
|
|
70
|
+
utimesSync(lock, old, old)
|
|
71
|
+
assert.equal(rememberLease(cwd, { id: 'lease-stale', ownerPid: process.pid, port: 1 }, { home }), true)
|
|
72
|
+
assert.notEqual(readLease(cwd, { home }), null)
|
|
73
|
+
assert.equal(existsSync(lock), false)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('a held lock degrades to a bounded unlocked write instead of hanging yds', () => {
|
|
77
|
+
const cwd = '/ws/held-lock'
|
|
78
|
+
const lock = `${leasesFile()}.lock`
|
|
79
|
+
mkdirSync(join(root, '.dsh-ris-yds'), { recursive: true })
|
|
80
|
+
const fd = openSync(lock, 'wx')
|
|
81
|
+
const started = Date.now()
|
|
82
|
+
try {
|
|
83
|
+
// The holder is this very process (never released), so the client must give up
|
|
84
|
+
// on the lock and still record the lease: a missing record is worse.
|
|
85
|
+
assert.equal(rememberLease(cwd, { id: 'lease-held', ownerPid: process.pid, port: 1 }, { home }), true)
|
|
86
|
+
const waited = Date.now() - started
|
|
87
|
+
assert.ok(waited < 4000, `bounded wait (waited ${waited}ms)`)
|
|
88
|
+
assert.notEqual(readLease(cwd, { home }), null)
|
|
89
|
+
} finally {
|
|
90
|
+
closeSync(fd)
|
|
91
|
+
rmSync(lock, { force: true })
|
|
92
|
+
}
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('concurrent writers in different workspaces never drop each other', async () => {
|
|
96
|
+
const count = 6
|
|
97
|
+
const code = `import { rememberLease } from ${JSON.stringify(PEER)};
|
|
98
|
+
rememberLease(process.argv[1], { id: process.argv[2], ownerPid: process.pid, port: 1 }, { home: process.argv[3] })`
|
|
99
|
+
const children = Array.from({ length: count }, (_, index) => new Promise((resolve) => {
|
|
100
|
+
const cwd = `/ws/concurrent-${index}`
|
|
101
|
+
const child = spawn(process.execPath, ['--input-type=module', '-e', code, cwd, `lease-c${index}`, home], { stdio: 'ignore' })
|
|
102
|
+
child.once('exit', (code_) => resolve({ cwd, id: `lease-c${index}`, code: code_ }))
|
|
103
|
+
}))
|
|
104
|
+
const results = await Promise.all(children)
|
|
105
|
+
assert.deepEqual(results.filter((row) => row.code !== 0), [], 'every writer exited cleanly')
|
|
106
|
+
const all = JSON.parse(readFileSync(leasesFile(), 'utf8'))
|
|
107
|
+
for (const row of results) {
|
|
108
|
+
assert.equal(all[row.cwd]?.id, row.id, `${row.cwd} kept its lease`)
|
|
109
|
+
}
|
|
110
|
+
assert.equal(readdirSync(join(root, '.dsh-ris-yds')).filter((name) => name.endsWith('.tmp')).length, 0)
|
|
111
|
+
})
|