Skip to content

Ctrl-C during --wait loses the accepted resource's ID (no signal handling, no request context) #34

Description

@kshahbw

What's wrong

band installs no signal handler and runs cobra without a cancellable context:

// cmd/root.go
func Execute() error {
    api.Version = version
    return rootCmd.Execute()   // no context
}

grep -rn "signal.Notify\|NotifyContext" cmd/ internal/ returns nothing. So cmd.Context() is context.Background() everywhere, and Ctrl-C kills the process via the Go default — no cleanup, no output.

Why it matters more than it looks

internal/cmdutil/poll.go grew a PollConfig.Context field specifically so a cancelled poll could exit gracefully, and cmd/tendlc/async.go's awaitTerminal has a cancellation branch whose entire job is to emit the partial-result receipt. Neither can ever fire from a real SIGINT, because nothing upstream produces a cancellable context. The plumbing exists with no producer.

For the 10DLC commands that matters concretely. Once POST /brands returns its 202 the write may already have succeeded, and bandwidthId is the only thing that cannot be recovered afterward — it's why awaitTerminal owns stdout on every failure path. Press Ctrl-C during band tendlc brand create --wait after the 202 lands and the process dies with nothing on stdout. The brand exists, is billable, and the caller has no ID for it.

Same shape on number order --wait, call create --wait, and transcription create --wait.

Reproduce

band tendlc brand create --customer-profile-id <id> --brand-type PRIVATE_PROFIT ... --wait
# wait for the 202, then Ctrl-C
# → no receipt, no bandwidthId; the brand exists and is billed

Suggested fix

Wire signal.NotifyContext in Execute() and pass it through ExecuteContext, so cmd.Context() is cancelled on SIGINT/SIGTERM:

func Execute() error {
    api.Version = version
    ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
    defer stop()
    return rootCmd.ExecuteContext(ctx)
}

Two things to get right beyond that:

  1. internal/api/client.go:137 builds requests with http.NewRequest, not NewRequestWithContext. Without that change an in-flight HTTP call still won't abort — cancellation would only be observed between polls, not during one.
  2. A second Ctrl-C should hard-exit. NotifyContext stops trapping after the first signal, which gives that for free, but it's worth an explicit test.

Interim state

AGENTS.md now documents this honestly — the receipt guarantee is scoped to paths the command itself takes, and the recovery for an interrupted create is band tendlc brand list --customer-profile-id <id>. That's a stopgap, not a fix.

Found by an adversarial review pass on #33.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions