Skip to content

fix: retry force_rate hook patching - #128

Merged
dngrtech merged 4 commits into
mainfrom
fix/force-rate-retry
Jun 26, 2026
Merged

fix: retry force_rate hook patching#128
dngrtech merged 4 commits into
mainfrom
fix/force-rate-retry

Conversation

@dngrtech

@dngrtech dngrtech commented Jun 26, 2026

Copy link
Copy Markdown
Owner

Summary

  • Add a qzeroded-only retry window before force_rate.so gives up when the target text page is not mapped yet
  • Keep inherited LD_PRELOAD wrapper/subprocess behavior quiet by skipping retries outside qzeroded
  • Rebuild the shipped force_rate.so and bump release metadata to 1.12.12
  • Add make verify-system-hooks plus GitHub Actions coverage to rebuild and compare the committed hook binary

Review fixes

  • Treat any mincore failure as not mapped instead of assuming unexpected errors are safe
  • Handle nanosleep(..., EINTR) during retry delay
  • Use PATH_MAX for /proc/self/exe detection
  • Replace the changelog TBD entry with PR fix: retry force_rate hook patching #128

Test Plan

  • make force-rate.so
  • make verify-system-hooks
  • gcc -shared -fPIC -Wall -Wextra -Werror -o /tmp/force_rate.check.so ql-assets/data/system-hooks/force_rate.c
  • LD_PRELOAD=/tmp/force_rate.check.so /bin/true emits no output
  • python -m json.tool docs/user/version.json
  • git diff --check
  • pytest tests/test_ld_preload_paths.py tests/test_system_hooks_predicate.py tests/test_apply_hooks_preflight.py -q

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR Review: v1.12.12 — force_rate retry handling

Strengths

  • is_qzeroded_process() guard (force_rate.c:25–32): Correctly restricts the retry wait to the real qzeroded process, so bash wrappers and subshells that inherit LD_PRELOAD still bail out instantly. This keeps the non-qzeroded fast path clean.
  • __builtin___clear_cache after memcpy (force_rate.c:98): This is a correctness win. Writing x86 instructions via memcpy then executing them can hit stale instruction-cache entries on some microarchitectures. Good catch.
  • page_size <= 0 guard (force_rate.c:70–71): Defensive, correct. sysconf(_SC_PAGESIZE) can return -1 on error; using that as the mincore length would be UB.
  • Diagnostic fprintf on retry exhaustion (force_rate.c:85–88): Makes silent patch failures visible in server logs, which will help diagnose future timing issues.

Issues

Critical (Must Fix)

Committed prebuilt binary with no verification (force_rate.so)

A compiled .so is committed alongside the source. There is no build script, Makefile target, or CI step in this diff that rebuilds the binary from force_rate.c and verifies the result matches the committed file. This means:

  • The committed binary could diverge from the source silently (accidental or deliberate).
  • Anyone deploying this repo gets the binary; there is no way to confirm it was compiled from this exact source.
  • Future PRs that edit force_rate.c may forget to rebuild, silently shipping stale code.

Fix: Build and hash-verify the .so in CI as part of the merge check, or generate it at install time. At minimum, add a make force_rate.so recipe and fail CI if the committed binary does not match a fresh build.


Important (Should Fix)

page_is_mapped() treats unexpected mincore errors as "mapped" (force_rate.c:35–39)

return errno != ENOMEM && errno != EINVAL;

ENOMEM means the range is not mapped — correct to return 0. But errors like EFAULT (invalid vec pointer) or other unexpected codes return 1 (treated as "mapped"), and the code proceeds to mprotect + memcpy on an address that may not actually be accessible. This would likely segfault. The safer idiom is to return 0 on any mincore failure:

return 0;  /* treat any error as not-yet-mapped */

The only reason to distinguish errors here is to avoid false negatives on ENOMEM; every other error should be treated conservatively.

wait_for_target_page() blocks the dynamic linker for up to ~1 s (force_rate.c:42–53)

Constructor functions (__attribute__((constructor))) run on the linker's critical path. Blocking here for 50 × 20 ms delays dlopen/process startup. This is gated on is_qzeroded_process(), so non-qzeroded processes are unaffected — but any tool that wraps or execves qzeroded (e.g., a launcher that does pre-exec setup) will stall. Consider logging a single message at attempt 0 so the delay is visible in server output rather than appearing as a hang.

nanosleep ignores EINTR (force_rate.c:48)

A signal arriving during the 20 ms sleep causes nanosleep to return early with EINTR. The remaining sleep time is discarded, effectively shortening the retry window under signal load. For a game server constructor this is low-risk, but the standard pattern is:

struct timespec rem;
while (nanosleep(&delay, &rem) == -1 && errno == EINTR)
    delay = rem;

"TBD" PR number in release notes (docs/user/releases.md:7)

The releases.md entry has | TBD | for the PR link. This should be the actual PR number before merge so the changelog remains navigable.


Minor (Nice to Have)

is_qzeroded_process() uses a 256-byte path buffer (force_rate.c:27)

readlink("/proc/self/exe", buf, 255) silently truncates paths longer than 255 bytes. PATH_MAX is 4096 on Linux. In practice qzeroded's install path is short, but using PATH_MAX or a heap-allocated buffer is more correct. If the path truncates mid-name, strstr(exe_path, "qzeroded") could produce a false negative.

Off-by-one retry count (force_rate.c:44–52)

The loop runs 50 iterations, then wait_for_target_page calls page_is_mapped one more time unconditionally on return (force_rate.c:52). The effective attempt count is 51, not 50. The comment /* 20ms, 1s total */ is therefore slightly off (1.02 s max). Either fold the final check into the loop or update the comment.

PATCH_RETRY_DELAY_NS comment (force_rate.c:23)

#define PATCH_RETRY_DELAY_NS 20000000L  /* 20ms, 1s total */

The "1s total" claim belongs on PATCH_RETRY_ATTEMPTS, not the per-delay constant. Consider moving the combined-total comment to a single block above both defines.


Assessment

Ready to merge? No — with fixes

Reasoning: The retry logic and icache flush are correct and solve a real race condition, but committing a prebuilt binary without a reproducible build step is a supply-chain integrity problem that should be addressed before the code ships to production hosts. The page_is_mapped error-handling issue is also a latent crash risk worth fixing first.

@dngrtech

Copy link
Copy Markdown
Owner Author

Addressed the review findings:

  • Added Makefile targets:
    • make force-rate.so
    • make verify-system-hooks
  • Added .github/workflows/system-hooks.yml so PRs touching system hooks rebuild force_rate.so and compare it against the committed binary.
  • Fixed page_is_mapped() to treat any mincore() failure as not mapped.
  • Added EINTR handling for nanosleep().
  • Switched /proc/self/exe buffer to PATH_MAX.
  • Removed the off-by-one final retry probe.
  • Replaced changelog TBD with PR fix: retry force_rate hook patching #128.
  • Pinned the verifier runner to ubuntu-22.04 and disabled ELF build-id so the rebuild comparison is stable.

Current verification:

  • make force-rate.so
  • make verify-system-hooks
  • pytest tests/test_ld_preload_paths.py tests/test_system_hooks_predicate.py tests/test_apply_hooks_preflight.py -q
  • GitHub Action Rebuild force_rate.so on latest head 42e08dc

@dngrtech
dngrtech merged commit 42e08dc into main Jun 26, 2026
1 check passed
@dngrtech
dngrtech deleted the fix/force-rate-retry branch June 26, 2026 03:30
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