-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathspeechflow-node-xio-exec.ts
More file actions
228 lines (204 loc) · 9.83 KB
/
Copy pathspeechflow-node-xio-exec.ts
File metadata and controls
228 lines (204 loc) · 9.83 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
/*
** SpeechFlow - Speech Processing Flow Graph
** Copyright (c) 2024-2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
** Licensed under GPL 3.0 <https://spdx.org/licenses/GPL-3.0-only>
*/
/* standard dependencies */
import Stream from "node:stream"
/* external dependencies */
import { execa, type Subprocess, type Options } from "execa"
import shellParser from "shell-parser"
/* internal dependencies */
import SpeechFlowNode from "./speechflow-node"
import * as util from "./speechflow-util"
/* SpeechFlow node for external command execution */
export default class SpeechFlowNodeXIOExec extends SpeechFlowNode {
/* declare official node name */
public static name = "xio-exec"
/* internal state */
private subprocess: Subprocess | null = null
/* construct node */
constructor (id: string, cfg: { [ id: string ]: any }, opts: { [ id: string ]: any }, args: any[]) {
super(id, cfg, opts, args)
/* declare node configuration parameters */
this.configure({
command: { type: "string", pos: 0, val: "" },
mode: { type: "string", pos: 1, val: "r", match: /^(?:r|w|rw)$/ },
type: { type: "string", pos: 2, val: "audio", match: /^(?:audio|text)$/ },
chunkAudio: { type: "number", val: 200, match: (n: number) => n >= 10 && n <= 1000 },
chunkText: { type: "number", val: 65536, match: (n: number) => n >= 1024 && n <= 131072 }
})
/* sanity check parameters */
if (this.params.command === "")
throw new Error("required parameter \"command\" has to be given")
/* declare node input/output format */
if (this.params.mode === "rw") {
this.input = this.params.type
this.output = this.params.type
}
else if (this.params.mode === "r") {
this.input = "none"
this.output = this.params.type
}
else if (this.params.mode === "w") {
this.input = this.params.type
this.output = "none"
}
}
/* open node */
async open () {
/* determine how many bytes we need per chunk when
the chunk should be of the required duration/size */
const highWaterMarkAudio = (
this.config.audioSampleRate *
(this.config.audioBitDepth / 8)
) / (1000 / this.params.chunkAudio)
const highWaterMarkText = this.params.chunkText
/* parse command into executable and arguments
(SECURITY: caller must ensure command parameter is properly validated
and does not contain untrusted user input to prevent command injection) */
const cmdParts = shellParser(this.params.command)
if (cmdParts.length === 0)
throw new Error("failed to parse command: no executable found")
/* warn about potentially dangerous shell metacharacters */
if (/[;&|`$()<>]/.test(this.params.command))
this.log("warning", "command contains shell metacharacters -- ensure input is trusted")
const executable = cmdParts[0]
const args = cmdParts.slice(1)
/* determine subprocess options */
const encoding = (this.params.type === "text" ?
this.config.textEncoding : "buffer") as Options["encoding"]
/* spawn subprocess */
this.log("info", `executing command: ${this.params.command}`)
this.subprocess = execa(executable, args, {
buffer: false,
encoding,
...(this.params.mode === "rw" ? { stdin: "pipe", stdout: "pipe" } : {}),
...(this.params.mode === "r" ? { stdin: "ignore", stdout: "pipe" } : {}),
...(this.params.mode === "w" ? { stdin: "pipe", stdout: "ignore" } : {})
})
/* handle subprocess errors */
this.subprocess.on("error", (err) => {
this.log("error", `subprocess error: ${err.message}`)
/* NOTICE: do not emit("error") on the node itself, since nothing
listens for it and it would become an uncaughtException tearing
down the whole graph. Route via the stream instead, where it is
handled by the graph supervisor. */
if (this.stream !== null && !this.stream.destroyed)
this.stream.emit("error", err)
})
/* handle subprocess exit */
this.subprocess.on("exit", (code, signal) => {
if (code !== 0 && code !== null)
this.log("warning", `subprocess exited with code ${code}`)
else if (signal)
this.log("warning", `subprocess terminated by signal ${signal}`)
else
this.log("info", "subprocess terminated gracefully")
})
/* determine high water mark based on type */
const highWaterMark = this.params.type === "audio" ? highWaterMarkAudio : highWaterMarkText
/* configure stream encoding */
if (this.subprocess.stdout && this.params.type === "text")
this.subprocess.stdout.setEncoding(this.config.textEncoding)
if (this.subprocess.stdin)
this.subprocess.stdin.setDefaultEncoding(this.params.type === "text" ?
this.config.textEncoding : "binary")
/* dispatch according to mode */
if (this.params.mode === "rw") {
/* bidirectional mode: both stdin and stdout */
this.stream = Stream.Duplex.from({
readable: this.subprocess.stdout,
writable: this.subprocess.stdin
})
const wrapper1 = util.createTransformStreamForWritableSide(this.params.type, highWaterMark)
const wrapper2 = util.createTransformStreamForReadableSide(
this.params.type, () => this.timeZero, highWaterMark,
this.config.audioSampleRate, this.config.audioBitDepth, this.config.audioChannels)
this.stream = Stream.compose(wrapper1, this.stream, wrapper2)
}
else if (this.params.mode === "r") {
/* read-only mode: stdout only */
const wrapper = util.createTransformStreamForReadableSide(
this.params.type, () => this.timeZero, highWaterMark,
this.config.audioSampleRate, this.config.audioBitDepth, this.config.audioChannels)
this.stream = Stream.compose(this.subprocess.stdout!, wrapper)
}
else if (this.params.mode === "w") {
/* write-only mode: stdin only */
const wrapper = util.createTransformStreamForWritableSide(
this.params.type, highWaterMark)
this.stream = Stream.compose(wrapper, this.subprocess.stdin!)
}
}
/* close node */
async close () {
/* terminate subprocess */
if (this.subprocess !== null) {
/* gracefully end stdin if in write or read/write mode */
if ((this.params.mode === "w" || this.params.mode === "rw") && this.subprocess.stdin
&& !this.subprocess.stdin.destroyed && !this.subprocess.stdin.writableEnded) {
const ac1 = new AbortController()
await Promise.race([
new Promise<void>((resolve, reject) => {
this.subprocess!.stdin!.end((err?: Error) => {
if (err) reject(err)
else resolve()
})
}),
util.timeout(2000, "timeout", ac1.signal)
]).finally(() => {
ac1.abort()
}).catch((err: unknown) => {
const error = util.ensureError(err)
this.log("warning", `failed to gracefully close stdin: ${error.message}`)
})
}
/* remove event listeners to prevent errors during kill sequence */
this.subprocess.removeAllListeners("error")
this.subprocess.removeAllListeners("exit")
/* wait for subprocess to exit gracefully */
const ac2 = new AbortController()
await Promise.race([
this.subprocess,
util.timeout(5000, "subprocess exit timeout", ac2.signal)
]).finally(() => {
ac2.abort()
}).catch(async (err: unknown) => {
/* force kill with SIGTERM */
const error = util.ensureError(err)
if (error.message.includes("timeout")) {
this.log("warning", "subprocess did not exit gracefully, forcing termination")
this.subprocess!.kill("SIGTERM")
const ac3 = new AbortController()
return Promise.race([
this.subprocess,
util.timeout(2000, "timeout", ac3.signal)
]).finally(() => {
ac3.abort()
})
}
}).catch(async () => {
/* force kill with SIGKILL */
this.log("warning", "subprocess did not respond to SIGTERM, forcing SIGKILL")
this.subprocess!.kill("SIGKILL")
const ac4 = new AbortController()
return Promise.race([
this.subprocess,
util.timeout(1000, "timeout", ac4.signal)
]).finally(() => {
ac4.abort()
})
}).catch(() => {
this.log("error", "subprocess did not terminate even after SIGKILL")
})
/* clear subprocess reference */
this.subprocess = null
}
/* shutdown stream */
if (this.stream !== null) {
await util.destroyStream(this.stream)
this.stream = null
}
}
}