SagerNet / SagerNet/sing-box

Remote profile auto-update retries in a tight loop with no backoff when the subscription URL fails

Open
#4,526 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
Go
Stars
38.2k
Forks
4.6k
Avg merge
19d 15h
Merged PRs (30d)
1

Description

Operating system

Windows

System version

11

Installation type

sing-box for Windows Graphical Client

If you are using a graphical client, please provide the version of the client.

1.14.0

Description

Summary

When a remote profile's subscription URL fails — blocked by a firewall, filtered by an ISP or middlebox, refused, or answered with a block page — the auto-update scheduler retries immediately and indefinitely with no delay. Depending on how the request fails, this produces anywhere from one request every 30 seconds to hundreds of requests per second against the subscription host, plus sustained CPU usage and log spam in the main process.

In effect the client can hammer a subscription provider for as long as the URL stays unreachable.

Where

src/main/profiles.tsupdateRemoteProfile(), runDueProfileUpdates(), reconfigureAutoUpdate()

Root cause

Three behaviours combine into a zero-delay retry loop:

  1. last_updated is only written on full success. In updateRemoteProfile(), the UPDATE profiles SET last_updated = ? write happens last, after both fetchRemoteContent() and checkConfig() succeed. If either throws, the timestamp is never advanced.

  2. The next run is clamped to "now". reconfigureAutoUpdate() computes the next fire time as Math.max(Date.now(), min(lastUpdated + intervalMs)). Because lastUpdated does not move on failure, lastUpdated + intervalMs is already in the past, so it clamps to Date.now() and schedules setTimeout(..., 0).

  3. It reschedules unconditionally. The timer callback is runDueProfileUpdates().finally(() => reconfigureAutoUpdate()). finally runs on failure as well, and runDueProfileUpdates() catches the error with only console.error. Nothing records that the attempt failed, so the profile is immediately "due" again.

Result: fail → reschedule at 0 ms → fetch → fail → reschedule at 0 ms. The only thing limiting the rate is how long each attempt takes to fail.

Impact by failure mode

How the request fails Time to fail Resulting request rate
Connection refused / TCP reset / DNS resolving to an unreachable host milliseconds hundreds per second
Non-200 response from a filter (403, 451, 5xx) fast very high; each attempt also reads up to 64 KB of error body
200 response with a block or captive-portal page fetch succeeds, checkConfig() rejects the content very high; each attempt downloads the full page (up to the 16 MB limit)
Packets silently dropped 30 s (AbortSignal.timeout) one per 30 s

The block-page case is especially relevant: many ISP and national filters answer with HTTP 200 and an HTML notice, which passes the status check but fails validation, so it loops at full speed while downloading the page body each time.

Aggravating factors

  • Profiles that have never updated loop from the first attempt. When lastUpdated is undefined the code uses (profile.lastUpdated ?? 0) + intervalMs, which is effectively 1970 and always in the past.
  • All failing profiles retry concurrently. runDueProfileUpdates() uses Promise.all over every due profile, so each spin fires every failing subscription at once.
  • MINIMUM_UPDATE_INTERVAL_MINUTES does not apply on failure. The 15-minute floor is added to lastUpdated, which is exactly the value that never advances when requests fail. The floor only protects the success path.
  • Every iteration writes a console.error, so logs grow quickly alongside the network traffic.

Suggested fix

  • Decouple scheduling from success. Record the time of every attempt (e.g. a separate last_attempt column, or in memory), and schedule the next run from that rather than from last_updated.
  • Back off on consecutive failures. Track a per-profile failure count and apply exponential backoff with jitter — for example starting at around 1 minute and doubling each time, capped at the profile's configured interval. Reset the count on success.
  • Never schedule at 0 ms after a failure. Clamping to Date.now() should only apply when an update is genuinely overdue and has not just been attempted.
  • Optionally, surface the last error and next retry time in the UI so users can see that a subscription is failing.
Reproduction

Steps to reproduce

  1. Add a remote profile whose URL refuses connections immediately, e.g. http://127.0.0.1:1/sub.
  2. Leave auto-update enabled (the default for remote profiles).
  3. Watch the main process output or a network monitor.

Expected: a failed update is retried after a delay, with the delay growing on repeated failures.
Actual: update profile <name>: errors are logged continuously and the request is re-sent as fast as the connection can be refused. Main-process CPU stays elevated.

To reproduce the block-page case, point the URL at a local server that returns 200 OK with any HTML body.

Logs

Supporter
Integrity requirements
  • I confirm that I have read the documentation, understand the meaning of all the configuration items I wrote, and did not pile up seemingly useful options or default values.
  • I confirm that I have provided the server and client configuration files and process that can be reproduced locally, instead of a complicated client configuration file that has been stripped of sensitive data.
  • I confirm that I have provided the simplest configuration that can be used to reproduce the error I reported, instead of depending on remote servers, TUN, graphical interface clients, or other closed-source software.
  • I confirm that I have provided the complete configuration files and logs, rather than just providing parts I think are useful out of confidence in my own intelligence.

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in src/main/profiles.ts by tracing updateRemoteProfile(), runDueProfileUpdates(), and reconfigureAutoUpdate(), then reproduce with http://127.0.0.1:1/sub or a local server returning 200 HTML. The fix is done when failed updates retry with a nonzero, increasing delay, successful updates retain normal scheduling, and concurrent failures no longer spin or flood logs.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
desktop, networking
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
55/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.