diff --git a/lib/__tests__/core.test.ts b/lib/__tests__/core.test.ts index 40a9b85..a7b0674 100644 --- a/lib/__tests__/core.test.ts +++ b/lib/__tests__/core.test.ts @@ -115,3 +115,103 @@ test("unknown alias exits 1 with empty stdout", async () => { expect(r.code).toBe(1); expect(r.stdout.trim()).toBe(""); }); + +// ───────────────────────────────────────────────────────────────────────── +// W6.5 — SIGPIPE immunity under bin/subctl's `set -uo pipefail` +// ───────────────────────────────────────────────────────────────────────── +// +// Regression for the 2026-06-11 resolver failure: subctl_list_accounts is +// slow (5 _subctl_trim command substitutions per row), and piping it +// straight into an early-exit awk closed the pipe mid-write. The producer +// took SIGPIPE, pipefail turned the pipeline status into 141, and +// `resolved=$(subctl_resolve_alias …) || subctl_die "unknown account"` +// died even though the alias had already been printed and captured. +// Position-dependent: an alias EARLY in a long accounts.conf always lost +// the race (reproduced 5/5 on the pre-fix code with this exact fixture), +// while last-row aliases always won. These tests run the helpers under +// the same shell options bin/subctl sets, with the target on row 1 of a +// 50-row roster so the race is deterministic, and repeat each lookup to +// catch flaky scheduling. + +function setupWideRoster(): Fixture { + const root = mkdtempSync(join(tmpdir(), "subctl-core-sigpipe-")); + const configDir = join(root, "userconfig"); + mkdirSync(configDir, { recursive: true }); + const accountsConf = join(configDir, "accounts.conf"); + const rows = [ + "# 50-row roster — target EARLY so an early-exit awk races the producer", + "openai-jason | openai-codex | oj@example.com | ~/.codex-jason | the row that lost the race", + ]; + for (let i = 1; i <= 49; i++) { + rows.push( + `claude-filler${i} | claude | f${i}@example.com | ~/.claude-f${i} | filler row ${i}`, + ); + } + writeFileSync(accountsConf, rows.join("\n") + "\n"); + return { root, accountsConf }; +} + +/** Run a core.sh snippet under bin/subctl's exact shell options. */ +async function runUnderPipefail(d: Fixture, snippet: string): Promise { + const proc = Bun.spawn( + ["bash", "-c", `set -uo pipefail; source "${CORE_SH}"; ${snippet}`], + { + env: { + ...process.env, + SUBCTL_REPO_ROOT: REPO_ROOT, + SUBCTL_CONFIG_DIR: join(d.root, "userconfig"), + SUBCTL_ACCOUNTS_CONF: d.accountsConf, + NO_COLOR: "1", + }, + stdout: "pipe", + stderr: "pipe", + }, + ); + const code = await proc.exited; + const stdout = await new Response(proc.stdout).text(); + const stderr = await new Response(proc.stderr).text(); + return { code, stdout, stderr }; +} + +test("pipefail: early-row alias resolves with exit 0, every time (5/5)", async () => { + d = setupWideRoster(); + for (let run = 1; run <= 5; run++) { + const r = await runUnderPipefail(d, `subctl_resolve_alias openai-jason`); + expect(r.code).toBe(0); + expect(r.stdout.trim()).toBe("openai-jason"); + } +}); + +test("pipefail: early-row field lookup returns the field with exit 0 (5/5)", async () => { + d = setupWideRoster(); + for (let run = 1; run <= 5; run++) { + const r = await runUnderPipefail(d, `subctl_account_field openai-jason 2`); + expect(r.code).toBe(0); + expect(r.stdout.trim()).toBe("openai-codex"); + } +}); + +test("pipefail: the bin/subctl caller shape (capture || die) survives", async () => { + d = setupWideRoster(); + // The exact consumption pattern that produced "unknown account": a + // command substitution followed by `|| die`. + const r = await runUnderPipefail( + d, + `resolved=$(subctl_resolve_alias openai-jason) || subctl_die "unknown account: openai-jason"; printf '%s\\n' "$resolved"`, + ); + expect(r.code).toBe(0); + expect(r.stdout.trim()).toBe("openai-jason"); + expect(r.stderr).not.toContain("unknown account"); +}); + +test("pipefail: bare→prefixed and prefixed→bare passes stay intact on the wide roster", async () => { + d = setupWideRoster(); + // Bare → prefixed: filler1 → claude-filler1 (pass 2). + const bare = await runUnderPipefail(d, `subctl_resolve_alias filler1`); + expect(bare.code).toBe(0); + expect(bare.stdout.trim()).toBe("claude-filler1"); + // No match still exits 1 with empty stdout (pass 3 falls through). + const miss = await runUnderPipefail(d, `subctl_resolve_alias nope-never`); + expect(miss.code).toBe(1); + expect(miss.stdout.trim()).toBe(""); +}); diff --git a/lib/core.sh b/lib/core.sh index d5bbe2e..b704a90 100755 --- a/lib/core.sh +++ b/lib/core.sh @@ -77,11 +77,22 @@ subctl_list_accounts() { done < "$SUBCTL_ACCOUNTS_CONF" } +# SIGPIPE note (W6.5): never pipe subctl_list_accounts straight into an +# early-exit awk (`{print; exit}`). The producer is slow (5 _subctl_trim +# command substitutions per row); awk's early exit closes the pipe while +# the producer is mid-write, the producer takes SIGPIPE, and under +# bin/subctl's `set -uo pipefail` the pipeline status becomes 141 — so the +# lookup "fails" even though the answer was already printed and captured. +# Position-dependent: aliases early in accounts.conf always lost the race +# (reproduced 5/5 with openai-jason mid-file, 2026-06-11). Buffer the +# roster once and feed awk via herestring instead. + # Get a specific field for an alias. Field index: 1=alias 2=provider 3=email 4=config_dir 5=description # Usage: subctl_account_field subctl_account_field() { - local want="$1" idx="$2" - subctl_list_accounts | awk -F'\t' -v w="$want" -v i="$idx" '$1==w {print $i; exit}' + local want="$1" idx="$2" all + all=$(subctl_list_accounts) + awk -F'\t' -v w="$want" -v i="$idx" '$1==w {print $i; exit}' <<<"$all" } # Resolve an alias that may be given with or without the provider prefix. @@ -90,20 +101,21 @@ subctl_account_field() { # profiles often carry bare aliases while muscle memory types prefixed ones). # Returns the canonical alias on stdout, or exit 1 if no match. subctl_resolve_alias() { - local want="$1" hit - subctl_list_accounts | awk -F'\t' -v w="$want" ' + local want="$1" hit all + all=$(subctl_list_accounts) + awk -F'\t' -v w="$want" ' $1==w { print $1; found=1; exit } END { if (!found) exit 1 } - ' && return 0 + ' <<<"$all" && return 0 # Bare → prefixed: any alias ending in "-$want" - hit=$(subctl_list_accounts | awk -F'\t' -v w="$want" ' + hit=$(awk -F'\t' -v w="$want" ' $1 ~ "-" w "$" { print $1; exit } - ') + ' <<<"$all") [[ -n "$hit" ]] && { printf '%s\n' "$hit"; return 0; } # Prefixed → bare: "$want" is exactly "-" - hit=$(subctl_list_accounts | awk -F'\t' -v w="$want" ' + hit=$(awk -F'\t' -v w="$want" ' w == $2 "-" $1 { print $1; exit } - ') + ' <<<"$all") [[ -n "$hit" ]] && { printf '%s\n' "$hit"; return 0; } # No match: fail so callers die with "unknown account: " instead of # threading an empty alias into downstream field lookups.