@hncode/hncode 0.5.2 → 0.5.4

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/src/tools/bash.js CHANGED
@@ -97,17 +97,32 @@ function decodeChunks(chunks) {
97
97
  }
98
98
 
99
99
 
100
- function killTree(child) {
100
+ /**
101
+ * Kill a child.
102
+ *
103
+ * `tree` is OFF for an interactive run, and that is not a style choice: a non-detached interactive child
104
+ * lives INSIDE our process tree, so `taskkill /t` walks up and kills the parent too — the TUI would die
105
+ * while stopping a background command. Both halves are pinned by `test/task-input.test.mjs`.
106
+ *
107
+ * A detached background job has no such risk: it was started as its own tree root, so `/t` reaches its
108
+ * children and nothing above it.
109
+ */
110
+ function killTree(child, opts = {}) {
101
111
  try {
102
112
  const exit = child.exitCode !== null || child.signalCode !== null;
103
113
  if (exit || child.killed) return;
104
114
  if (process.platform === 'win32') {
105
- try { cp.spawnSync('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore' }); } catch {}
115
+ const args = ['/pid', String(child.pid), '/f'];
116
+ if (opts.tree !== false) args.splice(2, 0, '/t');
117
+ try { cp.spawnSync('taskkill', args, { stdio: 'ignore' }); } catch {}
106
118
  } else {
107
119
  child.kill('SIGTERM');
108
120
  }
109
121
  } catch {}
110
- setTimeout(() => { try { child.kill('SIGKILL'); } catch {} }, 1000);
122
+ // The SIGKILL follow-up must not keep the process alive on its own: unref it,
123
+ // so a child that exits politely still lets the process quit on time.
124
+ const followUp = setTimeout(() => { try { child.kill('SIGKILL'); } catch {} }, 1000);
125
+ if (followUp.unref) followUp.unref();
111
126
  }
112
127
 
113
128
  export const spec = {
@@ -121,6 +136,7 @@ export const spec = {
121
136
  timeout: { type: 'integer', minimum: 1, description: 'Seconds before the command is killed. Foreground default 60s/max 300s; background default 600s/max 86400s.' },
122
137
  cwd: { type: 'string', description: 'Working directory (default: workspace/project root).' },
123
138
  run_in_background: { type: 'boolean', default: false, description: 'Run detached and return a task id instead of waiting.' },
139
+ interactive: { type: 'boolean', default: false, description: 'With run_in_background: keep stdin open so TaskInput can write to it — for a command that prompts.' },
124
140
  disable_timeout: { type: 'boolean', default: false, description: 'Disable the timeout (background only).' },
125
141
  },
126
142
  required: ['command'],
@@ -139,17 +155,24 @@ export const spec = {
139
155
  const def = bg ? DEFAULT_BACKGROUND_TIMEOUT_S : DEFAULT_TIMEOUT_S;
140
156
  const timeoutMs = args.disable_timeout ? undefined : Math.min(args.timeout ?? def, cap) * 1000;
141
157
  const command = args.command;
142
- // stdin is 'ignore' (NUL / /dev/null): leaving it as an open pipe made any
143
- // command that reads stdin (`cat`, `npm init`, …) block until the timeout.
158
+ // stdin is 'ignore' by default (NUL / /dev/null): leaving it as an open pipe made any command that
159
+ // reads stdin (`cat`, `npm init`, …) block until the timeout.
144
160
  //
145
- // The shell is RESOLVED, not hardcoded: `spawn('pwsh', …)` failed outright on a
146
- // box with only the built-in Windows PowerShell (see tools/shell.js). `env` is
147
- // passed explicitly rather than relying on Node's implicit inheritance, which is
148
- // what made the tool depend on pwsh having been added to PATH by hand.
161
+ // `interactive` OPTS INTO A PIPE, and only for a background run: a foreground command has nobody to
162
+ // type at it, while a background one can be fed through TaskInput. The pipe is closed by that tool
163
+ // when it is done, which is what keeps `cat` and friends from hanging.
164
+ if (args.interactive && !args.run_in_background) {
165
+ return 'Error: `interactive` requires `run_in_background: true` — a foreground run has no way to receive input.';
166
+ }
149
167
  let shell;
150
168
  try { shell = shellSpawnArgs(command); } catch (e) { return `Error: ${e.message}`; }
169
+ // Check the turn's signal BEFORE spawning. The check below ran only after the
170
+ // child existed: an abort landing in that window returned "Interrupted by user"
171
+ // while leaving the child RUNNING with open pipes until the timeout timer fired,
172
+ // and Esc could not stop it.
173
+ if (ctx.signal && ctx.signal.aborted) return 'Interrupted by user';
151
174
  const child = cp.spawn(shell.bin, shell.args, {
152
- cwd, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env },
175
+ cwd, stdio: [args.interactive ? 'pipe' : 'ignore', 'pipe', 'pipe'], env: { ...process.env },
153
176
  });
154
177
 
155
178
  const chunks = [];
@@ -298,17 +321,33 @@ function spawnBackground(args, cwd, ctx) {
298
321
  // task's output file instead, so the process survives and TaskOutput can
299
322
  // still read it. (In the TUI the parent rarely exits, but headless -p mode
300
323
  // does, and a background job should outlive the prompt.)
301
- let shell;
302
- try { shell = shellSpawnArgs(command); } catch (e) { return `Error: ${e.message}`; }
324
+ //
325
+ // AN INTERACTIVE RUN CANNOT BE DETACHED. Measured on Windows: `detached: true` with piped stdout
326
+ // returns exit 0 and NO output, while the same command not detached returns its output normally — the
327
+ // detached process gets no working pipe back to us. Interactive needs that pipe in both directions, so
328
+ // it stays attached. The trade: it will not outlive the parent, which is the same lifetime its stdin
329
+ // already has.
330
+ let shell;
331
+ try { shell = shellSpawnArgs(command, { interactive: args.interactive }); } catch (e) { return `Error: ${e.message}`; }
303
332
  const child = cp.spawn(shell.bin, shell.args, {
304
- cwd, detached: true, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env },
333
+ cwd, detached: !args.interactive, stdio: [args.interactive ? 'pipe' : 'ignore', 'pipe', 'pipe'], env: { ...process.env },
305
334
  });
306
335
  const task = createTask(ctx, 'process', {
307
336
  description: args.description || args.command,
308
337
  command: args.command,
309
338
  pid: child.pid,
310
- detached: true,
339
+ detached: !args.interactive,
311
340
  });
341
+ // The stdin pipe, when the caller asked for one. Kept on the TASK so `TaskInput` can reach it from a
342
+ // task id alone — a tool receives an id, not a handle. Only `interactive` gets here, so a plain
343
+ // background job is unaffected and cannot be fed input by accident.
344
+ if (args.interactive && child.stdin) {
345
+ task.stdin = child.stdin;
346
+ task.interactive = true;
347
+ // A closed pipe is normal at the end of a run; writing afterwards would throw EPIPE.
348
+ child.stdin.on('error', () => { task.stdin = null; });
349
+ child.on('close', () => { task.stdin = null; });
350
+ }
312
351
  defineOutput(task);
313
352
  const push = (d) => appendTaskOutput(task, d);
314
353
  if (child.stdout) child.stdout.on('data', push);
@@ -54,6 +54,19 @@ export async function stopLspClients() {
54
54
  for (const c of all) { try { await c.stop(); } catch { /* going away anyway */ } }
55
55
  }
56
56
 
57
+ /**
58
+ * Synchronous best-effort kill for the `exit` handler, which cannot await: a
59
+ * polite shutdown needs a round-trip the process no longer has time for, so the
60
+ * child is killed outright. rust-analyzer/pyright each hold hundreds of MB —
61
+ * leaving them running after hncode quits is the leak this closes.
62
+ */
63
+ export function killLspClientsSync() {
64
+ const all = [...clients.values()];
65
+ clients.clear();
66
+ for (const c of all) { try { c._child && c._child.kill(); } catch { /* already gone */ }
67
+ }
68
+ }
69
+
57
70
  /** For tests and for the TUI: which servers are live right now. */
58
71
  export function liveLspClients() {
59
72
  return [...clients.entries()].map(([k, c]) => ({ key: k, serverId: c.serverId, root: c.root }));