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/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
+ }
@@ -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
+ }