Skip to content

fix: prevent data loss on interrupt by implementing graceful shutdown#2393

Open
assakafpix wants to merge 1 commit intoprojectdiscovery:devfrom
assakafpix:fix/resume-lost-targets
Open

fix: prevent data loss on interrupt by implementing graceful shutdown#2393
assakafpix wants to merge 1 commit intoprojectdiscovery:devfrom
assakafpix:fix/resume-lost-targets

Conversation

@assakafpix
Copy link

@assakafpix assakafpix commented Feb 5, 2026

Problem

/claim #2345

When httpx is interrupted with Ctrl+C, resume.cfg saves an index based on how many targets were dispatched to the thread pool, not how many actually completed and produced
output. With the default 50 threads, the scanner races through all inputs before any HTTP response returns, so resume.cfg always points near the end of the list even if almost
nothing was written to output. On resume with -resume (not -r, which is the flag for custom resolvers), all those dispatched-but-incomplete targets are permanently skipped.

The root cause is in runner/runner.go:1406-1407 currentIndex is incremented the moment a target is read from the input hash map, before the HTTP request even starts. Then the
SIGINT handler in cmd/httpx/httpx.go saves this inflated index and calls os.Exit(1) immediately, without waiting for in-flight goroutines to finish.

Proposed Changes

  • runner/runner.go: Add an interruptCh channel to Runner. On Ctrl+C, processItem detects the closed channel and stops dispatching new items (without incrementing
    currentIndex). The existing wg.Wait() in RunEnumeration naturally drains all in-flight requests before returning.
  • cmd/httpx/httpx.go: The SIGINT handler now calls Interrupt() instead of SaveResumeConfig() + os.Exit(1). Resume saving moves to after RunEnumeration() returns, when
    currentIndex accurately reflects completed work. A second Ctrl+C force-exits.
  • runner/runner_test.go: Test proving that interrupted output + resumed output = full scan with no gaps.

Proof

Before (v1.8.1) — 108 inputs, 51 targets lost:

❯ httpx -l subs.txt > interrupted.txt

      __    __  __       _  __
     / /_  / /_/ /_____ | |/ /
    / __ \/ __/ __/ __ \|   /
   / / / / /_/ /_/ /_/ /   |
  /_/ /_/\__/\__/ .___/_/|_|
               /_/

          projectdiscovery.io

  [INF] Current httpx version v1.8.1 (latest)
  [WRN] UI Dashboard is disabled, Use -dashboard option to enable
  ^C[INF] CTRL+C pressed: Exiting
  [INF] Creating resume file: resume.cfg
  ❯ httpx -l subs.txt -resume > resumed.txt

      __    __  __       _  __
     / /_  / /_/ /_____ | |/ /
    / __ \/ __/ __/ __ \|   /
   / / / / /_/ /_/ /_/ /   |
  /_/ /_/\__/\__/ .___/_/|_|
               /_/

          projectdiscovery.io

  [INF] Current httpx version v1.8.1 (latest)
  [WRN] UI Dashboard is disabled, Use -dashboard option to enable
  ❯ wc -l interrupted.txt resumed.txt
        19 interrupted.txt
        38 resumed.txt       # 19 + 38 = 57, not 108
  ❯ cat resume.cfg
  resume_from=https://mobile-news.sandbox.google.fr
  index=70

After — 108 inputs, 0 targets lost:

./http-fixed -l subs.txt > interrupted.txt

    __    __  __       _  __
   / /_  / /_/ /_____ | |/ /
  / __ \/ __/ __/ __ \|   /
 / / / / /_/ /_/ /_/ /   |
/_/ /_/\__/\__/ .___/_/|_|
             /_/

        projectdiscovery.io

[INF] Current httpx version v1.8.1 (latest)
[WRN] UI Dashboard is disabled, Use -dashboard option to enable
^C[INF] CTRL+C pressed: Exiting
[INF] Creating resume file: resume.cfg
❯ ./http-fixed -l subs.txt -resume > resumed.txt

    __    __  __       _  __
   / /_  / /_/ /_____ | |/ /
  / __ \/ __/ __/ __ \|   /
 / / / / /_/ /_/ /_/ /   |
/_/ /_/\__/\__/ .___/_/|_|
             /_/

        projectdiscovery.io

[INF] Current httpx version v1.8.1 (latest)
[WRN] UI Dashboard is disabled, Use -dashboard option to enable
❯ wc -l interrupted.txt resumed.txt
      57 interrupted.txt
      51 resumed.txt       # 57 + 51 = 108 

❯ cat resume.cfg
resume_from=https://livres.google.fr
index=57                   # matches exactly the 57 lines written

Checklist

  • PR created against the correct branch (usually dev)
  • All checks passed (lint, unit/integration/regression tests)
  • Tests added that prove the fix is effective or feature works
  • Documentation added (if appropriate)

Summary by CodeRabbit

  • New Features

    • Two-stage Ctrl+C interruption: first Ctrl+C stops dispatch and lets in-flight requests finish; a second Ctrl+C forces immediate exit.
    • Scans interrupted during run can now create and save a resume file automatically when resume saving is enabled, allowing later resumption.
  • Tests

    • Added test coverage validating resume-after-interrupt scan and resumption behavior.

@coderabbitai
Copy link

coderabbitai bot commented Feb 5, 2026

Walkthrough

Implements a two-stage Ctrl+C shutdown: first interrupt signals the Runner to stop dispatching new work and drain in-flight requests, deferring cleanup; a second Ctrl+C forces exit. Adds resume-persistence after an interrupted run when resume saving is enabled, and exposes Runner.Interrupt() and Runner.IsInterrupted().

Changes

Cohort / File(s) Summary
Runner core
runner/runner.go
Adds interruptCh field; new public methods Interrupt() and IsInterrupted(); initializes channel in New; processing and streaming loops check interruption to stop dispatching new items.
CLI / Command
cmd/httpx/httpx.go
Refactors Ctrl+C handling to call runner.Interrupt() on first Ctrl+C and defer forced exit to a second Ctrl+C; after RunEnumeration if interrupted and resume saving enabled, creates a resume file via SaveResumeConfig() and logs errors.
Tests
runner/runner_test.go
Adds TestRunner_resumeAfterInterrupt to validate interrupted run saves resume index and that resuming produces the remaining items, comparing against a full run.

Sequence Diagram

sequenceDiagram
    actor User
    participant CLI as CLI Handler
    participant Runner as Runner
    participant Req as In-flight Requests
    participant FS as File System

    User->>CLI: Ctrl+C (first)
    CLI->>Runner: Interrupt()
    Note over Runner: Close interruptCh / mark interrupted
    Runner->>Runner: Stop dispatching new items
    Runner->>Req: Allow in-flight requests to complete
    Req-->>Runner: Responses complete
    Runner-->>CLI: RunEnumeration returns
    CLI->>Runner: IsInterrupted()?
    alt interrupted and resume enabled
        CLI->>FS: SaveResumeConfig()
        FS-->>CLI: Resume file created / error
    end
    User->>CLI: Ctrl+C (second)
    CLI->>CLI: Force exit
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐇 I nibbled signals, paused the run,
One tap to stop, let requests finish one by one,
A little file saved for journeys anew,
Two taps and off — hop, resume, pursue! 🥕

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main objective of the changeset: implementing graceful shutdown with resume capability to prevent data loss on interrupt.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@runner/runner.go`:
- Around line 1475-1480: The consumer breaks out when r.IsInterrupted() but the
producer (streamInput) may still be sending to streamChan (unbuffered), causing
a deadlock; update the producer(s) that do "out <- item" (e.g., streamInput
goroutine) to be interrupt-aware by selecting on a cancellation signal (use
r.IsInterrupted() via a done channel or context) before sending, and stop/return
when interrupted, or close the channel from a single owner before exit so the
consumer can range-finish; ensure all places that write "out <- item" use this
select pattern to avoid blocking sends after interruption.
🧹 Nitpick comments (2)
runner/runner.go (1)

107-124: Make Interrupt safe under concurrent calls.

If Interrupt() is ever called from multiple goroutines, the current close pattern can double-close the channel and panic. A sync.Once (or atomic CAS) makes this robust.

🔧 Suggested fix
type Runner struct {
    ...
-   interruptCh        chan struct{}
+   interruptCh        chan struct{}
+   interruptOnce      sync.Once
}

func (r *Runner) Interrupt() {
-   select {
-   case <-r.interruptCh:
-   default:
-       close(r.interruptCh)
-   }
+   r.interruptOnce.Do(func() { close(r.interruptCh) })
}
runner/runner_test.go (1)

18-84: Add runner cleanup and use the public interrupt API.

Closing runners avoids leaking hybrid-map/ratelimiter resources across tests, and IsInterrupted() keeps the test aligned with the public API rather than interruptCh internals.

♻️ Suggested fix
rFull, err := New(&Options{})
require.Nil(t, err, "could not create httpx runner")
+t.Cleanup(rFull.Close)

...

rInt, err := New(&Options{})
require.Nil(t, err, "could not create httpx runner")
+t.Cleanup(rInt.Close)

...
-       select {
-       case <-rInt.interruptCh:
-           continue
-       default:
-       }
+       if rInt.IsInterrupted() {
+           continue
+       }

...

rRes, err := New(&Options{})
require.Nil(t, err, "could not create httpx runner")
+t.Cleanup(rRes.Close)

@assakafpix assakafpix force-pushed the fix/resume-lost-targets branch from 32a8306 to fa95023 Compare February 5, 2026 04:05
@ayuxsec
Copy link

ayuxsec commented Feb 5, 2026

doesn't seem to be working from my tests:

ayux@pop-os:~/httpx$ echo -e "example.com\ntest.com\nfacebook.com\nhackerone.com\ngoogle.com\n" | go run cmd/httpx/httpx.go

    __    __  __       _  __
   / /_  / /_/ /_____ | |/ /
  / __ \/ __/ __/ __ \|   /
 / / / / /_/ /_/ /_/ /   |
/_/ /_/\__/\__/ .___/_/|_|
             /_/

		projectdiscovery.io

[INF] Current httpx version v1.8.1 (latest)
[WRN] UI Dashboard is disabled, Use -dashboard option to enable
https://example.com
https://google.com
https://facebook.com
^C[INF] CTRL+C pressed: Exiting
https://hackerone.com
[INF] Creating resume file: resume.cfg
[ble: exit 1]
ayux@pop-os:~/httpx$ cat resume.cfg 
resume_from=test.com
index=5
ayux@pop-os:~/httpx$ echo -e "example.com\ntest.com\nfacebook.com\nhackerone.com\ngoogle.com\n" | go run cmd/httpx/httpx.go -r resume.cfg -v

    __    __  __       _  __
   / /_  / /_/ /_____ | |/ /
  / __ \/ __/ __/ __ \|   /
 / / / / /_/ /_/ /_/ /   |
/_/ /_/\__/\__/ .___/_/|_|
             /_/

		projectdiscovery.io

[INF] Current httpx version v1.8.1 (latest)
[DBG] Using resolvers: resume_from=test.com,index=5
[WRN] UI Dashboard is disabled, Use -dashboard option to enable
[DBG] Failed 'http://facebook.com': Get "http://facebook.com": cause="no address found for host"
[DBG] Failed 'http://test.com': Get "http://test.com": cause="no address found for host"
[DBG] Failed 'http://hackerone.com': Get "http://hackerone.com": cause="no address found for host"
[DBG] Failed 'http://google.com': Get "http://google.com": cause="no address found for host"
[DBG] Failed 'http://example.com': Get "http://example.com": cause="no address found for host"
ayux@pop-os:~/httpx$ git branch
* fix/resume-lost-targets

@assakafpix
Copy link
Author

Hi @ayuxsec !
the flag to resume is -resume not -r and you don't need to specify the resume.cfg file. I think -r is for resolvers

@ayuxsec
Copy link

ayuxsec commented Feb 5, 2026

oh mb!

edit: though index of test.com was also wrong in resume.cfg you might wanna check on that

Copy link
Member

@Mzack9999 Mzack9999 left a comment

Choose a reason for hiding this comment

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

lgtm!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants