Skip to content

fix: enforce turnstyle wait deadlines#158

Merged
chenrui333 merged 9 commits into
masterfrom
rchen/turnstyle-quality-hardening-20260715
Jul 16, 2026
Merged

fix: enforce turnstyle wait deadlines#158
chenrui333 merged 9 commits into
masterfrom
rchen/turnstyle-quality-hardening-20260715

Conversation

@chenrui333

@chenrui333 chenrui333 commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Enforce continue-after-seconds and abort-after-seconds as hard total-elapsed-time deadlines beginning before the first Actions API read.
  • Reuse one monotonic deadline and lifecycle abort signal across repository workflow lookup, run discovery, queue, job, step, initial-wait, polling, and configured API retry backoffs.
  • Bind the signal and a synchronous monotonic checkpoint into Octokit's request boundary so every pagination page and retry attempt stops at the exact deadline even when timer delivery is delayed.
  • Keep the initial discovery window on its own monotonic epoch so repository workflow lookup cannot consume its retry opportunity, while the action deadline still bounds it.
  • Safely chunk long deadline, sleep, and retry timers within Node's supported timer range.
  • Preserve independent configured 5xx and one-time primary-rate-limit retry policies while making their waits abortable.
  • Preserve Octokit's secondary-before-primary classification and require decimal safe-integer reset epochs before scheduling a primary retry.
  • Cancel outstanding sibling API work on ordinary terminal failures without replacing the original error.
  • Add focused lifecycle, retry-policy, boundary, and malformed-header regressions while retaining the TypeScript strictness improvements and raised coverage gates.

Motivation

The waiter previously counted configured sleeps instead of actual elapsed time. The initial repository workflow lookup was outside the deadline, large valid timeout values could overflow Node's timer range and fire immediately, and a delayed timer callback could allow an operation resolving at or after the boundary to win.

Octokit's private retry timers could also remain queued after the request signal was aborted. The first abortable replacement then coupled 5xx and primary-rate-limit retries through one counter: a server retry could consume the primary retry, while a primary retry could change the first server delay from one second to four. Missing or malformed reset headers could produce an endless NaN timer loop, and an ordinary failure from one active-status query did not cancel pending siblings. A secondary-limit response carrying primary-limit headers could also be misclassified and retried once by the custom adapter.

Octokit's paginator discarded parameter-level request signals before issuing pages. After signal propagation was corrected, a final boundary remained: when monotonic time had expired but the deadline timer callback was delayed, a compound discovery callback, paginator, or retry loop could still start one more API request before the outer deadline race observed completion.

Behavior

The deadline starts after inputs are parsed and before repository workflow lookup. Covered Actions API reads receive the same lifecycle signal and monotonic checkpoint, including workflow lookup, current-run lookup, each workflow-run and repository-run status query/page, job pages, and step reads. The waiter receives the remaining portion of the original budget.

The deadline stores an absolute monotonic boundary. Each compound discovery read is raced separately, and the Octokit request hook checks the boundary before every attempt and after every fulfillment or rejection. Therefore elapsed >= deadline prevents another discovery read, pagination page, retry backoff, or retry request even if the scheduled deadline callback has not run.

Deadline, sleep, and retry timers are scheduled in chunks no greater than 2_147_483_647 milliseconds. Long valid values re-arm until the monotonic target is reached instead of overflowing. The initial discovery window begins when workflow-run discovery starts, so earlier repository workflow lookup does not consume it; its remaining sleep is still raced against the shared action deadline. The log describes that discovery window rather than promising a new full-duration sleep.

Retry policy uses independent counters:

  • total request attempts are used only for Octokit request metadata;
  • configured server retries use delays of 1, 4, 9... seconds based only on the server-error count;
  • the first valid primary-rate-limit response may retry once after its reset boundary, regardless of server retries.

Ordinary 4xx behavior remains unchanged. Secondary-limit messages are classified before primary-limit headers and propagate after the plugin's existing warning without a custom retry. Missing, blank, non-decimal, or unsafe-integer primary reset headers propagate without retrying. Quota exhaustion is logged for every primary-limit response, while Retrying after ... is logged only when the custom hook actually schedules its one retry.

ActionDeadline.cancel() aborts outstanding work without marking the deadline elapsed. main.run() uses it before reporting ordinary failures, and standalone waiters cancel deadlines they own. dispose() remains timer-only cleanup, and successful completion does not abort its lifecycle signal.

Action inputs, outputs, defaults, branch/queue matching, rerun ordering, predecessor selection, job/step fallback policy, GitHub Enterprise support, Node 24 runtime, dependencies, and esbuild bundling remain unchanged.

Tests

  • Pending repository workflow lookup in abort and continue modes, including shared signal propagation, output behavior, zero-second limits, and timer cleanup.
  • An 800 ms workflow lookup leaving 200 ms of a one-second waiter budget.
  • Pre-wait lifecycle work that does not consume the initial discovery window, plus a longer initial window still bounded by the remaining action deadline.
  • Long continue and abort deadlines, bounded timer chunks, re-arming, cleanup, and long sleeps.
  • Operations resolving or rejecting after a delayed callback, success immediately before the boundary, and exact-boundary expiry.
  • Delayed timer delivery at the exact monotonic boundary, proving no second discovery read, pagination page, retry timer, or retry request starts.
  • 500 -> primary limit -> success and primary limit -> 500 -> success mixed retry sequences.
  • A longer mixed sequence proving independent retry limits and server delays of 1, 4, and 9 seconds.
  • Exactly one primary retry, missing/empty/non-decimal/unsafe reset headers, and accurate two-response primary-limit logging.
  • Direct secondary-message variants and a real Octokit 403 carrying both secondary and primary indicators, proving one request, secondary-only logging, original-error propagation, and timer cleanup.
  • Real pending workflow, workflow-run fan-out, and job pagination fetches; page-two signal retention; and cancellation of a paginated 5xx retry backoff.
  • Action-level and standalone-waiter terminal cancellation with one original 401, one 5xx retry wait, and one pending sibling.
  • Idempotent cancellation that remains distinct from actual deadline expiration and successful disposal.
  • Existing discovery, queue, job, step, polling, rerun, ordering, fallback, API-error, ordinary 4xx, secondary-limit, and GitHub Enterprise behavior.

The suite now contains 9 test files and 173 tests.

Coverage

Metric Before After Threshold
Statements 97.79% 99.24% 98%
Branches 93.64% 96.02% 95%
Functions 96.87% 100.00% 98%
Lines 97.71% 99.22% 98%

src/retry.ts remains covered at 100% for statements, branches, functions, and lines.

Validation

  • npm ci: passed with 0 vulnerabilities.
  • npm run typecheck: passed for source and tests.
  • npm run test: passed, 173 tests and all coverage thresholds.
  • npm run fmtcheck: passed.
  • npm run build: passed.
  • git diff --check: passed.
  • Focused retry, GitHub, main, and waiter suites: passed.

Risk

The intentional compatibility change is timing: workflows that previously exceeded their configured maximum during workflow lookup, later API calls, a retry backoff, or a long sleep now continue or abort at the documented boundary. Ordinary terminal API failures now promptly cancel sibling work before the original error is reported.

The synchronous checkpoint is carried only as internal Octokit request metadata and is evaluated by Turnstyle's request hook; it does not change endpoint parameters or responses. Pagination results and page caps, per-lane retry counts and delays, secondary-limit no-retry behavior, queue ordering, and predecessor selection are unchanged. The lifecycle and retry adapter are exercised through deterministic scheduler tests and the real Octokit request boundary. No live API calls or secrets are used.

This does not modify PR #154's Dependabot automation or dependencies.

Generated bundle

dist/index.js was regenerated with the existing Node 24 esbuild command. A second post-commit build produced a clean worktree, confirming deterministic regeneration.

References

Signed-off-by: Rui Chen <rui@chenrui.dev>
@chenrui333
chenrui333 marked this pull request as ready for review July 15, 2026 11:27
@chenrui333
chenrui333 marked this pull request as draft July 15, 2026 12:39
@chenrui333
chenrui333 marked this pull request as ready for review July 15, 2026 12:39
Signed-off-by: Rui Chen <rui@chenrui.dev>
Signed-off-by: Rui Chen <rui@chenrui.dev>
Signed-off-by: Rui Chen <rui@chenrui.dev>
Signed-off-by: Rui Chen <rui@chenrui.dev>
Signed-off-by: Rui Chen <rui@chenrui.dev>
Signed-off-by: Rui Chen <rui@chenrui.dev>
Signed-off-by: Rui Chen <rui@chenrui.dev>
Signed-off-by: Rui Chen <rui@chenrui.dev>
@chenrui333
chenrui333 merged commit 15e8a19 into master Jul 16, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant