Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions lib/__tests__/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<BashResult> {
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("");
});
30 changes: 21 additions & 9 deletions lib/core.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 <alias> <field_num>
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.
Expand All @@ -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 "<provider>-<alias>"
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: <name>" instead of
# threading an empty alias into downstream field lookups.
Expand Down
Loading