dsh-ris-yds 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +160 -2
- package/assets/yds-chrome-resource-override.crx +0 -0
- package/bin/check-listing.mjs +247 -0
- package/bin/yds.js +19 -0
- package/cordis.patch.yml +13 -0
- package/lib/browser.js +428 -0
- package/lib/build.js +115 -0
- package/lib/cli.js +1130 -0
- package/lib/confcenter.js +377 -0
- package/lib/config.js +552 -0
- package/lib/fe-dev.js +206 -0
- package/lib/ide-proxy.js +906 -0
- package/lib/ide-watch.js +188 -0
- package/lib/ide.js +728 -0
- package/lib/index.js +267 -0
- package/lib/proc.js +270 -0
- package/lib/risd-peer.js +353 -0
- package/lib/rules.js +193 -0
- package/lib/socks.js +279 -0
- package/lib/token.js +36 -0
- package/lib/yms.js +336 -0
- package/lib/yoncode.js +249 -0
- package/native/yds-rust.darwin-arm64.node +0 -0
- package/native/yds-rust.darwin-x64.node +0 -0
- package/native/yds-rust.linux-arm64.node +0 -0
- package/native/yds-rust.linux-x64.node +0 -0
- package/native/yds-rust.win32-arm64.node +0 -0
- package/native/yds-rust.win32-x64.node +0 -0
- package/package.json +58 -4
- package/skills/dsh-ris-yds/SKILL.md +134 -0
- package/test/browser.test.mjs +442 -0
- package/test/cli-stop.test.mjs +40 -0
- package/test/cli.test.mjs +246 -0
- package/test/config.test.mjs +106 -0
- package/test/fe-dev.test.mjs +93 -0
- package/test/hints.test.mjs +13 -0
- package/test/ide-config.test.mjs +155 -0
- package/test/ide-proxy.test.mjs +256 -0
- package/test/ide-watch.test.mjs +79 -0
- package/test/launch-proxy.test.mjs +83 -0
- package/test/lease-release.test.mjs +76 -0
- package/test/mode-separation.test.mjs +147 -0
- package/test/pidfile.test.mjs +105 -0
- package/test/proc.test.mjs +172 -0
- package/test/proxy-standalone.test.mjs +121 -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/yoncode.test.mjs +159 -0
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { test } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import net from 'node:net'
|
|
7
|
+
|
|
8
|
+
import { run } from '../lib/cli.js'
|
|
9
|
+
|
|
10
|
+
// The suite inherits the developer's shell: strip every YDS_* override, then
|
|
11
|
+
// force the "dsh-ris-ide is not installed" path — the bundled SOCKS forwarder
|
|
12
|
+
// is the only tunnel left, which is exactly the standalone (public plugin)
|
|
13
|
+
// shape.
|
|
14
|
+
for (const key of Object.keys(process.env)) {
|
|
15
|
+
if (key.startsWith('YDS_')) delete process.env[key]
|
|
16
|
+
}
|
|
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-'))
|
|
21
|
+
|
|
22
|
+
const DB_PORT = 24531
|
|
23
|
+
const REDIS_PORT = 26891
|
|
24
|
+
|
|
25
|
+
const MIDDLEWARE = {
|
|
26
|
+
connectionPools: {
|
|
27
|
+
pool1: { url: 'jdbc:oracle:thin:@//172.20.235.80:1521/yonbipdev' },
|
|
28
|
+
},
|
|
29
|
+
'redis.pools': {
|
|
30
|
+
redis1: { host: '172.20.235.81', port: 6890 },
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function ok(result) {
|
|
35
|
+
// text() reports success without an exitCode; fail() sets 1.
|
|
36
|
+
assert.equal(result.exitCode ?? 0, 0, result.text)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function portOpen(port) {
|
|
40
|
+
return new Promise((resolve) => {
|
|
41
|
+
const socket = net.connect(port, '127.0.0.1')
|
|
42
|
+
socket.once('connect', () => { socket.destroy(); resolve(true) })
|
|
43
|
+
socket.once('error', () => resolve(false))
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A workspace with a cached confcenter pull; `socks` adds the tunnel .env. */
|
|
48
|
+
function workspace({ socks }) {
|
|
49
|
+
const dir = mkdtempSync(join(tmpdir(), 'yds-standalone-'))
|
|
50
|
+
mkdirSync(join(dir, '.dsh-ris-yds', 'confcenter'), { recursive: true })
|
|
51
|
+
writeFileSync(join(dir, '.dsh-ris-yds', 'yms-port'), '65001')
|
|
52
|
+
writeFileSync(join(dir, '.dsh-ris-yds', 'confcenter', 'yms_middleware.json'), JSON.stringify(MIDDLEWARE))
|
|
53
|
+
if (socks === true) {
|
|
54
|
+
writeFileSync(join(dir, '.env'), [
|
|
55
|
+
'socks.host = 127.0.0.1',
|
|
56
|
+
'socks.port = 1080',
|
|
57
|
+
`socks.dbPort = ${DB_PORT}`,
|
|
58
|
+
`socks.redisPort = ${REDIS_PORT}`,
|
|
59
|
+
'',
|
|
60
|
+
].join('\n'))
|
|
61
|
+
}
|
|
62
|
+
return dir
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
test('without dsh-ris-ide a direct workspace needs no tunnel at all', async () => {
|
|
66
|
+
const dir = workspace({ socks: false })
|
|
67
|
+
try {
|
|
68
|
+
const sync = await run(['proxy', 'sync'], { cwd: dir })
|
|
69
|
+
ok(sync)
|
|
70
|
+
assert.match(sync.text, /直连模式/)
|
|
71
|
+
const status = await run(['proxy', 'status'], { cwd: dir })
|
|
72
|
+
ok(status)
|
|
73
|
+
assert.match(status.text, /代理引擎不可用/)
|
|
74
|
+
assert.match(status.text, /不适用(未配置 SOCKS,直连模式)/)
|
|
75
|
+
// No tunnel => the sibling plugin has nothing to offer, so no hint either.
|
|
76
|
+
const doctor = await run(['doctor'], { cwd: dir })
|
|
77
|
+
ok(doctor)
|
|
78
|
+
assert.doesNotMatch(doctor.text, /未检测到 dsh-ris-ide/)
|
|
79
|
+
assert.equal(await portOpen(DB_PORT), false)
|
|
80
|
+
} finally {
|
|
81
|
+
rmSync(dir, { recursive: true, force: true })
|
|
82
|
+
}
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test('without dsh-ris-ide the bundled forwarder replaces the borrowed engine', async () => {
|
|
86
|
+
const dir = workspace({ socks: true })
|
|
87
|
+
try {
|
|
88
|
+
const sync = await run(['proxy', 'sync'], { cwd: dir })
|
|
89
|
+
ok(sync)
|
|
90
|
+
assert.match(sync.text, /代理引擎不可用/)
|
|
91
|
+
assert.match(sync.text, /内置 socks 转发器已启动/)
|
|
92
|
+
assert.equal(await portOpen(DB_PORT), true, 'Oracle 兜底端口由内置转发器监听')
|
|
93
|
+
assert.equal(await portOpen(REDIS_PORT), true, 'Redis 兜底端口由内置转发器监听')
|
|
94
|
+
|
|
95
|
+
const status = await run(['proxy', 'status'], { cwd: dir })
|
|
96
|
+
ok(status)
|
|
97
|
+
assert.match(status.text, /内置转发器: 运行中/)
|
|
98
|
+
assert.match(status.text, /172\.20\.235\.80:1521/)
|
|
99
|
+
|
|
100
|
+
// Tunnel without the borrowed engine: recommend the sibling plugin once, as
|
|
101
|
+
// an optional upgrade — never as a broken setup.
|
|
102
|
+
const doctor = await run(['doctor'], { cwd: dir })
|
|
103
|
+
ok(doctor)
|
|
104
|
+
assert.match(doctor.text, /未检测到 dsh-ris-ide(可选)/)
|
|
105
|
+
assert.match(doctor.text, /启停本地服务/)
|
|
106
|
+
|
|
107
|
+
// Starting twice is idempotent (no duplicate process / EADDRINUSE).
|
|
108
|
+
const again = await run(['proxy', 'start'], { cwd: dir })
|
|
109
|
+
ok(again)
|
|
110
|
+
assert.match(again.text, /已在运行/)
|
|
111
|
+
|
|
112
|
+
const stop = await run(['proxy', 'stop'], { cwd: dir })
|
|
113
|
+
ok(stop)
|
|
114
|
+
assert.match(stop.text, /内置转发器已停止/)
|
|
115
|
+
assert.equal(await portOpen(DB_PORT), false)
|
|
116
|
+
assert.equal(await portOpen(REDIS_PORT), false)
|
|
117
|
+
} finally {
|
|
118
|
+
await run(['proxy', 'stop'], { cwd: dir }).catch(() => {})
|
|
119
|
+
rmSync(dir, { recursive: true, force: true })
|
|
120
|
+
}
|
|
121
|
+
})
|
|
@@ -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
|
+
})
|