dsh-wsl-desktop 0.2.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/wsl/pty.js ADDED
@@ -0,0 +1,366 @@
1
+ /**
2
+ * Terminal transport for the WSL execution world.
3
+ *
4
+ * A `wsl.exe` pipe is not a terminal, so a real PTY is allocated inside the
5
+ * distribution by `terminal-bridge.py`. This module owns the two host-side
6
+ * processes that drive it and assembles the seam's terminal handle:
7
+ *
8
+ * - the **data** process runs the bridge; its stdin/stdout carry terminal bytes
9
+ * and its stderr carries one JSON control reply per line;
10
+ * - the **control** process holds the bridge's FIFO open so resize, foreground
11
+ * inspection, signalling and termination can be requested at any time.
12
+ *
13
+ * Byte-level transport stays with the host subprocess provider, so managed
14
+ * termination and disposal keep their owner.
15
+ * @module dsh-wsl-desktop/wsl/pty
16
+ */
17
+
18
+ import { appendFileSync } from 'node:fs'
19
+ import { randomUUID } from 'node:crypto'
20
+ import { homedir, tmpdir } from 'node:os'
21
+ import { join } from 'node:path'
22
+ import { buildWslExecArgv, runWslShell } from './world.js'
23
+
24
+ /** How long a control round trip may take before it is reported as failed. */
25
+ const DEFAULT_CONTROL_TIMEOUT_MS = 15_000
26
+
27
+ /** Prefix the bridge puts on every control reply line. */
28
+ const REPLY_PREFIX = '#dsh-pty '
29
+
30
+ /** Distributions already confirmed to carry the bridge's runtime. */
31
+ const verifiedDistros = new Set()
32
+
33
+ /**
34
+ * Append one diagnostic line to the terminal debug log (bounded). Temporary
35
+ * instrumentation for the live GUI-path terminal failure; harmless if the
36
+ * file cannot be written.
37
+ * @param {string} message - the diagnostic line.
38
+ */
39
+ export function termLog(message) {
40
+ // Debug instrumentation: opt-in via env, because an always-on log grows
41
+ // without bound on every terminal allocation.
42
+ if (process.env.WSL_DESKTOP_TERMINAL_DEBUG !== '1') return
43
+ try {
44
+ appendFileSync(join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'wsl-desktop-terminal-debug.log'), `${new Date().toISOString()} ${message}\n`)
45
+ } catch {
46
+ // Diagnostics must never break allocation.
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Confirm the distribution can run the bridge before allocating anything.
52
+ *
53
+ * The bridge is Python standard library only, so the runtime is the one real
54
+ * dependency. Checking once per distribution turns a confusing mid-spawn
55
+ * failure into a clear message.
56
+ * @param {{ distro: string }} plan - the execution plan.
57
+ * @param {object} options - probe inputs.
58
+ * @param {string} [options.username] - Linux user for the probe.
59
+ * @returns {Promise<void>} settlement.
60
+ * @throws Error naming the missing runtime.
61
+ */
62
+ async function requireBridgeRuntime(plan, options) {
63
+ if (verifiedDistros.has(plan.distro)) return
64
+ const probe = await runWslShell({
65
+ distro: plan.distro,
66
+ linuxCwd: '/',
67
+ ...options.username !== undefined && options.username !== '' ? { username: options.username } : {},
68
+ command: 'command -v python3 >/dev/null 2>&1 && echo yes || echo no',
69
+ // Non-login + strict equality: profile output can neither skew the answer
70
+ // nor get substring-matched into a false positive. The 60s ceiling matches
71
+ // the confinement identity probe: the desktop's first wsl.exe spawn after
72
+ // a restart can outlive a short ceiling on a cold VM.
73
+ loginShell: false,
74
+ timeoutMs: 60_000,
75
+ })
76
+ if (probe.stdout.trim() !== 'yes') {
77
+ termLog(`requireBridgeRuntime: distro=${plan.distro} probe=${JSON.stringify(probe.stdout.trim().slice(0, 120))} stderr=${JSON.stringify(probe.stderr.trim().slice(0, 200))} timedOut=${String(probe.timedOut)}`)
78
+ throw new Error(`wsl-pty: 发行版 ${plan.distro} 里没有 python3,无法分配终端`)
79
+ }
80
+ verifiedDistros.add(plan.distro)
81
+ termLog(`requireBridgeRuntime: distro=${plan.distro} python3 ok`)
82
+ }
83
+
84
+ /**
85
+ * Start one PTY session inside a distribution.
86
+ * @param {object} options - spawn inputs.
87
+ * @param {object} options.local - the host subprocess provider.
88
+ * @param {{ distro: string, linuxCwd: string, windowsCwd: string }} options.plan - the execution plan.
89
+ * @param {string} options.bridgeSource - the bridge program's source text.
90
+ * @param {readonly string[]} options.argv - the program to run on the PTY.
91
+ * @param {number} options.cols - initial columns.
92
+ * @param {number} options.rows - initial rows.
93
+ * @param {number} options.graceMs - kill escalation grace.
94
+ * @param {Record<string, string>} [options.env] - environment for the spawn.
95
+ * @param {AbortSignal} [options.signal] - allocation cancellation.
96
+ * @param {string} [options.wslPath] - `wsl.exe` path.
97
+ * @param {string} [options.username] - Linux user.
98
+ * @param {number} [options.controlTimeoutMs] - control round-trip bound.
99
+ * @returns {Promise<object>} the terminal handle.
100
+ */
101
+ export async function spawnWslTerminal({
102
+ local,
103
+ plan,
104
+ bridgeSource,
105
+ argv,
106
+ cols,
107
+ rows,
108
+ graceMs,
109
+ env,
110
+ signal,
111
+ wslPath,
112
+ username,
113
+ controlTimeoutMs = DEFAULT_CONTROL_TIMEOUT_MS,
114
+ }) {
115
+ const fifo = `/tmp/dsh-pty-${randomUUID().slice(0, 12)}.fifo`
116
+ const select = { ...(wslPath !== undefined ? { wslPath } : {}), ...(username !== undefined ? { username } : {}) }
117
+ termLog(`allocate: distro=${plan.distro} linuxCwd=${JSON.stringify(plan.linuxCwd)} windowsCwd=${JSON.stringify(plan.windowsCwd)} argv0=${JSON.stringify(argv[0])} cols=${String(cols)} rows=${String(rows)} fifo=${fifo}`)
118
+ await requireBridgeRuntime(plan, select)
119
+ termLog('allocate: bridge runtime ok')
120
+
121
+ const pending = new Map()
122
+ let requestSeq = 0
123
+ let replyBuffer = ''
124
+ /** Last stderr lines from the data process — diagnostics for a dead bridge. */
125
+ const stderrLines = []
126
+ /** Set once the data process (the bridge) has settled; further requests fail fast. */
127
+ let bridgeDown = false
128
+ let started
129
+ const startedPromise = new Promise((resolve, reject) => { started = { resolve, reject } })
130
+
131
+ /**
132
+ * Settle every outstanding control request with a failure. Used when the
133
+ * bridge exits, so a pending request fails with the real cause instead of
134
+ * burning its whole timeout, and after termination.
135
+ * @param {string} message - the failure text.
136
+ */
137
+ function failPending(message) {
138
+ const waiters = [...pending.values()]
139
+ pending.clear()
140
+ for (const waiter of waiters) waiter.reject(new Error(message))
141
+ }
142
+
143
+ const dataSpec = {
144
+ argv: buildWslExecArgv(plan, ['python3', '-c', bridgeSource, fifo, String(cols), String(rows), ...argv], select),
145
+ cwd: plan.windowsCwd,
146
+ stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' },
147
+ graceMs,
148
+ ...signal !== undefined ? { signal } : {},
149
+ ...env !== undefined ? { env } : {},
150
+ }
151
+ const data = local.spawn(dataSpec)
152
+ // EPIPE arrives as an ASYNC 'error' event on the stdin stream, not as a
153
+ // synchronous throw — swallow it so a dead bridge's late write cannot become
154
+ // an unhandled error event on the host process.
155
+ data.stdin?.on?.('error', () => {})
156
+
157
+ /**
158
+ * Hand one parsed reply to its request, matched by the id the request wrote.
159
+ * A reply whose request already timed out — or one the host never sent —
160
+ * finds no entry and is dropped: id pairing is what keeps a late reply from
161
+ * being consumed by the NEXT request and desyncing every later control op.
162
+ * @param {object} reply - the decoded reply.
163
+ */
164
+ function deliver(reply) {
165
+ if (reply.event === 'started') {
166
+ started.resolve(reply)
167
+ return
168
+ }
169
+ const waiter = typeof reply.id === 'string' ? pending.get(reply.id) : undefined
170
+ if (waiter === undefined) return
171
+ pending.delete(reply.id)
172
+ waiter.resolve(reply)
173
+ }
174
+
175
+ const stderr = data.stderr
176
+ if (stderr === undefined) throw new Error('wsl-pty: 数据进程没有 stderr,无法承载控制应答')
177
+ stderr.setEncoding('utf8')
178
+ stderr.on('data', (chunk) => {
179
+ replyBuffer += chunk
180
+ while (replyBuffer.includes('\n')) {
181
+ const index = replyBuffer.indexOf('\n')
182
+ const line = replyBuffer.slice(0, index)
183
+ replyBuffer = replyBuffer.slice(index + 1)
184
+ // Bounded diagnostics: keep every data-process stderr line (bridge
185
+ // tracebacks included), not only the prefixed control replies.
186
+ const trimmed = line.trim()
187
+ if (trimmed !== '') stderrLines.push(trimmed.length > 300 ? trimmed.slice(0, 300) : trimmed)
188
+ if (stderrLines.length > 40) stderrLines.shift()
189
+ if (!line.startsWith(REPLY_PREFIX)) continue
190
+ try {
191
+ deliver(JSON.parse(line.slice(REPLY_PREFIX.length)))
192
+ } catch {
193
+ // A malformed reply is dropped; the pending request times out instead.
194
+ }
195
+ }
196
+ })
197
+
198
+ data.done.then(
199
+ (outcome) => {
200
+ bridgeDown = true
201
+ termLog(`data done: exit=${String(outcome.exitCode)} timedOut=${String(outcome.timedOut)} stderrTail=${JSON.stringify(stderrLines.slice(-8).join(' | ').slice(0, 600))}`)
202
+ started.reject(new Error(`wsl-pty: 桥在报告会话之前退出(exit=${String(outcome.exitCode)})`))
203
+ failPending(`wsl-pty: 桥已退出(exit=${String(outcome.exitCode)}),控制请求不再有应答`)
204
+ },
205
+ (error) => {
206
+ bridgeDown = true
207
+ termLog(`data failed: ${String(error)}`)
208
+ started.reject(error instanceof Error ? error : new Error(String(error)))
209
+ failPending(`wsl-pty: 桥进程失败:${String(error)}`)
210
+ },
211
+ )
212
+
213
+ let control = null
214
+ try {
215
+ // The bridge creates the FIFO before announcing the session, so the control
216
+ // writer can safely be started only after that announcement.
217
+ const announce = await withTimeout(startedPromise, controlTimeoutMs, 'wsl-pty: 桥没有报告会话')
218
+ termLog(`announce ok: pid=${String(announce.pid)} pgrp=${String(announce.pgrp)} fifo=${fifo}`)
219
+
220
+ control = local.spawn({
221
+ argv: buildWslExecArgv(plan, [
222
+ 'bash', '-c',
223
+ 'exec 3>"$1" || exit 1; while IFS= read -r line; do printf \'%s\\n\' "$line" >&3; done',
224
+ 'bash', fifo,
225
+ ], select),
226
+ cwd: plan.windowsCwd,
227
+ stdio: { stdin: 'pipe', stdout: 'ignore', stderr: 'pipe' },
228
+ graceMs,
229
+ // Wire the allocation signal to the control writer too: an abort after
230
+ // the announce would otherwise leak the control process blocked on FIFO.
231
+ ...signal !== undefined ? { signal } : {},
232
+ ...env !== undefined ? { env } : {},
233
+ })
234
+ // Same async-EPIPE guard as the data process's stdin.
235
+ control.stdin?.on?.('error', () => {})
236
+ termLog('control spawned')
237
+
238
+ /**
239
+ * Send one control request and await its id-matched reply.
240
+ * @param {object} payload - the control request.
241
+ * @returns {Promise<object>} the bridge's reply.
242
+ */
243
+ function request(payload) {
244
+ // A dead bridge can never answer: fail now instead of burning the whole
245
+ // control timeout (the pre-fix symptom was a ~15s stall per op).
246
+ if (bridgeDown) return Promise.reject(new Error(`wsl-pty: 桥已退出,${String(payload.op)} 无法执行`))
247
+ return new Promise((resolve, reject) => {
248
+ const id = `ctl-${requestSeq += 1}`
249
+ const waiter = {
250
+ resolve: (reply) => { clearTimeout(timer); resolve(reply) },
251
+ reject: (error) => { clearTimeout(timer); reject(error) },
252
+ }
253
+ const timer = setTimeout(() => {
254
+ // Removed before rejecting: a late reply must find no entry, or it
255
+ // would be mispaired with a later request.
256
+ if (pending.delete(id)) waiter.reject(new Error(`wsl-pty: 控制请求超时(${String(payload.op)})`))
257
+ }, controlTimeoutMs)
258
+ pending.set(id, waiter)
259
+ try {
260
+ control.stdin?.write(`${JSON.stringify({ ...payload, id })}\n`)
261
+ } catch (error) {
262
+ // The control writer died (EPIPE): fail this request now instead of
263
+ // leaving it to the timeout.
264
+ if (pending.delete(id)) waiter.reject(error)
265
+ }
266
+ })
267
+ }
268
+
269
+ return {
270
+ pid: typeof announce.pid === 'number' ? announce.pid : 0,
271
+ output: data.stdout,
272
+ done: data.done,
273
+ write: async (chunk) => {
274
+ // A dead bridge's stdin pipe throws EPIPE on write — the caller
275
+ // should see the rejection, not an unhandled error event.
276
+ try {
277
+ data.stdin?.write(chunk)
278
+ } catch {
279
+ // The bridge is gone; the terminal is already flagged disconnected.
280
+ }
281
+ },
282
+ resize: async (nextCols, nextRows) => {
283
+ await request({ op: 'resize', cols: nextCols, rows: nextRows })
284
+ },
285
+ inspectForeground: async () => {
286
+ const reply = await request({ op: 'foreground' })
287
+ if (typeof reply.pgrp !== 'number') return undefined
288
+ // A pipe gives no input-waiting signal; the group is reported as not waiting.
289
+ return { processGroupId: reply.pgrp, inputWaiting: false }
290
+ },
291
+ inspectActivity: async () => {
292
+ if (bridgeDown) return { state: 'dead', revision: 0 }
293
+ const reply = await request({ op: 'activity' })
294
+ return {
295
+ state: reply.state === 'idle' || reply.state === 'busy' ? reply.state : 'unknown',
296
+ revision: typeof reply.revision === 'number' ? reply.revision : 0,
297
+ }
298
+ },
299
+ signalForeground: async (name) => {
300
+ const reply = await request({ op: 'signal', signal: name })
301
+ return typeof reply.pgrp === 'number' ? reply.pgrp : 0
302
+ },
303
+ terminate: async () => {
304
+ try {
305
+ await request({ op: 'terminate' })
306
+ } catch {
307
+ // A bridge that already died needs no termination request.
308
+ }
309
+ control.terminate()
310
+ data.terminate()
311
+ failPending('wsl-pty: 会话已终止')
312
+ await Promise.allSettled([data.waitForExit?.() ?? waitForHandle(data), waitForHandle(control)])
313
+ },
314
+ }
315
+ } catch (error) {
316
+ termLog(`allocate failed: ${error.message} stderrTail=${JSON.stringify(stderrLines.slice(-8).join(' | ').slice(0, 600))}`)
317
+ // A failed allocation must not leak the processes it already started: an
318
+ // abandoned bridge would sit on its PTY until wsl.exe tears the whole
319
+ // session down, and repeated failures would accumulate such processes.
320
+ try {
321
+ control?.terminate()
322
+ } catch {
323
+ // Already gone.
324
+ }
325
+ try {
326
+ data.terminate()
327
+ } catch {
328
+ // Already gone.
329
+ }
330
+ await Promise.allSettled([
331
+ waitForHandle(data),
332
+ control !== null ? waitForHandle(control) : Promise.resolve(),
333
+ ])
334
+ throw error
335
+ }
336
+ }
337
+
338
+ /**
339
+ * Bound one promise, reporting a named failure on expiry.
340
+ * @param {Promise<any>} promise - the work to bound.
341
+ * @param {number} timeoutMs - the bound.
342
+ * @param {string} message - failure text.
343
+ * @returns {Promise<any>} the result, or a rejection naming the timeout.
344
+ */
345
+ function withTimeout(promise, timeoutMs, message) {
346
+ return new Promise((resolve, reject) => {
347
+ const timer = setTimeout(() => reject(new Error(message)), timeoutMs)
348
+ promise.then(
349
+ (value) => { clearTimeout(timer); resolve(value) },
350
+ (error) => { clearTimeout(timer); reject(error) },
351
+ )
352
+ })
353
+ }
354
+
355
+ /**
356
+ * Await a subprocess handle's exit, tolerating providers without the method.
357
+ * @param {object} handle - a host subprocess handle.
358
+ * @returns {Promise<void>} settlement.
359
+ */
360
+ async function waitForHandle(handle) {
361
+ if (typeof handle.waitForExit === 'function') {
362
+ await handle.waitForExit()
363
+ return
364
+ }
365
+ await handle.done.catch(() => undefined)
366
+ }