ZHIWEI666 commited on
Commit
0fe2742
·
verified ·
1 Parent(s): 7282cc7

Upload 10 files

Browse files
app.py CHANGED
@@ -73,6 +73,7 @@ from router_comments import (router as comments_router, start_comment_writes, st
73
  from router_messages import (router as messages_router, start_message_io, stop_message_io, close_message_io) # ✉️ 私信系统
74
  from router_wallet import (router as wallet_router, start_wallet_sql, stop_wallet_sql, close_wallet_sql) # 💰 钱包/提现
75
  from proxy_access import start_proxy_access, stop_proxy_access, close_proxy_access
 
76
  from router_proxy import router as proxy_router # 🔗 代理下载
77
  from router_posts import (router as posts_router, start_posts_io, stop_posts_io, close_posts_io) # 💬 讨论区
78
  from router_prompts import (router as prompts_router, start_prompts_io, stop_prompts_io, close_prompts_io) # ✨ 提示词市场
@@ -325,6 +326,7 @@ async def on_startup():
325
  start_prompts_io()
326
  start_tasks_io()
327
  start_proxy_access()
 
328
  start_comment_reads()
329
  start_admin_scripts()
330
 
@@ -384,6 +386,7 @@ async def _shutdown_resources():
384
  failures.call('stop_warmup', stop_startup_warmup, app.state)
385
  for stage, stop in (
386
  ('stop_wallet_sql', stop_wallet_sql),
 
387
  ('stop_resource_reads', stop_resource_reads), ('stop_comment_writes', stop_comment_writes),
388
  ('stop_posts', stop_posts_io), ('stop_prompts', stop_prompts_io), ('stop_tasks', stop_tasks_io), ('stop_proxy_access', stop_proxy_access),
389
  ('stop_messages', stop_message_io), ('stop_resources', stop_resource_work), ('stop_open_sql', stop_open_sql), ('stop_config_sql', stop_config_sql), ('stop_shared', stop_shared_work),
@@ -400,6 +403,7 @@ async def _shutdown_resources():
400
  failures.call('stop_' + name, request_periodic_stop, app.state, (name,))
401
  for stage, close, args in (
402
  ('wait_warmup', wait_startup_warmup, (app.state,)),
 
403
  ('close_wallet_sql', close_wallet_sql, ()),
404
  ('close_resource_reads', close_resource_reads, ()), ('close_comment_writes', close_comment_writes, ()),
405
  ('close_posts', close_posts_io, ()), ('close_prompts', close_prompts_io, ()), ('close_tasks', close_tasks_io, ()), ('close_proxy_access', close_proxy_access, ()),
 
73
  from router_messages import (router as messages_router, start_message_io, stop_message_io, close_message_io) # ✉️ 私信系统
74
  from router_wallet import (router as wallet_router, start_wallet_sql, stop_wallet_sql, close_wallet_sql) # 💰 钱包/提现
75
  from proxy_access import start_proxy_access, stop_proxy_access, close_proxy_access
76
+ from proxy_download_work import start_proxy_downloads, stop_proxy_downloads, close_proxy_downloads
77
  from router_proxy import router as proxy_router # 🔗 代理下载
78
  from router_posts import (router as posts_router, start_posts_io, stop_posts_io, close_posts_io) # 💬 讨论区
79
  from router_prompts import (router as prompts_router, start_prompts_io, stop_prompts_io, close_prompts_io) # ✨ 提示词市场
 
326
  start_prompts_io()
327
  start_tasks_io()
328
  start_proxy_access()
329
+ start_proxy_downloads()
330
  start_comment_reads()
331
  start_admin_scripts()
332
 
 
386
  failures.call('stop_warmup', stop_startup_warmup, app.state)
387
  for stage, stop in (
388
  ('stop_wallet_sql', stop_wallet_sql),
389
+ ('stop_proxy_downloads', stop_proxy_downloads),
390
  ('stop_resource_reads', stop_resource_reads), ('stop_comment_writes', stop_comment_writes),
391
  ('stop_posts', stop_posts_io), ('stop_prompts', stop_prompts_io), ('stop_tasks', stop_tasks_io), ('stop_proxy_access', stop_proxy_access),
392
  ('stop_messages', stop_message_io), ('stop_resources', stop_resource_work), ('stop_open_sql', stop_open_sql), ('stop_config_sql', stop_config_sql), ('stop_shared', stop_shared_work),
 
403
  failures.call('stop_' + name, request_periodic_stop, app.state, (name,))
404
  for stage, close, args in (
405
  ('wait_warmup', wait_startup_warmup, (app.state,)),
406
+ ('close_proxy_downloads', close_proxy_downloads, ()),
407
  ('close_wallet_sql', close_wallet_sql, ()),
408
  ('close_resource_reads', close_resource_reads, ()), ('close_comment_writes', close_comment_writes, ()),
409
  ('close_posts', close_posts_io, ()), ('close_prompts', close_prompts_io, ()), ('close_tasks', close_tasks_io, ()), ('close_proxy_access', close_proxy_access, ()),
github_zip_service.py CHANGED
@@ -7,6 +7,7 @@ from skill_archive_source import source_header, installation_revision, source_ke
7
  from proxy_resources import ProxyResources
8
  from workflow_stream_response import OwnedProxyResponse
9
  from zip_stream import zip_response_length, stream_zip_bytes
 
10
 
11
 
12
  async def prepare_github_zip(item, item_id, *, store, token_selector, resolve_commit,
@@ -17,7 +18,7 @@ async def prepare_github_zip(item, item_id, *, store, token_selector, resolve_co
17
  source = github_item_source(item)
18
  repo_url = item.get("link", "")
19
  owner, repo = source["owner"], source["repo"]
20
- active_token = token_selector(item)
21
  skill_revision = installation_revision(item, source_key(source), active_token, signing_key=signing_key) if source['skill'] else None
22
  except ValueError:
23
  return error_response(content={"error": "资源仓库或凭据暂时无法核实"}, status_code=503)
@@ -26,7 +27,7 @@ async def prepare_github_zip(item, item_id, *, store, token_selector, resolve_co
26
  cache_resources = None
27
  cache_handed_off = False
28
  try:
29
- versions_db = store.load_data("versions.json", default_data={})
30
  item_version = versions_db.get(item_id)
31
  archive = cached_archive(item, active_token, item_version, repo_id=store.DATASET_REPO_ID)
32
  hf_cache_token = os.environ.get("HF_TOKEN")
 
7
  from proxy_resources import ProxyResources
8
  from workflow_stream_response import OwnedProxyResponse
9
  from zip_stream import zip_response_length, stream_zip_bytes
10
+ from owned_thread import run_owned_thread
11
 
12
 
13
  async def prepare_github_zip(item, item_id, *, store, token_selector, resolve_commit,
 
18
  source = github_item_source(item)
19
  repo_url = item.get("link", "")
20
  owner, repo = source["owner"], source["repo"]
21
+ active_token = await run_owned_thread(token_selector, item)
22
  skill_revision = installation_revision(item, source_key(source), active_token, signing_key=signing_key) if source['skill'] else None
23
  except ValueError:
24
  return error_response(content={"error": "资源仓库或凭据暂时无法核实"}, status_code=503)
 
27
  cache_resources = None
28
  cache_handed_off = False
29
  try:
30
+ versions_db = await run_owned_thread(store.load_data, "versions.json", default_data={})
31
  item_version = versions_db.get(item_id)
32
  archive = cached_archive(item, active_token, item_version, repo_id=store.DATASET_REPO_ID)
33
  hf_cache_token = os.environ.get("HF_TOKEN")
proxy_download_work.py ADDED
@@ -0,0 +1,189 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """下载准备、响应交付与应用关闭共用的有界归属。"""
2
+ import asyncio
3
+ from functools import wraps
4
+
5
+ from fastapi import HTTPException
6
+ from workflow_stream_response import OwnedProxyResponse
7
+
8
+
9
+ def _spawn(function):
10
+ async def execute():
11
+ try:
12
+ return True, await function()
13
+ except BaseException as error:
14
+ return False, error
15
+ coroutine = execute()
16
+ try:
17
+ return asyncio.create_task(coroutine)
18
+ except BaseException:
19
+ coroutine.close()
20
+ raise
21
+
22
+
23
+ async def _settle(task, interrupted=None):
24
+ cancelled = None
25
+ while not task.done():
26
+ try:
27
+ await asyncio.shield(task)
28
+ except asyncio.CancelledError as error:
29
+ cancelled = error
30
+ if interrupted is not None:
31
+ interrupted(task)
32
+ outcome = (False, asyncio.CancelledError()) if task.cancelled() else task.result()
33
+ return outcome, cancelled
34
+
35
+
36
+ class _Download:
37
+ def __init__(self, group):
38
+ self.group = group
39
+ self.active = None
40
+ self.response = None
41
+ self.aborting = False
42
+ self.released = False
43
+ self.sending = False
44
+ self.cancelled_tasks = set()
45
+ self.cleanup = None
46
+ self.finished = asyncio.Event()
47
+
48
+ def cancel(self, task):
49
+ self.aborting = True
50
+ if not task.done() and task not in self.cancelled_tasks:
51
+ self.cancelled_tasks.add(task)
52
+ task.cancel()
53
+
54
+ def release(self):
55
+ self.released = True
56
+ self.group._pending.discard(self)
57
+ self.finished.set()
58
+
59
+ async def prepare(self, function, args, kwargs):
60
+ result = await function(*args, **kwargs)
61
+ if isinstance(result, OwnedProxyResponse):
62
+ result.bind_download(self)
63
+ self.response = result
64
+ return result
65
+
66
+ async def abort(self):
67
+ self.aborting = True
68
+ self.cancel(self.active)
69
+ await _settle(self.active, self.cancel)
70
+ if self.response is None:
71
+ self.release()
72
+ return
73
+ if self.sending:
74
+ # 发送拥有者先完成迭代器和连接关闭,再归还名额。
75
+ await _settle(_spawn(self.finished.wait))
76
+ return
77
+ if self.cleanup is None:
78
+ async def close():
79
+ try:
80
+ await self.response.aclose()
81
+ finally:
82
+ self.release()
83
+ self.cleanup = _spawn(close)
84
+ outcome, cancelled = await _settle(self.cleanup)
85
+ succeeded, result = outcome
86
+ if cancelled is not None:
87
+ raise cancelled
88
+ if not succeeded:
89
+ raise result
90
+
91
+ async def deliver(self, response, scope, receive, send):
92
+ if self.released or self.aborting or self.sending or response is not self.response:
93
+ raise asyncio.CancelledError()
94
+ self.sending = True
95
+ try:
96
+ self.active = _spawn(lambda: response.send_owned(scope, receive, send))
97
+ outcome, cancelled = await _settle(self.active, self.cancel)
98
+ succeeded, result = outcome
99
+ if cancelled is not None:
100
+ raise cancelled
101
+ if not succeeded:
102
+ raise result
103
+ return result
104
+ finally:
105
+ try:
106
+ # 发送任务在首次执行前被取消时,也必须关闭尚未消费的响应。
107
+ await response.aclose()
108
+ finally:
109
+ self.release()
110
+
111
+
112
+ class ProxyDownloadWork:
113
+ def __init__(self, capacity=8):
114
+ if type(capacity) is not int or capacity < 1:
115
+ raise ValueError('invalid download capacity')
116
+ self.capacity = capacity
117
+ self._pending = set()
118
+ self._accepting = True
119
+ self._closing = None
120
+ self._closers = 0
121
+
122
+ def start(self):
123
+ if self._pending or self._closers or self._closing is not None and not self._closing.done():
124
+ raise RuntimeError('download work is still pending')
125
+ if self._closing is not None:
126
+ succeeded, result = self._closing.result()
127
+ if not succeeded:
128
+ raise result
129
+ self._closing = None
130
+ self._accepting = True
131
+
132
+ def stop(self):
133
+ self._accepting = False
134
+
135
+ def endpoint(self, function):
136
+ @wraps(function)
137
+ async def invoke(*args, **kwargs):
138
+ return await self.run(function, *args, **kwargs)
139
+ return invoke
140
+
141
+ async def run(self, function, *args, **kwargs):
142
+ if not self._accepting or len(self._pending) >= self.capacity:
143
+ raise HTTPException(status_code=503, detail='service_unavailable')
144
+ owner = _Download(self)
145
+ self._pending.add(owner)
146
+ handed_off = False
147
+ try:
148
+ owner.active = _spawn(lambda: owner.prepare(function, args, kwargs))
149
+ outcome, cancelled = await _settle(owner.active, owner.cancel)
150
+ succeeded, result = outcome
151
+ if cancelled is not None or owner.aborting:
152
+ raise cancelled or asyncio.CancelledError()
153
+ if not succeeded:
154
+ raise result
155
+ handed_off = owner.response is not None
156
+ return result
157
+ finally:
158
+ if owner.response is None:
159
+ owner.release()
160
+ elif not handed_off:
161
+ await owner.abort()
162
+
163
+ async def close(self):
164
+ self.stop()
165
+ self._closers += 1
166
+ try:
167
+ if self._closing is None:
168
+ async def drain():
169
+ tasks = [_spawn(owner.abort) for owner in tuple(self._pending)]
170
+ results = await asyncio.gather(*tasks)
171
+ for succeeded, result in results:
172
+ if not succeeded:
173
+ raise result
174
+ self._closing = _spawn(drain)
175
+ outcome, cancelled = await _settle(self._closing)
176
+ succeeded, result = outcome
177
+ if cancelled is not None:
178
+ raise cancelled
179
+ if not succeeded:
180
+ raise result
181
+ finally:
182
+ self._closers -= 1
183
+
184
+
185
+ _downloads = ProxyDownloadWork()
186
+ proxy_download_endpoint = _downloads.endpoint
187
+ start_proxy_downloads = _downloads.start
188
+ stop_proxy_downloads = _downloads.stop
189
+ close_proxy_downloads = _downloads.close
router_proxy.py CHANGED
@@ -25,6 +25,8 @@ from zip_stream import ZIP_DOWNLOAD_MAX_BYTES
25
  from github_zip_service import prepare_github_zip
26
  from proxy_resources import close_redirect_response
27
  from skill_archive_source import install_source, SkillSourceError
 
 
28
 
29
  router = APIRouter()
30
  MAX_PROXY_DOWNLOAD_BYTES = int(os.environ.get("MAX_PROXY_DOWNLOAD_BYTES", str(256 * 1024 * 1024)))
@@ -113,11 +115,12 @@ class SkillInstallSourceRequest(BaseModel):
113
 
114
  @router.post('/api/skill_install_source')
115
  @shared_limit(30)
 
116
  async def skill_install_source(request: Request, req_data: SkillInstallSourceRequest,
117
  account: str = Depends(require_auth)):
118
  try:
119
  item = await authorized_proxy_item(json_db, SessionLocal, req_data.item_id, account)
120
- result = install_source(item, account, token_selector=select_github_token, signing_key=JWT_SECRET)
121
  except ProxyAccessError as error:
122
  return JSONResponse(content={'error': error.message}, status_code=error.status_code,
123
  headers={'Cache-Control': 'no-store'})
@@ -128,6 +131,7 @@ async def skill_install_source(request: Request, req_data: SkillInstallSourceReq
128
 
129
  @router.post("/api/proxy_github_zip")
130
  @shared_limit(5)
 
131
  async def proxy_github_zip(request: Request, req_data: ProxyGithubZipRequest, account: str = Depends(require_auth)):
132
  """云端代理:校验所有权后,拉取 GitHub 私有库 ZIP 流透传给本地"""
133
  try:
@@ -190,6 +194,7 @@ class ProxyDownloadRequest(BaseModel):
190
 
191
  @router.post("/api/proxy_download")
192
  @shared_limit(10)
 
193
  async def proxy_download(request: Request, req_data: ProxyDownloadRequest, account: str = Depends(require_auth)):
194
  """云端代理:校验所有权后,拉取真实的工作流 JSON 文件并透传给本地"""
195
  try:
 
25
  from github_zip_service import prepare_github_zip
26
  from proxy_resources import close_redirect_response
27
  from skill_archive_source import install_source, SkillSourceError
28
+ from proxy_download_work import proxy_download_endpoint
29
+ from owned_thread import run_owned_thread
30
 
31
  router = APIRouter()
32
  MAX_PROXY_DOWNLOAD_BYTES = int(os.environ.get("MAX_PROXY_DOWNLOAD_BYTES", str(256 * 1024 * 1024)))
 
115
 
116
  @router.post('/api/skill_install_source')
117
  @shared_limit(30)
118
+ @proxy_download_endpoint
119
  async def skill_install_source(request: Request, req_data: SkillInstallSourceRequest,
120
  account: str = Depends(require_auth)):
121
  try:
122
  item = await authorized_proxy_item(json_db, SessionLocal, req_data.item_id, account)
123
+ result = await run_owned_thread(install_source, item, account, token_selector=select_github_token, signing_key=JWT_SECRET)
124
  except ProxyAccessError as error:
125
  return JSONResponse(content={'error': error.message}, status_code=error.status_code,
126
  headers={'Cache-Control': 'no-store'})
 
131
 
132
  @router.post("/api/proxy_github_zip")
133
  @shared_limit(5)
134
+ @proxy_download_endpoint
135
  async def proxy_github_zip(request: Request, req_data: ProxyGithubZipRequest, account: str = Depends(require_auth)):
136
  """云端代理:校验所有权后,拉取 GitHub 私有库 ZIP 流透传给本地"""
137
  try:
 
194
 
195
  @router.post("/api/proxy_download")
196
  @shared_limit(10)
197
+ @proxy_download_endpoint
198
  async def proxy_download(request: Request, req_data: ProxyDownloadRequest, account: str = Depends(require_auth)):
199
  """云端代理:校验所有权后,拉取真实的工作流 JSON 文件并透传给本地"""
200
  try:
workflow_download_service.py CHANGED
@@ -5,6 +5,7 @@ from urllib.parse import urlparse, urljoin
5
  from workflow_decode import workflow_encoding, stream_workflow_bytes, workflow_response_length, WorkflowSizeError
6
  from workflow_stream_response import OwnedWorkflowResponse
7
  from proxy_resources import ProxyResources
 
8
 
9
 
10
  async def prepare_workflow_download(item, item_id, *, store, source_authorizer,
@@ -17,7 +18,7 @@ async def prepare_workflow_download(item, item_id, *, store, source_authorizer,
17
  return error_response(content={"error": "该资源没有可用下载地址"}, status_code=400)
18
 
19
  try:
20
- workflow_path = await asyncio.to_thread(source_authorizer, store, item, target_url)
21
  except source_error as error:
22
  return error_response(content={"error": error.message}, status_code=error.status_code)
23
  original_target = target_url
 
5
  from workflow_decode import workflow_encoding, stream_workflow_bytes, workflow_response_length, WorkflowSizeError
6
  from workflow_stream_response import OwnedWorkflowResponse
7
  from proxy_resources import ProxyResources
8
+ from owned_thread import run_owned_thread
9
 
10
 
11
  async def prepare_workflow_download(item, item_id, *, store, source_authorizer,
 
18
  return error_response(content={"error": "该资源没有可用下载地址"}, status_code=400)
19
 
20
  try:
21
+ workflow_path = await run_owned_thread(source_authorizer, store, item, target_url)
22
  except source_error as error:
23
  return error_response(content={"error": error.message}, status_code=error.status_code)
24
  original_target = target_url
workflow_stream_response.py CHANGED
@@ -1,4 +1,5 @@
1
  """代理流式响应的交付收尾;即使发送头/正文失败也归还所属连接。"""
 
2
  from fastapi.responses import StreamingResponse
3
 
4
 
@@ -6,17 +7,44 @@ class OwnedProxyResponse(StreamingResponse):
6
  def __init__(self, content, *, close_callback, **kwargs):
7
  super().__init__(content, **kwargs)
8
  self._close_callback = close_callback
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
 
10
  async def __call__(self, scope, receive, send):
 
 
 
 
 
11
  try:
12
  await super().__call__(scope, receive, send)
13
  finally:
14
- try:
15
- close_iterator = getattr(self.body_iterator, "aclose", None)
16
- if close_iterator is not None:
17
- await close_iterator()
18
- finally:
19
- await self._close_callback()
20
 
21
  # 保留既有工作流调用方的导出名称。
22
  OwnedWorkflowResponse = OwnedProxyResponse
 
1
  """代理流式响应的交付收尾;即使发送头/正文失败也归还所属连接。"""
2
+ import asyncio
3
  from fastapi.responses import StreamingResponse
4
 
5
 
 
7
  def __init__(self, content, *, close_callback, **kwargs):
8
  super().__init__(content, **kwargs)
9
  self._close_callback = close_callback
10
+ self._download = None
11
+ self._close_task = None
12
+
13
+ def bind_download(self, download):
14
+ if self._download is not None or self._close_task is not None:
15
+ raise RuntimeError('response is already owned or closed')
16
+ self._download = download
17
+
18
+ async def aclose(self):
19
+ if self._close_task is None:
20
+ async def close():
21
+ try:
22
+ close_iterator = getattr(self.body_iterator, "aclose", None)
23
+ if close_iterator is not None:
24
+ await close_iterator()
25
+ finally:
26
+ await self._close_callback()
27
+ self._close_task = asyncio.create_task(close())
28
+ cancelled = None
29
+ while not self._close_task.done():
30
+ try:
31
+ await asyncio.shield(self._close_task)
32
+ except asyncio.CancelledError as error:
33
+ cancelled = error
34
+ self._close_task.result()
35
+ if cancelled is not None:
36
+ raise cancelled
37
 
38
  async def __call__(self, scope, receive, send):
39
+ if self._download is not None:
40
+ return await self._download.deliver(self, scope, receive, send)
41
+ return await self.send_owned(scope, receive, send)
42
+
43
+ async def send_owned(self, scope, receive, send):
44
  try:
45
  await super().__call__(scope, receive, send)
46
  finally:
47
+ await self.aclose()
 
 
 
 
 
48
 
49
  # 保留既有工作流调用方的导出名称。
50
  OwnedWorkflowResponse = OwnedProxyResponse
云端_定时版本检测引擎.py CHANGED
@@ -6,6 +6,7 @@ from zip_decode import stream_zip_archive_bytes
6
  from owned_thread import run_owned_thread
7
  from zip_validation import validate_zip_archive
8
  from proxy_resources import ProxyResources
 
9
  # 云端_定时版本检测引擎.py
10
  import asyncio
11
  import datetime
@@ -49,32 +50,36 @@ def _normalize_version_entry(versions_db: dict, item_id: str):
49
 
50
 
51
  async def fetch_github_commit(repo_url, token, ref=None):
52
- """
53
- 请求 GitHub API 获取最新版的 Commit Hash
54
- 🛡️ 稳定性优化:超时保护 + 异常捕获 + 重试机制
55
- """
56
  try:
57
  owner, repo = repository_parts(repo_url)
58
 
59
  api_url = f"https://api.github.com/repos/{owner}/{repo}/commits/" + commit_ref(ref) if ref is not None else f"https://api.github.com/repos/{owner}/{repo}/commits?per_page=1"
60
- headers = {"Accept": "application/vnd.github.v3+json", "User-Agent": "ComfyUI-Hub"}
 
61
  if token:
62
  headers["Authorization"] = f"Bearer {token}"
63
 
64
- # 🛡️ 稳定性优化:超时 + 重试
65
- async with httpx.AsyncClient(follow_redirects=False, trust_env=False, transport=ValidatedAsyncHTTPTransport(), timeout=httpx.Timeout(15.0, connect=5.0)) as client:
66
- for attempt in range(3): # 最多重试 3 次
 
 
67
  try:
68
- resp = await client.get(api_url, headers=headers)
 
69
  if resp.status_code == 200:
70
- data = resp.json()
71
- latest_commit = data[0] if ref is None and isinstance(data, list) and data else data
 
72
  if isinstance(latest_commit, dict):
73
  sha = commit_sha(latest_commit.get("sha"))
74
  if ref is not None and not sha.startswith(commit_ref(ref)):
75
  return None
76
  return sha
77
- elif resp.status_code == 403: # Rate limit
 
78
  logger.warning("GitHub API 速率限制: status=403")
79
  return None
80
  break
@@ -85,6 +90,9 @@ async def fetch_github_commit(repo_url, token, ref=None):
85
  logger.warning(f"版本检测请求失败: error_type={type(e).__name__}")
86
  except Exception as e:
87
  logger.error(f"检测版本失败: error_type={type(e).__name__}")
 
 
 
88
  return None
89
 
90
  async def fetch_latest_github_hash(repo_url, token):
 
6
  from owned_thread import run_owned_thread
7
  from zip_validation import validate_zip_archive
8
  from proxy_resources import ProxyResources
9
+ from skill_archive_source import read_metadata_response, METADATA_SECONDS
10
  # 云端_定时版本检测引擎.py
11
  import asyncio
12
  import datetime
 
50
 
51
 
52
  async def fetch_github_commit(repo_url, token, ref=None):
53
+ """在共同时间和正文预算内核实完整commit,失败不发布版本。"""
54
+ resources = None
 
 
55
  try:
56
  owner, repo = repository_parts(repo_url)
57
 
58
  api_url = f"https://api.github.com/repos/{owner}/{repo}/commits/" + commit_ref(ref) if ref is not None else f"https://api.github.com/repos/{owner}/{repo}/commits?per_page=1"
59
+ headers = {"Accept": "application/vnd.github.v3+json", "User-Agent": "ComfyUI-Hub",
60
+ "Accept-Encoding": "identity"}
61
  if token:
62
  headers["Authorization"] = f"Bearer {token}"
63
 
64
+ client = httpx.AsyncClient(follow_redirects=False, trust_env=False,
65
+ transport=ValidatedAsyncHTTPTransport(), timeout=httpx.Timeout(15.0, connect=5.0))
66
+ resources = ProxyResources(client)
67
+ async with asyncio.timeout(METADATA_SECONDS):
68
+ for attempt in range(3):
69
  try:
70
+ request = client.build_request('GET', api_url, headers=headers)
71
+ resp = resources.track(await client.send(request, stream=True, follow_redirects=False))
72
  if resp.status_code == 200:
73
+ # per_page=1只使用本页,不访问分页地址。
74
+ data = await read_metadata_response(resp, resources, allow_pagination=True)
75
+ latest_commit = data[0] if ref is None and isinstance(data, list) and len(data) == 1 else data if ref is not None else None
76
  if isinstance(latest_commit, dict):
77
  sha = commit_sha(latest_commit.get("sha"))
78
  if ref is not None and not sha.startswith(commit_ref(ref)):
79
  return None
80
  return sha
81
+ await resources.close_response(resp)
82
+ if resp.status_code == 403:
83
  logger.warning("GitHub API 速率限制: status=403")
84
  return None
85
  break
 
90
  logger.warning(f"版本检测请求失败: error_type={type(e).__name__}")
91
  except Exception as e:
92
  logger.error(f"检测版本失败: error_type={type(e).__name__}")
93
+ finally:
94
+ if resources is not None:
95
+ await resources.close()
96
  return None
97
 
98
  async def fetch_latest_github_hash(repo_url, token):