picopilot 0.1.5 → 0.2.1

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 (126) hide show
  1. package/dist/cli.d.ts.map +1 -1
  2. package/dist/cli.js +205 -17
  3. package/dist/cli.js.map +1 -1
  4. package/dist/commands/audio.d.ts +3 -3
  5. package/dist/commands/audio.d.ts.map +1 -1
  6. package/dist/commands/audio.js +69 -44
  7. package/dist/commands/audio.js.map +1 -1
  8. package/dist/commands/doctor.d.ts +22 -0
  9. package/dist/commands/doctor.d.ts.map +1 -0
  10. package/dist/commands/doctor.js +226 -0
  11. package/dist/commands/doctor.js.map +1 -0
  12. package/dist/commands/export.d.ts +3 -4
  13. package/dist/commands/export.d.ts.map +1 -1
  14. package/dist/commands/export.js +4 -9
  15. package/dist/commands/export.js.map +1 -1
  16. package/dist/commands/gfx.d.ts +2 -2
  17. package/dist/commands/gfx.d.ts.map +1 -1
  18. package/dist/commands/gfx.js +14 -7
  19. package/dist/commands/gfx.js.map +1 -1
  20. package/dist/commands/lint.d.ts +2 -2
  21. package/dist/commands/lint.d.ts.map +1 -1
  22. package/dist/commands/lint.js +3 -2
  23. package/dist/commands/lint.js.map +1 -1
  24. package/dist/commands/minify.d.ts +2 -2
  25. package/dist/commands/minify.d.ts.map +1 -1
  26. package/dist/commands/minify.js +3 -2
  27. package/dist/commands/minify.js.map +1 -1
  28. package/dist/commands/pico8-env.d.ts +112 -0
  29. package/dist/commands/pico8-env.d.ts.map +1 -0
  30. package/dist/commands/pico8-env.js +118 -0
  31. package/dist/commands/pico8-env.js.map +1 -0
  32. package/dist/commands/playtest.d.ts +2 -2
  33. package/dist/commands/playtest.d.ts.map +1 -1
  34. package/dist/commands/playtest.js +4 -9
  35. package/dist/commands/playtest.js.map +1 -1
  36. package/dist/commands/run.d.ts +3 -4
  37. package/dist/commands/run.d.ts.map +1 -1
  38. package/dist/commands/run.js +52 -19
  39. package/dist/commands/run.js.map +1 -1
  40. package/dist/commands/serve.d.ts +2 -2
  41. package/dist/commands/serve.d.ts.map +1 -1
  42. package/dist/commands/serve.js +4 -9
  43. package/dist/commands/serve.js.map +1 -1
  44. package/dist/commands/shrinko-env.d.ts +22 -0
  45. package/dist/commands/shrinko-env.d.ts.map +1 -0
  46. package/dist/commands/shrinko-env.js +15 -0
  47. package/dist/commands/shrinko-env.js.map +1 -0
  48. package/dist/commands/tokens.d.ts +4 -4
  49. package/dist/commands/tokens.d.ts.map +1 -1
  50. package/dist/commands/tokens.js +3 -2
  51. package/dist/commands/tokens.js.map +1 -1
  52. package/dist/commands/verify.d.ts +2 -2
  53. package/dist/commands/verify.d.ts.map +1 -1
  54. package/dist/commands/verify.js +3 -2
  55. package/dist/commands/verify.js.map +1 -1
  56. package/dist/engine/child-env.d.ts +14 -0
  57. package/dist/engine/child-env.d.ts.map +1 -0
  58. package/dist/engine/child-env.js +21 -0
  59. package/dist/engine/child-env.js.map +1 -0
  60. package/dist/engine/pico8/adapter.d.ts +23 -3
  61. package/dist/engine/pico8/adapter.d.ts.map +1 -1
  62. package/dist/engine/pico8/adapter.js.map +1 -1
  63. package/dist/engine/pico8/cleanup.d.ts +16 -0
  64. package/dist/engine/pico8/cleanup.d.ts.map +1 -0
  65. package/dist/engine/pico8/cleanup.js +74 -0
  66. package/dist/engine/pico8/cleanup.js.map +1 -0
  67. package/dist/engine/pico8/display.d.ts +149 -0
  68. package/dist/engine/pico8/display.d.ts.map +1 -0
  69. package/dist/engine/pico8/display.js +311 -0
  70. package/dist/engine/pico8/display.js.map +1 -0
  71. package/dist/engine/pico8/drive.d.ts.map +1 -1
  72. package/dist/engine/pico8/drive.js +28 -2
  73. package/dist/engine/pico8/drive.js.map +1 -1
  74. package/dist/engine/pico8/harness.d.ts +8 -0
  75. package/dist/engine/pico8/harness.d.ts.map +1 -1
  76. package/dist/engine/pico8/harness.js +9 -1
  77. package/dist/engine/pico8/harness.js.map +1 -1
  78. package/dist/engine/pico8/index.d.ts +3 -0
  79. package/dist/engine/pico8/index.d.ts.map +1 -1
  80. package/dist/engine/pico8/index.js +3 -0
  81. package/dist/engine/pico8/index.js.map +1 -1
  82. package/dist/engine/pico8/shell.d.ts +56 -3
  83. package/dist/engine/pico8/shell.d.ts.map +1 -1
  84. package/dist/engine/pico8/shell.js +188 -24
  85. package/dist/engine/pico8/shell.js.map +1 -1
  86. package/dist/engine/shrinko/shell.d.ts +3 -3
  87. package/dist/engine/shrinko/shell.js +3 -3
  88. package/package.json +2 -2
  89. package/src/cli.test.ts +229 -0
  90. package/src/cli.ts +230 -18
  91. package/src/commands/audio.test.ts +64 -1
  92. package/src/commands/audio.ts +86 -57
  93. package/src/commands/doctor.test.ts +149 -0
  94. package/src/commands/doctor.ts +284 -0
  95. package/src/commands/export.ts +9 -13
  96. package/src/commands/gfx.ts +19 -15
  97. package/src/commands/lint.ts +7 -6
  98. package/src/commands/minify.ts +7 -6
  99. package/src/commands/pico8-env.ts +192 -0
  100. package/src/commands/playtest.ts +8 -11
  101. package/src/commands/run.test.ts +110 -2
  102. package/src/commands/run.ts +68 -24
  103. package/src/commands/serve.ts +8 -11
  104. package/src/commands/shrinko-env.test.ts +68 -0
  105. package/src/commands/shrinko-env.ts +40 -0
  106. package/src/commands/tokens.ts +9 -6
  107. package/src/commands/verify.ts +7 -4
  108. package/src/engine/child-env.ts +22 -0
  109. package/src/engine/pico8/adapter.ts +25 -3
  110. package/src/engine/pico8/cleanup.test.ts +28 -0
  111. package/src/engine/pico8/cleanup.ts +76 -0
  112. package/src/engine/pico8/display.test.ts +210 -0
  113. package/src/engine/pico8/display.ts +429 -0
  114. package/src/engine/pico8/drive.test.ts +20 -0
  115. package/src/engine/pico8/drive.ts +25 -2
  116. package/src/engine/pico8/fake-xvfb.test-support.ts +55 -0
  117. package/src/engine/pico8/harness.test.ts +26 -0
  118. package/src/engine/pico8/harness.ts +18 -2
  119. package/src/engine/pico8/index.ts +3 -0
  120. package/src/engine/pico8/shell.test.ts +448 -9
  121. package/src/engine/pico8/shell.ts +255 -26
  122. package/src/engine/pico8/virtual-display.process.test.ts +206 -0
  123. package/src/engine/shrinko/shell.ts +3 -3
  124. package/src/mcp.process.test.ts +219 -0
  125. package/src/skills/picopilot-audio/SKILL.md +4 -8
  126. package/src/skills/picopilot-overview/SKILL.md +9 -1
@@ -4,6 +4,8 @@ import {
4
4
  mkdirSync,
5
5
  mkdtempSync,
6
6
  readdirSync,
7
+ readFileSync,
8
+ rmSync,
7
9
  statSync,
8
10
  } from 'node:fs';
9
11
  import {tmpdir} from 'node:os';
@@ -21,8 +23,19 @@ import {
21
23
  type Pico8Result,
22
24
  RECORD_WAV_BASENAME,
23
25
  type RecordOptions,
26
+ type RecordReport,
24
27
  type RunOptions,
25
28
  } from './adapter.js';
29
+ import {
30
+ DEFAULT_DISPLAY_MODE,
31
+ type DisplayMode,
32
+ findExecutable,
33
+ needsVirtualDisplay,
34
+ startVirtualDisplay,
35
+ type VirtualDisplayDeps,
36
+ } from './display.js';
37
+ import {overlayEnv} from '../child-env.js';
38
+ import {killOnAbort} from './cleanup.js';
26
39
  import {SentinelWatcher} from './sentinel.js';
27
40
 
28
41
  /**
@@ -30,13 +43,15 @@ import {SentinelWatcher} from './sentinel.js';
30
43
  * delivers stdout chunks (both `stdout` and `stderr` folded in, since PICO-8's
31
44
  * `printh` and its own lines can land on either); `onClose` fires when the
32
45
  * process exits on its own; `onError` fires on a spawn failure (ENOENT ⇒ PICO-8
33
- * absent). `kill` terminates the process tree (the backstop / sentinel kill).
46
+ * absent). `kill` terminates the process tree (the backstop / sentinel kill),
47
+ * with SIGKILL unless a gentler `signal` is given (the `xvfb-run` fallback is
48
+ * first sent SIGTERM so Xvfb can remove its lock file and socket).
34
49
  */
35
50
  export interface Pico8Process {
36
51
  onStdout(cb: (chunk: string) => void): void;
37
52
  onClose(cb: (code: number | null) => void): void;
38
53
  onError(cb: (err: NodeJS.ErrnoException) => void): void;
39
- kill(): void;
54
+ kill(signal?: NodeJS.Signals): void;
40
55
  /**
41
56
  * Writes raw bytes to the process's stdin, for the `drive` transport (the
42
57
  * fixed-size command blocks). Absent/undefined when stdin was detached
@@ -95,6 +110,76 @@ export function withPico8Home(args: string[]): string[] {
95
110
  return ['-home', home, ...args];
96
111
  }
97
112
 
113
+ /**
114
+ * The environment PICO-8 is spawned with: the parent env overlaid with the
115
+ * picopilot-resolved values ({@link overlayEnv}). A `-run` session cannot open a
116
+ * window or an audio device without DISPLAY, HOME, XDG_RUNTIME_DIR, SDL_*, ...
117
+ */
118
+ export function pico8ChildEnv(
119
+ resolved: Readonly<Record<string, string | undefined>>,
120
+ parent: NodeJS.ProcessEnv,
121
+ ): NodeJS.ProcessEnv {
122
+ return overlayEnv(resolved, parent);
123
+ }
124
+
125
+ /**
126
+ * A private `XDG_RUNTIME_DIR` (mode 0700, one per picopilot process) for a
127
+ * PICO-8 launched without one. SDL logs "XDG_RUNTIME_DIR is invalid or not set"
128
+ * otherwise, which is noise in every headless report.
129
+ */
130
+ let cachedRuntimeDir: string | undefined;
131
+ export function pico8RuntimeDir(): string {
132
+ if (cachedRuntimeDir !== undefined && existsSync(cachedRuntimeDir)) {
133
+ return cachedRuntimeDir;
134
+ }
135
+ cachedRuntimeDir = mkdtempSync(join(tmpdir(), 'picopilot-xdg-runtime-'));
136
+ return cachedRuntimeDir;
137
+ }
138
+
139
+ /** `env` with a private {@link pico8RuntimeDir} filled in when XDG_RUNTIME_DIR is absent. */
140
+ export function withRuntimeDir(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
141
+ const current = env.XDG_RUNTIME_DIR;
142
+ if (current !== undefined && current.trim() !== '') return env;
143
+ return {...env, XDG_RUNTIME_DIR: pico8RuntimeDir()};
144
+ }
145
+
146
+ /** How many lines of PICO-8's `log.txt` a failed record reports. */
147
+ const LOG_TAIL_LINES = 25;
148
+
149
+ /** The last lines of PICO-8's `log.txt` in the isolated `-home`, or undefined if absent. */
150
+ export function readPico8LogTail(
151
+ home: string = pico8HomeDir(),
152
+ lines: number = LOG_TAIL_LINES,
153
+ ): string | undefined {
154
+ try {
155
+ const text = readFileSync(join(home, 'log.txt'), 'utf8');
156
+ const all = text.trimEnd().split('\n');
157
+ return all.slice(-lines).join('\n');
158
+ } catch {
159
+ return undefined;
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Says WHY a record run produced no WAV, from how it ended. The sentinel is the
165
+ * harness's last act (after `audio_end`), so anything other than a sentinel
166
+ * means the harness never finished; a sentinel with no WAV means `audio_end`
167
+ * saved nothing usable.
168
+ */
169
+ export function explainNoWav(
170
+ exitReason: ExitReason,
171
+ ctx: {backstopMs: number; wavDir: string; basename: string},
172
+ ): string {
173
+ switch (exitReason) {
174
+ case 'timeout':
175
+ return `the backstop fired after ${ctx.backstopMs}ms before the record harness printed its done-sentinel: the cart never reached audio_end (it failed to load, errored at runtime, or its game loop never ran). See pico8LogTail and printh.`;
176
+ case 'exit':
177
+ return 'PICO-8 exited before the record harness printed its done-sentinel: the cart failed to load or crashed, or PICO-8 itself could not start (e.g. no display or audio device). See pico8LogTail and printh.';
178
+ case 'sentinel':
179
+ return `the record harness reached its done-sentinel but no non-empty ${ctx.basename}.wav was written to ${ctx.wavDir}: audio_end saved nothing usable (an empty 0-frame capture).`;
180
+ }
181
+ }
182
+
98
183
  export const spawnRunner: SpawnRunner = (file, args, env, options) => {
99
184
  // `drive` pipes fixed-size command blocks to stdin; `run`/`record` detach it so
100
185
  // the cart never blocks on console input.
@@ -124,12 +209,12 @@ export const spawnRunner: SpawnRunner = (file, args, env, options) => {
124
209
  // A closed stdin (the cart exited) is not fatal to the run.
125
210
  }
126
211
  },
127
- kill() {
212
+ kill(signal = 'SIGKILL') {
128
213
  try {
129
214
  // Negative pid kills the process GROUP (detached), so any child dies too.
130
- if (child.pid !== undefined) process.kill(-child.pid, 'SIGKILL');
215
+ if (child.pid !== undefined) process.kill(-child.pid, signal);
131
216
  } catch {
132
- child.kill('SIGKILL'); // fall back to killing just the process
217
+ child.kill(signal); // fall back to killing just the process
133
218
  }
134
219
  },
135
220
  };
@@ -202,8 +287,24 @@ export interface ShellPico8Options {
202
287
  readonly env: NodeJS.ProcessEnv;
203
288
  /** The spawn runner (defaults to {@link spawnRunner}); tests inject a fake. */
204
289
  readonly spawn?: SpawnRunner;
290
+ /**
291
+ * The display mode for `-run` sessions (ADR-0017; defaults to `auto`). Only
292
+ * {@link ShellPico8Adapter.record} consults it: the `-x` paths never need a
293
+ * display and never start one.
294
+ */
295
+ readonly display?: DisplayMode;
296
+ /** The virtual-display dependencies (Xvfb lookup + spawn); tests inject fakes. */
297
+ readonly virtualDisplay?: VirtualDisplayDeps;
298
+ /**
299
+ * How long a gracefully-ended run (the `xvfb-run` fallback) gets between
300
+ * SIGTERM and SIGKILL, in ms (default {@link DEFAULT_KILL_GRACE_MS}).
301
+ */
302
+ readonly killGraceMs?: number;
205
303
  }
206
304
 
305
+ /** The default SIGTERM-to-SIGKILL grace for a gracefully-ended run. */
306
+ export const DEFAULT_KILL_GRACE_MS = 2_000;
307
+
207
308
  /**
208
309
  * The v1 {@link Pico8Adapter}: shells out to the user's native `pico8 -x`
209
310
  * (ADR-0006, native path first). It owns the ORCHESTRATION the command should
@@ -217,10 +318,16 @@ export interface ShellPico8Options {
217
318
  export class ShellPico8Adapter implements Pico8Adapter {
218
319
  private readonly env: NodeJS.ProcessEnv;
219
320
  private readonly spawnRun: SpawnRunner;
321
+ private readonly display: DisplayMode;
322
+ private readonly virtualDisplay: VirtualDisplayDeps;
323
+ private readonly killGraceMs: number;
220
324
 
221
325
  constructor(options: ShellPico8Options) {
326
+ this.killGraceMs = options.killGraceMs ?? DEFAULT_KILL_GRACE_MS;
222
327
  this.env = options.env;
223
328
  this.spawnRun = options.spawn ?? spawnRunner;
329
+ this.display = options.display ?? DEFAULT_DISPLAY_MODE;
330
+ this.virtualDisplay = options.virtualDisplay ?? {};
224
331
  }
225
332
 
226
333
  run(options: RunOptions): Promise<Pico8Result> {
@@ -322,24 +429,95 @@ export class ShellPico8Adapter implements Pico8Adapter {
322
429
  }
323
430
 
324
431
  record(options: RecordOptions): Promise<Pico8RecordResult> {
432
+ if (needsVirtualDisplay(this.display, this.env)) {
433
+ return this.recordOnVirtualDisplay(options);
434
+ }
435
+ return this.recordWith(options, 'native');
436
+ }
437
+
438
+ /**
439
+ * The record session on a private virtual display (ADR-0017): start Xvfb
440
+ * (or fall back to `xvfb-run`), run PICO-8 on it with a dummy SDL audio
441
+ * driver (unless the caller chose one; `audio_rec` captures PICO-8's own
442
+ * mixer, not the device), and ALWAYS stop the display afterwards, however
443
+ * the run ended (sentinel, backstop, crash, spawn failure).
444
+ */
445
+ private async recordOnVirtualDisplay(
446
+ options: RecordOptions,
447
+ ): Promise<Pico8RecordResult> {
448
+ const [file] = pico8Candidates(this.env) as [string, ...string[]];
449
+ // Absent PICO-8 is reported as such BEFORE paying for (or failing on) a
450
+ // display, and wrapped in xvfb-run a missing binary would not ENOENT.
451
+ const find = this.virtualDisplay.find ?? findExecutable;
452
+ if (find(file, this.env) === undefined) return pico8NotFound();
453
+
454
+ const started = await startVirtualDisplay(this.env, this.virtualDisplay);
455
+ if (!started.ok) return started;
456
+ const vd = started.display;
457
+ try {
458
+ const env: NodeJS.ProcessEnv = {...this.env, ...vd.env};
459
+ const audioDriver = env.SDL_AUDIODRIVER;
460
+ if (audioDriver === undefined || audioDriver.trim() === '') {
461
+ env.SDL_AUDIODRIVER = 'dummy';
462
+ }
463
+ return await this.recordWith(options, vd.backend, {
464
+ env,
465
+ wrap: (f, a) => vd.wrap(f, a),
466
+ // xvfb-run's Xvfb lives in PICO-8's process group: SIGTERM first so it
467
+ // removes its /tmp/.X<n>-lock and socket (a SIGKILL leaves them behind).
468
+ endGracefully: vd.backend === 'xvfb-run',
469
+ });
470
+ } finally {
471
+ await vd.stop();
472
+ }
473
+ }
474
+
475
+ /** The record launch itself, natively or under a virtual display's env/wrapper. */
476
+ private recordWith(
477
+ options: RecordOptions,
478
+ display: RecordReport['display'],
479
+ launch?: {
480
+ env: NodeJS.ProcessEnv;
481
+ wrap: (file: string, args: string[]) => {file: string; args: string[]};
482
+ endGracefully?: boolean;
483
+ },
484
+ ): Promise<Pico8RecordResult> {
325
485
  const [file] = pico8Candidates(this.env) as [string, ...string[]];
326
486
  const basename = options.wavBasename ?? RECORD_WAV_BASENAME;
327
487
  // `-root_path <wavDir>` steers the `audio_end(1)`-to-current-folder WAV into
328
488
  // the isolated temp dir (never ~/Desktop or the carts root). `-run` is a real
329
489
  // A/V session (recording needs audio to capture; `-x` yields an empty WAV).
330
490
  const args = ['-root_path', options.wavDir, '-run', options.cartPath];
491
+ // The `-home` is shared by every launch in this process: drop the previous
492
+ // log.txt so a failed record never reports another run's log as its own.
493
+ rmSync(join(pico8HomeDir(), 'log.txt'), {force: true});
331
494
  return this.orchestrate<Pico8RecordResult>(
332
495
  file,
333
496
  args,
334
497
  {sentinel: options.sentinel, backstopMs: options.backstopMs},
335
- (exitReason, printh) => ({
336
- ok: true,
337
- value: {
338
- wavPath: collectWav(options.wavDir, basename),
339
- printh,
340
- exitReason,
341
- },
342
- }),
498
+ (exitReason, printh) => {
499
+ const wavPath = collectWav(options.wavDir, basename);
500
+ if (wavPath !== undefined) {
501
+ return {ok: true, value: {wavPath, printh, exitReason, display}};
502
+ }
503
+ const pico8LogTail = readPico8LogTail();
504
+ return {
505
+ ok: true,
506
+ value: {
507
+ wavPath,
508
+ printh,
509
+ exitReason,
510
+ display,
511
+ noWavReason: explainNoWav(exitReason, {
512
+ backstopMs: options.backstopMs,
513
+ wavDir: options.wavDir,
514
+ basename,
515
+ }),
516
+ ...(pico8LogTail !== undefined ? {pico8LogTail} : {}),
517
+ },
518
+ };
519
+ },
520
+ launch,
343
521
  );
344
522
  }
345
523
 
@@ -356,22 +534,72 @@ export class ShellPico8Adapter implements Pico8Adapter {
356
534
  args: string[],
357
535
  opts: {sentinel?: string; backstopMs: number},
358
536
  buildOk: (exitReason: ExitReason, printh: string) => R,
359
- spawnOpts?: {stdin?: boolean; onSpawn?: (proc: Pico8Process) => void},
537
+ spawnOpts?: {
538
+ stdin?: boolean;
539
+ onSpawn?: (proc: Pico8Process) => void;
540
+ /** The child env (defaults to the adapter's); a virtual display adds DISPLAY etc. */
541
+ env?: NodeJS.ProcessEnv;
542
+ /** Rewrites the launch (the `xvfb-run` fallback wraps the PICO-8 command). */
543
+ wrap?: (file: string, args: string[]) => {file: string; args: string[]};
544
+ /**
545
+ * End the run with SIGTERM, then SIGKILL after the grace period, and wait
546
+ * for the process to close before resolving (default: an immediate SIGKILL).
547
+ */
548
+ endGracefully?: boolean;
549
+ },
360
550
  ): Promise<R | ReturnType<typeof pico8NotFound>> {
361
551
  const watcher = new SentinelWatcher(opts.sentinel);
362
552
 
363
553
  return new Promise<R | ReturnType<typeof pico8NotFound>>((resolve) => {
364
554
  let settled = false;
365
555
  let backstop: NodeJS.Timeout | undefined;
366
- const proc = this.spawnRun(file, withPico8Home(args), this.env, {
367
- stdin: spawnOpts?.stdin,
368
- });
556
+ const homeArgs = withPico8Home(args);
557
+ const launch =
558
+ spawnOpts?.wrap !== undefined
559
+ ? spawnOpts.wrap(file, homeArgs)
560
+ : {file, args: homeArgs};
561
+ const proc = this.spawnRun(
562
+ launch.file,
563
+ launch.args,
564
+ withRuntimeDir(spawnOpts?.env ?? this.env),
565
+ {stdin: spawnOpts?.stdin},
566
+ );
567
+
568
+ // If picopilot itself is stopped mid-run, take PICO-8 down with it.
569
+ const disposeAbort = killOnAbort(() => proc.kill());
570
+ let onClosed: (() => void) | undefined;
369
571
 
370
- const finish = (exitReason: ExitReason): void => {
572
+ /**
573
+ * Ends the run once. `kill` = PICO-8 is still running (sentinel/backstop):
574
+ * SIGKILL its group, or for a graceful end SIGTERM it and resolve when it
575
+ * has closed (SIGKILL after the grace period).
576
+ */
577
+ const finish = (exitReason: ExitReason, kill = false): void => {
371
578
  if (settled) return;
372
579
  settled = true;
373
580
  if (backstop !== undefined) clearTimeout(backstop);
374
- resolve(buildOk(exitReason, watcher.text));
581
+ let resolved = false;
582
+ const done = () => {
583
+ if (resolved) return;
584
+ resolved = true;
585
+ // Only now: a signal during the graceful wait must still kill PICO-8.
586
+ disposeAbort();
587
+ resolve(buildOk(exitReason, watcher.text));
588
+ };
589
+ if (!kill) return done();
590
+ if (spawnOpts?.endGracefully !== true) {
591
+ proc.kill();
592
+ return done();
593
+ }
594
+ proc.kill('SIGTERM');
595
+ const hard = setTimeout(() => {
596
+ proc.kill();
597
+ done();
598
+ }, this.killGraceMs);
599
+ onClosed = () => {
600
+ clearTimeout(hard);
601
+ done();
602
+ };
375
603
  };
376
604
 
377
605
  proc.onError((err) => {
@@ -381,6 +609,7 @@ export class ShellPico8Adapter implements Pico8Adapter {
381
609
  if (settled) return;
382
610
  if (err.code === 'ENOENT') {
383
611
  settled = true;
612
+ disposeAbort();
384
613
  if (backstop !== undefined) clearTimeout(backstop);
385
614
  resolve(pico8NotFound());
386
615
  } else {
@@ -389,13 +618,14 @@ export class ShellPico8Adapter implements Pico8Adapter {
389
618
  });
390
619
 
391
620
  proc.onStdout((chunk) => {
392
- if (watcher.push(chunk) && !settled) {
393
- proc.kill(); // sentinel matched: end the run promptly
394
- finish('sentinel');
395
- }
621
+ // sentinel matched: end the run promptly
622
+ if (watcher.push(chunk) && !settled) finish('sentinel', true);
396
623
  });
397
624
 
398
- proc.onClose(() => finish('exit')); // PICO-8 exited on its own
625
+ proc.onClose(() => {
626
+ if (onClosed !== undefined) return onClosed();
627
+ finish('exit'); // PICO-8 exited on its own
628
+ });
399
629
 
400
630
  // For `drive`: write the command blocks to the live stdin now that it is
401
631
  // spawned. A spawn ENOENT resolves via onError before this matters.
@@ -404,8 +634,7 @@ export class ShellPico8Adapter implements Pico8Adapter {
404
634
  // Backstop: a cart that neither signals nor exits gets killed.
405
635
  backstop = setTimeout(() => {
406
636
  if (settled) return;
407
- proc.kill();
408
- finish('timeout');
637
+ finish('timeout', true);
409
638
  }, opts.backstopMs);
410
639
  });
411
640
  }
@@ -0,0 +1,206 @@
1
+ import {
2
+ chmodSync,
3
+ existsSync,
4
+ mkdirSync,
5
+ mkdtempSync,
6
+ readFileSync,
7
+ rmSync,
8
+ writeFileSync,
9
+ } from 'node:fs';
10
+ import {tmpdir} from 'node:os';
11
+ import {join} from 'node:path';
12
+ import {spawn} from 'node:child_process';
13
+ import {createRequire} from 'node:module';
14
+ import {afterEach, beforeEach, describe, expect, it} from 'vitest';
15
+ import {DONE_SENTINEL} from './adapter.js';
16
+ import {findExecutable} from './display.js';
17
+ import {ShellPico8Adapter} from './shell.js';
18
+
19
+ /** An absolute `sleep` (the scripts run with a minimal PATH; NixOS has no /bin/sleep). */
20
+ const SLEEP = findExecutable('sleep', process.env) ?? '/bin/sleep';
21
+
22
+ /**
23
+ * The no-stray-process guarantee (ADR-0017), proven with REAL processes: a fake
24
+ * `Xvfb` (a shell script honouring `-displayfd 3`) and a fake `pico8` (a shell
25
+ * script that prints the sentinel, hangs, or crashes), spawned by the real
26
+ * spawners. After every record run, BOTH pids must be gone. POSIX-only (sh).
27
+ */
28
+
29
+ let dir: string;
30
+ let pidDir: string;
31
+
32
+ /** Writes an executable shell script. */
33
+ function script(name: string, body: string): string {
34
+ const path = join(dir, name);
35
+ writeFileSync(path, `#!/bin/sh\n${body}\n`);
36
+ chmodSync(path, 0o755);
37
+ return path;
38
+ }
39
+
40
+ beforeEach(() => {
41
+ dir = mkdtempSync(join(tmpdir(), 'picopilot-vd-proc-'));
42
+ pidDir = join(dir, 'pids');
43
+ mkdirSync(pidDir);
44
+ // The fake X server: record its pid, report display 77 on fd 3, then idle.
45
+ script(
46
+ 'Xvfb',
47
+ `echo $$ > "$FAKE_PID_DIR/xvfb.pid"\necho 77 >&3\nexec ${SLEEP} 15`,
48
+ );
49
+ // The fake PICO-8: record its pid and DISPLAY, then behave per FAKE_MODE.
50
+ script(
51
+ 'pico8',
52
+ [
53
+ 'echo $$ > "$FAKE_PID_DIR/pico8.pid"',
54
+ 'echo "$DISPLAY" > "$FAKE_PID_DIR/display"',
55
+ 'case "$FAKE_MODE" in',
56
+ ` sentinel) echo ${DONE_SENTINEL}; exec ${SLEEP} 15;;`,
57
+ ` hang) exec ${SLEEP} 15;;`,
58
+ ' crash) exit 3;;',
59
+ 'esac',
60
+ ].join('\n'),
61
+ );
62
+ });
63
+
64
+ afterEach(() => {
65
+ // If an assertion failed before the run cleaned up, never leave the fake
66
+ // Xvfb / PICO-8 behind (they are `sleep`s, but still strays).
67
+ for (const name of ['xvfb', 'pico8']) {
68
+ const pid = readPid(name);
69
+ if (pid !== undefined && Number.isInteger(pid) && pid > 0) {
70
+ try {
71
+ process.kill(pid, 'SIGKILL');
72
+ } catch {
73
+ // already gone (the expected case)
74
+ }
75
+ }
76
+ }
77
+ rmSync(dir, {recursive: true, force: true});
78
+ });
79
+
80
+ /** Whether a pid is still alive (signal 0 probes without killing). */
81
+ function alive(pid: number): boolean {
82
+ try {
83
+ process.kill(pid, 0);
84
+ return true;
85
+ } catch {
86
+ return false;
87
+ }
88
+ }
89
+
90
+ /** Waits (bounded) for a pid to disappear; the kernel may lag the kill slightly. */
91
+ async function gone(pid: number): Promise<boolean> {
92
+ for (let i = 0; i < 100; i++) {
93
+ if (!alive(pid)) return true;
94
+ await new Promise((r) => setTimeout(r, 20));
95
+ }
96
+ return false;
97
+ }
98
+
99
+ function readPid(name: string): number | undefined {
100
+ const p = join(pidDir, `${name}.pid`);
101
+ if (!existsSync(p)) return undefined;
102
+ const pid = Number(readFileSync(p, 'utf8').trim());
103
+ return Number.isInteger(pid) && pid > 0 ? pid : undefined;
104
+ }
105
+
106
+ function adapter(mode: string, pico8 = join(dir, 'pico8')) {
107
+ return new ShellPico8Adapter({
108
+ display: 'xvfb',
109
+ env: {
110
+ PATH: `${dir}:/usr/bin:/bin`,
111
+ PICO8_PATH: pico8,
112
+ FAKE_PID_DIR: pidDir,
113
+ FAKE_MODE: mode,
114
+ },
115
+ });
116
+ }
117
+
118
+ describe.skipIf(process.platform === 'win32')(
119
+ 'virtual display with real processes: nothing is left behind',
120
+ () => {
121
+ for (const [mode, backstopMs, exitReason] of [
122
+ ['sentinel', 10_000, 'sentinel'],
123
+ ['hang', 300, 'timeout'],
124
+ ['crash', 10_000, 'exit'],
125
+ ] as const) {
126
+ it(`${mode}: PICO-8 saw the virtual DISPLAY, and both it and Xvfb are gone`, async () => {
127
+ const result = await adapter(mode).record({
128
+ cartPath: join(dir, 'h.p8'),
129
+ wavDir: dir,
130
+ backstopMs,
131
+ });
132
+ expect(result.ok).toBe(true);
133
+ if (result.ok) {
134
+ expect(result.value.exitReason).toBe(exitReason);
135
+ expect(result.value.display).toBe('Xvfb');
136
+ }
137
+ expect(readFileSync(join(pidDir, 'display'), 'utf8').trim()).toBe(
138
+ ':77',
139
+ );
140
+ const xvfb = readPid('xvfb');
141
+ const pico8 = readPid('pico8');
142
+ expect(xvfb).toBeDefined();
143
+ expect(pico8).toBeDefined();
144
+ expect(await gone(xvfb!)).toBe(true);
145
+ expect(await gone(pico8!)).toBe(true);
146
+ });
147
+ }
148
+
149
+ it('spawn failure: PICO-8 cannot exec, and Xvfb is still stopped', async () => {
150
+ // Executable, so the pre-flight lookup finds it, but its interpreter does
151
+ // not exist, so the spawn itself fails (ENOENT) after Xvfb is up.
152
+ const broken = join(dir, 'broken-pico8');
153
+ writeFileSync(broken, '#!/nonexistent/interpreter\n');
154
+ chmodSync(broken, 0o755);
155
+ const result = await adapter('sentinel', broken).record({
156
+ cartPath: join(dir, 'h.p8'),
157
+ wavDir: dir,
158
+ backstopMs: 10_000,
159
+ });
160
+ expect(!result.ok && result.reason).toBe('pico8-not-found');
161
+ const xvfb = readPid('xvfb');
162
+ expect(xvfb).toBeDefined();
163
+ expect(await gone(xvfb!)).toBe(true);
164
+ });
165
+
166
+ it('picopilot itself killed mid-run (SIGTERM): PICO-8 and Xvfb die with it', async () => {
167
+ // A separate node process runs the record (hanging cart); we SIGTERM it
168
+ // once both children are up, as an agent harness or `timeout` would.
169
+ const tsx = createRequire(import.meta.url).resolve('tsx');
170
+ const shell = new URL('./shell.ts', import.meta.url).pathname;
171
+ const code = `
172
+ const {ShellPico8Adapter} = await import(${JSON.stringify(shell)});
173
+ await new ShellPico8Adapter({display: 'xvfb', env: ${JSON.stringify({
174
+ PATH: `${dir}:/usr/bin:/bin`,
175
+ PICO8_PATH: join(dir, 'pico8'),
176
+ FAKE_PID_DIR: pidDir,
177
+ FAKE_MODE: 'hang',
178
+ })}}).record({cartPath: 'h.p8', wavDir: ${JSON.stringify(dir)}, backstopMs: 60000});
179
+ `;
180
+ const child = spawn(
181
+ process.execPath,
182
+ ['--import', tsx, '--input-type=module', '-e', code],
183
+ {stdio: 'ignore'},
184
+ );
185
+ const exited = new Promise((r) => child.on('exit', r));
186
+ try {
187
+ for (let i = 0; i < 250 && readPid('pico8') === undefined; i++) {
188
+ await new Promise((r) => setTimeout(r, 20));
189
+ }
190
+ const xvfb = readPid('xvfb');
191
+ const pico8 = readPid('pico8');
192
+ expect(xvfb).toBeDefined();
193
+ expect(pico8).toBeDefined();
194
+ child.kill('SIGTERM');
195
+ await exited;
196
+ expect(await gone(xvfb!)).toBe(true);
197
+ expect(await gone(pico8!)).toBe(true);
198
+ } finally {
199
+ // Never leave the runner process behind if an assertion failed.
200
+ if (child.exitCode === null && child.signalCode === null) {
201
+ child.kill('SIGKILL');
202
+ }
203
+ }
204
+ }, 15_000);
205
+ },
206
+ );
@@ -41,9 +41,9 @@ export type ChildRunner = (
41
41
  * The default {@link ChildRunner}: an `execFile` that captures stdout/stderr and
42
42
  * NEVER rejects. A failed spawn (ENOENT) becomes `spawnError`; a nonzero exit
43
43
  * becomes a `code`, so both outcomes are data the adapter maps to a structured
44
- * result. The child inherits ONLY the `env` it is handed (the command layer
45
- * passes the incur-resolved env, whose `PATH` a test can override) so there is
46
- * no ambient-environment leak.
44
+ * result. The child gets exactly the `env` it is handed and reads nothing
45
+ * ambiently; the command layer builds that env as the parent env overlaid with
46
+ * the resolved `PATH` (`commands/shrinko-env.ts`), and a test injects its own.
47
47
  */
48
48
  export const execFileRunner: ChildRunner = (file, args, env) =>
49
49
  new Promise((resolve) => {