@naxodev/apnea 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,13 @@
1
- import { spawnSync } from "node:child_process"
1
+ import { spawn, spawnSync, type ChildProcess } from "node:child_process"
2
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs"
3
+ import { tmpdir } from "node:os"
2
4
  import * as path from "node:path"
3
5
  import { Context, Effect, Layer } from "effect"
6
+ import {
7
+ formatVerifyBlock,
8
+ normalizeVerifySource,
9
+ type VerifyBlock,
10
+ } from "../domain/verify-commands.ts"
4
11
  import { VcsError } from "../errors.ts"
5
12
  import type { VcsBackend } from "../domain/types.ts"
6
13
  import { FileSystem } from "./file-system.ts"
@@ -27,7 +34,7 @@ export interface VcsService {
27
34
  ) => Effect.Effect<void>
28
35
  readonly runVerify: (
29
36
  root: string,
30
- commands: readonly string[],
37
+ blocks: readonly VerifyBlock[],
31
38
  timeoutMs: number,
32
39
  ) => Effect.Effect<{ ok: boolean; log: string }>
33
40
  }
@@ -52,6 +59,300 @@ function run(
52
59
  }
53
60
  }
54
61
 
62
+ function verificationError(
63
+ error: unknown,
64
+ temporaryDirectory?: string,
65
+ ): string {
66
+ const message =
67
+ error instanceof Error ? `${error.name}: ${error.message}` : String(error)
68
+ return temporaryDirectory
69
+ ? message.replaceAll(
70
+ temporaryDirectory,
71
+ "[temporary verification directory]",
72
+ )
73
+ : message
74
+ }
75
+
76
+ const VERIFY_LOG_LIMIT = 10 * 1024 * 1024
77
+ const VERIFY_KILL_CLOSE_GRACE_MS = 2_500
78
+ const VERIFY_RESULT_RESERVE = 2_048
79
+ const VERIFY_WRAPPER_SOURCE = `exec 2>&1
80
+ exec "$1" -e "$2"
81
+ `
82
+ const VERIFY_DISPLAY_LIMIT_NOTICE = `verification log limit of ${VERIFY_LOG_LIMIT} bytes would be exceeded by the verification block display; block was not executed`
83
+ const VERIFY_LOG_LIMIT_NOTICE = `verification log limit of ${VERIFY_LOG_LIMIT} bytes reached; output was truncated and verification stopped`
84
+ const VERIFY_LIMIT_NOTICE_RESERVE =
85
+ 1 +
86
+ Math.max(
87
+ Buffer.byteLength(VERIFY_DISPLAY_LIMIT_NOTICE),
88
+ Buffer.byteLength(VERIFY_LOG_LIMIT_NOTICE),
89
+ )
90
+
91
+ export function utf8BytesAfterAppend(
92
+ usedBytes: number,
93
+ limitBytes: number,
94
+ text: string,
95
+ ): number | null {
96
+ const nextBytes = usedBytes + Buffer.byteLength(text)
97
+ return nextBytes <= limitBytes ? nextBytes : null
98
+ }
99
+
100
+ class VerificationLog {
101
+ readonly #chunks: string[] = []
102
+ readonly #contentLimit: number
103
+ #bytes = 0
104
+ #limited = false
105
+
106
+ constructor(readonly limit: number) {
107
+ this.#contentLimit = Math.max(0, limit - VERIFY_LIMIT_NOTICE_RESERVE)
108
+ }
109
+
110
+ get remaining(): number {
111
+ return this.#contentLimit - this.#bytes
112
+ }
113
+
114
+ canAppendBytes(bytes: number): boolean {
115
+ return bytes <= this.remaining
116
+ }
117
+
118
+ append(text: string): boolean {
119
+ const nextBytes = utf8BytesAfterAppend(
120
+ this.#bytes,
121
+ this.#contentLimit,
122
+ text,
123
+ )
124
+ if (nextBytes === null) return false
125
+ this.#chunks.push(text)
126
+ this.#bytes = nextBytes
127
+ return true
128
+ }
129
+
130
+ addLimitNotice(notice: string): void {
131
+ if (this.#limited) return
132
+ this.#limited = true
133
+ const previous = this.#chunks.at(-1)
134
+ if (this.#bytes > 0 && !previous?.endsWith("\n")) {
135
+ this.#chunks.push("\n")
136
+ this.#bytes += 1
137
+ }
138
+ this.#chunks.push(notice)
139
+ this.#bytes += Buffer.byteLength(notice)
140
+ }
141
+
142
+ toString(): string {
143
+ return this.#chunks.join("").trimEnd()
144
+ }
145
+ }
146
+
147
+ export function verifyBlockDisplayByteLength(block: VerifyBlock): number {
148
+ const source = block.source
149
+ const bodyEnd = source.endsWith("\n") ? source.length - 1 : source.length
150
+ let lineCount = 1
151
+ for (let index = 0; index < bodyEnd; index++) {
152
+ if (source.charCodeAt(index) === 10) lineCount += 1
153
+ }
154
+ const bodyBytes =
155
+ Buffer.byteLength(source) - (bodyEnd < source.length ? 1 : 0)
156
+ return (
157
+ Buffer.byteLength(`${block.interpreter} -e [verification block]\n`) +
158
+ bodyBytes +
159
+ lineCount * 2
160
+ )
161
+ }
162
+
163
+ async function taskkillTree(pid: number): Promise<boolean> {
164
+ return new Promise((resolve) => {
165
+ let settled = false
166
+ let killer: ChildProcess
167
+ const finish = (ok: boolean) => {
168
+ if (settled) return
169
+ settled = true
170
+ clearTimeout(timer)
171
+ resolve(ok)
172
+ }
173
+
174
+ try {
175
+ killer = spawn("taskkill", ["/PID", String(pid), "/T", "/F"], {
176
+ stdio: "ignore",
177
+ windowsHide: true,
178
+ })
179
+ } catch {
180
+ resolve(false)
181
+ return
182
+ }
183
+
184
+ const timer = setTimeout(() => {
185
+ try {
186
+ killer.kill("SIGKILL")
187
+ } catch {
188
+ // The fallback below still targets the verification child.
189
+ }
190
+ finish(false)
191
+ }, 2_000)
192
+ killer.once("error", () => finish(false))
193
+ killer.once("close", (code) => finish(code === 0))
194
+ })
195
+ }
196
+
197
+ function snapshotProcessDescendants(rootPid: number): number[] {
198
+ const snapshot = spawnSync("ps", ["-axo", "pid=,ppid="], {
199
+ encoding: "utf8",
200
+ maxBuffer: 10 * 1024 * 1024,
201
+ })
202
+ if (snapshot.status !== 0 || snapshot.error) return []
203
+
204
+ const children = new Map<number, number[]>()
205
+ for (const line of (snapshot.stdout ?? "").split("\n")) {
206
+ const match = /^\s*(\d+)\s+(\d+)\s*$/.exec(line)
207
+ if (!match) continue
208
+ const pid = Number(match[1])
209
+ const parentPid = Number(match[2])
210
+ const siblings = children.get(parentPid)
211
+ if (siblings) siblings.push(pid)
212
+ else children.set(parentPid, [pid])
213
+ }
214
+
215
+ const descendants: number[] = []
216
+ const pending = [...(children.get(rootPid) ?? [])]
217
+ for (let index = 0; index < pending.length; index++) {
218
+ const pid = pending[index]!
219
+ descendants.push(pid)
220
+ pending.push(...(children.get(pid) ?? []))
221
+ }
222
+ return descendants
223
+ }
224
+
225
+ async function killProcessTree(child: ChildProcess): Promise<void> {
226
+ const pid = child.pid
227
+ if (process.platform === "win32" && pid !== undefined) {
228
+ if (await taskkillTree(pid)) return
229
+ } else if (pid !== undefined) {
230
+ const descendants = snapshotProcessDescendants(pid)
231
+ try {
232
+ process.kill(-pid, "SIGKILL")
233
+ } catch {
234
+ // The process may have exited between timeout and termination.
235
+ }
236
+ for (const descendantPid of descendants) {
237
+ try {
238
+ process.kill(descendantPid, "SIGKILL")
239
+ } catch {
240
+ // Process-group termination may already have killed this descendant.
241
+ }
242
+ }
243
+ }
244
+
245
+ // This is also the fallback when Windows taskkill cannot kill the tree.
246
+ try {
247
+ child.kill("SIGKILL")
248
+ } catch {
249
+ // A concurrently exited child needs no further termination.
250
+ }
251
+ }
252
+
253
+ type VerificationProcessResult = {
254
+ code: number
255
+ output: string
256
+ error?: string
257
+ }
258
+
259
+ function runVerificationProcess(
260
+ wrapper: string,
261
+ interpreter: VerifyBlock["interpreter"],
262
+ script: string,
263
+ cwd: string,
264
+ timeoutMs: number,
265
+ outputLimit: number,
266
+ ): Promise<VerificationProcessResult> {
267
+ return new Promise((resolve) => {
268
+ let child: ChildProcess
269
+ try {
270
+ child = spawn("sh", [wrapper, interpreter, script], {
271
+ cwd,
272
+ detached: process.platform !== "win32",
273
+ stdio: ["ignore", "pipe", "ignore"],
274
+ windowsHide: true,
275
+ })
276
+ } catch (error) {
277
+ resolve({
278
+ code: 1,
279
+ output: "",
280
+ error: verificationError(error),
281
+ })
282
+ return
283
+ }
284
+
285
+ const output: Buffer[] = []
286
+ let outputSize = 0
287
+ let outputExceeded = false
288
+ let processError: string | undefined
289
+ let termination: Promise<void> | undefined
290
+ let timeout: ReturnType<typeof setTimeout> | undefined
291
+ let postKillCompletion: ReturnType<typeof setTimeout> | undefined
292
+ let settled = false
293
+
294
+ const terminate = (message: string) => {
295
+ if (settled) return
296
+ processError ??= message
297
+ if (termination) return
298
+ termination = killProcessTree(child)
299
+ postKillCompletion = setTimeout(() => {
300
+ child.stdout?.destroy()
301
+ finish(1)
302
+ }, VERIFY_KILL_CLOSE_GRACE_MS)
303
+ }
304
+ const capture = (chunk: Buffer) => {
305
+ if (outputExceeded) return
306
+ const available = outputLimit - outputSize
307
+ if (chunk.length > available) {
308
+ if (available > 0) output.push(chunk.subarray(0, available))
309
+ outputSize = outputLimit
310
+ outputExceeded = true
311
+ terminate(`verification output exceeded ${outputLimit} bytes`)
312
+ return
313
+ }
314
+ output.push(chunk)
315
+ outputSize += chunk.length
316
+ }
317
+ const onStdout = (chunk: Buffer) => capture(chunk)
318
+ const onError = (error: Error) => {
319
+ terminate(`verification process error: ${verificationError(error)}`)
320
+ }
321
+ const finish = (code: number) => {
322
+ if (settled) return
323
+ settled = true
324
+ if (timeout) clearTimeout(timeout)
325
+ if (postKillCompletion) clearTimeout(postKillCompletion)
326
+ child.stdout?.off("data", onStdout)
327
+ child.off("error", onError)
328
+ child.off("close", onClose)
329
+ resolve({
330
+ code,
331
+ output: Buffer.concat(output).toString("utf8"),
332
+ ...(processError === undefined ? {} : { error: processError }),
333
+ })
334
+ }
335
+ const onClose = (code: number | null) => {
336
+ if (termination) {
337
+ void termination.then(
338
+ () => finish(code ?? 1),
339
+ () => finish(code ?? 1),
340
+ )
341
+ } else {
342
+ finish(code ?? 1)
343
+ }
344
+ }
345
+
346
+ child.stdout?.on("data", onStdout)
347
+ child.once("error", onError)
348
+ child.once("close", onClose)
349
+
350
+ timeout = setTimeout(() => {
351
+ terminate(`verification timed out after ${timeoutMs}ms`)
352
+ }, timeoutMs)
353
+ })
354
+ }
355
+
55
356
  /** Drop .apnea/ runtime paths from VCS summaries (artifacts are allowed). */
56
357
  export function filterAppPaths(summary: string): string {
57
358
  return summary
@@ -205,32 +506,103 @@ export const VcsLive = Layer.effect(
205
506
 
206
507
  const runVerify = (
207
508
  root: string,
208
- commands: readonly string[],
509
+ blocks: readonly VerifyBlock[],
209
510
  timeoutMs: number,
210
511
  ): Effect.Effect<{ ok: boolean; log: string }> =>
211
- Effect.sync(() => {
212
- const lines: string[] = []
213
- for (const cmd of commands) {
214
- lines.push(`$ ${cmd}`)
215
- const r = spawnSync("bash", ["-lc", cmd], {
216
- cwd: root,
512
+ Effect.promise(async () => {
513
+ const log = new VerificationLog(VERIFY_LOG_LIMIT)
514
+ let temporaryDirectory: string | undefined
515
+ let ok = true
516
+ let operation = "create temporary verification directory"
517
+ try {
518
+ temporaryDirectory = mkdtempSync(path.join(tmpdir(), "apnea-verify-"))
519
+ const wrapper = path.join(temporaryDirectory, "run-block.sh")
520
+ operation = "write verification wrapper"
521
+ writeFileSync(wrapper, VERIFY_WRAPPER_SOURCE, {
217
522
  encoding: "utf8",
218
- timeout: timeoutMs,
219
- maxBuffer: 10 * 1024 * 1024,
523
+ mode: 0o600,
220
524
  })
221
- const out = `${r.stdout ?? ""}${r.stderr ?? ""}`.trimEnd()
222
- if (out) lines.push(out)
223
- lines.push(`exit=${r.status ?? 1}`)
224
- if (r.error) {
225
- lines.push(String(r.error))
226
- return { ok: false, log: lines.join("\n") }
525
+ for (const [index, block] of blocks.entries()) {
526
+ const source = normalizeVerifySource(block.source)
527
+ const normalizedBlock = { ...block, source }
528
+ const script = path.join(
529
+ temporaryDirectory,
530
+ `block-${index + 1}.${block.interpreter}`,
531
+ )
532
+ const displayBytes =
533
+ 2 + verifyBlockDisplayByteLength(normalizedBlock) + 1
534
+ if (!log.canAppendBytes(displayBytes)) {
535
+ log.addLimitNotice(VERIFY_DISPLAY_LIMIT_NOTICE)
536
+ ok = false
537
+ break
538
+ }
539
+ log.append(`$ ${formatVerifyBlock(normalizedBlock)}\n`)
540
+ operation = `write ${block.interpreter} verification block`
541
+ writeFileSync(script, source, {
542
+ encoding: "utf8",
543
+ mode: 0o600,
544
+ })
545
+ operation = `run ${block.interpreter} verification block`
546
+ const result = await runVerificationProcess(
547
+ wrapper,
548
+ block.interpreter,
549
+ script,
550
+ root,
551
+ timeoutMs,
552
+ Math.max(0, log.remaining - VERIFY_RESULT_RESERVE),
553
+ )
554
+ const output = verificationError(
555
+ result.output.trimEnd(),
556
+ temporaryDirectory,
557
+ )
558
+ if (output && !log.append(`${output}\n`)) {
559
+ log.addLimitNotice(VERIFY_LOG_LIMIT_NOTICE)
560
+ ok = false
561
+ break
562
+ }
563
+ if (!log.append(`exit=${result.code}\n`)) {
564
+ log.addLimitNotice(VERIFY_LOG_LIMIT_NOTICE)
565
+ ok = false
566
+ break
567
+ }
568
+ if (result.error) {
569
+ const error = verificationError(result.error, temporaryDirectory)
570
+ if (!log.append(`${error}\n`)) {
571
+ log.addLimitNotice(VERIFY_LOG_LIMIT_NOTICE)
572
+ }
573
+ ok = false
574
+ break
575
+ }
576
+ if (result.code !== 0) {
577
+ ok = false
578
+ break
579
+ }
580
+ if (index < blocks.length - 1 && !log.append("\n")) {
581
+ log.addLimitNotice(VERIFY_LOG_LIMIT_NOTICE)
582
+ ok = false
583
+ break
584
+ }
585
+ }
586
+ } catch (error) {
587
+ const message = `${operation} failed: ${verificationError(error, temporaryDirectory)}\n`
588
+ if (!log.append(message)) {
589
+ log.addLimitNotice(VERIFY_LOG_LIMIT_NOTICE)
227
590
  }
228
- if (r.status !== 0) {
229
- return { ok: false, log: lines.join("\n") }
591
+ ok = false
592
+ } finally {
593
+ if (temporaryDirectory) {
594
+ try {
595
+ rmSync(temporaryDirectory, { recursive: true, force: true })
596
+ } catch (error) {
597
+ const message = `clean up temporary verification directory failed: ${verificationError(error, temporaryDirectory)}\n`
598
+ if (!log.append(message)) {
599
+ log.addLimitNotice(VERIFY_LOG_LIMIT_NOTICE)
600
+ }
601
+ ok = false
602
+ }
230
603
  }
231
- lines.push("")
232
604
  }
233
- return { ok: true, log: lines.join("\n") }
605
+ return { ok, log: log.toString() }
234
606
  })
235
607
 
236
608
  return Vcs.of({
@@ -3,7 +3,10 @@ import { Effect, Result } from "effect"
3
3
  import { asVerdict, parseFrontMatter } from "../domain/frontmatter.ts"
4
4
  import { abs, phaseDir, rel } from "../domain/paths.ts"
5
5
  import { nextAfter, toolAllowed } from "../domain/state-machine.ts"
6
- import { extractVerifyCommands } from "../domain/verify-commands.ts"
6
+ import {
7
+ extractVerifyBlocks,
8
+ formatVerifyCommand,
9
+ } from "../domain/verify-commands.ts"
7
10
  import {
8
11
  ArtifactInvalid,
9
12
  GateRefused,
@@ -86,8 +89,8 @@ export const commitWorkflow = (
86
89
  })
87
90
  }
88
91
  const pkgText = yield* fs.readFile(pkgAbs)
89
- const cmds = extractVerifyCommands(pkgText)
90
- if (!cmds.length) {
92
+ const blocks = extractVerifyBlocks(pkgText)
93
+ if (!blocks.length) {
91
94
  return yield* new ArtifactInvalid({
92
95
  artifact: pkgRel,
93
96
  message: "no verify commands found in phase package (need ```sh block)",
@@ -96,7 +99,7 @@ export const commitWorkflow = (
96
99
 
97
100
  const cfg = yield* config.load(root)
98
101
  const verifyTimeout = cfg.timeouts_ms.verify ?? 900_000
99
- const verify = yield* vcs.runVerify(root, cmds, verifyTimeout)
102
+ const verify = yield* vcs.runVerify(root, blocks, verifyTimeout)
100
103
 
101
104
  const vlog = path.join(path.dirname(reviewAbs), "verify.log")
102
105
  yield* fs.mkdir(path.dirname(vlog), { recursive: true })
@@ -104,7 +107,7 @@ export const commitWorkflow = (
104
107
 
105
108
  if (!verify.ok) {
106
109
  return yield* new VerifyFailed({
107
- commands: cmds,
110
+ commands: blocks.map(formatVerifyCommand),
108
111
  outputs: [verify.log.slice(-2000)],
109
112
  // `outputs` is a tail — point the caller at the full log on disk.
110
113
  verify_log: rel(vlog, root),