@taliesinb/mash 0.0.0-stage → 0.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.
Files changed (203) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +275 -2
  3. package/bin/mash.ts +253 -0
  4. package/dist/bin/mash.d.ts +39 -0
  5. package/dist/bin/mash.d.ts.map +1 -0
  6. package/dist/bin/mash.js +281 -0
  7. package/dist/bin/mash.js.map +1 -0
  8. package/dist/src/analysis/analyze.d.ts +97 -0
  9. package/dist/src/analysis/analyze.d.ts.map +1 -0
  10. package/dist/src/analysis/analyze.js +1406 -0
  11. package/dist/src/analysis/analyze.js.map +1 -0
  12. package/dist/src/analysis/fs.d.ts +39 -0
  13. package/dist/src/analysis/fs.d.ts.map +1 -0
  14. package/dist/src/analysis/fs.js +117 -0
  15. package/dist/src/analysis/fs.js.map +1 -0
  16. package/dist/src/analysis/outputs.d.ts +16 -0
  17. package/dist/src/analysis/outputs.d.ts.map +1 -0
  18. package/dist/src/analysis/outputs.js +277 -0
  19. package/dist/src/analysis/outputs.js.map +1 -0
  20. package/dist/src/analysis/value.d.ts +76 -0
  21. package/dist/src/analysis/value.d.ts.map +1 -0
  22. package/dist/src/analysis/value.js +248 -0
  23. package/dist/src/analysis/value.js.map +1 -0
  24. package/dist/src/behaviors/defs/core.d.ts +15 -0
  25. package/dist/src/behaviors/defs/core.d.ts.map +1 -0
  26. package/dist/src/behaviors/defs/core.js +1084 -0
  27. package/dist/src/behaviors/defs/core.js.map +1 -0
  28. package/dist/src/behaviors/defs/devtools.d.ts +9 -0
  29. package/dist/src/behaviors/defs/devtools.d.ts.map +1 -0
  30. package/dist/src/behaviors/defs/devtools.js +432 -0
  31. package/dist/src/behaviors/defs/devtools.js.map +1 -0
  32. package/dist/src/behaviors/defs/macos.d.ts +12 -0
  33. package/dist/src/behaviors/defs/macos.d.ts.map +1 -0
  34. package/dist/src/behaviors/defs/macos.js +454 -0
  35. package/dist/src/behaviors/defs/macos.js.map +1 -0
  36. package/dist/src/behaviors/defs/tools.d.ts +9 -0
  37. package/dist/src/behaviors/defs/tools.d.ts.map +1 -0
  38. package/dist/src/behaviors/defs/tools.js +639 -0
  39. package/dist/src/behaviors/defs/tools.js.map +1 -0
  40. package/dist/src/behaviors/dsl.d.ts +95 -0
  41. package/dist/src/behaviors/dsl.d.ts.map +1 -0
  42. package/dist/src/behaviors/dsl.js +267 -0
  43. package/dist/src/behaviors/dsl.js.map +1 -0
  44. package/dist/src/behaviors/index.d.ts +12 -0
  45. package/dist/src/behaviors/index.d.ts.map +1 -0
  46. package/dist/src/behaviors/index.js +18 -0
  47. package/dist/src/behaviors/index.js.map +1 -0
  48. package/dist/src/behaviors/registry.d.ts +45 -0
  49. package/dist/src/behaviors/registry.d.ts.map +1 -0
  50. package/dist/src/behaviors/registry.js +266 -0
  51. package/dist/src/behaviors/registry.js.map +1 -0
  52. package/dist/src/behaviors/roles.d.ts +29 -0
  53. package/dist/src/behaviors/roles.d.ts.map +1 -0
  54. package/dist/src/behaviors/roles.js +198 -0
  55. package/dist/src/behaviors/roles.js.map +1 -0
  56. package/dist/src/behaviors/types.d.ts +120 -0
  57. package/dist/src/behaviors/types.d.ts.map +1 -0
  58. package/dist/src/behaviors/types.js +12 -0
  59. package/dist/src/behaviors/types.js.map +1 -0
  60. package/dist/src/index.d.ts +27 -0
  61. package/dist/src/index.d.ts.map +1 -0
  62. package/dist/src/index.js +24 -0
  63. package/dist/src/index.js.map +1 -0
  64. package/dist/src/integrations/deepsea.d.ts +121 -0
  65. package/dist/src/integrations/deepsea.d.ts.map +1 -0
  66. package/dist/src/integrations/deepsea.js +199 -0
  67. package/dist/src/integrations/deepsea.js.map +1 -0
  68. package/dist/src/machine.d.ts +139 -0
  69. package/dist/src/machine.d.ts.map +1 -0
  70. package/dist/src/machine.js +400 -0
  71. package/dist/src/machine.js.map +1 -0
  72. package/dist/src/parser/ast.d.ts +369 -0
  73. package/dist/src/parser/ast.d.ts.map +1 -0
  74. package/dist/src/parser/ast.js +17 -0
  75. package/dist/src/parser/ast.js.map +1 -0
  76. package/dist/src/parser/parser.d.ts +48 -0
  77. package/dist/src/parser/parser.d.ts.map +1 -0
  78. package/dist/src/parser/parser.js +1950 -0
  79. package/dist/src/parser/parser.js.map +1 -0
  80. package/dist/src/policy/effects.d.ts +86 -0
  81. package/dist/src/policy/effects.d.ts.map +1 -0
  82. package/dist/src/policy/effects.js +53 -0
  83. package/dist/src/policy/effects.js.map +1 -0
  84. package/dist/src/policy/policy.d.ts +130 -0
  85. package/dist/src/policy/policy.d.ts.map +1 -0
  86. package/dist/src/policy/policy.js +532 -0
  87. package/dist/src/policy/policy.js.map +1 -0
  88. package/dist/src/printer/doc.d.ts +151 -0
  89. package/dist/src/printer/doc.d.ts.map +1 -0
  90. package/dist/src/printer/doc.js +409 -0
  91. package/dist/src/printer/doc.js.map +1 -0
  92. package/dist/src/printer/highlight.d.ts +53 -0
  93. package/dist/src/printer/highlight.d.ts.map +1 -0
  94. package/dist/src/printer/highlight.js +339 -0
  95. package/dist/src/printer/highlight.js.map +1 -0
  96. package/dist/src/printer/html.d.ts +72 -0
  97. package/dist/src/printer/html.d.ts.map +1 -0
  98. package/dist/src/printer/html.js +423 -0
  99. package/dist/src/printer/html.js.map +1 -0
  100. package/dist/src/printer/print.d.ts +63 -0
  101. package/dist/src/printer/print.d.ts.map +1 -0
  102. package/dist/src/printer/print.js +484 -0
  103. package/dist/src/printer/print.js.map +1 -0
  104. package/dist/src/printer/summary.d.ts +50 -0
  105. package/dist/src/printer/summary.d.ts.map +1 -0
  106. package/dist/src/printer/summary.js +621 -0
  107. package/dist/src/printer/summary.js.map +1 -0
  108. package/dist/src/trace/events.d.ts +183 -0
  109. package/dist/src/trace/events.d.ts.map +1 -0
  110. package/dist/src/trace/events.js +9 -0
  111. package/dist/src/trace/events.js.map +1 -0
  112. package/dist/src/trace/progress.d.ts +58 -0
  113. package/dist/src/trace/progress.d.ts.map +1 -0
  114. package/dist/src/trace/progress.js +83 -0
  115. package/dist/src/trace/progress.js.map +1 -0
  116. package/dist/src/vm/arith.d.ts +29 -0
  117. package/dist/src/vm/arith.d.ts.map +1 -0
  118. package/dist/src/vm/arith.js +258 -0
  119. package/dist/src/vm/arith.js.map +1 -0
  120. package/dist/src/vm/builtins.d.ts +31 -0
  121. package/dist/src/vm/builtins.d.ts.map +1 -0
  122. package/dist/src/vm/builtins.js +1773 -0
  123. package/dist/src/vm/builtins.js.map +1 -0
  124. package/dist/src/vm/emulate.d.ts +75 -0
  125. package/dist/src/vm/emulate.d.ts.map +1 -0
  126. package/dist/src/vm/emulate.js +380 -0
  127. package/dist/src/vm/emulate.js.map +1 -0
  128. package/dist/src/vm/expand.d.ts +68 -0
  129. package/dist/src/vm/expand.d.ts.map +1 -0
  130. package/dist/src/vm/expand.js +744 -0
  131. package/dist/src/vm/expand.js.map +1 -0
  132. package/dist/src/vm/external.d.ts +34 -0
  133. package/dist/src/vm/external.d.ts.map +1 -0
  134. package/dist/src/vm/external.js +228 -0
  135. package/dist/src/vm/external.js.map +1 -0
  136. package/dist/src/vm/fifo.d.ts +66 -0
  137. package/dist/src/vm/fifo.d.ts.map +1 -0
  138. package/dist/src/vm/fifo.js +232 -0
  139. package/dist/src/vm/fifo.js.map +1 -0
  140. package/dist/src/vm/interp.d.ts +209 -0
  141. package/dist/src/vm/interp.d.ts.map +1 -0
  142. package/dist/src/vm/interp.js +1641 -0
  143. package/dist/src/vm/interp.js.map +1 -0
  144. package/dist/src/vm/io.d.ts +180 -0
  145. package/dist/src/vm/io.d.ts.map +1 -0
  146. package/dist/src/vm/io.js +502 -0
  147. package/dist/src/vm/io.js.map +1 -0
  148. package/dist/src/vm/pattern.d.ts +46 -0
  149. package/dist/src/vm/pattern.d.ts.map +1 -0
  150. package/dist/src/vm/pattern.js +498 -0
  151. package/dist/src/vm/pattern.js.map +1 -0
  152. package/dist/src/vm/proc.d.ts +94 -0
  153. package/dist/src/vm/proc.d.ts.map +1 -0
  154. package/dist/src/vm/proc.js +130 -0
  155. package/dist/src/vm/proc.js.map +1 -0
  156. package/dist/src/vm/state.d.ts +104 -0
  157. package/dist/src/vm/state.d.ts.map +1 -0
  158. package/dist/src/vm/state.js +233 -0
  159. package/dist/src/vm/state.js.map +1 -0
  160. package/dist/src/vm/test.d.ts +11 -0
  161. package/dist/src/vm/test.d.ts.map +1 -0
  162. package/dist/src/vm/test.js +277 -0
  163. package/dist/src/vm/test.js.map +1 -0
  164. package/package.json +63 -4
  165. package/src/analysis/analyze.ts +1395 -0
  166. package/src/analysis/fs.ts +121 -0
  167. package/src/analysis/outputs.ts +242 -0
  168. package/src/analysis/value.ts +264 -0
  169. package/src/behaviors/defs/core.ts +970 -0
  170. package/src/behaviors/defs/devtools.ts +400 -0
  171. package/src/behaviors/defs/macos.ts +426 -0
  172. package/src/behaviors/defs/tools.ts +596 -0
  173. package/src/behaviors/dsl.ts +293 -0
  174. package/src/behaviors/index.ts +19 -0
  175. package/src/behaviors/registry.ts +261 -0
  176. package/src/behaviors/roles.ts +187 -0
  177. package/src/behaviors/types.ts +127 -0
  178. package/src/index.ts +27 -0
  179. package/src/integrations/deepsea.ts +259 -0
  180. package/src/machine.ts +483 -0
  181. package/src/parser/ast.ts +472 -0
  182. package/src/parser/parser.ts +1826 -0
  183. package/src/policy/effects.ts +102 -0
  184. package/src/policy/policy.ts +578 -0
  185. package/src/printer/doc.ts +454 -0
  186. package/src/printer/highlight.ts +283 -0
  187. package/src/printer/html.ts +441 -0
  188. package/src/printer/print.ts +528 -0
  189. package/src/printer/summary.ts +601 -0
  190. package/src/trace/events.ts +102 -0
  191. package/src/trace/progress.ts +119 -0
  192. package/src/vm/arith.ts +236 -0
  193. package/src/vm/builtins.ts +1590 -0
  194. package/src/vm/emulate.ts +337 -0
  195. package/src/vm/expand.ts +721 -0
  196. package/src/vm/external.ts +265 -0
  197. package/src/vm/fifo.ts +226 -0
  198. package/src/vm/interp.ts +1623 -0
  199. package/src/vm/io.ts +570 -0
  200. package/src/vm/pattern.ts +477 -0
  201. package/src/vm/proc.ts +147 -0
  202. package/src/vm/state.ts +267 -0
  203. package/src/vm/test.ts +253 -0
@@ -0,0 +1,265 @@
1
+ /**
2
+ * Running external commands as real child processes.
3
+ *
4
+ * stdio is connected through the VM's open files: pipes and captures are
5
+ * pumped (so trace listeners see the bytes), /dev/null maps to `ignore`, and
6
+ * untraced host files are passed as raw fds. Process-substitution paths
7
+ * (`/dev/fd/N` naming a VM fd) are mapped onto extra child stdio slots
8
+ * (`devfd`, macOS) or onto named FIFOs (`fifo`, Linux; see fifo.ts).
9
+ */
10
+
11
+ import { spawn as nodeSpawn, type ChildProcess, type StdioOptions } from 'node:child_process'
12
+ import * as os from 'node:os'
13
+ import type { Interpreter } from './interp.ts'
14
+ import { FifoSet } from './fifo.ts'
15
+ import { BrokenPipeError, HostFile, NullFile, type OpenFile } from './io.ts'
16
+ import type { Proc } from './proc.ts'
17
+ import { HOST_UMASK } from './state.ts'
18
+
19
+ export interface SpawnRequest {
20
+ file: string
21
+ args: string[]
22
+ argv0: string
23
+ cwd: string
24
+ env: Record<string, string>
25
+ stdio: StdioOptions
26
+ }
27
+
28
+ /** Spawn a child. Replace it to confine children (e.g. through the harness's `ctx.sandbox.confine`). */
29
+ export type SpawnFn = (req: SpawnRequest) => ChildProcess | Promise<ChildProcess>
30
+
31
+ export const defaultSpawn: SpawnFn = (req) =>
32
+ nodeSpawn(req.file, req.args, { argv0: req.argv0, cwd: req.cwd, env: req.env, stdio: req.stdio })
33
+
34
+ export interface ExternalRequest {
35
+ execId: number
36
+ path: string
37
+ argv: string[]
38
+ env: Record<string, string>
39
+ cwd: string
40
+ proc: Proc
41
+ readsStdin: boolean | 'maybe'
42
+ }
43
+
44
+ function signalNumber(sig: NodeJS.Signals | null): number {
45
+ if (!sig) return 0
46
+ return (os.constants.signals as Record<string, number>)[sig] ?? 15
47
+ }
48
+
49
+ type Slot =
50
+ | { kind: 'ignore' }
51
+ | { kind: 'fd'; fd: number }
52
+ | { kind: 'pump-in'; file: OpenFile }
53
+ | { kind: 'pump-out'; file: OpenFile }
54
+
55
+ function outSlot(file: OpenFile | undefined, traced: boolean): Slot {
56
+ if (!file || !file.writable) return { kind: 'ignore' }
57
+ if (file instanceof NullFile) return { kind: 'ignore' }
58
+ if (file instanceof HostFile && !traced) return { kind: 'fd', fd: file.fd }
59
+ return { kind: 'pump-out', file }
60
+ }
61
+
62
+ function inSlot(file: OpenFile | undefined, traced: boolean, readsStdin: boolean | 'maybe'): Slot {
63
+ if (!file || !file.readable || readsStdin === false) return { kind: 'ignore' }
64
+ if (file instanceof NullFile) return { kind: 'ignore' }
65
+ if (file instanceof HostFile && !traced && file.buffered === 0) return { kind: 'fd', fd: file.fd }
66
+ return { kind: 'pump-in', file }
67
+ }
68
+
69
+ /** Arguments that open one of the child's own stdio fds by name. */
70
+ const STD_NAMES: Record<string, number> = { '/dev/stdin': 0, '/dev/stdout': 1, '/dev/stderr': 2, '/dev/fd/0': 0, '/dev/fd/1': 1, '/dev/fd/2': 2 }
71
+
72
+ /** An argument served through a named FIFO (`fifo` fd passing). */
73
+ interface FifoArg {
74
+ arg: number
75
+ file: OpenFile
76
+ /** The child reads it (mash feeds the FIFO); else the child writes it (mash drains it). */
77
+ feed: boolean
78
+ /** The VM fd it names (≥ 3), for the `/dev/fd/K` fallback when no FIFO can be made. */
79
+ vmFd?: number
80
+ /** stdin slot this argument displaced (restored on fallback). */
81
+ displaced?: Slot
82
+ }
83
+
84
+ export async function runExternal(interp: Interpreter, req: ExternalRequest): Promise<number> {
85
+ const rt = interp.rt
86
+ const { proc } = req
87
+ const traced = rt.listeners.length > 0
88
+ // a behavior's "file operands, so no stdin" does not hold for an operand that names stdin
89
+ const readsStdin = req.readsStdin === false && req.argv.some((a, i) => i > 0 && STD_NAMES[a] === 0) ? 'maybe' : req.readsStdin
90
+ const slots: Slot[] = [
91
+ inSlot(proc.fds.get(0), traced, readsStdin),
92
+ outSlot(proc.fds.get(1), traced),
93
+ outSlot(proc.fds.get(2), traced),
94
+ ]
95
+ const fifoMode = rt.fdPassing === 'fifo'
96
+ const fifoArgs: FifoArg[] = []
97
+ const extraSlot = (file: OpenFile): string => {
98
+ const k = slots.length
99
+ slots.push(file.readable && !file.writable ? { kind: 'pump-in', file } : { kind: 'pump-out', file })
100
+ return `/dev/fd/${k}`
101
+ }
102
+ // /dev/fd/N arguments naming VM fds (process substitution, `exec 3<...`)
103
+ const args = req.argv.slice(1).map((a, i) => {
104
+ const m = /^\/dev\/fd\/(\d+)$/.exec(a)
105
+ const n = m ? Number(m[1]) : fifoMode ? STD_NAMES[a] : undefined
106
+ if (n === undefined) return a
107
+ if (n < 3) {
108
+ // The child's own fd 0-2. Under `fifo` (Linux) a pumped slot is a socket, which the
109
+ // child cannot open by name: serve the argument through a FIFO on the same VM file.
110
+ const slot = slots[n]!
111
+ if (!fifoMode || (slot.kind !== 'pump-in' && slot.kind !== 'pump-out')) return a
112
+ const feed = slot.kind === 'pump-in'
113
+ // one consumer for the stdin data: the child's fd 0 becomes /dev/null
114
+ if (feed) slots[n] = { kind: 'ignore' }
115
+ fifoArgs.push({ arg: i, file: slot.file, feed, displaced: feed ? slot : undefined })
116
+ return a
117
+ }
118
+ const file = proc.fds.get(n)
119
+ if (!file) return a
120
+ if (!fifoMode) return extraSlot(file)
121
+ fifoArgs.push({ arg: i, file, feed: file.readable && !file.writable, vmFd: n })
122
+ return a
123
+ })
124
+ let fifos: { set: FifoSet; paths: string[] } | undefined
125
+ if (fifoArgs.length > 0) {
126
+ try {
127
+ fifos = await FifoSet.create(fifoArgs.length)
128
+ fifoArgs.forEach((f, j) => (args[f.arg] = fifos!.paths[j]!))
129
+ } catch {
130
+ // no FIFO possible (mkfifo missing, temp dir not writable): fall back to socket slots
131
+ for (const f of fifoArgs) {
132
+ if (f.vmFd !== undefined) args[f.arg] = extraSlot(f.file)
133
+ else if (f.displaced) slots[0] = f.displaced
134
+ }
135
+ fifoArgs.length = 0
136
+ }
137
+ }
138
+ try {
139
+ return await spawnAndPump(interp, req, slots, args, fifos ? fifoArgs.map((f, j) => ({ ...f, path: fifos!.paths[j]! })) : [], fifos?.set)
140
+ } finally {
141
+ fifos?.set.cleanup()
142
+ }
143
+ }
144
+
145
+ async function spawnAndPump(
146
+ interp: Interpreter,
147
+ req: ExternalRequest,
148
+ slots: Slot[],
149
+ args: string[],
150
+ fifoArgs: (FifoArg & { path: string })[],
151
+ fifoSet: FifoSet | undefined,
152
+ ): Promise<number> {
153
+ const rt = interp.rt
154
+ const { proc } = req
155
+ const stdio: StdioOptions = slots.map((s) => (s.kind === 'ignore' ? 'ignore' : s.kind === 'fd' ? s.fd : 'pipe'))
156
+
157
+ // Node cannot start a child with a closed fd or another umask: use a tiny sh exec prelude when needed
158
+ const prelude: string[] = []
159
+ if (proc.state.umask !== HOST_UMASK) prelude.push(`umask ${proc.state.umask.toString(8).padStart(4, '0')}`)
160
+ for (const fd of [0, 1, 2]) if (!proc.fds.get(fd)) prelude.push(`exec ${fd}>&-`)
161
+ let file = req.path
162
+ let spawnArgs = args
163
+ if (prelude.length > 0) {
164
+ file = '/bin/sh'
165
+ spawnArgs = ['-c', `${prelude.join('; ')}; exec "$@"`, 'sh', req.path, ...args]
166
+ }
167
+
168
+ let child: ChildProcess
169
+ try {
170
+ child = await rt.spawn({ file, args: spawnArgs, argv0: prelude.length > 0 ? 'sh' : req.argv[0], cwd: req.cwd, env: req.env, stdio })
171
+ } catch (e) {
172
+ interp.errorMessage(proc, `${req.argv[0]}: ${(e as Error).message}`)
173
+ return 126
174
+ }
175
+
176
+ const exited = new AbortController()
177
+ const pumps: Promise<void>[] = []
178
+ let spawnError: NodeJS.ErrnoException | null = null
179
+
180
+ const exit = new Promise<number>((resolve) => {
181
+ child.once('error', (e: NodeJS.ErrnoException) => {
182
+ spawnError = e
183
+ exited.abort()
184
+ resolve(e.code === 'ENOENT' ? 127 : 126)
185
+ })
186
+ child.once('exit', (code, signal) => {
187
+ exited.abort()
188
+ resolve(code ?? 128 + signalNumber(signal))
189
+ })
190
+ })
191
+
192
+ rt.emit({ type: 'command:spawn', execId: req.execId, osPid: child.pid })
193
+
194
+ const onAbort = (): void => {
195
+ child.kill('SIGTERM')
196
+ setTimeout(() => child.kill('SIGKILL'), 2000).unref()
197
+ }
198
+ rt.signal.addEventListener('abort', onAbort, { once: true })
199
+ proc.jobChildren?.add(child)
200
+ if (proc.jobSignal?.aborted) child.kill('SIGTERM')
201
+
202
+ slots.forEach((slot, i) => {
203
+ const stream = child.stdio[i]
204
+ if (!stream) return
205
+ if (slot.kind === 'pump-in') {
206
+ const w = stream as NodeJS.WritableStream & { destroyed?: boolean; writableNeedDrain?: boolean }
207
+ w.on('error', () => {}) // EPIPE when the child exits without reading everything
208
+ pumps.push((async () => {
209
+ try {
210
+ for (;;) {
211
+ const chunk = await slot.file.read(exited.signal)
212
+ if (chunk === null) {
213
+ if (!exited.signal.aborted) w.end()
214
+ return
215
+ }
216
+ if (exited.signal.aborted || w.destroyed) {
217
+ slot.file.unread(chunk)
218
+ return
219
+ }
220
+ if (!w.write(chunk)) {
221
+ await new Promise<void>((resolve) => {
222
+ const done = (): void => {
223
+ w.removeListener('drain', done)
224
+ exited.signal.removeEventListener('abort', done)
225
+ resolve()
226
+ }
227
+ w.once('drain', done)
228
+ exited.signal.addEventListener('abort', done, { once: true })
229
+ })
230
+ }
231
+ }
232
+ } catch {
233
+ // reading the source failed: give the child EOF
234
+ w.end()
235
+ }
236
+ })())
237
+ } else if (slot.kind === 'pump-out') {
238
+ const r = stream as NodeJS.ReadableStream & { destroy(): void }
239
+ pumps.push((async () => {
240
+ try {
241
+ for await (const chunk of r) {
242
+ await slot.file.write(chunk as Uint8Array)
243
+ }
244
+ } catch (e) {
245
+ if (e instanceof BrokenPipeError) r.destroy()
246
+ else r.destroy()
247
+ }
248
+ })())
249
+ }
250
+ })
251
+
252
+ if (fifoSet) {
253
+ for (const f of fifoArgs) pumps.push(f.feed ? fifoSet.feed(f.path, f.file, exited.signal) : fifoSet.drain(f.path, f.file, exited.signal))
254
+ }
255
+
256
+ const status = await exit
257
+ // stdout/stderr pumps finish at EOF; stdin pumps stop at exit
258
+ await Promise.all(pumps)
259
+ rt.signal.removeEventListener('abort', onAbort)
260
+ proc.jobChildren?.delete(child)
261
+ if (spawnError) {
262
+ interp.errorMessage(proc, `${req.argv[0]}: ${(spawnError as NodeJS.ErrnoException).message}`)
263
+ }
264
+ return status
265
+ }
package/src/vm/fifo.ts ADDED
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Named-FIFO fallback for handing VM file descriptors to external commands.
3
+ *
4
+ * Node creates child stdio pipes as UNIX socketpairs. macOS lets a child open
5
+ * `/dev/fd/N` on a socket (it dups the descriptor); Linux reopens the file
6
+ * through `/proc/self/fd/N`, which fails with ENXIO for sockets. So on Linux a
7
+ * process substitution (`diff <(a) <(b)`) or an `exec 3<f; cmd /dev/fd/3`
8
+ * passed to an external command cannot use the socket slot. Instead the child
9
+ * gets the path of a named FIFO in a private temp dir, and mash pumps between
10
+ * the FIFO and the VM file (so taps and tracing still see the bytes).
11
+ *
12
+ * Opening a FIFO blocks until the other side opens it, and a blocking open
13
+ * would hold one of libuv's four threadpool threads. So mash never does a
14
+ * blocking open: everything here uses `O_NONBLOCK` descriptors and polls with
15
+ * a short backoff (immediate while data flows, at most {@link MAX_DELAY} ms
16
+ * when idle).
17
+ *
18
+ * - **The child reads** (`<(cmd)`): mash polls `open(O_WRONLY|O_NONBLOCK)`,
19
+ * which fails with ENXIO until the child has opened the FIFO for reading.
20
+ * Only then does it write, so no data can be lost in a pipe nobody holds,
21
+ * and closing it at the VM file's EOF gives the child EOF. If the child
22
+ * exits without opening it, nothing is consumed from the VM file.
23
+ * - **The child writes** (`>(cmd)`): mash opens the read side
24
+ * `O_RDONLY|O_NONBLOCK` up front (that never blocks, and lets the child's
25
+ * own open for writing succeed at once) and polls `read`. A read of 0 means
26
+ * "no writer right now", so the pump only stops once the child has exited
27
+ * and every writer (including grandchildren) has closed.
28
+ *
29
+ * The strategy is chosen per machine: {@link defaultFdPassing} (Linux and the
30
+ * other non-macOS systems → `fifo`, macOS → `devfd`), overridable with the
31
+ * `MASH_PROCSUBST=fifo|devfd` environment variable or `MachineOptions.procSubst`.
32
+ */
33
+
34
+ import { execFile } from 'node:child_process'
35
+ import * as fs from 'node:fs'
36
+ import * as os from 'node:os'
37
+ import * as path from 'node:path'
38
+ import type { OpenFile } from './io.ts'
39
+
40
+ /** How VM fds named by `/dev/fd/N` arguments reach an external command. */
41
+ export type FdPassing = 'devfd' | 'fifo'
42
+
43
+ /** `MASH_PROCSUBST` if set to `fifo`/`devfd`, else `devfd` on macOS and `fifo` elsewhere. */
44
+ export function defaultFdPassing(env: Record<string, string | undefined> = process.env): FdPassing {
45
+ const v = env.MASH_PROCSUBST
46
+ if (v === 'fifo' || v === 'devfd') return v
47
+ return process.platform === 'darwin' ? 'devfd' : 'fifo'
48
+ }
49
+
50
+ const MAX_DELAY = 16
51
+
52
+ /** Exponential backoff for polling: 0 (a macrotask turn), 1, 2, 4 … MAX_DELAY ms. */
53
+ class Backoff {
54
+ private delay = 0
55
+ reset(): void {
56
+ this.delay = 0
57
+ }
58
+ /** Sleep; if `signal` aborts *during* the sleep, wake early. */
59
+ wait(signal?: AbortSignal): Promise<void> {
60
+ const ms = this.delay
61
+ this.delay = Math.min(MAX_DELAY, ms === 0 ? 1 : ms * 2)
62
+ return new Promise((resolve) => {
63
+ if (signal?.aborted) signal = undefined
64
+ const done = (): void => {
65
+ clearTimeout(t)
66
+ signal?.removeEventListener('abort', done)
67
+ resolve()
68
+ }
69
+ const t = ms === 0 ? setTimeout(done, 0) : setTimeout(done, ms)
70
+ signal?.addEventListener('abort', done, { once: true })
71
+ })
72
+ }
73
+ }
74
+
75
+ function unlink(p: string): void {
76
+ try {
77
+ fs.unlinkSync(p)
78
+ } catch {
79
+ // already gone
80
+ }
81
+ }
82
+
83
+ function code(e: unknown): string | undefined {
84
+ return (e as NodeJS.ErrnoException | null)?.code
85
+ }
86
+
87
+ /** A private temp dir holding the FIFOs of one external command. */
88
+ export class FifoSet {
89
+ readonly dir: string
90
+ private readonly feeds: string[] = []
91
+ private constructor(dir: string) {
92
+ this.dir = dir
93
+ }
94
+
95
+ /** Create a private dir with `n` FIFOs (`0`, `1`, …), mode 0600, via one `mkfifo` call. */
96
+ static async create(n: number): Promise<{ set: FifoSet; paths: string[] }> {
97
+ const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'mash-fifo-'))
98
+ const set = new FifoSet(dir)
99
+ const paths = Array.from({ length: n }, (_, i) => path.join(dir, String(i)))
100
+ try {
101
+ await new Promise<void>((resolve, reject) => {
102
+ execFile('mkfifo', ['-m', '600', ...paths], { env: { PATH: `/usr/bin:/bin:${process.env.PATH ?? ''}` } }, (err) => (err ? reject(err) : resolve()))
103
+ })
104
+ } catch (e) {
105
+ set.cleanup()
106
+ throw e
107
+ }
108
+ return { set, paths }
109
+ }
110
+
111
+ /**
112
+ * Feed the FIFO at `p` from `file` (the child reads it). Resolves at the
113
+ * file's EOF, when the child stops reading, or once `exited` aborts.
114
+ */
115
+ async feed(p: string, file: OpenFile, exited: AbortSignal): Promise<void> {
116
+ this.feeds.push(p)
117
+ const C = fs.constants
118
+ const backoff = new Backoff()
119
+ let fd = -1
120
+ while (fd < 0) {
121
+ if (exited.aborted) return
122
+ try {
123
+ fd = fs.openSync(p, C.O_WRONLY | C.O_NONBLOCK)
124
+ } catch (e) {
125
+ if (code(e) !== 'ENXIO') return // no reader yet is ENXIO; anything else: give up
126
+ await backoff.wait(exited)
127
+ }
128
+ }
129
+ backoff.reset()
130
+ try {
131
+ for (;;) {
132
+ const chunk = await file.read(exited)
133
+ if (chunk === null) return // EOF (or the child exited: nothing consumed)
134
+ let off = 0
135
+ while (off < chunk.length) {
136
+ if (exited.aborted) {
137
+ file.unread(chunk.subarray(off))
138
+ return
139
+ }
140
+ try {
141
+ off += fs.writeSync(fd, chunk, off, chunk.length - off)
142
+ backoff.reset()
143
+ } catch (e) {
144
+ if (code(e) === 'EAGAIN') {
145
+ await backoff.wait(exited)
146
+ continue
147
+ }
148
+ // EPIPE: the child closed it; leave the rest for other readers of a shared file
149
+ file.unread(chunk.subarray(off))
150
+ return
151
+ }
152
+ }
153
+ }
154
+ } catch {
155
+ // reading the source failed: the child gets EOF
156
+ } finally {
157
+ fs.closeSync(fd)
158
+ // a second open for reading would wait for a writer forever: make it fail instead
159
+ unlink(p)
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Drain the FIFO at `p` into `file` (the child writes it). Resolves once
165
+ * the child has exited and no writer holds the FIFO, or when `file` has no
166
+ * readers left (the child's writes then fail with EPIPE/SIGPIPE).
167
+ */
168
+ async drain(p: string, file: OpenFile, exited: AbortSignal): Promise<void> {
169
+ const C = fs.constants
170
+ let fd: number
171
+ try {
172
+ fd = fs.openSync(p, C.O_RDONLY | C.O_NONBLOCK)
173
+ } catch {
174
+ return
175
+ }
176
+ const buf = Buffer.allocUnsafe(65536)
177
+ const backoff = new Backoff()
178
+ try {
179
+ for (;;) {
180
+ let n: number
181
+ try {
182
+ n = fs.readSync(fd, buf, 0, buf.length, null)
183
+ } catch (e) {
184
+ if (code(e) !== 'EAGAIN') return
185
+ await backoff.wait() // a writer is connected but has nothing yet
186
+ continue
187
+ }
188
+ if (n > 0) {
189
+ backoff.reset()
190
+ await file.write(Buffer.from(buf.subarray(0, n)))
191
+ continue
192
+ }
193
+ // n === 0: no writer at the moment (not opened yet, or closed)
194
+ if (exited.aborted) return
195
+ await backoff.wait(exited)
196
+ }
197
+ } catch {
198
+ // BrokenPipeError: nobody reads any more. A later open for writing must fail, not block
199
+ unlink(p)
200
+ } finally {
201
+ fs.closeSync(fd)
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Remove the FIFOs and the dir. A reader of a fed FIFO still blocked in
207
+ * `open` (a grandchild that opened it late) is released first: a brief
208
+ * non-blocking writer open lets its open return, and it reads EOF.
209
+ */
210
+ cleanup(): void {
211
+ const C = fs.constants
212
+ for (const p of this.feeds) {
213
+ if (!fs.existsSync(p)) continue
214
+ try {
215
+ fs.closeSync(fs.openSync(p, C.O_WRONLY | C.O_NONBLOCK))
216
+ } catch {
217
+ // ENXIO: no reader waiting
218
+ }
219
+ }
220
+ try {
221
+ fs.rmSync(this.dir, { recursive: true, force: true })
222
+ } catch {
223
+ // best effort
224
+ }
225
+ }
226
+ }