native-containment.spec.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295
  1. import { spawn } from 'node:child_process'
  2. import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
  3. import { tmpdir } from 'node:os'
  4. import { join } from 'node:path'
  5. import { afterAll, describe, expect, it } from 'vitest'
  6. import { Context } from '@deepseek-ai/cordis'
  7. import type { SubprocessSpawnSpec, SubprocessTerminalHandle } from '@deepseek-ai/dsh-subprocess'
  8. import LocalSubprocessRuntime from '../src/index.ts'
  9. import { launchLinuxScope, probeLinuxScope } from '../src/linux-scope.ts'
  10. import { targetEnvironment } from '../src/runner-launch.ts'
  11. import { bindManagedProcess } from '../src/spawn.ts'
  12. const scratch = mkdtempSync(join(tmpdir(), 'dsh-native-containment-'))
  13. afterAll(() => { rmSync(scratch, { recursive: true, force: true }) })
  14. function spec(argv: string[], graceMs = 100): SubprocessSpawnSpec {
  15. return {
  16. argv,
  17. cwd: scratch,
  18. stdio: {
  19. stdin: 'ignore',
  20. stdout: { maxBytes: 64_000 },
  21. stderr: { maxBytes: 64_000 },
  22. },
  23. graceMs,
  24. }
  25. }
  26. type SpawnFailure = NodeJS.ErrnoException & { path?: string }
  27. function directSpawnFailure(argv: readonly string[]): Promise<SpawnFailure> {
  28. return new Promise((resolve, reject) => {
  29. const child = spawn(argv[0] as string, argv.slice(1), { cwd: scratch, stdio: 'ignore' })
  30. child.once('error', resolve)
  31. child.once('spawn', () => { reject(new Error(`expected ${argv[0]} to fail before spawn`)) })
  32. })
  33. }
  34. async function waitForPid(path: string): Promise<number> {
  35. const deadline = Date.now() + 5_000
  36. while (Date.now() < deadline) {
  37. try {
  38. const pid = Number(readFileSync(path, 'utf8').trim())
  39. if (Number.isSafeInteger(pid) && pid > 0) return pid
  40. } catch {
  41. // The target has not written the file yet.
  42. }
  43. await new Promise(resolve => setTimeout(resolve, 20))
  44. }
  45. throw new Error(`pid file ${path} was not written`)
  46. }
  47. async function waitGone(pid: number): Promise<void> {
  48. const deadline = Date.now() + 5_000
  49. while (Date.now() < deadline) {
  50. try {
  51. process.kill(pid, 0)
  52. try {
  53. const stat = readFileSync(`/proc/${pid}/stat`, 'utf8')
  54. const state = stat.slice(stat.lastIndexOf(')') + 2, stat.lastIndexOf(')') + 3)
  55. if (state === 'Z' || state === 'X') return
  56. } catch {
  57. return
  58. }
  59. } catch {
  60. return
  61. }
  62. await new Promise(resolve => setTimeout(resolve, 20))
  63. }
  64. throw new Error(`pid ${pid} remained alive`)
  65. }
  66. interface LinuxProcessState {
  67. parentPid: number
  68. processGroupId: number
  69. sessionId: number
  70. ttyNumber: number
  71. foregroundProcessGroupId: number
  72. }
  73. function readLinuxProcessState(pid: number): LinuxProcessState {
  74. const stat = readFileSync(`/proc/${pid}/stat`, 'utf8')
  75. const fields = stat.slice(stat.lastIndexOf(')') + 2).trim().split(/\s+/)
  76. const [parentPid, processGroupId, sessionId, ttyNumber, foregroundProcessGroupId] = fields
  77. .slice(1, 6)
  78. .map(Number)
  79. if ([parentPid, processGroupId, sessionId, ttyNumber, foregroundProcessGroupId]
  80. .some(value => !Number.isSafeInteger(value))) {
  81. throw new Error(`invalid /proc state for pid ${String(pid)}`)
  82. }
  83. return {
  84. parentPid: parentPid as number,
  85. processGroupId: processGroupId as number,
  86. sessionId: sessionId as number,
  87. ttyNumber: ttyNumber as number,
  88. foregroundProcessGroupId: foregroundProcessGroupId as number,
  89. }
  90. }
  91. async function waitReparented(pid: number, originalParentPid: number): Promise<LinuxProcessState> {
  92. const deadline = Date.now() + 5_000
  93. while (Date.now() < deadline) {
  94. const state = readLinuxProcessState(pid)
  95. if (state.parentPid !== originalParentPid) return state
  96. await new Promise(resolve => setTimeout(resolve, 20))
  97. }
  98. throw new Error(`pid ${String(pid)} remained parented to ${String(originalParentPid)}`)
  99. }
  100. function captureTerminalOutput(handle: SubprocessTerminalHandle): {
  101. text(): string
  102. waitFor(marker: string): Promise<string>
  103. } {
  104. let output = ''
  105. handle.output.on('data', (chunk: Buffer) => { output += chunk.toString() })
  106. return {
  107. text: () => output,
  108. waitFor: async (marker) => {
  109. const deadline = Date.now() + 5_000
  110. while (!output.includes(marker) && Date.now() < deadline) {
  111. await new Promise(resolve => setTimeout(resolve, 20))
  112. }
  113. if (!output.includes(marker)) {
  114. throw new Error(`terminal did not emit ${JSON.stringify(marker)}; output: ${JSON.stringify(output)}`)
  115. }
  116. return output
  117. },
  118. }
  119. }
  120. async function waitForInputReadiness(handle: SubprocessTerminalHandle): Promise<{
  121. processGroupId: number
  122. inputWaiting: boolean
  123. }> {
  124. const deadline = Date.now() + 5_000
  125. while (Date.now() < deadline) {
  126. const foreground = await handle.inspectForeground()
  127. if (foreground?.inputWaiting === true) return foreground
  128. await new Promise(resolve => setTimeout(resolve, 20))
  129. }
  130. throw new Error(`terminal ${String(handle.pid)} never became input-ready`)
  131. }
  132. const linuxNative = process.platform === 'linux' && probeLinuxScope()
  133. describe.skipIf(!linuxNative)('Linux user-systemd native containment', () => {
  134. it('aborts an established scope before bootstrap consumption and joins its managed handle', async () => {
  135. const controller = new AbortController()
  136. const request: SubprocessSpawnSpec = {
  137. ...spec(['bash', '-c', 'exit 0']),
  138. signal: controller.signal,
  139. stdio: { stdin: 'pipe', stdout: { maxBytes: 1_024 }, stderr: { maxBytes: 1_024 } },
  140. }
  141. const handle = bindManagedProcess(request, launchLinuxScope(request, targetEnvironment(request), {
  142. runnerInvocation: [process.execPath, join(import.meta.dirname, 'fixtures/hold-linux-bootstrap.ts')],
  143. }))
  144. try {
  145. const deadline = Date.now() + 5_000
  146. while (!handle.collected.stdout?.readFrom(0).text.includes('BOOTSTRAP_WAITING')) {
  147. if (Date.now() >= deadline) throw new Error('bootstrap did not reach its input barrier')
  148. await new Promise(resolve => setTimeout(resolve, 20))
  149. }
  150. controller.abort(new Error('cancel before target execution'))
  151. await expect(handle.done).resolves.toEqual({ exitCode: null, signal: 'SIGTERM' })
  152. await expect(handle.waitForExit()).resolves.toBe(true)
  153. } finally {
  154. handle.terminate()
  155. await Promise.allSettled([handle.done, handle.waitForExit()])
  156. }
  157. })
  158. it('terminates a setsid descendant and waits for the scope to become empty', async () => {
  159. const pidFile = join(scratch, `setsid-${Date.now()}.pid`)
  160. const command = `setsid sh -c 'echo $$ > "$1"; trap "" TERM; while :; do sleep 60; done' sh ${JSON.stringify(pidFile)} & wait`
  161. const request = spec(['bash', '-c', command], 80)
  162. const handle = bindManagedProcess(request, launchLinuxScope(request, targetEnvironment(request)))
  163. let descendant: number | undefined
  164. try {
  165. descendant = await waitForPid(pidFile)
  166. handle.terminate()
  167. await handle.done
  168. await expect(handle.waitForExit()).resolves.toBe(true)
  169. await waitGone(descendant)
  170. } finally {
  171. handle.terminate()
  172. await Promise.allSettled([handle.done, handle.waitForExit()])
  173. if (descendant !== undefined) {
  174. try { process.kill(descendant, 'SIGKILL') } catch { /* already contained */ }
  175. }
  176. }
  177. })
  178. it('preserves Node-shaped ENOENT and EACCES spawn failures without replay', async () => {
  179. const missingArgv = [`missing-native-target-${Date.now()}`, 'literal arg']
  180. const expectedMissing = await directSpawnFailure(missingArgv)
  181. const missing = spec(missingArgv)
  182. const missingHandle = bindManagedProcess(missing, launchLinuxScope(missing, targetEnvironment(missing)))
  183. await expect(missingHandle.done).rejects.toMatchObject({
  184. name: expectedMissing.name,
  185. message: expectedMissing.message,
  186. code: expectedMissing.code,
  187. syscall: expectedMissing.syscall,
  188. path: expectedMissing.path,
  189. })
  190. const deniedPath = join(scratch, `not-executable-${Date.now()}`)
  191. writeFileSync(deniedPath, '#!/bin/sh\nexit 0\n', { mode: 0o600 })
  192. chmodSync(deniedPath, 0o600)
  193. const deniedArgv = [deniedPath, 'literal arg']
  194. const expectedDenied = await directSpawnFailure(deniedArgv)
  195. const denied = spec(deniedArgv)
  196. const deniedHandle = bindManagedProcess(denied, launchLinuxScope(denied, targetEnvironment(denied)))
  197. await expect(deniedHandle.done).rejects.toMatchObject({
  198. name: expectedDenied.name,
  199. message: expectedDenied.message,
  200. code: expectedDenied.code,
  201. syscall: expectedDenied.syscall,
  202. path: expectedDenied.path,
  203. })
  204. })
  205. it('keeps PTY identity and readiness while containing a reparented setsid descendant', async () => {
  206. const escapedPath = join(scratch, `escaped-terminal-${Date.now()}.sh`)
  207. const terminalPath = join(scratch, `terminal-${Date.now()}.sh`)
  208. const launcherPidFile = join(scratch, `terminal-launcher-${Date.now()}.pid`)
  209. const descendantPidFile = join(scratch, `terminal-descendant-${Date.now()}.pid`)
  210. writeFileSync(escapedPath, `#!/bin/sh
  211. printf '%s\\n' "$$" > "$1"
  212. trap '' TERM
  213. while :; do sleep 60; done
  214. `, { mode: 0o700 })
  215. writeFileSync(terminalPath, `#!/bin/bash
  216. set -eu
  217. launcher_pid_file=$1
  218. descendant_pid_file=$2
  219. escaped_path=$3
  220. sh -c 'printf "%s\\n" "$$" > "$1"; setsid "$2" "$3" </dev/null >/dev/null 2>&1 &' sh "$launcher_pid_file" "$escaped_path" "$descendant_pid_file"
  221. while [ ! -s "$descendant_pid_file" ]; do sleep 0.01; done
  222. if [ -r /dev/tty ] && [ -w /dev/tty ]; then tty_ready=yes; else tty_ready=no; fi
  223. printf 'PTY_READY pid=%s tty=%s\\n' "$$" "$tty_ready" > /dev/tty
  224. IFS= read -r value < /dev/tty
  225. printf 'PTY_INPUT=%s\\n' "$value" > /dev/tty
  226. while :; do sleep 60; done
  227. `, { mode: 0o700 })
  228. const ctx = new Context()
  229. const fiber = await ctx.plugin(LocalSubprocessRuntime)
  230. let descendant: number | undefined
  231. let handle: SubprocessTerminalHandle | undefined
  232. try {
  233. handle = await ctx.subprocess.spawnTerminal({
  234. argv: [terminalPath, launcherPidFile, descendantPidFile, escapedPath],
  235. cwd: scratch,
  236. rows: 24,
  237. cols: 80,
  238. graceMs: 100,
  239. })
  240. const output = captureTerminalOutput(handle)
  241. const readyOutput = await output.waitFor('PTY_READY')
  242. const reportedPid = Number(/PTY_READY pid=(\d+) tty=yes/.exec(readyOutput)?.[1])
  243. expect(reportedPid, readyOutput).toBe(handle.pid)
  244. const top = readLinuxProcessState(handle.pid)
  245. expect(top).toMatchObject({
  246. processGroupId: handle.pid,
  247. sessionId: handle.pid,
  248. foregroundProcessGroupId: handle.pid,
  249. })
  250. expect(top.ttyNumber).not.toBe(0)
  251. const foreground = await waitForInputReadiness(handle)
  252. expect(foreground).toEqual({ processGroupId: handle.pid, inputWaiting: true })
  253. await handle.write('continue\n')
  254. await output.waitFor('PTY_INPUT=continue')
  255. const launcher = await waitForPid(launcherPidFile)
  256. descendant = await waitForPid(descendantPidFile)
  257. const escaped = await waitReparented(descendant, launcher)
  258. expect(escaped.parentPid).not.toBe(launcher)
  259. expect(escaped.processGroupId).toBe(descendant)
  260. expect(escaped.sessionId).toBe(descendant)
  261. expect(escaped.sessionId).not.toBe(handle.pid)
  262. await handle.terminate()
  263. await handle.done
  264. await waitGone(descendant)
  265. } finally {
  266. if (handle !== undefined) await handle.terminate().catch(() => {})
  267. if (descendant !== undefined) {
  268. try { process.kill(descendant, 'SIGKILL') } catch { /* already contained */ }
  269. }
  270. await fiber.dispose()
  271. }
  272. }, 15_000)
  273. })