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/proc.js
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
// Cross-platform process helpers.
|
|
2
|
+
//
|
|
3
|
+
// The Linux implementations read /proc/<pid>/cmdline to prove a pidfile still
|
|
4
|
+
// points at OUR process; Windows has no /proc, so the same check must come from
|
|
5
|
+
// the Win32 process table (PowerShell CIM). Without this, `yds status` reported
|
|
6
|
+
// "not running" on Windows, `yds start` spawned a second YMS and `yds stop`
|
|
7
|
+
// could never find the forwarder (review: Windows P0).
|
|
8
|
+
//
|
|
9
|
+
// Identity checks are THREE-valued on purpose (review D-P1-6): "ours", "gone" and
|
|
10
|
+
// "could not tell". Collapsing "could not tell" into "not ours" deleted the pidfile
|
|
11
|
+
// after a transient PowerShell/EPERM failure, which is exactly how a second YMS got
|
|
12
|
+
// started on top of a live one. Callers must only act destructively on `dead`, only
|
|
13
|
+
// signal on `ours`, and keep the pidfile when the answer is `unknown`.
|
|
14
|
+
|
|
15
|
+
import { spawnSync } from 'node:child_process'
|
|
16
|
+
import { readFileSync } from 'node:fs'
|
|
17
|
+
|
|
18
|
+
const DEFAULT_PLATFORM = process.platform
|
|
19
|
+
const WINDOWS_PROBE_OK = 'YDS_PROBE_OK'
|
|
20
|
+
const WINDOWS_PROBE_ABSENT = 'YDS_PROBE_ABSENT'
|
|
21
|
+
|
|
22
|
+
// One PowerShell per identity check costs ~0.5 s on Windows, and `yds status` /
|
|
23
|
+
// `yds start` run several checks back to back (pidfile + forwarder). Keep the answers
|
|
24
|
+
// for a few seconds so those paths do not spawn four processes (review D-P2-3). The
|
|
25
|
+
// cache is only used on the real Windows probe (injected executors stay uncached so
|
|
26
|
+
// tests remain deterministic) and `stopPid` invalidates it before/after the kill.
|
|
27
|
+
const WINDOWS_PROBE_CACHE_MS = 5000
|
|
28
|
+
const WINDOWS_PROBE_CACHE_MAX = 200
|
|
29
|
+
const windowsProbeCache = new Map()
|
|
30
|
+
|
|
31
|
+
/** Forget a cached probe (a stop is authoritative; never answer from before it). */
|
|
32
|
+
export function clearProbeCache(pid) {
|
|
33
|
+
const value = Number(pid)
|
|
34
|
+
if (Number.isInteger(value) && value > 0) windowsProbeCache.delete(`win32:${value}`)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Run a command once and report everything the caller may need: exit code, both
|
|
40
|
+
* streams and the spawn error. Swallowing the exit code made `taskkill` failures
|
|
41
|
+
* look like successes (review D-P1-7).
|
|
42
|
+
* @returns {{code: number|null, stdout: string, stderr: string, error: Error|null}}
|
|
43
|
+
*/
|
|
44
|
+
export function runCommand(command, args, timeout = 8000) {
|
|
45
|
+
try {
|
|
46
|
+
const out = spawnSync(command, args, { encoding: 'utf8', timeout, windowsHide: true })
|
|
47
|
+
return {
|
|
48
|
+
code: Number.isInteger(out.status) ? out.status : null,
|
|
49
|
+
stdout: String(out.stdout ?? ''),
|
|
50
|
+
stderr: String(out.stderr ?? ''),
|
|
51
|
+
error: out.error ?? null,
|
|
52
|
+
}
|
|
53
|
+
} catch (error) {
|
|
54
|
+
return { code: null, stdout: '', stderr: '', error }
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The PowerShell argument list used to read a pid's command line. Kept separate so a
|
|
60
|
+
* test can assert the WQL quoting without spawning Windows.
|
|
61
|
+
*
|
|
62
|
+
* The pid is the only interpolated value and is validated as a positive integer by
|
|
63
|
+
* `probePid`, so it cannot inject anything. The WQL predicate is single-quoted: the
|
|
64
|
+
* old form embedded double quotes inside `-Command`, which only survived because of
|
|
65
|
+
* one specific argument-quoting rule on Windows (review D-P2).
|
|
66
|
+
*/
|
|
67
|
+
export function windowsProbeArgs(pid) {
|
|
68
|
+
const value = Number(pid)
|
|
69
|
+
const script = [
|
|
70
|
+
`$p = Get-CimInstance Win32_Process -Filter 'ProcessId=${value}'`,
|
|
71
|
+
`if ($null -eq $p) { '${WINDOWS_PROBE_ABSENT}' } else { '${WINDOWS_PROBE_OK}:' + $p.CommandLine }`,
|
|
72
|
+
].join('; ')
|
|
73
|
+
return ['-NoProfile', '-NonInteractive', '-Command', script]
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function parseWindowsProbe(result) {
|
|
77
|
+
// Spawn failure/timeout: we cannot tell whether the pid exists.
|
|
78
|
+
if (result?.error !== null && result?.error !== undefined) return { state: 'unknown', commandLine: '' }
|
|
79
|
+
const text = String(result?.stdout ?? '').trim()
|
|
80
|
+
const marker = `${WINDOWS_PROBE_OK}:`
|
|
81
|
+
// The command line is echoed verbatim after the OK marker, so the absent marker
|
|
82
|
+
// must be matched as the WHOLE output: `includes` used to call a live process dead
|
|
83
|
+
// when its own command line contained the marker string (review D-P2-2).
|
|
84
|
+
if (text === WINDOWS_PROBE_ABSENT) return { state: 'dead', commandLine: '' }
|
|
85
|
+
const at = text.indexOf(marker)
|
|
86
|
+
if (at !== -1) return { state: 'alive', commandLine: text.slice(at + marker.length).trim() }
|
|
87
|
+
// Empty output with a non-zero exit code is a permission/availability failure,
|
|
88
|
+
// NOT proof that the process is gone. Keep it unknown.
|
|
89
|
+
return { state: 'unknown', commandLine: '' }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Default POSIX liveness probe: EPERM means the pid exists but is not ours. */
|
|
93
|
+
export function signalState(pid) {
|
|
94
|
+
try {
|
|
95
|
+
process.kill(pid, 0)
|
|
96
|
+
return 'alive'
|
|
97
|
+
} catch (error) {
|
|
98
|
+
if (error?.code === 'ESRCH') return 'dead'
|
|
99
|
+
if (error?.code === 'EPERM') return 'alive'
|
|
100
|
+
return 'unknown'
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Identify a pid: `state` is `alive`, `dead` or `unknown`, plus its command line when
|
|
106
|
+
* it can be read. Everything else (pidfiles, markers, stop) is built on this.
|
|
107
|
+
* @param {number} pid - process id.
|
|
108
|
+
* @param {{platform?: NodeJS.Platform, exec?: Function, signal?: Function,
|
|
109
|
+
* commandLine?: Function}} [options] - injectable for tests.
|
|
110
|
+
* @returns {{state: 'alive'|'dead'|'unknown', commandLine: string}}
|
|
111
|
+
*/
|
|
112
|
+
export function probePid(pid, { platform = DEFAULT_PLATFORM, exec = runCommand, signal = signalState, commandLine = null } = {}) {
|
|
113
|
+
const value = Number(pid)
|
|
114
|
+
if (!Number.isInteger(value) || value <= 0) return { state: 'dead', commandLine: '' }
|
|
115
|
+
if (platform === 'win32') {
|
|
116
|
+
// Cached only for the real probe: tests inject `exec` and must see every call.
|
|
117
|
+
const cacheable = exec === runCommand
|
|
118
|
+
const cacheKey = `win32:${value}`
|
|
119
|
+
if (cacheable) {
|
|
120
|
+
const hit = windowsProbeCache.get(cacheKey)
|
|
121
|
+
if (hit !== undefined && Date.now() - hit.at <= WINDOWS_PROBE_CACHE_MS) {
|
|
122
|
+
return { state: hit.state, commandLine: hit.commandLine }
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const probe = parseWindowsProbe(exec('powershell.exe', windowsProbeArgs(value), 8000))
|
|
126
|
+
if (cacheable) {
|
|
127
|
+
if (windowsProbeCache.size >= WINDOWS_PROBE_CACHE_MAX) windowsProbeCache.clear()
|
|
128
|
+
windowsProbeCache.set(cacheKey, { at: Date.now(), state: probe.state, commandLine: probe.commandLine })
|
|
129
|
+
}
|
|
130
|
+
return probe
|
|
131
|
+
}
|
|
132
|
+
const state = signal(value)
|
|
133
|
+
if (state === 'dead') return { state: 'dead', commandLine: '' }
|
|
134
|
+
if (state !== 'alive') return { state: 'unknown', commandLine: '' }
|
|
135
|
+
let line = ''
|
|
136
|
+
if (typeof commandLine === 'function') {
|
|
137
|
+
line = String(commandLine(value) ?? '').trim()
|
|
138
|
+
} else {
|
|
139
|
+
try { line = readFileSync(`/proc/${value}/cmdline`, 'utf8').replace(/\0/g, ' ').trim() } catch { line = '' }
|
|
140
|
+
}
|
|
141
|
+
return { state: 'alive', commandLine: line }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The full command line of a live process, or '' when it cannot be read.
|
|
146
|
+
* Windows: one CIM query per call (~0.5 s); Linux/WSL: /proc.
|
|
147
|
+
* @param {number} pid - process id.
|
|
148
|
+
* @returns {string} NUL/space-joined command line.
|
|
149
|
+
*/
|
|
150
|
+
export function processCommandLine(pid, options = {}) {
|
|
151
|
+
return probePid(pid, options).commandLine
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Is the pid alive? `false` also covers "could not tell" — use probePid for a
|
|
155
|
+
* three-valued answer. */
|
|
156
|
+
export function pidAlive(pid, options = {}) {
|
|
157
|
+
return probePid(pid, options).state === 'alive'
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Which of OUR processes a pid belongs to, without ever confusing "unreadable" with
|
|
162
|
+
* "not ours":
|
|
163
|
+
* - `ours` - alive and its command line contains one of the markers
|
|
164
|
+
* - `foreign` - alive, command line readable, marker absent (pid was reused or the
|
|
165
|
+
* pidfile is stale; safe to unlink the pidfile)
|
|
166
|
+
* - `dead` - definitely gone
|
|
167
|
+
* - `unknown` - could not probe / could not read the command line: keep the pidfile
|
|
168
|
+
* @returns {{state: 'ours'|'foreign'|'dead'|'unknown', commandLine: string}}
|
|
169
|
+
*/
|
|
170
|
+
export function pidIdentity(pid, markers, options = {}) {
|
|
171
|
+
const list = (Array.isArray(markers) ? markers : [markers]).filter((marker) => typeof marker === 'string' && marker !== '')
|
|
172
|
+
const probe = probePid(pid, options)
|
|
173
|
+
if (probe.state === 'dead') return { state: 'dead', commandLine: '' }
|
|
174
|
+
if (probe.state === 'unknown') return { state: 'unknown', commandLine: probe.commandLine }
|
|
175
|
+
if (probe.commandLine === '') return { state: 'unknown', commandLine: '' }
|
|
176
|
+
return {
|
|
177
|
+
state: list.some((marker) => probe.commandLine.includes(marker)) ? 'ours' : 'foreign',
|
|
178
|
+
commandLine: probe.commandLine,
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Default POSIX kill used by `stopPid`; injectable so the signal path is testable. */
|
|
183
|
+
function defaultKill(pid, sig) {
|
|
184
|
+
try {
|
|
185
|
+
process.kill(pid, sig)
|
|
186
|
+
return { ok: true }
|
|
187
|
+
} catch (error) {
|
|
188
|
+
// Already gone is success for a stop request.
|
|
189
|
+
if (error?.code === 'ESRCH') return { ok: true }
|
|
190
|
+
return { ok: false, error: String(error?.message ?? error) }
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Stop a process, preferring a clean signal. Windows has no SIGTERM: use
|
|
196
|
+
* `taskkill /T /F` so the whole tree (java + helpers) goes away and the pidfile
|
|
197
|
+
* cannot be left pointing at an orphan.
|
|
198
|
+
*
|
|
199
|
+
* The result is REPORTED, not assumed: callers must only unlink the pidfile (and
|
|
200
|
+
* tell the user "stopped") when `ok === true` (review D-P1-7).
|
|
201
|
+
* @param {number} pid - process id.
|
|
202
|
+
* @param {{force?: boolean, graceMs?: number, platform?: NodeJS.Platform,
|
|
203
|
+
* exec?: Function, signal?: Function, probe?: Function,
|
|
204
|
+
* kill?: (pid: number, sig: string) => {ok: boolean, error?: string},
|
|
205
|
+
* group?: boolean}} [options]
|
|
206
|
+
* @returns {Promise<{ok: boolean, code: number|null, error?: string}>}
|
|
207
|
+
*/
|
|
208
|
+
export async function stopPid(pid, {
|
|
209
|
+
force = false,
|
|
210
|
+
graceMs = 30000,
|
|
211
|
+
platform = DEFAULT_PLATFORM,
|
|
212
|
+
exec = runCommand,
|
|
213
|
+
signal = signalState,
|
|
214
|
+
probe = probePid,
|
|
215
|
+
kill = defaultKill,
|
|
216
|
+
group = false,
|
|
217
|
+
} = {}) {
|
|
218
|
+
const value = Number(pid)
|
|
219
|
+
if (!Number.isInteger(value) || value <= 0) return { ok: false, code: null, error: 'invalid pid' }
|
|
220
|
+
if (platform === 'win32') {
|
|
221
|
+
// A stop invalidates every cached answer: the pre-kill state must not leak into
|
|
222
|
+
// the post-kill verification below.
|
|
223
|
+
clearProbeCache(value)
|
|
224
|
+
const result = exec('taskkill', ['/PID', String(value), '/T', '/F'], 30000)
|
|
225
|
+
clearProbeCache(value)
|
|
226
|
+
if (result?.code === 0) return { ok: true, code: 0 }
|
|
227
|
+
// taskkill also fails when the process exited between probe and kill: that is
|
|
228
|
+
// success for our purposes, so verify instead of trusting the message.
|
|
229
|
+
const after = probe(value, { platform, exec, signal })
|
|
230
|
+
if (after.state === 'dead') return { ok: true, code: result?.code ?? null }
|
|
231
|
+
return {
|
|
232
|
+
ok: false,
|
|
233
|
+
code: result?.code ?? null,
|
|
234
|
+
error: String(result?.stderr ?? '').trim() || `taskkill exited with ${String(result?.code ?? 'no status')}`,
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
// A detached child (YMS) is its own group leader: signalling -pid also stops the
|
|
238
|
+
// helpers it spawned. Windows does the same via `taskkill /T`.
|
|
239
|
+
const target = group ? -value : value
|
|
240
|
+
if (force) {
|
|
241
|
+
const killed = kill(target, 'SIGKILL')
|
|
242
|
+
if (killed.ok !== true) return { ok: false, code: null, error: killed.error }
|
|
243
|
+
// SIGKILL is not instantly observable: verify instead of claiming success.
|
|
244
|
+
for (let i = 0; i < 10; i += 1) {
|
|
245
|
+
// eslint-disable-next-line no-await-in-loop
|
|
246
|
+
if (probe(value, { platform, exec, signal }).state === 'dead') return { ok: true, code: null }
|
|
247
|
+
// eslint-disable-next-line no-await-in-loop
|
|
248
|
+
await new Promise((resolve) => setTimeout(resolve, 150))
|
|
249
|
+
}
|
|
250
|
+
return { ok: false, code: null, error: 'SIGKILL 后进程仍存活' }
|
|
251
|
+
}
|
|
252
|
+
const term = kill(target, 'SIGTERM')
|
|
253
|
+
if (term.ok !== true) return { ok: false, code: null, error: term.error }
|
|
254
|
+
const deadline = Date.now() + Math.max(0, Number(graceMs) || 0)
|
|
255
|
+
while (Date.now() < deadline) {
|
|
256
|
+
// eslint-disable-next-line no-await-in-loop
|
|
257
|
+
if (probe(value, { platform, exec, signal }).state === 'dead') return { ok: true, code: null }
|
|
258
|
+
// eslint-disable-next-line no-await-in-loop
|
|
259
|
+
await new Promise((resolve) => setTimeout(resolve, 300))
|
|
260
|
+
}
|
|
261
|
+
const killed = kill(target, 'SIGKILL')
|
|
262
|
+
if (killed.ok !== true) return { ok: false, code: null, error: killed.error }
|
|
263
|
+
for (let i = 0; i < 10; i += 1) {
|
|
264
|
+
// eslint-disable-next-line no-await-in-loop
|
|
265
|
+
if (probe(value, { platform, exec, signal }).state === 'dead') return { ok: true, code: null }
|
|
266
|
+
// eslint-disable-next-line no-await-in-loop
|
|
267
|
+
await new Promise((resolve) => setTimeout(resolve, 150))
|
|
268
|
+
}
|
|
269
|
+
return { ok: false, code: null, error: 'SIGKILL 后进程仍存活' }
|
|
270
|
+
}
|
package/lib/risd-peer.js
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
// Talk to the dsh-ris-ide daemon (`risd`) over its HTTP API.
|
|
2
|
+
//
|
|
3
|
+
// This is the preferred tier: when risd owns the engine, its rule table and the
|
|
4
|
+
// system-level intercept, yds must not start or stop anything — it only (a) takes a
|
|
5
|
+
// lease so the daemon does not tear the tunnel down while a launch is in flight,
|
|
6
|
+
// (b) writes its `source: 'yds'` rules through the daemon's CAS, and (c) releases
|
|
7
|
+
// the lease on `yds stop` (the engine stays up: it is machine-wide and an IDE may
|
|
8
|
+
// still be using it).
|
|
9
|
+
//
|
|
10
|
+
// The daemon's state file (`risd.json`) carries a short-lived token; `GET /v1/health`
|
|
11
|
+
// is unauthenticated and cheap, so discovery is always "read state + probe health".
|
|
12
|
+
import { mergeYdsRules, withdrawnBetween, ydsRowsOf } from './rules.js'
|
|
13
|
+
import {
|
|
14
|
+
closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync,
|
|
15
|
+
} from 'node:fs'
|
|
16
|
+
import { homedir } from 'node:os'
|
|
17
|
+
import { dirname, join } from 'node:path'
|
|
18
|
+
|
|
19
|
+
/** API version this peer understands. Mirrors the daemon's `RISD_API_VERSION`. */
|
|
20
|
+
export const RISD_API_VERSION = 1
|
|
21
|
+
/** Header carrying the API version; a mismatch is refused (409) rather than guessed. */
|
|
22
|
+
const API_VERSION_HEADER = 'x-risd-api-version'
|
|
23
|
+
const REQUEST_TIMEOUT_MS = 2000
|
|
24
|
+
const PROBE_TIMEOUT_MS = 700
|
|
25
|
+
|
|
26
|
+
/** `YDS_RISD=none` is the documented escape hatch (tests, broken install). */
|
|
27
|
+
export function risdDisabled() {
|
|
28
|
+
return process.env.YDS_RISD === 'none'
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Where the shared plugin home lives (the daemon writes `risd.json` there). */
|
|
32
|
+
export function risdHome() {
|
|
33
|
+
const explicit = process.env.DSH_RIS_IDE_PROXY_HOME || process.env.YDS_RISD_HOME
|
|
34
|
+
return typeof explicit === 'string' && explicit !== '' ? explicit : join(homedir(), '.dsh-ris-ide')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function risdStatePath(home = risdHome()) {
|
|
38
|
+
return join(home, 'risd.json')
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Read the daemon state file; unreadable/partial files mean "not running". */
|
|
42
|
+
export function readRisdState(home = risdHome()) {
|
|
43
|
+
const path = risdStatePath(home)
|
|
44
|
+
if (existsSync(path) !== true) return null
|
|
45
|
+
try {
|
|
46
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'))
|
|
47
|
+
if (parsed === null || typeof parsed !== 'object') return null
|
|
48
|
+
if (!Number.isInteger(parsed.port) || parsed.port <= 0) return null
|
|
49
|
+
return parsed
|
|
50
|
+
} catch {
|
|
51
|
+
return null
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** One daemon request. Never throws; a transport failure is `{ ok:false, error }`. */
|
|
56
|
+
export async function risdRequest(state, path, { method = 'GET', body, timeoutMs = REQUEST_TIMEOUT_MS } = {}) {
|
|
57
|
+
const headers = { [API_VERSION_HEADER]: String(RISD_API_VERSION) }
|
|
58
|
+
if (typeof state?.token === 'string' && state.token !== '') headers.authorization = `Bearer ${state.token}`
|
|
59
|
+
if (body !== undefined) headers['content-type'] = 'application/json'
|
|
60
|
+
let response
|
|
61
|
+
try {
|
|
62
|
+
response = await fetch(`http://127.0.0.1:${state.port}${path}`, {
|
|
63
|
+
method,
|
|
64
|
+
headers,
|
|
65
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
66
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
67
|
+
})
|
|
68
|
+
} catch (error) {
|
|
69
|
+
return { ok: false, status: 0, body: null, error: String(error?.message ?? error) }
|
|
70
|
+
}
|
|
71
|
+
let payload = null
|
|
72
|
+
try { payload = await response.json() } catch { payload = null }
|
|
73
|
+
return { ok: response.ok === true && (payload === null || payload.ok !== false), status: response.status, body: payload, error: payload !== null && typeof payload.message === 'string' ? payload.message : '' }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Is a usable daemon running?
|
|
78
|
+
* Unauthenticated health is the liveness signal; a mismatched API version is
|
|
79
|
+
* reported explicitly so an old daemon is not silently half-used.
|
|
80
|
+
*/
|
|
81
|
+
export async function probeRisd({ home = risdHome() } = {}) {
|
|
82
|
+
if (risdDisabled()) return { usable: false, reason: 'disabled', state: null }
|
|
83
|
+
const state = readRisdState(home)
|
|
84
|
+
if (state === null) return { usable: false, reason: 'absent', state: null }
|
|
85
|
+
const health = await risdRequest(state, '/v1/health', { timeoutMs: PROBE_TIMEOUT_MS })
|
|
86
|
+
if (health.ok !== true) return { usable: false, reason: 'unreachable', state, error: health.error }
|
|
87
|
+
const apiVersion = Number(health.body?.apiVersion)
|
|
88
|
+
if (apiVersion !== RISD_API_VERSION) {
|
|
89
|
+
return { usable: false, reason: 'api-mismatch', state, daemonVersion: apiVersion, version: health.body?.version }
|
|
90
|
+
}
|
|
91
|
+
return { usable: true, reason: '', state, health: health.body }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Take a lease so the daemon keeps the engine while this launch is in flight. */
|
|
95
|
+
export async function acquireLease({ cwd, state = null, ownerPid = process.pid } = {}) {
|
|
96
|
+
const resolved = state ?? (await probeRisd()).state
|
|
97
|
+
if (resolved === null) return { ok: false, error: 'risd 未运行' }
|
|
98
|
+
const result = await risdRequest(resolved, '/v1/leases', {
|
|
99
|
+
method: 'POST',
|
|
100
|
+
body: { ownerPid, sessionId: 'yds', cwd: typeof cwd === 'string' ? cwd : undefined },
|
|
101
|
+
})
|
|
102
|
+
const id = typeof result.body?.lease?.id === 'string' ? result.body.lease.id : ''
|
|
103
|
+
return { ok: result.ok === true && id !== '', id, state: resolved, status: result.status, error: result.error || (id === '' ? 'risd 未返回租约 id' : '') }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Release one lease (null-safe: nothing to do when none was taken). */
|
|
107
|
+
export async function releaseLease(id, { state = null } = {}) {
|
|
108
|
+
if (typeof id !== 'string' || id === '') return { ok: true, released: false }
|
|
109
|
+
const resolved = state ?? (await probeRisd()).state
|
|
110
|
+
if (resolved === null) return { ok: true, released: false }
|
|
111
|
+
const result = await risdRequest(resolved, `/v1/leases/${encodeURIComponent(id)}`, { method: 'DELETE' })
|
|
112
|
+
return { ok: result.ok === true, released: result.ok === true, status: result.status, error: result.error }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// A lease must outlive the `yds` process that took it (the YMS runtime keeps using
|
|
116
|
+
// the tunnel after the CLI exits), so it is recorded on disk keyed by workspace and
|
|
117
|
+
// owned by the long-lived pid (the YMS process, or the foreground CLI).
|
|
118
|
+
|
|
119
|
+
function leasesPath(home = risdHome()) {
|
|
120
|
+
return join(home, '..', '.dsh-ris-yds', 'risd-leases.json')
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function readLeases(home = risdHome()) {
|
|
124
|
+
const path = leasesPath(home)
|
|
125
|
+
if (existsSync(path) !== true) return {}
|
|
126
|
+
try {
|
|
127
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'))
|
|
128
|
+
return parsed !== null && typeof parsed === 'object' ? parsed : {}
|
|
129
|
+
} catch {
|
|
130
|
+
return {}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const LEASE_LOCK_TIMEOUT_MS = 2000
|
|
135
|
+
const LEASE_LOCK_STALE_MS = 5000
|
|
136
|
+
const LEASE_LOCK_RETRY_MS = 20
|
|
137
|
+
|
|
138
|
+
function sleepSync(ms) {
|
|
139
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Run `fn` while holding a lock beside the lease file.
|
|
144
|
+
*
|
|
145
|
+
* The lease record is a tiny read-modify-write file, and two `yds start/stop` runs in
|
|
146
|
+
* different workspaces could otherwise drop each other's entry (review D-P2-6). The
|
|
147
|
+
* lock is `O_EXCL`, so a crashed writer cannot deadlock us: a lock older than
|
|
148
|
+
* LEASE_LOCK_STALE_MS is treated as abandoned. On timeout we still run `fn` unlocked —
|
|
149
|
+
* losing the lease record is worse than an unlocked write.
|
|
150
|
+
*/
|
|
151
|
+
function withLeaseLock(path, fn) {
|
|
152
|
+
const lockPath = `${path}.lock`
|
|
153
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
154
|
+
const deadline = Date.now() + LEASE_LOCK_TIMEOUT_MS
|
|
155
|
+
let held = false
|
|
156
|
+
while (held !== true) {
|
|
157
|
+
try {
|
|
158
|
+
const fd = openSync(lockPath, 'wx')
|
|
159
|
+
try {
|
|
160
|
+
writeFileSync(fd, String(process.pid), 'utf8')
|
|
161
|
+
} finally {
|
|
162
|
+
closeSync(fd)
|
|
163
|
+
}
|
|
164
|
+
held = true
|
|
165
|
+
} catch (error) {
|
|
166
|
+
if (error?.code !== 'EEXIST') throw error
|
|
167
|
+
try {
|
|
168
|
+
if (Date.now() - statSync(lockPath).mtimeMs > LEASE_LOCK_STALE_MS) {
|
|
169
|
+
unlinkSync(lockPath)
|
|
170
|
+
continue
|
|
171
|
+
}
|
|
172
|
+
} catch {
|
|
173
|
+
continue // raced with the owner releasing it: retry immediately
|
|
174
|
+
}
|
|
175
|
+
if (Date.now() > deadline) return fn()
|
|
176
|
+
sleepSync(LEASE_LOCK_RETRY_MS)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
try {
|
|
180
|
+
return fn()
|
|
181
|
+
} finally {
|
|
182
|
+
try { unlinkSync(lockPath) } catch { /* ignore */ }
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Write the lease map atomically so a crash never leaves half a JSON object. */
|
|
187
|
+
function writeLeases(path, all) {
|
|
188
|
+
const tmp = `${path}.${process.pid}.tmp`
|
|
189
|
+
writeFileSync(tmp, `${JSON.stringify(all, null, 2)}\n`, { mode: 0o600 })
|
|
190
|
+
renameSync(tmp, path)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Remember a lease id so a later `yds stop` (another process) can release it. */
|
|
194
|
+
export function rememberLease(cwd, entry, { home = risdHome() } = {}) {
|
|
195
|
+
const path = leasesPath(home)
|
|
196
|
+
try {
|
|
197
|
+
return withLeaseLock(path, () => {
|
|
198
|
+
const all = readLeases(home)
|
|
199
|
+
all[cwd] = Object.assign({}, entry, { at: new Date().toISOString() })
|
|
200
|
+
writeLeases(path, all)
|
|
201
|
+
return true
|
|
202
|
+
})
|
|
203
|
+
} catch {
|
|
204
|
+
return false
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** The lease recorded for this workspace, or null. */
|
|
209
|
+
export function readLease(cwd, { home = risdHome() } = {}) {
|
|
210
|
+
const entry = readLeases(home)[cwd]
|
|
211
|
+
return entry !== null && typeof entry === 'object' ? entry : null
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Drop the record (after releasing, or when the daemon says it is gone). */
|
|
215
|
+
export function forgetLease(cwd, { home = risdHome() } = {}) {
|
|
216
|
+
const path = leasesPath(home)
|
|
217
|
+
try {
|
|
218
|
+
withLeaseLock(path, () => {
|
|
219
|
+
const all = readLeases(home)
|
|
220
|
+
if (all[cwd] === undefined) return
|
|
221
|
+
delete all[cwd]
|
|
222
|
+
writeLeases(path, all)
|
|
223
|
+
})
|
|
224
|
+
} catch { /* best effort */ }
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function pidIsAlive(pid) {
|
|
228
|
+
if (Number.isInteger(pid) !== true || pid <= 0) return false
|
|
229
|
+
try {
|
|
230
|
+
process.kill(pid, 0)
|
|
231
|
+
return true
|
|
232
|
+
} catch (error) {
|
|
233
|
+
return error?.code === 'EPERM'
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Drop a recorded lease whose owner process is gone (YMS killed with -9).
|
|
239
|
+
*
|
|
240
|
+
* The daemon only reaps by pid, so a dead owner keeps the engine alive until the
|
|
241
|
+
* 3 s sweep — clearing our own record first means `yds stop` will not try to release
|
|
242
|
+
* a lease the daemon already forgot (review D-P2-6).
|
|
243
|
+
*/
|
|
244
|
+
export function dropDeadLease(cwd, { home = risdHome(), isAlive = pidIsAlive } = {}) {
|
|
245
|
+
const entry = readLease(cwd, { home })
|
|
246
|
+
if (entry === null) return false
|
|
247
|
+
if (isAlive(Number(entry.ownerPid)) === true) return false
|
|
248
|
+
forgetLease(cwd, { home })
|
|
249
|
+
return true
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** The daemon's own view of the rule table (carries the CAS revision). */
|
|
253
|
+
export async function getView(cwd, { state = null } = {}) {
|
|
254
|
+
const resolved = state ?? (await probeRisd()).state
|
|
255
|
+
if (resolved === null) return { ok: false, error: 'risd 未运行' }
|
|
256
|
+
const result = await risdRequest(resolved, `/v1/proxy?cwd=${encodeURIComponent(cwd)}`)
|
|
257
|
+
return { ok: result.ok === true, view: result.body, status: result.status, error: result.error }
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Replace the `source: 'yds'` rows of the global rule table through the daemon.
|
|
262
|
+
*
|
|
263
|
+
* The table is one machine-wide list and the panel saves it wholesale, so the
|
|
264
|
+
* payload below is the COMPLETE table: every non-yds row read from proxy.json is
|
|
265
|
+
* passed through unchanged and the yds rows are exactly `rules` (an empty array
|
|
266
|
+
* therefore removes them all). The file is read directly for the other sources'
|
|
267
|
+
* rows: the daemon's view omits them on purpose (the plugin decorates the table per
|
|
268
|
+
* session), and a blind write would drop user/auto rules. A revision conflict is
|
|
269
|
+
* retried once with the fresh revision — another dsh window may have saved in between.
|
|
270
|
+
*/
|
|
271
|
+
export async function putRules(cwd, rules, { gitHost = '', home = risdHome(), attempts = 2, log } = {}) {
|
|
272
|
+
const state = readRisdState(home)
|
|
273
|
+
if (state === null) return { ok: false, error: 'risd 未运行' }
|
|
274
|
+
let lastError = ''
|
|
275
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
276
|
+
const view = await getView(cwd, { state })
|
|
277
|
+
if (view.ok !== true) return { ok: false, status: view.status, error: view.error || '读取 risd 视图失败' }
|
|
278
|
+
// An empty revision is the daemon's deliberate "force" path. yds must never take
|
|
279
|
+
// it silently: if the view carries no revision we cannot tell whether somebody
|
|
280
|
+
// else wrote in between, so fail instead of clobbering their change (D-P2-8).
|
|
281
|
+
const baseRevision = String(view.view?.revision ?? '').trim()
|
|
282
|
+
if (baseRevision === '') {
|
|
283
|
+
return {
|
|
284
|
+
ok: false,
|
|
285
|
+
status: 0,
|
|
286
|
+
error: 'revision-missing:risd 未返回规则表 revision(旧版/异常 daemon),已拒绝无 CAS 的强写,请升级 dsh-ris-ide 后重试',
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
const stored = readStoredConfig(home)
|
|
290
|
+
// Whole-table save: non-yds rows are preserved, the yds rows become exactly the
|
|
291
|
+
// set this run generated (global table, no cross-workspace bookkeeping).
|
|
292
|
+
const before = ydsRowsOf(stored.rules, { log })
|
|
293
|
+
const table = mergeYdsRules(stored.rules, rules, { log })
|
|
294
|
+
// Only the fields the daemon actually consumes are sent. `shadows` used to be
|
|
295
|
+
// included, but POST /v1/proxy reads socks/listenPort/rules only, so a value here
|
|
296
|
+
// could never take effect and looked like a successful mapping update
|
|
297
|
+
// (review D-P2-8). The daemon keeps the stored shadow list itself.
|
|
298
|
+
const merged = {
|
|
299
|
+
socks: stored.socks ?? { host: '', port: 0 },
|
|
300
|
+
listenPort: stored.listenPort,
|
|
301
|
+
rules: table,
|
|
302
|
+
}
|
|
303
|
+
const result = await risdRequest(state, '/v1/proxy', {
|
|
304
|
+
method: 'POST',
|
|
305
|
+
body: {
|
|
306
|
+
cwd,
|
|
307
|
+
config: merged,
|
|
308
|
+
revision: baseRevision,
|
|
309
|
+
gitHost,
|
|
310
|
+
},
|
|
311
|
+
timeoutMs: 8000,
|
|
312
|
+
})
|
|
313
|
+
if (result.ok === true) {
|
|
314
|
+
return {
|
|
315
|
+
ok: true,
|
|
316
|
+
revision: result.body?.revision,
|
|
317
|
+
reloadNote: result.body?.reloadNote ?? '',
|
|
318
|
+
withdrawn: withdrawnBetween(before, table),
|
|
319
|
+
status: result.status,
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
lastError = result.error || `risd 保存失败(${result.status})`
|
|
323
|
+
if (result.status !== 409) return { ok: false, status: result.status, error: lastError }
|
|
324
|
+
}
|
|
325
|
+
return { ok: false, status: 409, error: lastError }
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function readStoredConfig(home) {
|
|
329
|
+
const path = join(home, 'proxy.json')
|
|
330
|
+
if (existsSync(path) !== true) return {}
|
|
331
|
+
try {
|
|
332
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'))
|
|
333
|
+
return parsed !== null && typeof parsed === 'object' ? parsed : {}
|
|
334
|
+
} catch {
|
|
335
|
+
return {}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Ensure the daemon owns a running engine for this workspace. */
|
|
340
|
+
export async function startEngine(cwd, { state = null } = {}) {
|
|
341
|
+
const resolved = state ?? (await probeRisd()).state
|
|
342
|
+
if (resolved === null) return { ok: false, error: 'risd 未运行' }
|
|
343
|
+
const result = await risdRequest(resolved, '/v1/action', { method: 'POST', body: { action: 'start', cwd }, timeoutMs: 30000 })
|
|
344
|
+
return { ok: result.ok === true, status: result.status, result: result.body?.result, error: result.error }
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Read the daemon engine's status (pid, listen ports, log path). */
|
|
348
|
+
export async function engineStatus(cwd, { state = null } = {}) {
|
|
349
|
+
const resolved = state ?? (await probeRisd()).state
|
|
350
|
+
if (resolved === null) return { ok: false, error: 'risd 未运行' }
|
|
351
|
+
const response = await risdRequest(resolved, `/v1/engine?cwd=${encodeURIComponent(cwd)}`)
|
|
352
|
+
return { ok: response.ok === true, status: response.status, engine: response.body?.engine ?? null, error: response.error }
|
|
353
|
+
}
|