dsh-remote-vs 0.0.0-stage → 0.8.3
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.en.md +385 -0
- package/README.md +347 -2
- package/cordis.patch.yml +259 -0
- package/icon.svg +7 -0
- package/lib/client.js +62602 -0
- package/lib/index.js +4287 -0
- package/lib/p2-bridge-refs.js +10 -0
- package/lib/p2-bridge.js +16 -0
- package/lib/p2-router-fs.js +252 -0
- package/lib/p2-router-refs.js +466 -0
- package/lib/p2-router-spill.js +62 -0
- package/lib/p2-router-subprocess.js +64 -0
- package/lib/p2-shell-bash.js +24 -0
- package/lib/ssh-proc-arI5lm4W.js +632 -0
- package/lib/types/changes/recorder.d.ts +240 -0
- package/lib/types/changes/status.d.ts +201 -0
- package/lib/types/changes/text-diff.d.ts +65 -0
- package/lib/types/client/CodeEditor.d.ts +125 -0
- package/lib/types/client/Dialog.d.ts +14 -0
- package/lib/types/client/DirectoryFlow.d.ts +30 -0
- package/lib/types/client/EditorPane.d.ts +30 -0
- package/lib/types/client/HostsView.d.ts +10 -0
- package/lib/types/client/NativeExplorer.d.ts +39 -0
- package/lib/types/client/RemoteDirectoryPicker.d.ts +16 -0
- package/lib/types/client/RemoteEditorView.d.ts +20 -0
- package/lib/types/client/RemoteExplorer.d.ts +18 -0
- package/lib/types/client/RemoteRoot.d.ts +16 -0
- package/lib/types/client/SearchView.d.ts +14 -0
- package/lib/types/client/SettingsHosts.d.ts +15 -0
- package/lib/types/client/TerminalView.d.ts +16 -0
- package/lib/types/client/WorkspaceWizard.d.ts +16 -0
- package/lib/types/client/WorkspacesView.d.ts +14 -0
- package/lib/types/client/XTerm.d.ts +15 -0
- package/lib/types/client/api.d.ts +162 -0
- package/lib/types/client/changes/ChangesDock.d.ts +35 -0
- package/lib/types/client/changes/model.d.ts +36 -0
- package/lib/types/client/changes/undo-affordance.d.ts +48 -0
- package/lib/types/client/changes/undo-context.d.ts +25 -0
- package/lib/types/client/composer.d.ts +165 -0
- package/lib/types/client/i18n.d.ts +17 -0
- package/lib/types/client/index.d.ts +14 -0
- package/lib/types/client/remote-file-open-target.d.ts +247 -0
- package/lib/types/client/remote-file-open.d.ts +35 -0
- package/lib/types/client/reveal.d.ts +15 -0
- package/lib/types/client/scm/DiffView.d.ts +24 -0
- package/lib/types/client/scm/HistoryPanel.d.ts +12 -0
- package/lib/types/client/scm/RepoPicker.d.ts +24 -0
- package/lib/types/client/scm/ScmPanel.d.ts +15 -0
- package/lib/types/client/scm/actions.d.ts +170 -0
- package/lib/types/client/scm/diff-model.d.ts +29 -0
- package/lib/types/client/scm/stage-queue.d.ts +99 -0
- package/lib/types/client/scm/status-map.d.ts +157 -0
- package/lib/types/client/scm/untracked-dirs.d.ts +72 -0
- package/lib/types/client/selection-popup.d.ts +61 -0
- package/lib/types/client/store.d.ts +437 -0
- package/lib/types/client/theme.d.ts +15 -0
- package/lib/types/client/ui.d.ts +46 -0
- package/lib/types/client/upload-plan.d.ts +78 -0
- package/lib/types/client/upload-source.d.ts +46 -0
- package/lib/types/client/use-remote-chat-target.d.ts +17 -0
- package/lib/types/client/vsc/FindWidget.d.ts +81 -0
- package/lib/types/client/vsc/change-marks.d.ts +150 -0
- package/lib/types/client/vsc/editor-langs.d.ts +5 -0
- package/lib/types/client/vsc/editor-palette.d.ts +37 -0
- package/lib/types/client/vsc/editor-theme.d.ts +32 -0
- package/lib/types/client/vsc/fallback-highlight.d.ts +26 -0
- package/lib/types/client/vsc/kit.d.ts +195 -0
- package/lib/types/client/vsc/markdown.d.ts +11 -0
- package/lib/types/client/vsc/reveal-line.d.ts +38 -0
- package/lib/types/client/vsc/tokenize.d.ts +104 -0
- package/lib/types/deploy.d.ts +94 -0
- package/lib/types/fs-ops.d.ts +8 -0
- package/lib/types/git-format.d.ts +34 -0
- package/lib/types/git-ops.d.ts +183 -0
- package/lib/types/http.d.ts +14 -0
- package/lib/types/importers.d.ts +20 -0
- package/lib/types/index.d.ts +87 -0
- package/lib/types/keygen.d.ts +19 -0
- package/lib/types/loopback.d.ts +7 -0
- package/lib/types/p2/bridge-refs.d.ts +21 -0
- package/lib/types/p2/bridge.d.ts +24 -0
- package/lib/types/p2/file-refs.d.ts +73 -0
- package/lib/types/p2/helper-rpc.d.ts +63 -0
- package/lib/types/p2/in-flight-gate.d.ts +25 -0
- package/lib/types/p2/router-fs.d.ts +92 -0
- package/lib/types/p2/router-refs.d.ts +45 -0
- package/lib/types/p2/router-spill.d.ts +11 -0
- package/lib/types/p2/router-subprocess.d.ts +23 -0
- package/lib/types/p2/shell-bash.d.ts +16 -0
- package/lib/types/p2/ssh-proc.d.ts +22 -0
- package/lib/types/p2/worlds.d.ts +179 -0
- package/lib/types/paths.d.ts +12 -0
- package/lib/types/pool.d.ts +105 -0
- package/lib/types/protocol.d.ts +452 -0
- package/lib/types/routes.d.ts +27 -0
- package/lib/types/search-ops.d.ts +68 -0
- package/lib/types/sftp.d.ts +90 -0
- package/lib/types/store.d.ts +66 -0
- package/lib/types/terminal.d.ts +51 -0
- package/lib/types/timeouts.d.ts +83 -0
- package/lib/types/tools.d.ts +41 -0
- package/lib/worlds-PUumRIh7.js +18765 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +189 -4
- package/src/changes/recorder.ts +902 -0
- package/src/changes/status.ts +448 -0
- package/src/changes/text-diff.ts +377 -0
- package/src/client/CodeEditor.tsx +923 -0
- package/src/client/Dialog.tsx +76 -0
- package/src/client/DirectoryFlow.tsx +431 -0
- package/src/client/EditorPane.tsx +512 -0
- package/src/client/HostsView.tsx +312 -0
- package/src/client/NativeExplorer.tsx +121 -0
- package/src/client/RemoteDirectoryPicker.tsx +157 -0
- package/src/client/RemoteEditorView.tsx +54 -0
- package/src/client/RemoteExplorer.tsx +1398 -0
- package/src/client/RemoteRoot.tsx +389 -0
- package/src/client/SearchView.tsx +407 -0
- package/src/client/SettingsHosts.tsx +136 -0
- package/src/client/TerminalView.tsx +308 -0
- package/src/client/WorkspaceWizard.tsx +199 -0
- package/src/client/WorkspacesView.tsx +93 -0
- package/src/client/XTerm.tsx +308 -0
- package/src/client/api.ts +331 -0
- package/src/client/changes/ChangesDock.tsx +348 -0
- package/src/client/changes/changes.css +230 -0
- package/src/client/changes/model.ts +347 -0
- package/src/client/changes/undo-affordance.ts +389 -0
- package/src/client/changes/undo-context.ts +34 -0
- package/src/client/composer.ts +436 -0
- package/src/client/css.d.ts +7 -0
- package/src/client/i18n.ts +702 -0
- package/src/client/index.tsx +286 -0
- package/src/client/remote-file-open-target.ts +553 -0
- package/src/client/remote-file-open.ts +261 -0
- package/src/client/reveal.ts +33 -0
- package/src/client/scm/DiffView.tsx +570 -0
- package/src/client/scm/HistoryPanel.tsx +353 -0
- package/src/client/scm/RepoPicker.tsx +238 -0
- package/src/client/scm/ScmPanel.tsx +857 -0
- package/src/client/scm/actions.ts +460 -0
- package/src/client/scm/diff-model.ts +206 -0
- package/src/client/scm/stage-queue.ts +264 -0
- package/src/client/scm/status-map.ts +381 -0
- package/src/client/scm/untracked-dirs.ts +139 -0
- package/src/client/selection-popup.ts +157 -0
- package/src/client/store.tsx +1670 -0
- package/src/client/theme.ts +54 -0
- package/src/client/ui.tsx +136 -0
- package/src/client/upload-plan.ts +153 -0
- package/src/client/upload-source.ts +145 -0
- package/src/client/use-remote-chat-target.ts +153 -0
- package/src/client/vsc/FindWidget.tsx +446 -0
- package/src/client/vsc/change-marks.ts +586 -0
- package/src/client/vsc/editor-langs.ts +137 -0
- package/src/client/vsc/editor-palette.ts +107 -0
- package/src/client/vsc/editor-theme.ts +93 -0
- package/src/client/vsc/fallback-highlight.ts +145 -0
- package/src/client/vsc/kit.tsx +777 -0
- package/src/client/vsc/markdown.tsx +172 -0
- package/src/client/vsc/reveal-line.ts +142 -0
- package/src/client/vsc/tokenize.ts +569 -0
- package/src/client/vsc/vsc.css +2669 -0
- package/src/deploy.ts +481 -0
- package/src/fs-ops.ts +57 -0
- package/src/git-format.ts +205 -0
- package/src/git-ops.ts +693 -0
- package/src/http.ts +55 -0
- package/src/importers.ts +141 -0
- package/src/index.ts +442 -0
- package/src/keygen.ts +77 -0
- package/src/loopback.ts +47 -0
- package/src/p2/bridge-refs.ts +27 -0
- package/src/p2/bridge.ts +32 -0
- package/src/p2/file-refs.ts +392 -0
- package/src/p2/helper-rpc.ts +394 -0
- package/src/p2/in-flight-gate.ts +63 -0
- package/src/p2/router-fs.ts +327 -0
- package/src/p2/router-refs.ts +166 -0
- package/src/p2/router-spill.ts +75 -0
- package/src/p2/router-subprocess.ts +92 -0
- package/src/p2/shell-bash.ts +30 -0
- package/src/p2/ssh-proc.ts +802 -0
- package/src/p2/worlds.ts +735 -0
- package/src/paths.ts +50 -0
- package/src/pool.ts +811 -0
- package/src/protocol.ts +460 -0
- package/src/routes.ts +802 -0
- package/src/search-ops.ts +220 -0
- package/src/sftp.ts +677 -0
- package/src/store.ts +364 -0
- package/src/terminal.ts +291 -0
- package/src/timeouts.ts +122 -0
- package/src/tools.ts +320 -0
- package/vendor/dsh-remote-helpers/dsh-ssh-helper-0.2.1-alpha.2-linux-x64.helper.json +16 -0
- package/vendor/dsh-remote-helpers/dsh-ssh-helper-0.2.1-alpha.2-linux-x64.tar.gz +4 -0
- package/vendor/dsh-remote-helpers/dsh-ssh-helper-0.2.1-alpha.2-linux-x64.tar.gz.sha256 +1 -0
- package/vendor/dsh-remote-helpers/glibc-ceiling.patch +31 -0
|
@@ -0,0 +1,802 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remote subprocess runtime over ssh2 exec channels: stdout/stderr separation,
|
|
3
|
+
* collect-mode offset readers, stdin dispositions, TERM→KILL escalation and
|
|
4
|
+
* exit facts. No PTY, no control channel, no spill files in P2-v1 (honest
|
|
5
|
+
* limits, documented at each seam).
|
|
6
|
+
*
|
|
7
|
+
* Wrapper design (proven by .spike/dbg6 + dbg7 against util-linux 2.37.2):
|
|
8
|
+
*
|
|
9
|
+
* - `setsid --wait sh -c 'echo RV_PID=$$; exec "$@"' _ <argv...>` puts the
|
|
10
|
+
* target in its own session/process group AND propagates its exit code.
|
|
11
|
+
* Plain `setsid` (no --wait) double-forks: the outer shell exits at once
|
|
12
|
+
* (target reparented to PPID=1) and the channel reports the outer shell's
|
|
13
|
+
* exit 0 instead of the target's code — exit facts would be fiction.
|
|
14
|
+
* - Group kill (`kill -TERM -- -<pgid>`) is the ONLY reliable termination:
|
|
15
|
+
* `channel.signal()` addresses the outer shell, which is useless once the
|
|
16
|
+
* wrapper detaches. When the pgid is not learned yet, terminate() waits for
|
|
17
|
+
* the marker (bounded) instead of signalling blindly.
|
|
18
|
+
* - Platforms without a compatible setsid fall back to a leader-only line
|
|
19
|
+
* (`cd … && env … exec <argv...>`, no new session): channel.signal hits the
|
|
20
|
+
* exec'd target directly. First failure auto-retries inside the same spawn;
|
|
21
|
+
* later spawns skip setsid (self-healing, no caller-visible error).
|
|
22
|
+
*
|
|
23
|
+
* Managed range = the exec channel lifetime plus the target's process group.
|
|
24
|
+
* Double-fork daemons that escape the group survive termination (documented,
|
|
25
|
+
* same as Yan-Zero); waitForExit observes channel close.
|
|
26
|
+
*/
|
|
27
|
+
import { PassThrough, Writable, type Readable } from 'node:stream'
|
|
28
|
+
import { closeSync, mkdirSync, openSync, readdirSync, rmSync, statSync, writeSync } from 'node:fs'
|
|
29
|
+
import { tmpdir } from 'node:os'
|
|
30
|
+
import { join } from 'node:path'
|
|
31
|
+
import { randomUUID } from 'node:crypto'
|
|
32
|
+
import type { ClientChannel } from 'ssh2'
|
|
33
|
+
import { SubprocessExecutableNotFoundError } from '@deepseek-ai/dsh-subprocess'
|
|
34
|
+
import type {
|
|
35
|
+
SubprocessCollect,
|
|
36
|
+
SubprocessHandle,
|
|
37
|
+
SubprocessOutcome,
|
|
38
|
+
SubprocessOutputMode,
|
|
39
|
+
SubprocessOutputReader,
|
|
40
|
+
SubprocessSpawnSpec,
|
|
41
|
+
SubprocessStdinMode,
|
|
42
|
+
} from '@deepseek-ai/dsh-subprocess'
|
|
43
|
+
import type { RemotePool } from '../pool.ts'
|
|
44
|
+
|
|
45
|
+
/** Single-quote one argv/env word for POSIX sh. */
|
|
46
|
+
function shq(word: string): string {
|
|
47
|
+
if (word.includes('\0')) throw new Error('NUL byte in spawn argument')
|
|
48
|
+
return `'${word.replace(/'/g, `'\\''`)}'`
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Strip a Windows absolute executable to a remote-resolvable bare name. */
|
|
52
|
+
export function remoteCommand(input: string): string {
|
|
53
|
+
const base = input.replace(/\\/g, '/').split('/').pop() ?? input
|
|
54
|
+
return base.replace(/\.exe$/i, '')
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** How long terminate() waits for the RV_PID marker before last-resort kill. */
|
|
58
|
+
const MARKER_TIMEOUT_MS = 8000
|
|
59
|
+
|
|
60
|
+
/** Safety net after KILL: force local channel close if sshd stays silent. */
|
|
61
|
+
const SAFETY_CLOSE_MS = 5000
|
|
62
|
+
|
|
63
|
+
/** Host-side spill dir for over-cap collected output (seam-internal, transient). */
|
|
64
|
+
function spillDir(): string {
|
|
65
|
+
const dir = join(tmpdir(), 'dsh-remote-spills')
|
|
66
|
+
mkdirSync(dir, { recursive: true })
|
|
67
|
+
return dir
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Best-effort TTL sweep: spills older than a day are dead (crashed owner). */
|
|
71
|
+
function sweepSpills(now: number): void {
|
|
72
|
+
let dir: string
|
|
73
|
+
try {
|
|
74
|
+
dir = spillDir()
|
|
75
|
+
} catch {
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
let names: string[]
|
|
79
|
+
try {
|
|
80
|
+
names = readdirSync(dir)
|
|
81
|
+
} catch {
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
for (const name of names) {
|
|
85
|
+
if (!name.startsWith('dsh-remote-spill-')) continue
|
|
86
|
+
try {
|
|
87
|
+
if (now - statSync(join(dir, name)).mtimeMs > 24 * 3600 * 1000) rmSync(join(dir, name), { force: true })
|
|
88
|
+
} catch {
|
|
89
|
+
// Ignore per-file failures.
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* One collected stream: tail window + offset readers + optional full-stream
|
|
96
|
+
* spill file (Host disk). Mirrors the stock local runtime's contract
|
|
97
|
+
* (dsh-subprocess SubprocessCollect): with `spill` configured, overflow
|
|
98
|
+
* appends to the spill file; readFrom ALWAYS returns the tail window (with
|
|
99
|
+
* honest lossy flags) and attaches spillPath when the spill is intact — the
|
|
100
|
+
* complete stream lives in the FILE (read by model/tool via fs), never
|
|
101
|
+
* inline. Past spill.maxBytes the incomplete spill is discarded (tail only).
|
|
102
|
+
* Without `spill`, tail-only, no path (diagnostic-tail shape).
|
|
103
|
+
*/
|
|
104
|
+
class CollectStream {
|
|
105
|
+
private buffers: Buffer[] = []
|
|
106
|
+
private bufferedBytes = 0
|
|
107
|
+
private totalBytes = 0
|
|
108
|
+
private readonly maxBytes: number
|
|
109
|
+
private readonly spillCap: number | undefined
|
|
110
|
+
private readonly streamName: string
|
|
111
|
+
private spillPath: string | undefined
|
|
112
|
+
private spillFd: number | undefined
|
|
113
|
+
private spilledBytes = 0
|
|
114
|
+
private spillDead = false
|
|
115
|
+
private spillWarned = false
|
|
116
|
+
|
|
117
|
+
constructor(collect: SubprocessCollect, streamName: string) {
|
|
118
|
+
this.maxBytes = Math.max(1, Math.floor(collect.maxBytes))
|
|
119
|
+
this.spillCap = collect.spill === undefined ? undefined : Math.max(1, Math.floor(collect.spill.maxBytes))
|
|
120
|
+
this.streamName = streamName
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
push(chunk: Buffer): void {
|
|
124
|
+
if (this.spillCap !== undefined && !this.spillDead) {
|
|
125
|
+
if (this.totalBytes + chunk.length > this.spillCap) {
|
|
126
|
+
this.discardSpill()
|
|
127
|
+
} else {
|
|
128
|
+
// Overflow begins (or continues): spill holds [0, total) complete.
|
|
129
|
+
if (this.spillPath === undefined && this.bufferedBytes + chunk.length > this.maxBytes) {
|
|
130
|
+
this.spillCreate()
|
|
131
|
+
// Flush the bytes buffered so far, then the new chunk below.
|
|
132
|
+
if (!this.spillDead) {
|
|
133
|
+
for (const b of this.buffers) this.spillWrite(b)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (!this.spillDead) this.spillWrite(chunk)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
this.totalBytes += chunk.length
|
|
140
|
+
this.buffers.push(chunk)
|
|
141
|
+
this.bufferedBytes += chunk.length
|
|
142
|
+
while (this.bufferedBytes > this.maxBytes && this.buffers.length > 1) {
|
|
143
|
+
const head = this.buffers.shift() as Buffer
|
|
144
|
+
this.bufferedBytes -= head.length
|
|
145
|
+
}
|
|
146
|
+
if (this.bufferedBytes > this.maxBytes) {
|
|
147
|
+
const only = this.buffers[0] as Buffer
|
|
148
|
+
this.buffers = [only.subarray(only.length - this.maxBytes)]
|
|
149
|
+
this.bufferedBytes = this.buffers[0]?.length ?? 0
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
reader(): SubprocessOutputReader {
|
|
154
|
+
return {
|
|
155
|
+
readFrom: (fromByte: number) => {
|
|
156
|
+
// Stock-faithful: text is ALWAYS the tail window; the spill file
|
|
157
|
+
// (when intact) carries the complete stream out-of-band.
|
|
158
|
+
const spill = this.spillPathNow()
|
|
159
|
+
const windowStart = this.totalBytes - this.bufferedBytes
|
|
160
|
+
if (fromByte < windowStart) {
|
|
161
|
+
return {
|
|
162
|
+
text: Buffer.concat(this.buffers).toString('utf8'),
|
|
163
|
+
nextOffset: this.totalBytes,
|
|
164
|
+
lossy: true,
|
|
165
|
+
...(spill === undefined ? {} : { spillPath: spill }),
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
const slice = Buffer.concat(this.buffers).subarray(Math.max(0, fromByte - windowStart))
|
|
169
|
+
return {
|
|
170
|
+
text: slice.toString('utf8'),
|
|
171
|
+
nextOffset: this.totalBytes,
|
|
172
|
+
lossy: false,
|
|
173
|
+
...(spill === undefined ? {} : { spillPath: spill }),
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Release the spill fd (the FILE persists for model readback; TTL-swept). */
|
|
180
|
+
dispose(): void {
|
|
181
|
+
if (this.spillFd !== undefined) {
|
|
182
|
+
try {
|
|
183
|
+
closeSync(this.spillFd)
|
|
184
|
+
} catch {
|
|
185
|
+
// Ignore.
|
|
186
|
+
}
|
|
187
|
+
this.spillFd = undefined
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private spillCreate(): void {
|
|
192
|
+
try {
|
|
193
|
+
sweepSpills(Date.now())
|
|
194
|
+
const path = join(spillDir(), `dsh-remote-spill-${this.streamName}-${randomUUID()}.bin`)
|
|
195
|
+
this.spillFd = openSync(path, 'w')
|
|
196
|
+
this.spillPath = path
|
|
197
|
+
} catch {
|
|
198
|
+
this.warnSpill('cannot create spill file; degrading to tail-only')
|
|
199
|
+
this.spillDead = true
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
private spillWrite(chunk: Buffer): void {
|
|
204
|
+
if (this.spillFd === undefined) return
|
|
205
|
+
try {
|
|
206
|
+
writeSync(this.spillFd, chunk)
|
|
207
|
+
this.spilledBytes += chunk.length
|
|
208
|
+
} catch {
|
|
209
|
+
this.warnSpill('cannot append spill file; degrading to tail-only')
|
|
210
|
+
this.discardSpill()
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
private discardSpill(): void {
|
|
215
|
+
if (this.spillFd !== undefined) {
|
|
216
|
+
try {
|
|
217
|
+
closeSync(this.spillFd)
|
|
218
|
+
} catch {
|
|
219
|
+
// Ignore.
|
|
220
|
+
}
|
|
221
|
+
this.spillFd = undefined
|
|
222
|
+
}
|
|
223
|
+
if (this.spillPath !== undefined) {
|
|
224
|
+
try {
|
|
225
|
+
rmSync(this.spillPath, { force: true })
|
|
226
|
+
} catch {
|
|
227
|
+
// Ignore.
|
|
228
|
+
}
|
|
229
|
+
this.spillPath = undefined
|
|
230
|
+
}
|
|
231
|
+
this.spillDead = true
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Intact spill path, or undefined (discarded, unconfigured, or desynced). */
|
|
235
|
+
private spillPathNow(): string | undefined {
|
|
236
|
+
if (this.spillPath === undefined || this.spillDead) return undefined
|
|
237
|
+
// Only advertise when the file holds every byte (a post-dispose stray
|
|
238
|
+
// push would desync it — withhold the path instead of lying).
|
|
239
|
+
if (this.spilledBytes !== this.totalBytes) return undefined
|
|
240
|
+
return this.spillPath
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
private warnSpill(message: string): void {
|
|
244
|
+
if (this.spillWarned) return
|
|
245
|
+
this.spillWarned = true
|
|
246
|
+
// eslint-disable-next-line no-console
|
|
247
|
+
console.warn(`[dsh-remote-vs] ${message}`)
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function isCollect(mode: SubprocessOutputMode): mode is SubprocessCollect {
|
|
252
|
+
return typeof mode === 'object'
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Build the remote shell line: cd + env + setsid --wait wrapper + argv. */
|
|
256
|
+
function buildCommand(spec: SubprocessSpawnSpec): string {
|
|
257
|
+
const parts: string[] = [`cd ${shq(spec.cwd)}`, '&&']
|
|
258
|
+
const env = spec.env ?? {}
|
|
259
|
+
const unsets = Object.entries(env).filter(([, value]) => value === undefined).map(([key]) => key)
|
|
260
|
+
const sets = Object.entries(env).filter(([, value]) => value !== undefined) as [string, string][]
|
|
261
|
+
const inner: string[] = []
|
|
262
|
+
if (unsets.length > 0 || sets.length > 0) {
|
|
263
|
+
inner.push('env')
|
|
264
|
+
for (const key of unsets) inner.push('-u', key)
|
|
265
|
+
for (const [key, value] of sets) inner.push(`${key}=${shq(value)}`)
|
|
266
|
+
}
|
|
267
|
+
inner.push(...spec.argv.map(shq))
|
|
268
|
+
parts.push(`setsid --wait sh -c ${shq('echo RV_PID=$$; exec "$@"')} _ ${inner.join(' ')}`)
|
|
269
|
+
if (spec.stdio.stdin === 'ignore') {
|
|
270
|
+
// Seam contract: 'ignore' leaves fd 0 on /dev/null (NOT an empty pipe:
|
|
271
|
+
// tools like rg treat no-path + pipe-stdin as "search stdin" and return
|
|
272
|
+
// zero matches — 2026-10-13 grep-omitted-path incident).
|
|
273
|
+
parts.push('< /dev/null')
|
|
274
|
+
}
|
|
275
|
+
return parts.join(' ')
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Same line without setsid (platforms lacking it; leader-only range). `exec`
|
|
280
|
+
* replaces the outer shell so channel.signal addresses the target itself.
|
|
281
|
+
*/
|
|
282
|
+
function buildCommandNoSetsid(spec: SubprocessSpawnSpec): string {
|
|
283
|
+
const parts: string[] = [`cd ${shq(spec.cwd)}`, '&&']
|
|
284
|
+
const env = spec.env ?? {}
|
|
285
|
+
const unsets = Object.entries(env).filter(([, value]) => value === undefined).map(([key]) => key)
|
|
286
|
+
const sets = Object.entries(env).filter(([, value]) => value !== undefined) as [string, string][]
|
|
287
|
+
if (unsets.length > 0 || sets.length > 0) {
|
|
288
|
+
parts.push('env')
|
|
289
|
+
for (const key of unsets) parts.push('-u', key)
|
|
290
|
+
for (const [key, value] of sets) parts.push(`${key}=${shq(value)}`)
|
|
291
|
+
}
|
|
292
|
+
parts.push('exec', ...spec.argv.map(shq))
|
|
293
|
+
// Seam contract: 'ignore' leaves fd 0 on /dev/null (see buildCommand).
|
|
294
|
+
if (spec.stdio.stdin === 'ignore') parts.push('< /dev/null')
|
|
295
|
+
return parts.join(' ')
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export class SshProcRuntime {
|
|
299
|
+
private readonly live = new Set<() => void>()
|
|
300
|
+
/** Whether the remote offers a compatible setsid(1); optimistic until disproven. */
|
|
301
|
+
private setsid: boolean | undefined
|
|
302
|
+
|
|
303
|
+
constructor(
|
|
304
|
+
private readonly pool: RemotePool,
|
|
305
|
+
private readonly alias: string,
|
|
306
|
+
) {}
|
|
307
|
+
|
|
308
|
+
/** Resolve via remote `command -v` (Windows paths reduce to bare names). */
|
|
309
|
+
async resolveExecutable(command: string): Promise<string> {
|
|
310
|
+
const name = /^[A-Za-z]:[\\/]/.test(command) || command.includes('\\') ? remoteCommand(command) : command
|
|
311
|
+
const result = await this.pool.exec(this.alias, `command -v -- ${shq(name)}`, { timeoutMs: 15000 })
|
|
312
|
+
const resolved = result.stdout.trim().split('\n')[0]?.trim() ?? ''
|
|
313
|
+
if (!result.success || resolved === '') {
|
|
314
|
+
throw new SubprocessExecutableNotFoundError(`remote executable not found: ${command}`)
|
|
315
|
+
}
|
|
316
|
+
return resolved
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
terminalEnvironment(): { platform: 'posix'; defaultShell: string } {
|
|
320
|
+
return { platform: 'posix', defaultShell: '/bin/bash' }
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Spawn one managed remote process. Throws synchronously on invalid spec. */
|
|
324
|
+
spawn(spec: SubprocessSpawnSpec): SubprocessHandle {
|
|
325
|
+
if (spec.argv.length === 0 || typeof spec.argv[0] !== 'string' || spec.argv[0] === '') {
|
|
326
|
+
throw new Error('spawn requires a non-empty argv[0]')
|
|
327
|
+
}
|
|
328
|
+
if (typeof spec.cwd !== 'string' || !spec.cwd.startsWith('/')) {
|
|
329
|
+
throw new Error(`remote spawn needs an absolute POSIX cwd, got: ${spec.cwd}`)
|
|
330
|
+
}
|
|
331
|
+
if (!Number.isFinite(spec.graceMs) || spec.graceMs < 0) {
|
|
332
|
+
throw new Error(`remote spawn needs a non-negative graceMs, got: ${spec.graceMs}`)
|
|
333
|
+
}
|
|
334
|
+
if (spec.stdio.control === 'pipe') {
|
|
335
|
+
throw new Error('remote control channels are not implemented in P2-v1')
|
|
336
|
+
}
|
|
337
|
+
if (spec.signal?.aborted === true) {
|
|
338
|
+
throw new Error('spawn aborted before start')
|
|
339
|
+
}
|
|
340
|
+
const useSetsid = this.setsid ?? true
|
|
341
|
+
return new SshProcHandle(this.pool, this.alias, spec, useSetsid, () => {
|
|
342
|
+
this.setsid = false
|
|
343
|
+
}, this.live)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** Terminate every live remote process (service disposal). */
|
|
347
|
+
async dispose(): Promise<void> {
|
|
348
|
+
const pending = [...this.live]
|
|
349
|
+
this.live.clear()
|
|
350
|
+
await Promise.allSettled(pending.map(async (terminate) => {
|
|
351
|
+
try {
|
|
352
|
+
terminate()
|
|
353
|
+
} catch {
|
|
354
|
+
// Ignore teardown races.
|
|
355
|
+
}
|
|
356
|
+
}))
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
class SshProcHandle implements SubprocessHandle {
|
|
361
|
+
readonly stdin: Writable | undefined
|
|
362
|
+
readonly stdout: Readable | undefined
|
|
363
|
+
readonly stderr: Readable | undefined
|
|
364
|
+
readonly control = undefined
|
|
365
|
+
readonly collected: { stdout?: SubprocessOutputReader; stderr?: SubprocessOutputReader }
|
|
366
|
+
readonly done: Promise<SubprocessOutcome>
|
|
367
|
+
|
|
368
|
+
private channel: ClientChannel | undefined
|
|
369
|
+
private settled = false
|
|
370
|
+
private killRequested = false
|
|
371
|
+
private terminating = false
|
|
372
|
+
private killedSignal: NodeJS.Signals | undefined
|
|
373
|
+
private termFired = false
|
|
374
|
+
private useSetsid = true
|
|
375
|
+
private retriedNoSetsid = false
|
|
376
|
+
private pgid: number | undefined
|
|
377
|
+
private pgidWaiters: Array<() => void> = []
|
|
378
|
+
private markerPending = true
|
|
379
|
+
private markerBuf = Buffer.alloc(0)
|
|
380
|
+
private stderrHead = ''
|
|
381
|
+
private readonly outCollect: CollectStream | undefined
|
|
382
|
+
private readonly errCollect: CollectStream | undefined
|
|
383
|
+
private readonly outPipe: PassThrough | undefined
|
|
384
|
+
private readonly errPipe: PassThrough | undefined
|
|
385
|
+
private readonly stdoutStripped = new PassThrough()
|
|
386
|
+
private stdinFlush: () => void = () => {}
|
|
387
|
+
private readonly abortListener: (() => void) | undefined
|
|
388
|
+
private readonly resolveDone: (outcome: SubprocessOutcome) => void
|
|
389
|
+
private readonly rejectDone: (error: unknown) => void
|
|
390
|
+
private readonly finish: () => void
|
|
391
|
+
|
|
392
|
+
constructor(
|
|
393
|
+
private readonly pool: RemotePool,
|
|
394
|
+
private readonly alias: string,
|
|
395
|
+
private readonly spec: SubprocessSpawnSpec,
|
|
396
|
+
useSetsid: boolean,
|
|
397
|
+
private readonly onSetsidMissing: () => void,
|
|
398
|
+
live: Set<() => void>,
|
|
399
|
+
) {
|
|
400
|
+
this.useSetsid = useSetsid
|
|
401
|
+
this.markerPending = useSetsid
|
|
402
|
+
const terminate = (): void => {
|
|
403
|
+
this.terminate()
|
|
404
|
+
}
|
|
405
|
+
live.add(terminate)
|
|
406
|
+
this.finish = (): void => {
|
|
407
|
+
live.delete(terminate)
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
if (isCollect(spec.stdio.stdout)) {
|
|
411
|
+
this.outCollect = new CollectStream(spec.stdio.stdout, 'stdout')
|
|
412
|
+
} else if (spec.stdio.stdout === 'pipe') {
|
|
413
|
+
this.outPipe = new PassThrough()
|
|
414
|
+
}
|
|
415
|
+
if (isCollect(spec.stdio.stderr)) {
|
|
416
|
+
this.errCollect = new CollectStream(spec.stdio.stderr, 'stderr')
|
|
417
|
+
} else if (spec.stdio.stderr === 'pipe') {
|
|
418
|
+
this.errPipe = new PassThrough()
|
|
419
|
+
}
|
|
420
|
+
this.collected = {
|
|
421
|
+
...(this.outCollect !== undefined ? { stdout: this.outCollect.reader() } : {}),
|
|
422
|
+
...(this.errCollect !== undefined ? { stderr: this.errCollect.reader() } : {}),
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
let resolveDone!: (outcome: SubprocessOutcome) => void
|
|
426
|
+
let rejectDone!: (error: unknown) => void
|
|
427
|
+
this.done = new Promise<SubprocessOutcome>((resolve, reject) => {
|
|
428
|
+
resolveDone = resolve
|
|
429
|
+
rejectDone = reject
|
|
430
|
+
})
|
|
431
|
+
this.resolveDone = resolveDone
|
|
432
|
+
this.rejectDone = rejectDone
|
|
433
|
+
// Latch rejection fast: a stored rejection nobody awaits is an unhandled rejection.
|
|
434
|
+
void this.done.catch(() => {})
|
|
435
|
+
|
|
436
|
+
if (spec.signal !== undefined) {
|
|
437
|
+
const onAbort = (): void => {
|
|
438
|
+
this.terminate()
|
|
439
|
+
}
|
|
440
|
+
spec.signal.addEventListener('abort', onAbort, { once: true })
|
|
441
|
+
this.abortListener = () => spec.signal?.removeEventListener('abort', onAbort)
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const stdinMode: SubprocessStdinMode = spec.stdio.stdin
|
|
445
|
+
if (stdinMode === 'pipe') {
|
|
446
|
+
const pending: Buffer[] = []
|
|
447
|
+
let ended = false
|
|
448
|
+
let bound: ClientChannel | undefined
|
|
449
|
+
const flush = (): void => {
|
|
450
|
+
const channel = bound ?? this.channel
|
|
451
|
+
if (channel === undefined) return
|
|
452
|
+
bound = channel
|
|
453
|
+
while (pending.length > 0) {
|
|
454
|
+
const chunk = pending.shift() as Buffer
|
|
455
|
+
try {
|
|
456
|
+
channel.write(chunk)
|
|
457
|
+
} catch {
|
|
458
|
+
// Channel raced away.
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
if (ended) {
|
|
462
|
+
try {
|
|
463
|
+
channel.end()
|
|
464
|
+
} catch {
|
|
465
|
+
// Ignore.
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
this.stdinFlush = flush
|
|
470
|
+
this.stdin = new Writable({
|
|
471
|
+
write(chunk: Buffer, _encoding: string, callback: (error?: Error | null) => void): void {
|
|
472
|
+
pending.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
|
|
473
|
+
flush()
|
|
474
|
+
callback()
|
|
475
|
+
},
|
|
476
|
+
final(callback: (error?: Error | null) => void): void {
|
|
477
|
+
ended = true
|
|
478
|
+
flush()
|
|
479
|
+
callback()
|
|
480
|
+
},
|
|
481
|
+
})
|
|
482
|
+
} else {
|
|
483
|
+
this.stdin = undefined
|
|
484
|
+
}
|
|
485
|
+
this.stdout = this.outPipe
|
|
486
|
+
this.stderr = this.errPipe
|
|
487
|
+
|
|
488
|
+
// Output fan-out. Stdout passes a marker stripper that consumes the
|
|
489
|
+
// leading `RV_PID=<pgid>` line (setsid sessions only).
|
|
490
|
+
const forward = (
|
|
491
|
+
stream: NodeJS.ReadableStream,
|
|
492
|
+
mode: SubprocessOutputMode,
|
|
493
|
+
collect: CollectStream | undefined,
|
|
494
|
+
pipe: PassThrough | undefined,
|
|
495
|
+
host: NodeJS.WritableStream,
|
|
496
|
+
): void => {
|
|
497
|
+
stream.on('data', (chunk: Buffer) => {
|
|
498
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array)
|
|
499
|
+
if (collect !== undefined) collect.push(buffer)
|
|
500
|
+
if (pipe !== undefined) pipe.write(buffer)
|
|
501
|
+
if (mode === 'inherit') {
|
|
502
|
+
try {
|
|
503
|
+
host.write(buffer)
|
|
504
|
+
} catch {
|
|
505
|
+
// Ignore host stream errors.
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
})
|
|
509
|
+
}
|
|
510
|
+
forward(this.stdoutStripped, spec.stdio.stdout, this.outCollect, this.outPipe, process.stdout)
|
|
511
|
+
|
|
512
|
+
void this.open()
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** Open the exec channel (once; setsid-incompatible remotes retry internally). */
|
|
516
|
+
private async open(): Promise<void> {
|
|
517
|
+
const { spec } = this
|
|
518
|
+
const stdinMode: SubprocessStdinMode = spec.stdio.stdin
|
|
519
|
+
try {
|
|
520
|
+
const channel = await this.pool.execChannel(this.alias, this.useSetsid ? buildCommand(spec) : buildCommandNoSetsid(spec))
|
|
521
|
+
if (this.settled) {
|
|
522
|
+
channel.close()
|
|
523
|
+
return
|
|
524
|
+
}
|
|
525
|
+
this.channel = channel
|
|
526
|
+
// Listeners first: a pending terminate() needs the marker and close
|
|
527
|
+
// events (attaching after the killRequested check would strand the
|
|
528
|
+
// handle with no pgid and no settlement).
|
|
529
|
+
channel.on('data', (chunk: Buffer) => {
|
|
530
|
+
this.stripMarker(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array))
|
|
531
|
+
})
|
|
532
|
+
channel.stderr.on('data', (chunk: Buffer) => {
|
|
533
|
+
if (this.stderrHead.length < 2048) {
|
|
534
|
+
this.stderrHead += chunk.toString('utf8').slice(0, 2048 - this.stderrHead.length)
|
|
535
|
+
}
|
|
536
|
+
})
|
|
537
|
+
const forwardErr = (chunk: Buffer): void => {
|
|
538
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array)
|
|
539
|
+
if (this.errCollect !== undefined) this.errCollect.push(buffer)
|
|
540
|
+
if (this.errPipe !== undefined) this.errPipe.write(buffer)
|
|
541
|
+
if (spec.stdio.stderr === 'inherit') {
|
|
542
|
+
try {
|
|
543
|
+
process.stderr.write(buffer)
|
|
544
|
+
} catch {
|
|
545
|
+
// Ignore host stream errors.
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
channel.stderr.on('data', forwardErr)
|
|
550
|
+
channel.on('close', (code: number | null, signalName?: string | null) => {
|
|
551
|
+
this.onClose(code, signalName)
|
|
552
|
+
})
|
|
553
|
+
channel.on('error', (error: Error) => {
|
|
554
|
+
this.fail(error)
|
|
555
|
+
})
|
|
556
|
+
if (this.killRequested) {
|
|
557
|
+
this.terminate()
|
|
558
|
+
return
|
|
559
|
+
}
|
|
560
|
+
// stdin disposition (skipped when already terminating: no point feeding
|
|
561
|
+
// a process we are about to kill).
|
|
562
|
+
if (stdinMode === 'ignore') {
|
|
563
|
+
try {
|
|
564
|
+
channel.end()
|
|
565
|
+
} catch {
|
|
566
|
+
// Ignore.
|
|
567
|
+
}
|
|
568
|
+
} else if (typeof stdinMode === 'object') {
|
|
569
|
+
try {
|
|
570
|
+
channel.write(stdinMode.data)
|
|
571
|
+
} catch {
|
|
572
|
+
// Ignore.
|
|
573
|
+
}
|
|
574
|
+
try {
|
|
575
|
+
channel.end()
|
|
576
|
+
} catch {
|
|
577
|
+
// Ignore.
|
|
578
|
+
}
|
|
579
|
+
} else {
|
|
580
|
+
this.stdinFlush()
|
|
581
|
+
}
|
|
582
|
+
} catch (error) {
|
|
583
|
+
this.fail(error)
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
private onClose(code: number | null, signalName?: string | null): void {
|
|
588
|
+
// setsid-incompatible remote (missing binary or no --wait): the wrapper
|
|
589
|
+
// died pre-exec, the target never ran. Degrade once, internally.
|
|
590
|
+
if (this.useSetsid && this.pgid === undefined && !this.retriedNoSetsid && /setsid/i.test(this.stderrHead)) {
|
|
591
|
+
this.retriedNoSetsid = true
|
|
592
|
+
this.useSetsid = false
|
|
593
|
+
this.markerPending = false
|
|
594
|
+
this.onSetsidMissing()
|
|
595
|
+
this.channel = undefined
|
|
596
|
+
this.stderrHead = ''
|
|
597
|
+
void this.open()
|
|
598
|
+
return
|
|
599
|
+
}
|
|
600
|
+
this.flushMarker()
|
|
601
|
+
if (this.killedSignal !== undefined) {
|
|
602
|
+
// We killed it: report our signal, not the channel's code (the wrapper
|
|
603
|
+
// chain's exit status is noise once we intervene).
|
|
604
|
+
this.settle({ exitCode: null, signal: this.killedSignal })
|
|
605
|
+
return
|
|
606
|
+
}
|
|
607
|
+
this.settle({
|
|
608
|
+
exitCode: signalName !== undefined && signalName !== null ? null : (code ?? 0),
|
|
609
|
+
signal: signalName !== undefined && signalName !== null ? (`SIG${signalName}` as NodeJS.Signals) : null,
|
|
610
|
+
})
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
terminate(): void {
|
|
614
|
+
this.killRequested = true
|
|
615
|
+
if (this.settled || this.terminating) return
|
|
616
|
+
if (this.channel === undefined) return // Post-open hook re-enters.
|
|
617
|
+
this.terminating = true
|
|
618
|
+
this.killedSignal = 'SIGTERM'
|
|
619
|
+
if (!this.useSetsid) {
|
|
620
|
+
// Leader-only line: the outer shell exec'd the target, so the channel
|
|
621
|
+
// signal addresses the target itself.
|
|
622
|
+
this.signalLeader('TERM')
|
|
623
|
+
this.scheduleEscalation()
|
|
624
|
+
return
|
|
625
|
+
}
|
|
626
|
+
// setsid world: group kill once the pgid is learned (bounded wait);
|
|
627
|
+
// signalling the channel would only hit the wrapper chain.
|
|
628
|
+
this.onPgidKnown(() => this.fireTerm())
|
|
629
|
+
setTimeout(() => {
|
|
630
|
+
if (!this.termFired && !this.settled) this.fallbackKill()
|
|
631
|
+
}, MARKER_TIMEOUT_MS).unref?.()
|
|
632
|
+
this.scheduleEscalation()
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
private scheduleEscalation(): void {
|
|
636
|
+
const grace = Math.min(Math.max(0, this.spec.graceMs), 2147483647)
|
|
637
|
+
setTimeout(() => {
|
|
638
|
+
if (this.settled) return
|
|
639
|
+
this.killedSignal = 'SIGKILL'
|
|
640
|
+
if (this.useSetsid) {
|
|
641
|
+
this.onPgidKnown(() => this.groupKill('KILL'))
|
|
642
|
+
} else {
|
|
643
|
+
this.signalLeader('KILL')
|
|
644
|
+
}
|
|
645
|
+
setTimeout(() => {
|
|
646
|
+
if (this.settled) return
|
|
647
|
+
try {
|
|
648
|
+
this.channel?.close()
|
|
649
|
+
} catch {
|
|
650
|
+
// Ignore.
|
|
651
|
+
}
|
|
652
|
+
}, SAFETY_CLOSE_MS).unref?.()
|
|
653
|
+
}, grace).unref?.()
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
private fireTerm(): void {
|
|
657
|
+
if (this.termFired || this.settled) return
|
|
658
|
+
this.termFired = true
|
|
659
|
+
this.groupKill('TERM')
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/** Marker never arrived: last resort (channel signal + warn about orphans). */
|
|
663
|
+
private fallbackKill(): void {
|
|
664
|
+
if (this.settled) return
|
|
665
|
+
this.termFired = true
|
|
666
|
+
try {
|
|
667
|
+
this.channel?.signal('TERM')
|
|
668
|
+
} catch {
|
|
669
|
+
// Ignore.
|
|
670
|
+
}
|
|
671
|
+
// eslint-disable-next-line no-console
|
|
672
|
+
console.warn(`[dsh-remote-vs] remote kill without pgid for ${this.alias} (marker missing): the wrapper chain was signalled but detached descendants may survive`)
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
private groupKill(signal: 'TERM' | 'KILL'): void {
|
|
676
|
+
const pgid = this.pgid
|
|
677
|
+
if (pgid === undefined || this.settled) return
|
|
678
|
+
void this.pool.exec(this.alias, `kill -${signal} -- -${pgid}`, { timeoutMs: 15000 }).catch(() => undefined)
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
private signalLeader(signal: 'TERM' | 'KILL'): void {
|
|
682
|
+
try {
|
|
683
|
+
this.channel?.signal(signal)
|
|
684
|
+
} catch {
|
|
685
|
+
// Fall through to close.
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/** Run cb now if the pgid is known, else when the marker lands. */
|
|
690
|
+
private onPgidKnown(cb: () => void): void {
|
|
691
|
+
if (this.pgid !== undefined) {
|
|
692
|
+
cb()
|
|
693
|
+
return
|
|
694
|
+
}
|
|
695
|
+
this.pgidWaiters.push(cb)
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/** Consume a leading `RV_PID=<pgid>` line; forward everything else. */
|
|
699
|
+
private stripMarker(chunk: Buffer): void {
|
|
700
|
+
const out = this.stdoutStripped
|
|
701
|
+
if (!this.markerPending) {
|
|
702
|
+
out.write(chunk)
|
|
703
|
+
return
|
|
704
|
+
}
|
|
705
|
+
this.markerBuf = Buffer.concat([this.markerBuf, chunk])
|
|
706
|
+
const newline = this.markerBuf.indexOf(0x0a)
|
|
707
|
+
if (newline === -1) {
|
|
708
|
+
if (this.markerBuf.length > 1024) {
|
|
709
|
+
// Not a marker after all: flush and stop looking.
|
|
710
|
+
this.markerPending = false
|
|
711
|
+
out.write(this.markerBuf)
|
|
712
|
+
this.markerBuf = Buffer.alloc(0)
|
|
713
|
+
}
|
|
714
|
+
return
|
|
715
|
+
}
|
|
716
|
+
const line = this.markerBuf.subarray(0, newline).toString('utf8').trim()
|
|
717
|
+
const rest = this.markerBuf.subarray(newline + 1)
|
|
718
|
+
this.markerBuf = Buffer.alloc(0)
|
|
719
|
+
this.markerPending = false
|
|
720
|
+
const match = /^RV_PID=(\d+)$/.exec(line)
|
|
721
|
+
if (match?.[1] !== undefined) {
|
|
722
|
+
this.pgid = Number(match[1])
|
|
723
|
+
const waiters = this.pgidWaiters
|
|
724
|
+
this.pgidWaiters = []
|
|
725
|
+
for (const waiter of waiters) {
|
|
726
|
+
try {
|
|
727
|
+
waiter()
|
|
728
|
+
} catch {
|
|
729
|
+
// Kill dispatch must never throw.
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
} else {
|
|
733
|
+
// First line wasn't ours (unexpected wrapper output): keep it.
|
|
734
|
+
out.write(Buffer.concat([Buffer.from(`${line}\n`, 'utf8'), rest]))
|
|
735
|
+
return
|
|
736
|
+
}
|
|
737
|
+
if (rest.length > 0) out.write(rest)
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/** Flush a partial marker buffer at close (no pgid learned). */
|
|
741
|
+
private flushMarker(): void {
|
|
742
|
+
if (this.markerBuf.length > 0) {
|
|
743
|
+
this.stdoutStripped.write(this.markerBuf)
|
|
744
|
+
this.markerBuf = Buffer.alloc(0)
|
|
745
|
+
}
|
|
746
|
+
this.markerPending = false
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
async waitForExit(signal?: AbortSignal): Promise<boolean> {
|
|
750
|
+
if (this.settled) return true
|
|
751
|
+
if (signal?.aborted === true) return false
|
|
752
|
+
return new Promise<boolean>((resolve) => {
|
|
753
|
+
const cleanup = (): void => {
|
|
754
|
+
signal?.removeEventListener('abort', onAbort)
|
|
755
|
+
}
|
|
756
|
+
const onAbort = (): void => {
|
|
757
|
+
cleanup()
|
|
758
|
+
resolve(false)
|
|
759
|
+
}
|
|
760
|
+
void this.done.then(
|
|
761
|
+
() => {
|
|
762
|
+
cleanup()
|
|
763
|
+
resolve(true)
|
|
764
|
+
},
|
|
765
|
+
() => {
|
|
766
|
+
cleanup()
|
|
767
|
+
resolve(true)
|
|
768
|
+
},
|
|
769
|
+
)
|
|
770
|
+
signal?.addEventListener('abort', onAbort, { once: true })
|
|
771
|
+
})
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
private settle(outcome: SubprocessOutcome): void {
|
|
775
|
+
if (this.settled) return
|
|
776
|
+
this.settled = true
|
|
777
|
+
this.outPipe?.end()
|
|
778
|
+
this.errPipe?.end()
|
|
779
|
+
this.outCollect?.dispose()
|
|
780
|
+
this.errCollect?.dispose()
|
|
781
|
+
this.abortListener?.()
|
|
782
|
+
this.resolveDone(outcome)
|
|
783
|
+
this.finish()
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
private fail(error: unknown): void {
|
|
787
|
+
if (this.settled) return
|
|
788
|
+
this.settled = true
|
|
789
|
+
this.outPipe?.destroy()
|
|
790
|
+
this.errPipe?.destroy()
|
|
791
|
+
this.outCollect?.dispose()
|
|
792
|
+
this.errCollect?.dispose()
|
|
793
|
+
try {
|
|
794
|
+
this.channel?.close()
|
|
795
|
+
} catch {
|
|
796
|
+
// Ignore.
|
|
797
|
+
}
|
|
798
|
+
this.abortListener?.()
|
|
799
|
+
this.rejectDone(error)
|
|
800
|
+
this.finish()
|
|
801
|
+
}
|
|
802
|
+
}
|