@indigoai-us/hq-cli 5.103.0 → 5.103.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,21 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.103.1] — 2026-08-18
6
+
7
+ ### Fixed
8
+
9
+ - `hq secrets env` now terminates deterministically instead of leaking a hung
10
+ process. The prior fix exited inside the stdout write callback
11
+ (`process.stdout.write(payload, () => process.exit(0))`), which only fires
12
+ once the bytes flush to a live reader. When the parent shell running
13
+ `source <(hq secrets env …)` was killed on timeout, the stdout pipe lost its
14
+ reader, the callback never fired, and the process parked forever — reparented
15
+ to PID 1. Hundreds accumulated on shared build hosts, exhausting swap and
16
+ spiking load. `env` now writes synchronously to stdout and exits
17
+ unconditionally, with no dependence on the event loop draining or the reader
18
+ still being attached (a gone reader surfaces as EPIPE and is ignored).
19
+
5
20
  ## [5.103.0] — 2026-08-18
6
21
 
7
22
  ### Added
@@ -1,4 +1,5 @@
1
1
  import { Command } from "commander";
2
+ import { writeSync } from "node:fs";
2
3
  import { vaultApiFetch, getCompanyUid, getEntityUid } from "../utils/vault-api.js";
3
4
  export type { VaultApiOptions } from "../utils/vault-api.js";
4
5
  export { vaultApiFetch, getCompanyUid, getEntityUid };
@@ -51,5 +52,30 @@ export declare function parseDestinationUrl(raw: string): {
51
52
  export declare function collectSecretNames(value: string, previous?: string[]): string[];
52
53
  export declare function scrubSandboxOutput(text: string, secretNames?: string[]): string;
53
54
  export declare function loadRevealedSecrets(token: string, companyUid: string, keys: string[], usage?: SecretUsage): Promise<Map<string, string>>;
55
+ /**
56
+ * Emit `hq secrets env` output and terminate deterministically.
57
+ *
58
+ * The process MUST exit even when the consuming pipe has gone away. The
59
+ * orphaned-process leak (personal/projects/hq-secrets-process-leak) was caused
60
+ * by relying on `process.stdout.write(payload, () => process.exit(0))`: when the
61
+ * parent shell reading `source <(hq secrets env …)` is killed on timeout, the
62
+ * stdout pipe loses its reader, the write callback never fires, `process.exit(0)`
63
+ * is never reached, and — because `env` forces no other exit — the process parks
64
+ * in `epoll_pwait(-1)` forever, reparented to PID 1. Hundreds accumulated this
65
+ * way, exhausting swap and driving load into triple digits.
66
+ *
67
+ * Writing synchronously to fd 1 and then exiting unconditionally removes every
68
+ * dependency on the event loop draining and on the reader still being attached:
69
+ * - a live reader receives the export lines (synchronous write reaches the
70
+ * kernel pipe buffer before we exit, so nothing is truncated);
71
+ * - a dead reader surfaces as EPIPE, which we swallow — the secret is already
72
+ * resolved and there is nothing left to deliver, so a clean exit is correct;
73
+ * - EAGAIN (stdout in non-blocking mode with a momentarily full buffer) is
74
+ * retried a bounded number of times. `env` payloads are a few short export
75
+ * lines, far under the 64 KiB pipe buffer, so this effectively never spins.
76
+ *
77
+ * `exit` and `write` are injectable for tests; production uses the real ones.
78
+ */
79
+ export declare function emitEnvExportsAndExit(payload: string, exit?: (code: number) => never, write?: typeof writeSync): never;
54
80
  export declare function registerSecretsCommand(program: Command): void;
55
81
  //# sourceMappingURL=secrets.d.ts.map
@@ -1,6 +1,7 @@
1
1
  import chalk from "chalk";
2
2
  import * as readline from "node:readline";
3
3
  import { spawn } from "node:child_process";
4
+ import { writeSync } from "node:fs";
4
5
  import * as nodePath from "node:path";
5
6
  import { ensureCognitoToken } from "../utils/cognito-session.js";
6
7
  import { DEFAULT_SECRETS_CACHE_TTL_MS, writeCache, removeCacheEntry, clearAllCache, } from "../utils/secrets-cache.js";
@@ -666,6 +667,50 @@ export async function loadRevealedSecrets(token, companyUid, keys, usage) {
666
667
  throw err;
667
668
  }
668
669
  }
670
+ /**
671
+ * Emit `hq secrets env` output and terminate deterministically.
672
+ *
673
+ * The process MUST exit even when the consuming pipe has gone away. The
674
+ * orphaned-process leak (personal/projects/hq-secrets-process-leak) was caused
675
+ * by relying on `process.stdout.write(payload, () => process.exit(0))`: when the
676
+ * parent shell reading `source <(hq secrets env …)` is killed on timeout, the
677
+ * stdout pipe loses its reader, the write callback never fires, `process.exit(0)`
678
+ * is never reached, and — because `env` forces no other exit — the process parks
679
+ * in `epoll_pwait(-1)` forever, reparented to PID 1. Hundreds accumulated this
680
+ * way, exhausting swap and driving load into triple digits.
681
+ *
682
+ * Writing synchronously to fd 1 and then exiting unconditionally removes every
683
+ * dependency on the event loop draining and on the reader still being attached:
684
+ * - a live reader receives the export lines (synchronous write reaches the
685
+ * kernel pipe buffer before we exit, so nothing is truncated);
686
+ * - a dead reader surfaces as EPIPE, which we swallow — the secret is already
687
+ * resolved and there is nothing left to deliver, so a clean exit is correct;
688
+ * - EAGAIN (stdout in non-blocking mode with a momentarily full buffer) is
689
+ * retried a bounded number of times. `env` payloads are a few short export
690
+ * lines, far under the 64 KiB pipe buffer, so this effectively never spins.
691
+ *
692
+ * `exit` and `write` are injectable for tests; production uses the real ones.
693
+ */
694
+ export function emitEnvExportsAndExit(payload, exit = process.exit, write = writeSync) {
695
+ const buf = Buffer.from(payload, "utf8");
696
+ const MAX_EAGAIN_RETRIES = 10_000;
697
+ let offset = 0;
698
+ let eagainRetries = 0;
699
+ while (offset < buf.length) {
700
+ try {
701
+ offset += write(1, buf, offset, buf.length - offset);
702
+ }
703
+ catch (err) {
704
+ const code = err.code;
705
+ if (code === "EAGAIN" && eagainRetries++ < MAX_EAGAIN_RETRIES)
706
+ continue;
707
+ // EPIPE (reader gone) or retries exhausted: the output cannot be
708
+ // delivered, but the process must never hang. Stop and exit cleanly.
709
+ break;
710
+ }
711
+ }
712
+ return exit(0);
713
+ }
669
714
  export function registerSecretsCommand(program) {
670
715
  const secrets = program
671
716
  .command("secrets")
@@ -1408,16 +1453,10 @@ export function registerSecretsCommand(program) {
1408
1453
  const out = redact ? "[REDACTED]" : value;
1409
1454
  payload += `export ${key}=${shellSingleQuote(out)}\n`;
1410
1455
  }
1411
- // Deterministically terminate once stdout has flushed. Unlike `exec`
1412
- // (which exits on its child's close), `env` has nothing to exit on and
1413
- // otherwise relies on the event loop draining — so a single lingering
1414
- // handle (e.g. a slow keep-alive socket to the vault) would keep this
1415
- // process alive forever, the mechanism behind the orphaned-process leak.
1416
- // Exit inside the write callback, which fires only after the bytes reach
1417
- // the pipe/file, so a `source <(hq secrets env …)` consumer never loses
1418
- // the export lines — a bare process.exit() would truncate buffered
1419
- // stdout on a pipe.
1420
- process.stdout.write(payload, () => process.exit(0));
1456
+ // Write synchronously and exit unconditionally never depend on the
1457
+ // event loop draining or the stdout write callback firing. See
1458
+ // emitEnvExportsAndExit for the full rationale (orphaned-process leak).
1459
+ emitEnvExportsAndExit(payload);
1421
1460
  }
1422
1461
  catch (err) {
1423
1462
  console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.103.0",
3
+ "version": "5.103.1",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {