fauxnix-cli 0.7.1 → 0.8.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.
package/dist/executor.js CHANGED
@@ -5,7 +5,7 @@ import path from 'node:path';
5
5
  import { normalizeLiteralPath, wrapScript } from './translator.js';
6
6
  import { decodeOutput, normalizeHostNewlines, resolveNativePref } from './encoding.js';
7
7
  import { normalizeStderr } from './errors.js';
8
- import { PowerShellHost, PS_MISSING_MESSAGE } from './ps-host.js';
8
+ import { DEFAULT_STDERR_LIMIT, DEFAULT_STDOUT_LIMIT, PowerShellHost, PS_MISSING_MESSAGE, } from './ps-host.js';
9
9
  const DEFAULT_TIMEOUT_MS = 120_000;
10
10
  const DEFAULT_WINDOWS_PATHEXT = '.COM;.EXE;.BAT;.CMD';
11
11
  function hasEnvKey(env, name) {
@@ -153,6 +153,7 @@ function planRedirects(redirects) {
153
153
  }
154
154
  /** Session persists cwd and env across segments, like a real shell. */
155
155
  export class FauxnixSession {
156
+ id;
156
157
  cwd = null;
157
158
  env = {};
158
159
  /** Exit code of the previous segment — powers bash's `$?`. */
@@ -162,16 +163,23 @@ export class FauxnixSession {
162
163
  scriptFile;
163
164
  hostFile;
164
165
  host = null;
165
- runLock = Promise.resolve();
166
+ lifecycleLock = Promise.resolve();
166
167
  constructor() {
167
- this.bindFiles(randomUUID().slice(0, 8));
168
+ this.id = randomUUID().slice(0, 8);
169
+ this.bindFiles(this.id);
168
170
  }
169
171
  bindFiles(id) {
172
+ this.id = id;
170
173
  this.cwdFile = path.join(os.tmpdir(), 'fauxnix-' + id + '-cwd.txt');
171
174
  this.envFile = path.join(os.tmpdir(), 'fauxnix-' + id + '-env.json');
172
175
  this.scriptFile = path.join(os.tmpdir(), 'fauxnix-' + id + '-script.ps1');
173
176
  this.hostFile = path.join(os.tmpdir(), 'fauxnix-' + id + '-host.ps1');
174
177
  }
178
+ withLock(fn) {
179
+ const done = this.lifecycleLock.then(fn, fn);
180
+ this.lifecycleLock = done.then(() => undefined, () => undefined);
181
+ return done;
182
+ }
175
183
  syncFromDisk() {
176
184
  try {
177
185
  if (existsSync(this.cwdFile)) {
@@ -201,10 +209,22 @@ export class FauxnixSession {
201
209
  return this.host;
202
210
  }
203
211
  /** Boot powershell.exe now so the first run() is not the 1.1s cold start. */
204
- async prewarm() {
205
- await this.ensureHost().ready();
212
+ prewarm() {
213
+ return this.withLock(async () => {
214
+ await this.ensureHost().ready();
215
+ });
216
+ }
217
+ dispose() {
218
+ return this.withLock(() => this.disposeUnlocked());
219
+ }
220
+ /** Kill the host and re-prewarm the same session object (no second FauxnixSession). */
221
+ reset() {
222
+ return this.withLock(async () => {
223
+ await this.disposeUnlocked();
224
+ await this.ensureHost().ready();
225
+ });
206
226
  }
207
- async dispose() {
227
+ async disposeUnlocked() {
208
228
  if (this.host) {
209
229
  await this.host.stop();
210
230
  this.host = null;
@@ -255,18 +275,22 @@ export class FauxnixSession {
255
275
  return env;
256
276
  }
257
277
  run(plans, opts = {}) {
258
- const done = this.runLock.then(() => runPlans(plans, this, opts, () => this.syncFromDisk(), () => this.ensureHost()));
259
- this.runLock = done.then(() => undefined, () => undefined);
260
- return done;
278
+ return this.withLock(() => runPlans(plans, this, opts, () => this.syncFromDisk(), () => this.ensureHost()));
261
279
  }
262
280
  }
263
281
  async function runPlans(plans, session, opts, afterSegment, ensureHost) {
264
282
  const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
265
283
  const deadline = Date.now() + timeoutMs;
284
+ const stdoutLimit = opts.stdoutLimit ?? DEFAULT_STDOUT_LIMIT;
285
+ const stderrLimit = opts.stderrLimit ?? DEFAULT_STDERR_LIMIT;
266
286
  const timeoutMessage = '\nbash: command timed out after ' + Math.round(timeoutMs / 1000) + 's';
267
287
  let stdout = '';
268
288
  let stderr = '';
269
289
  let exitCode = 0;
290
+ let timedOut = false;
291
+ let cancelled = false;
292
+ let truncated = false;
293
+ let spawnError;
270
294
  // bash list semantics: `a && b ; c` runs c regardless of a; `a && b && c`
271
295
  // skips b AND c when a fails. chainOk models the value of the current
272
296
  // &&/|| chain; `;` segments always run and restart the chain.
@@ -282,9 +306,16 @@ async function runPlans(plans, session, opts, afterSegment, ensureHost) {
282
306
  continue;
283
307
  if (plan.op === '||' && chainOk)
284
308
  continue;
309
+ if (opts.signal?.aborted) {
310
+ cancelled = true;
311
+ exitCode = 130;
312
+ session.prevExit = exitCode;
313
+ break;
314
+ }
285
315
  if (Date.now() >= deadline) {
286
316
  stderr += timeoutMessage;
287
317
  exitCode = 124;
318
+ timedOut = true;
288
319
  session.prevExit = exitCode;
289
320
  break;
290
321
  }
@@ -361,9 +392,16 @@ async function runPlans(plans, session, opts, afterSegment, ensureHost) {
361
392
  continue;
362
393
  }
363
394
  const remainingMs = deadline - Date.now();
395
+ if (opts.signal?.aborted) {
396
+ cancelled = true;
397
+ exitCode = 130;
398
+ session.prevExit = exitCode;
399
+ break;
400
+ }
364
401
  if (remainingMs <= 0) {
365
402
  stderr += timeoutMessage;
366
403
  exitCode = 124;
404
+ timedOut = true;
367
405
  session.prevExit = exitCode;
368
406
  break;
369
407
  }
@@ -372,14 +410,22 @@ async function runPlans(plans, session, opts, afterSegment, ensureHost) {
372
410
  FAUXNIX_CWD: currentDir,
373
411
  FAUXNIX_PREV_EXIT: session.prevExit === null ? '' : String(session.prevExit),
374
412
  FAUXNIX_STDIN_FILE: red.stdinFile || '',
375
- }, remainingMs);
376
- if (inv.spawnError === 'ENOENT') {
413
+ }, remainingMs, opts.signal);
414
+ if (inv.spawnError === 'ENOENT' || inv.spawnError === 'START') {
377
415
  stderr += inv.stderr.toString('utf8') || PS_MISSING_MESSAGE;
378
416
  exitCode = 127;
417
+ spawnError = inv.spawnError;
379
418
  session.prevExit = exitCode;
380
419
  chainOk = false;
381
420
  continue;
382
421
  }
422
+ if (inv.cancelled) {
423
+ cancelled = true;
424
+ exitCode = 130;
425
+ session.prevExit = exitCode;
426
+ chainOk = false;
427
+ break;
428
+ }
383
429
  afterSegment();
384
430
  const decodePref = resolveNativePref();
385
431
  // GNU line discipline: the PS host terminates Write-Output lines with
@@ -430,7 +476,11 @@ async function runPlans(plans, session, opts, afterSegment, ensureHost) {
430
476
  }
431
477
  stdout += segOut;
432
478
  stderr += segErr;
433
- exitCode = inv.timedOut ? 124 : inv.exitCode;
479
+ if (inv.truncated)
480
+ truncated = true;
481
+ exitCode = inv.timedOut ? 124 : inv.cancelled ? 130 : inv.exitCode;
482
+ if (inv.timedOut)
483
+ timedOut = true;
434
484
  session.prevExit = exitCode;
435
485
  chainOk = exitCode === 0;
436
486
  // Only inherit cwd from a segment that actually ran and whose
@@ -445,5 +495,25 @@ async function runPlans(plans, session, opts, afterSegment, ensureHost) {
445
495
  closePrepFds(prepFds);
446
496
  }
447
497
  }
448
- return { stdout, stderr, exitCode };
498
+ const clippedOut = clipUtf8(stdout, stdoutLimit);
499
+ const clippedErr = clipUtf8(stderr, stderrLimit);
500
+ if (clippedOut.truncated || clippedErr.truncated)
501
+ truncated = true;
502
+ return {
503
+ stdout: clippedOut.text,
504
+ stderr: clippedErr.text,
505
+ exitCode,
506
+ timedOut,
507
+ cancelled,
508
+ truncated,
509
+ spawnError,
510
+ };
511
+ }
512
+ function clipUtf8(text, limit) {
513
+ if (Buffer.byteLength(text, 'utf8') <= limit)
514
+ return { text, truncated: false };
515
+ let end = text.length;
516
+ while (end > 0 && Buffer.byteLength(text.slice(0, end), 'utf8') > limit)
517
+ end--;
518
+ return { text: text.slice(0, end), truncated: true };
449
519
  }
package/dist/mcp.d.ts CHANGED
@@ -1,4 +1,23 @@
1
+ import { ExecResult } from './executor.js';
1
2
  import { translatePipelineBody } from './translator.js';
2
3
  import './commands/install-all.js';
4
+ export declare function formatBashText(r: Pick<ExecResult, 'stdout' | 'stderr' | 'exitCode' | 'timedOut' | 'cancelled'>): string;
5
+ export declare function bashToolResult(r: ExecResult, sessionId: string, infra: boolean): {
6
+ isError?: true | undefined;
7
+ content: {
8
+ type: "text";
9
+ text: string;
10
+ }[];
11
+ structuredContent: {
12
+ schemaVersion: 1;
13
+ stdout: string;
14
+ stderr: string;
15
+ exitCode: number;
16
+ timedOut: boolean;
17
+ cancelled: boolean;
18
+ truncated: boolean;
19
+ sessionId: string;
20
+ };
21
+ };
3
22
  export declare function startMcpServer(): Promise<void>;
4
23
  export { translatePipelineBody };
package/dist/mcp.js CHANGED
@@ -33,15 +33,46 @@ Output is formatted to look like GNU/Linux tooling (ls -l, ps aux, df -h ...), e
33
33
 
34
34
  Supported: pipes (|), && / || / ;, redirections (> >> 2> 2>&1 < /dev/null), variables ($VAR $HOME ~), command substitution $(...), and ${registeredNames().length}+ coreutils-style commands (${registeredNames().slice(0, 18).join(', ')}...).
35
35
  Unknown commands (git, node, npm, python, cargo...) are passed through and executed natively with argv-style quoting.
36
- Not supported: heredocs, while/until/case, background jobs. if/then/elif/else/fi, for-in loops, and word-level \$((...)) arithmetic expansion are supported.
36
+ Not supported: heredocs, while/until/case, env -i/--ignore-environment, background jobs. if/then/elif/else/fi, for-in loops, and word-level \$((...)) arithmetic expansion are supported.
37
37
 
38
38
  CWD, environment variables, export/unset and cd persist across calls within this session — a resident PowerShell 5.1 host is started when the MCP session begins (and after reset), so the first bash tool call is already warm.
39
- Exit codes follow bash conventions (0 ok, 1 fail, 2 usage/serious, 127 command not found, 124 timeout).
39
+ Exit codes follow bash conventions (0 ok, 1 fail, 2 usage/serious, 127 command not found, 124 timeout, 130 cancelled). The tool also returns structuredContent (schemaVersion 1) with stdout/stderr/exitCode/timedOut/cancelled/truncated/sessionId.
40
40
 
41
41
  Platform requirement: the execution backend is native Windows PowerShell 5.1+. On hosts without PowerShell on PATH (e.g. Linux containers/sandboxes), the bash tool returns exit code 127 with an actionable error instead of running the command.`;
42
+ export function formatBashText(r) {
43
+ const parts = [];
44
+ if (r.stdout.length)
45
+ parts.push(r.stdout.replace(/\n$/, ''));
46
+ if (r.stderr.length)
47
+ parts.push(r.stderr.replace(/\n$/, ''));
48
+ if (r.cancelled)
49
+ parts.push('Cancelled');
50
+ else if (r.timedOut)
51
+ parts.push('Exit code: 124');
52
+ else if (r.exitCode !== 0)
53
+ parts.push('Exit code: ' + r.exitCode);
54
+ return parts.length ? parts.join('\n') : '(no output)';
55
+ }
56
+ export function bashToolResult(r, sessionId, infra) {
57
+ const structuredContent = {
58
+ schemaVersion: 1,
59
+ stdout: r.stdout,
60
+ stderr: r.stderr,
61
+ exitCode: r.exitCode,
62
+ timedOut: r.timedOut,
63
+ cancelled: r.cancelled,
64
+ truncated: r.truncated,
65
+ sessionId,
66
+ };
67
+ return {
68
+ content: [{ type: 'text', text: formatBashText(r) }],
69
+ structuredContent,
70
+ ...(infra ? { isError: true } : {}),
71
+ };
72
+ }
42
73
  export async function startMcpServer() {
43
74
  const server = new McpServer({ name: 'fauxnix', version: packageVersion }, { capabilities: { tools: {} } });
44
- let session = new FauxnixSession();
75
+ const session = new FauxnixSession();
45
76
  await session.prewarm();
46
77
  server.tool(TOOL_NAME, TOOL_DESCRIPTION, {
47
78
  command: z.string().describe('The bash-style command line to run'),
@@ -52,23 +83,26 @@ export async function startMcpServer() {
52
83
  .max(600_000)
53
84
  .optional()
54
85
  .describe('Timeout in milliseconds (default 120000)'),
55
- }, EXEC_ANNOTATIONS, async ({ command, timeout_ms }) => {
86
+ }, EXEC_ANNOTATIONS, async ({ command, timeout_ms }, extra) => {
56
87
  try {
57
88
  const plans = translateCommandList(parseCommand(command));
58
- const result = await session.run(plans, { timeoutMs: timeout_ms });
59
- const parts = [];
60
- if (result.stdout.trim())
61
- parts.push(result.stdout.replace(/\n$/, ''));
62
- if (result.stderr.trim())
63
- parts.push(result.stderr.replace(/\n$/, ''));
64
- if (result.exitCode !== 0)
65
- parts.push('Exit code: ' + result.exitCode);
66
- const text = parts.length ? parts.join('\n') : '(no output)';
67
- return { content: [{ type: 'text', text }] };
89
+ const result = await session.run(plans, {
90
+ timeoutMs: timeout_ms,
91
+ signal: extra.signal,
92
+ });
93
+ const infra = result.spawnError === 'ENOENT' || result.spawnError === 'START';
94
+ return bashToolResult(result, session.id, infra);
68
95
  }
69
96
  catch (e) {
70
97
  const msg = e instanceof Error ? e.message : String(e);
71
- return { content: [{ type: 'text', text: msg }], isError: true };
98
+ return bashToolResult({
99
+ stdout: '',
100
+ stderr: msg,
101
+ exitCode: 2,
102
+ timedOut: false,
103
+ cancelled: false,
104
+ truncated: false,
105
+ }, session.id, true);
72
106
  }
73
107
  });
74
108
  server.tool('fauxnix_translate', 'Translate a bash-style command into the equivalent PowerShell script WITHOUT executing it. Useful for learning/debugging what fauxnix does under the hood.', { command: z.string().describe('The bash-style command line to translate (never executed)') }, TRANSLATE_ANNOTATIONS, async ({ command }) => {
@@ -90,18 +124,45 @@ export async function startMcpServer() {
90
124
  .describe('"status" shows the session state (cwd, tracked env keys); "reset" clears it back to a fresh shell'),
91
125
  }, SESSION_ANNOTATIONS, async ({ action }) => {
92
126
  if (action === 'reset') {
93
- await session.dispose();
94
- session = new FauxnixSession();
95
- await session.prewarm();
127
+ await session.reset();
96
128
  return { content: [{ type: 'text', text: 'fauxnix: session reset' }] };
97
129
  }
98
130
  const envKeys = Object.keys(session.env).sort();
99
131
  const text = 'cwd: ' + (session.cwd ?? '(inherit from server start)') +
100
132
  '\nenv keys: ' + (envKeys.length ? envKeys.join(', ') : '(none tracked)') +
133
+ '\nsession: ' + session.id +
101
134
  '\ncommands registered: ' + registeredNames().length;
102
135
  return { content: [{ type: 'text', text }] };
103
136
  });
104
137
  const transport = new StdioServerTransport();
138
+ let shuttingDown = false;
139
+ const shutdown = async () => {
140
+ if (shuttingDown)
141
+ return;
142
+ shuttingDown = true;
143
+ await session.dispose();
144
+ try {
145
+ await server.close();
146
+ }
147
+ catch {
148
+ /* ignore */
149
+ }
150
+ };
151
+ process.stdin.on('end', () => {
152
+ void shutdown();
153
+ });
154
+ process.stdin.on('close', () => {
155
+ void shutdown();
156
+ });
157
+ process.on('SIGINT', () => {
158
+ void shutdown();
159
+ });
160
+ process.on('SIGTERM', () => {
161
+ void shutdown();
162
+ });
163
+ transport.onclose = () => {
164
+ void shutdown();
165
+ };
105
166
  await server.connect(transport);
106
167
  }
107
168
  // keep referenced for tree-shaking clarity
package/dist/parser.js CHANGED
@@ -586,28 +586,62 @@ export function parseCommand(input) {
586
586
  let pos = 0;
587
587
  const peek = () => tokens[pos];
588
588
  const next = () => tokens[pos++];
589
+ const isListSep = (o) => o === ';' || o === '\n';
590
+ /** Consume `;` / newline / `&&` / `||`. Trailing `&&`/`||` and `;;` fail loud (bash). */
591
+ const consumeListOp = (stops) => {
592
+ const t = peek();
593
+ if (t.type !== 'OP')
594
+ return null;
595
+ if (!(t.op === '&&' || t.op === '||' || isListSep(t.op)))
596
+ return null;
597
+ if (t.op === ';') {
598
+ next();
599
+ if (peek().type === 'OP' && peek().op === ';') {
600
+ throw new FauxnixParseError("fauxnix: syntax error near unexpected token `;;'");
601
+ }
602
+ return ';';
603
+ }
604
+ if (t.op === '\n') {
605
+ next();
606
+ while (peek().type === 'OP' && peek().op === '\n')
607
+ next();
608
+ return ';';
609
+ }
610
+ const sep = t.op;
611
+ next();
612
+ while (peek().type === 'OP' && peek().op === '\n')
613
+ next();
614
+ const n = peek();
615
+ const kw = peekKw();
616
+ if (n.type === 'OP' && n.op === ';') {
617
+ throw new FauxnixParseError("fauxnix: syntax error near unexpected token `" + sep + "'");
618
+ }
619
+ if (n.type === 'EOF' || (stops && kw && stops.has(kw))) {
620
+ throw new FauxnixParseError(n.type === 'EOF'
621
+ ? 'fauxnix: syntax error: unexpected end of file after `' + sep + "'"
622
+ : "fauxnix: syntax error near unexpected token `" + sep + "'");
623
+ }
624
+ return sep;
625
+ };
589
626
  const parseList = () => {
590
627
  const segments = [];
591
628
  let op = ';';
592
- const isListSep = (o) => o === ';' || o === '\n';
593
- while (peek().type === 'OP' && isListSep(peek().op))
629
+ while (peek().type === 'OP' && isListSep(peek().op)) {
630
+ if (peek().op === ';' && tokens[pos + 1]?.type === 'OP' && tokens[pos + 1]?.op === ';') {
631
+ throw new FauxnixParseError("fauxnix: syntax error near unexpected token `;;'");
632
+ }
594
633
  next();
634
+ }
595
635
  while (peek().type !== 'EOF') {
596
636
  const pipeline = parsePipeline();
597
637
  segments.push({ pipeline, op });
598
- const t = peek();
599
- if (t.type === 'OP' && (t.op === '&&' || t.op === '||' || isListSep(t.op))) {
600
- op = t.op === '\n' ? ';' : t.op;
601
- next();
602
- while (peek().type === 'OP' && isListSep(peek().op))
603
- next();
604
- }
605
- else if (t.type === 'EOF') {
606
- break;
607
- }
608
- else {
638
+ const nextOp = consumeListOp();
639
+ if (nextOp === null) {
640
+ if (peek().type === 'EOF')
641
+ break;
609
642
  throw new FauxnixParseError('fauxnix: unexpected token after pipeline');
610
643
  }
644
+ op = nextOp;
611
645
  }
612
646
  if (segments.length === 0)
613
647
  throw new FauxnixParseError('fauxnix: empty command');
@@ -672,25 +706,22 @@ export function parseCommand(input) {
672
706
  const stop = new Set(stops);
673
707
  const segments = [];
674
708
  let op = ';';
675
- const isListSep = (o) => o === ';' || o === '\n';
676
- while (peek().type === 'OP' && isListSep(peek().op))
709
+ while (peek().type === 'OP' && isListSep(peek().op)) {
710
+ if (peek().op === ';' && tokens[pos + 1]?.type === 'OP' && tokens[pos + 1]?.op === ';') {
711
+ throw new FauxnixParseError("fauxnix: syntax error near unexpected token `;;'");
712
+ }
677
713
  next();
714
+ }
678
715
  while (peek().type !== 'EOF') {
679
716
  const kw = peekKw();
680
717
  if (kw && stop.has(kw))
681
718
  break;
682
719
  const pipeline = parsePipeline();
683
720
  segments.push({ pipeline, op });
684
- const t = peek();
685
- if (t.type === 'OP' && (t.op === '&&' || t.op === '||' || isListSep(t.op))) {
686
- op = t.op === '\n' ? ';' : t.op;
687
- next();
688
- while (peek().type === 'OP' && isListSep(peek().op))
689
- next();
690
- }
691
- else {
721
+ const nextOp = consumeListOp(stop);
722
+ if (nextOp === null)
692
723
  break;
693
- }
724
+ op = nextOp;
694
725
  }
695
726
  if (segments.length === 0) {
696
727
  throw new FauxnixParseError('fauxnix: empty command');
package/dist/ps-host.d.ts CHANGED
@@ -1,9 +1,13 @@
1
1
  export declare const PS_MISSING_MESSAGE: string;
2
+ export declare const DEFAULT_STDOUT_LIMIT = 8388608;
3
+ export declare const DEFAULT_STDERR_LIMIT = 1048576;
2
4
  export interface HostInvokeResult {
3
5
  stdout: Buffer;
4
6
  stderr: Buffer;
5
7
  exitCode: number;
6
8
  timedOut: boolean;
9
+ cancelled: boolean;
10
+ truncated: boolean;
7
11
  spawnError?: 'ENOENT' | 'START';
8
12
  spawnMessage?: string;
9
13
  }
@@ -39,8 +43,10 @@ export declare class PowerShellHost {
39
43
  constructor(hostFile: string, envFn: () => NodeJS.ProcessEnv);
40
44
  /** Start the resident process and wait for the ready handshake (B1 prewarm). */
41
45
  ready(): Promise<HostInvokeResult | null>;
42
- invoke(script: string, env: HostRequestEnv, timeoutMs: number): Promise<HostInvokeResult>;
46
+ invoke(script: string, env: HostRequestEnv, timeoutMs: number, signal?: AbortSignal): Promise<HostInvokeResult>;
47
+ drainNativeStderr(): Buffer;
43
48
  stop(): Promise<void>;
49
+ private cancelledResult;
44
50
  private invokeSerial;
45
51
  private ensureStarted;
46
52
  private deadRestart;
package/dist/ps-host.js CHANGED
@@ -6,6 +6,8 @@ const READY_TIMEOUT_MS = 30_000;
6
6
  export const PS_MISSING_MESSAGE = 'fauxnix: powershell.exe not found — fauxnix executes bash via native Windows PowerShell 5.1+.\n' +
7
7
  'This host has no PowerShell on PATH (typical for Linux containers/sandboxes).\n' +
8
8
  'Run fauxnix on Windows, or install PowerShell and make powershell.exe reachable on PATH.\n';
9
+ export const DEFAULT_STDOUT_LIMIT = 8_388_608;
10
+ export const DEFAULT_STDERR_LIMIT = 1_048_576;
9
11
  export function encodeHostRequest(id, script, env) {
10
12
  return JSON.stringify({
11
13
  id,
@@ -50,11 +52,18 @@ export class PowerShellHost {
50
52
  async ready() {
51
53
  return this.ensureStarted();
52
54
  }
53
- async invoke(script, env, timeoutMs) {
54
- const run = this.invokeLock.then(() => this.invokeSerial(script, env, timeoutMs));
55
+ async invoke(script, env, timeoutMs, signal) {
56
+ const run = this.invokeLock.then(() => this.invokeSerial(script, env, timeoutMs, signal));
55
57
  this.invokeLock = run.then(() => undefined, () => undefined);
56
58
  return run;
57
59
  }
60
+ drainNativeStderr() {
61
+ if (!this.stderrChunks.length)
62
+ return Buffer.alloc(0);
63
+ const b = Buffer.concat(this.stderrChunks);
64
+ this.stderrChunks = [];
65
+ return b;
66
+ }
58
67
  async stop() {
59
68
  const proc = this.proc;
60
69
  this.proc = null;
@@ -82,10 +91,24 @@ export class PowerShellHost {
82
91
  });
83
92
  });
84
93
  }
85
- async invokeSerial(script, env, timeoutMs) {
94
+ cancelledResult() {
95
+ return {
96
+ stdout: Buffer.alloc(0),
97
+ stderr: Buffer.alloc(0),
98
+ exitCode: 130,
99
+ timedOut: false,
100
+ cancelled: true,
101
+ truncated: false,
102
+ };
103
+ }
104
+ async invokeSerial(script, env, timeoutMs, signal) {
105
+ if (signal?.aborted) {
106
+ await this.stop();
107
+ return this.cancelledResult();
108
+ }
86
109
  const started = await this.ensureStarted();
87
110
  if (started)
88
- return started;
111
+ return { ...started, cancelled: false, truncated: false };
89
112
  const id = 'f' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
90
113
  const line = encodeHostRequest(id, script, env);
91
114
  try {
@@ -98,24 +121,45 @@ export class PowerShellHost {
98
121
  stderr: Buffer.from('fauxnix: powershell host exited unexpectedly\n', 'utf8'),
99
122
  exitCode: 1,
100
123
  timedOut: false,
124
+ cancelled: false,
125
+ truncated: false,
101
126
  spawnMessage: e.message,
102
127
  };
103
128
  }
129
+ let cancelled = false;
130
+ const onAbort = () => {
131
+ cancelled = true;
132
+ void this.stop();
133
+ };
134
+ signal?.addEventListener('abort', onAbort, { once: true });
104
135
  try {
105
136
  const raw = await this.nextJsonLine(timeoutMs, id);
106
137
  const msg = decodeHostResponse(raw);
138
+ const native = this.drainNativeStderr();
107
139
  return {
108
140
  stdout: msg.stdout,
109
- stderr: msg.stderr,
141
+ stderr: native.length ? Buffer.concat([msg.stderr, native]) : msg.stderr,
110
142
  exitCode: msg.exitCode,
111
143
  timedOut: false,
144
+ cancelled: false,
145
+ truncated: false,
112
146
  };
113
147
  }
114
148
  catch (e) {
115
149
  const timedOut = e.timedOut === true;
116
150
  await this.stop();
151
+ this.drainNativeStderr();
152
+ if (cancelled || signal?.aborted)
153
+ return this.cancelledResult();
117
154
  if (timedOut) {
118
- return { stdout: Buffer.alloc(0), stderr: Buffer.alloc(0), exitCode: 124, timedOut: true };
155
+ return {
156
+ stdout: Buffer.alloc(0),
157
+ stderr: Buffer.alloc(0),
158
+ exitCode: 124,
159
+ timedOut: true,
160
+ cancelled: false,
161
+ truncated: false,
162
+ };
119
163
  }
120
164
  if (this.closeErr && this.closeErr.code === 'ENOENT') {
121
165
  return {
@@ -123,6 +167,8 @@ export class PowerShellHost {
123
167
  stderr: Buffer.from(PS_MISSING_MESSAGE, 'utf8'),
124
168
  exitCode: 127,
125
169
  timedOut: false,
170
+ cancelled: false,
171
+ truncated: false,
126
172
  spawnError: 'ENOENT',
127
173
  };
128
174
  }
@@ -134,8 +180,13 @@ export class PowerShellHost {
134
180
  '\n', 'utf8'),
135
181
  exitCode: code === 0 ? 1 : code,
136
182
  timedOut: false,
183
+ cancelled: false,
184
+ truncated: false,
137
185
  };
138
186
  }
187
+ finally {
188
+ signal?.removeEventListener('abort', onAbort);
189
+ }
139
190
  }
140
191
  async ensureStarted() {
141
192
  if (this.proc && !this.closed)
@@ -154,6 +205,8 @@ export class PowerShellHost {
154
205
  stderr: Buffer.from(PS_MISSING_MESSAGE, 'utf8'),
155
206
  exitCode: 127,
156
207
  timedOut: false,
208
+ cancelled: false,
209
+ truncated: false,
157
210
  spawnError: 'ENOENT',
158
211
  };
159
212
  }
@@ -162,6 +215,8 @@ export class PowerShellHost {
162
215
  stderr: Buffer.from('fauxnix: failed to start powershell.exe: ' + err.message + '\n', 'utf8'),
163
216
  exitCode: 127,
164
217
  timedOut: false,
218
+ cancelled: false,
219
+ truncated: false,
165
220
  spawnError: 'START',
166
221
  spawnMessage: err.message,
167
222
  };
@@ -189,14 +244,26 @@ export class PowerShellHost {
189
244
  windowsHide: true,
190
245
  });
191
246
  this.proc = child;
192
- child.stdout.on('data', (d) => this.onStdout(d));
193
- child.stderr.on('data', (d) => this.stderrChunks.push(d));
247
+ child.stdout.on('data', (d) => {
248
+ if (this.proc !== child)
249
+ return;
250
+ this.onStdout(d);
251
+ });
252
+ child.stderr.on('data', (d) => {
253
+ if (this.proc !== child)
254
+ return;
255
+ this.stderrChunks.push(d);
256
+ });
194
257
  child.on('error', (e) => {
258
+ if (this.proc !== child)
259
+ return;
195
260
  this.closeErr = e;
196
261
  this.closed = true;
197
262
  this.failWaiters(e);
198
263
  });
199
264
  child.on('close', (c) => {
265
+ if (this.proc !== child)
266
+ return;
200
267
  this.closeCode = c;
201
268
  this.closed = true;
202
269
  this.proc = null;