@swimmingliu/autovpn 1.6.8 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -65,6 +65,11 @@ autovpn run --project-root . --output jsonl
65
65
 
66
66
  Runtime notes:
67
67
 
68
+ - Each run writes `artifact_dir/run.db`. This SQLite database is the authoritative local record for run state, node identity, stage results, and resume checkpoints. The `.txt` and `.json` files in the artifact directory are compatibility exports; do not use them as the source of truth for an in-progress run.
69
+ - Pipeline mode sends each newly discovered unique node from the extract callback to a reachability probe. Only reachable nodes receive the full speed measurement, and only nodes that pass the configured speed threshold proceed to availability checks. Dedupe, probe/speedtest, and availability can be active while extraction is still running, so their displayed totals can increase during the run.
70
+ - The streaming queues use bounded concurrency and backpressure. A slow speed or availability worker therefore limits queued work instead of allowing unbounded memory growth.
71
+ - `resume pipeline`, `resume speedtest`, `run --resume-latest`, and `retry-stage` continue from the SQLite checkpoints. Existing legacy artifact directories can be imported on resume; subsequent state is recorded in `run.db` while compatibility exports remain available.
72
+ - Pipeline mode does not apply the legacy global `max_download_candidates` ranking gate before availability. If that field remains in a profile, it only applies when the speed module is run independently in its legacy ranked-candidate mode.
68
73
  - Detached job management runs in Node for `run --detach`, `jobs resume --detach`, and `jobs retry --detach`; detached run/resume/retry workers also use the Node CLI worker.
69
74
  - Non-detached `retry-stage` runs through the Node backend for retryable artifact stages from `speedtest` through `verify`; non-detached `resume pipeline`, `resume speedtest`, and `run --resume-latest` continue existing sessions through the Node backend.
70
75
  - Add `--skip-deploy --skip-verify` when you want an offline Node pipeline check.
@@ -1,9 +1,8 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import { createRequire } from 'node:module';
4
3
  import { resumeNodePipeline, retryNodePipelineStage, runNodePipeline } from '../pipeline/orchestrator.js';
5
4
  import { resolveArtifactsRoot } from '../runtime/paths.js';
6
- const require = createRequire(import.meta.url);
5
+ import { readLatestStageStatuses, readRunStatus } from '../pipeline/run-store.js';
7
6
  function unsupported(method) {
8
7
  return new Error(`Node backend ${method} is not available in this execution path`);
9
8
  }
@@ -12,7 +11,6 @@ function latestIncompleteRunArtifact(projectRoot, env) {
12
11
  if (!fs.existsSync(artifactsRoot)) {
13
12
  return undefined;
14
13
  }
15
- const { DatabaseSync } = require('node:sqlite');
16
14
  const candidates = fs.readdirSync(artifactsRoot, { withFileTypes: true })
17
15
  .filter((entry) => entry.isDirectory())
18
16
  .map((entry) => {
@@ -23,27 +21,14 @@ function latestIncompleteRunArtifact(projectRoot, env) {
23
21
  .filter((candidate) => fs.existsSync(candidate.dbPath))
24
22
  .sort((left, right) => fs.statSync(right.artifactDir).mtimeMs - fs.statSync(left.artifactDir).mtimeMs);
25
23
  for (const candidate of candidates) {
26
- let db;
27
- try {
28
- db = new DatabaseSync(candidate.dbPath);
29
- const runRow = db.prepare('SELECT status FROM runs ORDER BY run_id DESC LIMIT 1').get();
30
- const runStatus = String(runRow?.status ?? '').trim();
31
- if (['success', 'failed', 'stopped'].includes(runStatus)) {
32
- continue;
33
- }
34
- const stageRows = db.prepare('SELECT stage_name, status FROM stage_events ORDER BY rowid ASC').all();
35
- const stageStatus = new Map(stageRows.map((row) => [String(row.stage_name ?? ''), String(row.status ?? '')]));
36
- if (stageStatus.get('verify') === 'success') {
37
- continue;
38
- }
39
- return candidate.artifactDir;
40
- }
41
- catch {
24
+ const runStatus = readRunStatus(candidate.dbPath);
25
+ if (!runStatus || ['success', 'failed', 'stopped', 'cancelled'].includes(runStatus)) {
42
26
  continue;
43
27
  }
44
- finally {
45
- db?.close();
46
- }
28
+ const stageStatus = readLatestStageStatuses(candidate.dbPath);
29
+ if (stageStatus.verify === 'success')
30
+ continue;
31
+ return candidate.artifactDir;
47
32
  }
48
33
  return undefined;
49
34
  }
@@ -1,5 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { RunStore, readRunStatus } from '../pipeline/run-store.js';
3
4
  import { randomBytes } from 'node:crypto';
4
5
  import { spawn as defaultSpawn } from 'node:child_process';
5
6
  import { fileURLToPath } from 'node:url';
@@ -45,18 +46,47 @@ function spawnDetached(command, args, job, options) {
45
46
  function markArtifactStopped(job) {
46
47
  const artifactDir = String(job.artifact_dir ?? '');
47
48
  if (!artifactDir) {
48
- return;
49
+ return undefined;
49
50
  }
50
51
  const reportPath = path.join(artifactDir, 'pipeline_report.json');
51
- if (!fs.existsSync(reportPath)) {
52
- return;
52
+ const dbPath = path.join(artifactDir, 'run.db');
53
+ let authoritativeStatus;
54
+ if (fs.existsSync(dbPath)) {
55
+ let runStore;
56
+ try {
57
+ runStore = RunStore.open(dbPath);
58
+ if (!runStore.stopForResume('Stopped by user'))
59
+ authoritativeStatus = readRunStatus(dbPath);
60
+ }
61
+ catch {
62
+ // The compatibility report remains the fallback when SQLite is corrupt or locked.
63
+ }
64
+ finally {
65
+ try {
66
+ runStore?.close();
67
+ }
68
+ catch { /* best effort */ }
69
+ }
70
+ }
71
+ if (authoritativeStatus) {
72
+ if (fs.existsSync(reportPath)) {
73
+ try {
74
+ const report = JSON.parse(fs.readFileSync(reportPath, 'utf8'));
75
+ report.run_status = authoritativeStatus;
76
+ fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
77
+ }
78
+ catch { /* SQLite remains authoritative */ }
79
+ }
80
+ return authoritativeStatus;
53
81
  }
82
+ if (!fs.existsSync(reportPath))
83
+ return 'stopped';
54
84
  try {
55
85
  const report = JSON.parse(fs.readFileSync(reportPath, 'utf8'));
56
86
  const stageStatus = { ...(report.stage_status ?? {}) };
57
87
  for (const [stage, status] of Object.entries(stageStatus)) {
58
88
  if (status === 'running') {
59
- stageStatus[stage] = 'failed';
89
+ stageStatus[stage] = 'stopped';
60
90
  }
61
91
  }
62
92
  report.stage_status = stageStatus;
@@ -67,6 +97,7 @@ function markArtifactStopped(job) {
67
97
  catch {
68
98
  // Best-effort cleanup so stopping a job never fails because its report is corrupt.
69
99
  }
100
+ return 'stopped';
70
101
  }
71
102
  export async function startDetachedRun(command, options = {}) {
72
103
  const outputFormat = command.outputFormat ?? 'jsonl';
@@ -184,10 +215,10 @@ export async function stopManagedJob(projectRoot, jobId, options = {}) {
184
215
  throw new Error(`refusing to stop pid ${pid}: command does not match AutoVPN job`);
185
216
  }
186
217
  await terminateProcessGroup(pid, options);
187
- markArtifactStopped(job);
188
- job.status = 'stopped';
218
+ const artifactStatus = markArtifactStopped(job);
219
+ job.status = artifactStatus === 'success' || artifactStatus === 'failed' ? artifactStatus : 'stopped';
189
220
  job.finished_at = options.now?.() ?? new Date().toISOString().replace(/\.\d{3}Z$/, '+00:00');
190
- job.exit_code = 1;
221
+ job.exit_code = job.status === 'success' ? 0 : 1;
191
222
  job.signal = 'SIGTERM';
192
223
  return store.writeJob(job);
193
224
  }
package/dist/jobs/read.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import { createRequire } from 'node:module';
4
3
  import { resolveProfilePath, readOptionValue } from '../runtime/paths.js';
5
4
  import { redactText } from '../runtime/redaction.js';
5
+ import { readRunStatus } from '../pipeline/run-store.js';
6
6
  function jobsRoot(projectRoot, env = process.env) {
7
7
  return path.join(path.dirname(resolveProfilePath(projectRoot, env)), 'jobs');
8
8
  }
@@ -79,29 +79,16 @@ function reconcileFromRunDb(job) {
79
79
  if (!fs.existsSync(runDbPath)) {
80
80
  return undefined;
81
81
  }
82
- try {
83
- const require = createRequire(import.meta.url);
84
- const sqlite = require('node:sqlite');
85
- const db = new sqlite.DatabaseSync(runDbPath);
86
- try {
87
- const row = db.prepare('SELECT status FROM runs ORDER BY run_id DESC LIMIT 1').get();
88
- const runStatus = String(row?.status ?? '');
89
- if (!['success', 'failed', 'stopped'].includes(runStatus)) {
90
- return undefined;
91
- }
92
- return {
93
- status: runStatus,
94
- finished_at: job.finished_at || nowIso(),
95
- exit_code: runStatus === 'success' ? 0 : 1
96
- };
97
- }
98
- finally {
99
- db.close();
100
- }
101
- }
102
- catch {
82
+ const storedStatus = readRunStatus(runDbPath);
83
+ const runStatus = storedStatus === 'cancelled' ? 'stopped' : storedStatus;
84
+ if (!runStatus || !['success', 'failed', 'stopped'].includes(runStatus)) {
103
85
  return undefined;
104
86
  }
87
+ return {
88
+ status: runStatus,
89
+ finished_at: job.finished_at || nowIso(),
90
+ exit_code: runStatus === 'success' ? 0 : 1
91
+ };
105
92
  }
106
93
  function writeJob(job) {
107
94
  const jobFile = String(job.job_file ?? '');
@@ -2,6 +2,7 @@ import http from 'node:http';
2
2
  import net from 'node:net';
3
3
  import tls from 'node:tls';
4
4
  import { openMihomoRuntime as defaultOpenMihomoRuntime } from './proxy-runtime.js';
5
+ import { isTransientNetworkError, retryTransientNetwork } from './network-retry.js';
5
6
  const PROVIDER_TARGETS = [
6
7
  { name: 'gemini', url: 'https://gemini.google.com', allowed_hosts: ['gemini.google.com'], negative_phrases: [] },
7
8
  { name: 'chatgpt_ios', url: 'https://ios.chat.openai.com/', allowed_hosts: ['ios.chat.openai.com'], negative_phrases: [] },
@@ -615,44 +616,45 @@ export async function fetchUrlViaHttpProxy(url, proxyUrl, timeoutSeconds) {
615
616
  async function checkLinkAvailabilityMihomo(speedResult, config, options) {
616
617
  const openRuntime = options.openMihomoRuntime ?? defaultOpenMihomoRuntime;
617
618
  const fetchViaProxy = options.fetchUrlViaHttpProxy ?? fetchUrlViaHttpProxy;
618
- let runtime;
619
- try {
620
- runtime = await openRuntime(speedResult.link, {
621
- runtimePath: options.runtimePath,
622
- startupWaitSeconds: numberOrDefault(config.startup_wait_seconds, 1),
623
- env: options.env
624
- });
625
- const providerResults = {};
626
- const timeoutSeconds = Math.max(1, numberOrDefault(config.timeout_seconds, 20));
627
- for (const target of options.targets) {
628
- try {
629
- const response = await fetchViaProxy(target.url, runtime.proxies.http, timeoutSeconds);
630
- providerResults[target.name] = evaluateProviderResponse(target, {
631
- final_url: response.final_url,
632
- status_code: response.status_code,
633
- title: extractTitle(response.body),
634
- body: response.body
635
- });
636
- }
637
- catch (error) {
638
- providerResults[target.name] = {
639
- provider: target.name,
640
- passed: false,
641
- reason: 'runtime_error',
642
- status_code: 0,
643
- final_url: target.url,
644
- matched_phrase: error instanceof Error ? error.message : String(error)
645
- };
619
+ return retryTransientNetwork(async () => {
620
+ let runtime;
621
+ try {
622
+ runtime = await openRuntime(speedResult.link, {
623
+ runtimePath: options.runtimePath,
624
+ startupWaitSeconds: numberOrDefault(config.startup_wait_seconds, 1),
625
+ env: options.env
626
+ });
627
+ const providerResults = {};
628
+ const timeoutSeconds = Math.max(1, numberOrDefault(config.timeout_seconds, 20));
629
+ for (const target of options.targets) {
630
+ try {
631
+ const response = await retryTransientNetwork(() => fetchViaProxy(target.url, runtime.proxies.http, timeoutSeconds));
632
+ providerResults[target.name] = evaluateProviderResponse(target, {
633
+ final_url: response.final_url,
634
+ status_code: response.status_code,
635
+ title: extractTitle(response.body),
636
+ body: response.body
637
+ });
638
+ }
639
+ catch (error) {
640
+ if (isTransientNetworkError(error))
641
+ throw error;
642
+ providerResults[target.name] = {
643
+ provider: target.name,
644
+ passed: false,
645
+ reason: 'runtime_error',
646
+ status_code: 0,
647
+ final_url: target.url,
648
+ matched_phrase: error instanceof Error ? error.message : String(error)
649
+ };
650
+ }
646
651
  }
652
+ return { speed_result: speedResult, provider_results: providerResults };
647
653
  }
648
- return {
649
- speed_result: speedResult,
650
- provider_results: providerResults
651
- };
652
- }
653
- finally {
654
- await runtime?.close();
655
- }
654
+ finally {
655
+ await runtime?.close();
656
+ }
657
+ });
656
658
  }
657
659
  async function checkBatchInNode(input, options) {
658
660
  if (input.results.length === 0) {
@@ -0,0 +1,54 @@
1
+ const DEFAULT_MAX_ATTEMPTS = 2;
2
+ const DEFAULT_RETRY_DELAY_MS = 100;
3
+ export function isTransientNetworkError(error) {
4
+ const message = error instanceof Error ? error.message : String(error);
5
+ const code = typeof error === 'object' && error !== null && 'code' in error
6
+ ? String(error.code ?? '').toUpperCase()
7
+ : '';
8
+ const statusMatch = /(?:unexpected status|failed with status)\s+(\d{3})\b/i.exec(message);
9
+ if (statusMatch) {
10
+ const status = Number(statusMatch[1]);
11
+ return status >= 500 && status <= 599;
12
+ }
13
+ if ([
14
+ 'AUTOVPN_INTERNAL_TIMEOUT',
15
+ 'ECONNRESET',
16
+ 'ETIMEDOUT',
17
+ 'EPIPE',
18
+ 'ECONNREFUSED',
19
+ 'EAI_AGAIN',
20
+ 'ENETUNREACH',
21
+ 'EHOSTUNREACH',
22
+ 'UND_ERR_CONNECT_TIMEOUT',
23
+ 'UND_ERR_HEADERS_TIMEOUT',
24
+ 'UND_ERR_BODY_TIMEOUT'
25
+ ].includes(code)) {
26
+ return true;
27
+ }
28
+ if (error instanceof TypeError && message.trim().toLowerCase() === 'fetch failed') {
29
+ return true;
30
+ }
31
+ return /(?:connection|connect|download|response body|tls handshake).*timed out/i.test(message)
32
+ || /socket hang up|network is unreachable|temporary failure/i.test(message);
33
+ }
34
+ export async function retryTransientNetwork(operation, options = {}) {
35
+ const maxAttempts = Math.max(1, Math.trunc(options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS));
36
+ const delayMs = Math.max(0, Math.trunc(options.delayMs ?? DEFAULT_RETRY_DELAY_MS));
37
+ const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
38
+ let lastError;
39
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
40
+ try {
41
+ return await operation();
42
+ }
43
+ catch (error) {
44
+ lastError = error;
45
+ if (attempt >= maxAttempts || !isTransientNetworkError(error)) {
46
+ throw error;
47
+ }
48
+ if (delayMs > 0) {
49
+ await sleep(delayMs * attempt);
50
+ }
51
+ }
52
+ }
53
+ throw lastError;
54
+ }