@swimmingliu/autovpn 2.2.3 → 2.3.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.
@@ -61,6 +61,22 @@ function preserveRedactedSecrets(incoming, current) {
61
61
  }
62
62
  return merged;
63
63
  }
64
+ /**
65
+ * Merge the live database totals over the report's per-source counts.
66
+ *
67
+ * The database is authoritative for link counts, but it knows nothing about
68
+ * what only the pipeline reports — `area_count`, the length of the frozen route
69
+ * list that the drawer's lap estimate reads. Spreading its map whole dropped
70
+ * those keys, so a finished run's estimate fell back to the reference area count
71
+ * (26 real areas shown as the 31-area default).
72
+ */
73
+ function mergeSourceCounts(reported, progress) {
74
+ const merged = { ...(reported ?? {}) };
75
+ for (const [source, counts] of Object.entries(progress)) {
76
+ merged[source] = { ...(merged[source] ?? {}), ...counts };
77
+ }
78
+ return merged;
79
+ }
64
80
  function normalizeLatestArtifact(projectRoot, env) {
65
81
  const latest = artifactLatest(projectRoot, env);
66
82
  if (!latest.ok || !latest.artifact_dir) {
@@ -68,12 +84,20 @@ function normalizeLatestArtifact(projectRoot, env) {
68
84
  }
69
85
  const preview = previewArtifact(String(latest.artifact_dir), { includeNodeRows: true });
70
86
  const progress = readRunProgress(path.join(String(latest.artifact_dir), 'run.db'));
87
+ // A live run's own mirror is newer than the report it replaced at the last
88
+ // stage boundary, and it is what the page was just following. Replaying the
89
+ // report's `failed` summary over it is how a resumed run read as failed on
90
+ // every reload. History stays the report's when the log is finished.
91
+ const artifactLog = artifactEventLog(String(latest.artifact_dir));
92
+ const logEvents = artifactLog.events;
93
+ const liveLog = artifactLog.active && logEvents.length > 0;
71
94
  return {
72
95
  ...latest,
73
96
  ...preview,
97
+ ...(liveLog ? { run_status: 'running' } : {}),
74
98
  // The report can still be empty mid-stage. Its display preview must not
75
99
  // replace the authoritative totals that already exist in the run database.
76
- ...(progress ? { counts: { ...(preview.counts ?? {}), ...progress.counts }, source_counts: progress.source_counts, progress_snapshot: progress } : {}),
100
+ ...(progress ? { counts: { ...(preview.counts ?? {}), ...progress.counts }, source_counts: mergeSourceCounts(preview.source_counts, progress.source_counts), progress_snapshot: progress } : {}),
77
101
  outputFiles: (preview.files ?? []),
78
102
  // The results page and the country donut read these rows. Answering with an
79
103
  // empty array left a browser session at 0 nodes and an empty 国家分布 while
@@ -204,6 +228,82 @@ function latestJobEvents(job, maxEvents = 1000) {
204
228
  return [];
205
229
  }
206
230
  }
231
+ /**
232
+ * Events that announce a run has started.
233
+ *
234
+ * They carry no run status, so the follower reads them as `running`. A resume
235
+ * writes one of these (a pipeline resume writes it late, after re-running the
236
+ * extract it found incomplete), which is why the "is this log live" question
237
+ * cannot be answered from them alone — `artifactEventLog` asks it from the last
238
+ * line instead.
239
+ */
240
+ const RUN_START_EVENTS = new Set([
241
+ 'run_started',
242
+ 'resume_latest_state',
243
+ 'resume_pipeline_state',
244
+ 'speedtest_resume_state'
245
+ ]);
246
+ /** Same directory, even across a symlinked root (`/var` vs `/private/var`). */
247
+ function sameDir(left, right) {
248
+ if (!left || !right)
249
+ return false;
250
+ if (left === right)
251
+ return true;
252
+ try {
253
+ return fs.realpathSync(left) === fs.realpathSync(right);
254
+ }
255
+ catch {
256
+ return false;
257
+ }
258
+ }
259
+ /**
260
+ * Lines that end a run's event stream.
261
+ *
262
+ * A run is live while the last line in its log is something else. That rule
263
+ * lets one artifact log hold several runs: a resume appends to the log the
264
+ * interrupted run wrote, so the old `summary failed` sits in the middle of the
265
+ * file. Scanning for run boundaries instead was wrong in both directions — a
266
+ * pipeline resume re-runs extract before it writes its own boundary, so the
267
+ * file read as finished while it was working.
268
+ */
269
+ function isTerminalEvent(event) {
270
+ return event.type === 'summary' || event.type === 'run_failed' || event.type === 'finished';
271
+ }
272
+ /**
273
+ * Byte offset just past the last terminal line in an artifact event log.
274
+ *
275
+ * Everything before it belongs to a run that already ended and must not be
276
+ * replayed to the page. Zero when the file holds no terminal line at all.
277
+ */
278
+ function artifactFollowOffset(filePath) {
279
+ let text = '';
280
+ try {
281
+ text = fs.readFileSync(filePath, 'utf8');
282
+ }
283
+ catch {
284
+ return 0;
285
+ }
286
+ // Byte offsets, not character offsets: the log carries Chinese vendor
287
+ // messages, and seeking by character count would land mid-line.
288
+ let offset = 0;
289
+ let resumeAt = 0;
290
+ for (const rawLine of text.split('\n')) {
291
+ // Both writers terminate every line, so a line without its newline is the
292
+ // trailing fragment of a write that has not landed yet. Advancing past it
293
+ // would put the next read one byte into the following event.
294
+ const width = Buffer.byteLength(rawLine, 'utf8');
295
+ const terminated = offset + width < text.length;
296
+ const line = rawLine.trim();
297
+ if (line) {
298
+ const event = parseJsonLine(line);
299
+ if (event && typeof event === 'object' && isTerminalEvent(event)) {
300
+ resumeAt = offset + width + (terminated ? 1 : 0);
301
+ }
302
+ }
303
+ offset += width + (terminated ? 1 : 0);
304
+ }
305
+ return resumeAt;
306
+ }
207
307
  /**
208
308
  * Stage/substep states and log lines for the latest artifact.
209
309
  *
@@ -219,18 +319,10 @@ function artifactEventLog(artifactDir) {
219
319
  try {
220
320
  const lines = fs.readFileSync(filePath, 'utf8').split(/\r?\n/).filter(Boolean);
221
321
  const events = lines.map((line) => parseJsonLine(line)).filter(Boolean);
222
- // Only the LAST run in the file decides whether it is still going. A resume
223
- // appends to the same artifact log, so an earlier run's terminal line would
224
- // otherwise mark the live resume finished and the page would never follow it.
225
- let finished = false;
226
- for (const event of events) {
227
- if (event.type === 'run_started' || event.type === 'resume_latest_state' || event.type === 'resume_pipeline_state') {
228
- finished = false;
229
- }
230
- else if (event.type === 'summary' || event.type === 'run_failed' || event.type === 'finished') {
231
- finished = true;
232
- }
233
- }
322
+ // The last line decides: a run is live while it has not written a terminal
323
+ // event yet. Only the last one counts, or the interrupted run's summary
324
+ // would mark the resume that followed it finished.
325
+ const finished = events.length > 0 && isTerminalEvent(events[events.length - 1]);
234
326
  let fresh = false;
235
327
  try {
236
328
  fresh = Date.now() - fs.statSync(filePath).mtimeMs < 2 * 60 * 1000;
@@ -467,6 +559,14 @@ export function createServerRuntime(options) {
467
559
  let unsubscribeArtifactLogs;
468
560
  let followedArtifactDir = '';
469
561
  let expiryRefreshInFlight = false;
562
+ // Set by close(): the timer and the followers are torn down, and nothing may
563
+ // re-arm one afterwards. Without this a shutdown that follows a cron run can
564
+ // attach a fresh 15s poller and keep the process alive on its own work.
565
+ let closed = false;
566
+ // The artifact whose run the operator ended. Its log is still being written
567
+ // by a process the server does not own, so it stays "active" — the watcher
568
+ // must not read that as a run to pick back up.
569
+ let stoppedArtifactDir = '';
470
570
  const pruneHistory = createHistoryPruner(options);
471
571
  const subscribers = new Set();
472
572
  const startDetachedRun = options.startDetachedRun ?? defaultStartDetachedRun;
@@ -478,6 +578,12 @@ export function createServerRuntime(options) {
478
578
  subscriber(event);
479
579
  }
480
580
  };
581
+ // The watcher only reads two files, so a cadence well under the follower's
582
+ // is cheap; it is the difference between a page that waits for a reload and
583
+ // one that shows tonight's run.
584
+ const runWatchMs = Math.max(1000, Math.trunc(Number(options.runWatchIntervalMs ?? 15_000)) || 15_000);
585
+ const watchTimer = setInterval(watchForNewRun, runWatchMs);
586
+ watchTimer.unref?.();
481
587
  /**
482
588
  * Follow a plain event log that no job directory owns.
483
589
  *
@@ -488,6 +594,8 @@ export function createServerRuntime(options) {
488
594
  * page needs sub-second freshness, not inotify precision.
489
595
  */
490
596
  function followArtifactEvents(artifactDir) {
597
+ if (closed)
598
+ return;
491
599
  if (followedArtifactDir === artifactDir && unsubscribeArtifactLogs) {
492
600
  return;
493
601
  }
@@ -495,7 +603,11 @@ export function createServerRuntime(options) {
495
603
  followedArtifactDir = artifactDir;
496
604
  const filePath = path.join(artifactDir, 'events.jsonl');
497
605
  let cancelled = false;
498
- let offset = 0;
606
+ // Start at the current run, not at the top of the file: a resumed run
607
+ // shares the artifact with the run it resumes, and replaying the earlier
608
+ // run's `summary failed` republished a stale terminal state and then stopped
609
+ // this follower — the live run's later events never reached the page.
610
+ let offset = artifactFollowOffset(filePath);
499
611
  let pending = '';
500
612
  const timer = setInterval(() => {
501
613
  if (cancelled)
@@ -525,29 +637,40 @@ export function createServerRuntime(options) {
525
637
  }
526
638
  const lines = chunk.split(/\r?\n/);
527
639
  pending = lines.pop() ?? '';
528
- let terminal = false;
640
+ let finalState;
641
+ let lastEvent;
529
642
  for (const line of lines) {
530
643
  const event = parseJsonLine(line);
531
644
  if (!event)
532
645
  continue;
533
- const nextRunState = eventRunState(event);
534
- if (nextRunState) {
535
- runState = nextRunState;
536
- terminal = true;
537
- }
646
+ const record = event;
647
+ lastEvent = record;
538
648
  // Same contract as the job follower: a result line replayed after a
539
649
  // page load must carry absolute database totals, or the renderer adds
540
650
  // it on top of the snapshot it already hydrated and doubles the count.
541
- const record = event;
542
651
  const metricEvent = ['extract_iteration', 'extract_source_completed', 'speedtest_result', 'availability_link_result'].includes(String(record.type));
543
652
  const progress = metricEvent ? readRunProgress(path.join(artifactDir, 'run.db')) : undefined;
544
653
  publish(progress ? { ...record, progress_snapshot: { ...progress, artifact_dir: artifactDir } } : event);
545
- if (nextRunState)
546
- publish({ type: 'server_state', run_state: runState });
654
+ // The state is resolved over the whole chunk and published once, at the
655
+ // end: a chunk can hold a summary and the events of the resume that
656
+ // followed it, and publishing the summary's `failed` on the way past
657
+ // flashed it on the page before the resume took it back.
658
+ const nextRunState = eventRunState(record);
659
+ if (nextRunState) {
660
+ finalState = nextRunState;
661
+ }
662
+ else if (RUN_START_EVENTS.has(String(record.type))) {
663
+ finalState = 'running';
664
+ }
665
+ }
666
+ if (finalState) {
667
+ runState = finalState;
668
+ publish({ type: 'server_state', run_state: runState });
547
669
  }
548
- // The run is over: stop polling instead of stat-ing a finished file every
549
- // 800ms for the life of the server. A later run follows its own artifact.
550
- if (terminal) {
670
+ // The run is over only when its LAST line says so. A summary followed by
671
+ // a resume in the same chunk is a live run, and stopping here is what
672
+ // left the resumed run unfollowed.
673
+ if (lastEvent && isTerminalEvent(lastEvent)) {
551
674
  unsubscribeArtifactLogs?.();
552
675
  unsubscribeArtifactLogs = undefined;
553
676
  followedArtifactDir = '';
@@ -607,6 +730,52 @@ export function createServerRuntime(options) {
607
730
  }
608
731
  });
609
732
  }
733
+ /**
734
+ * Notice a run this server is not following yet.
735
+ *
736
+ * Consecutive nightly runs are the case: the follower clears itself at one
737
+ * run's summary, and the next night's artifact appears with no state load to
738
+ * trigger `restoreRunState`. Without this heartbeat an open page keeps last
739
+ * night's finished run until someone reloads — the exact "logs stopped
740
+ * refreshing" complaint, one run later.
741
+ */
742
+ function watchForNewRun() {
743
+ if (closed)
744
+ return;
745
+ if (runState === 'running' || runState === 'stopping')
746
+ return;
747
+ if (unsubscribeLogs || activeJobId) {
748
+ // A managed job's follower leaves both guards set when its stream ends,
749
+ // and it can never see a later scheduled run. Release them once the job
750
+ // itself is no longer live, or the watcher stays dead after one click of
751
+ // 开始运行.
752
+ const job = latestJob(options.projectRoot, options.env ?? process.env);
753
+ if (job && ['running', 'stopping'].includes(String(job.status ?? '')))
754
+ return;
755
+ unsubscribeLogs?.();
756
+ unsubscribeLogs = undefined;
757
+ activeJobId = '';
758
+ }
759
+ if (unsubscribeArtifactLogs && followedArtifactDir)
760
+ return;
761
+ // This reads and folds the whole artifact log, which is the price of
762
+ // knowing whether the run is still going. It deliberately stops short of a
763
+ // state load, which would also open `run.db` (creating WAL sidecars), parse
764
+ // the final node list and run the retention sweep — every 15 seconds.
765
+ const latest = artifactLatest(options.projectRoot, options.env ?? process.env);
766
+ const artifactDir = String(latest?.artifact_dir ?? '');
767
+ if (!artifactDir || sameDir(artifactDir, stoppedArtifactDir))
768
+ return;
769
+ if (!artifactEventLog(artifactDir).active)
770
+ return;
771
+ beginFollowingArtifact(artifactDir);
772
+ }
773
+ /** Adopt an artifact log that is still being written as the live run. */
774
+ function beginFollowingArtifact(artifactDir) {
775
+ runState = 'running';
776
+ publish({ type: 'server_state', run_state: runState });
777
+ followArtifactEvents(artifactDir);
778
+ }
610
779
  function restoreRunState(artifact) {
611
780
  const env = options.env ?? process.env;
612
781
  // A terminal state is a report about the *previous* run, not a latch. A new
@@ -621,8 +790,7 @@ export function createServerRuntime(options) {
621
790
  if (liveArtifact && runState !== 'running' && runState !== 'stopping') {
622
791
  unsubscribeLogs?.();
623
792
  activeJobId = '';
624
- runState = 'running';
625
- followArtifactEvents(liveArtifact);
793
+ beginFollowingArtifact(liveArtifact);
626
794
  return;
627
795
  }
628
796
  const job = latestJob(options.projectRoot, env);
@@ -646,8 +814,7 @@ export function createServerRuntime(options) {
646
814
  // after the server restarts mid-run.
647
815
  const artifactDir = String(artifact?.artifact_dir ?? '');
648
816
  if (artifactDir && artifactEventLog(artifactDir).active) {
649
- runState = 'running';
650
- followArtifactEvents(artifactDir);
817
+ beginFollowingArtifact(artifactDir);
651
818
  }
652
819
  }
653
820
  }
@@ -857,10 +1024,18 @@ export function createServerRuntime(options) {
857
1024
  async stopRun() {
858
1025
  if (runState !== 'running' || !activeJobId) {
859
1026
  // A scheduled run has no managed job to signal; stop following its log
860
- // so the page does not keep reporting a run the operator ended.
1027
+ // so the page does not keep reporting a run the operator ended. The
1028
+ // process itself keeps writing, so the log still looks live — remember
1029
+ // which artifact was ended, or the watcher picks it straight back up.
1030
+ if (followedArtifactDir)
1031
+ stoppedArtifactDir = followedArtifactDir;
861
1032
  unsubscribeArtifactLogs?.();
862
1033
  unsubscribeArtifactLogs = undefined;
863
1034
  followedArtifactDir = '';
1035
+ if (runState === 'running') {
1036
+ runState = 'idle';
1037
+ publish({ type: 'server_state', run_state: runState });
1038
+ }
864
1039
  return { ok: true, requested: false, run_state: runState };
865
1040
  }
866
1041
  runState = 'stopping';
@@ -886,6 +1061,8 @@ export function createServerRuntime(options) {
886
1061
  return () => subscribers.delete(handler);
887
1062
  },
888
1063
  async close() {
1064
+ closed = true;
1065
+ clearInterval(watchTimer);
889
1066
  unsubscribeLogs?.();
890
1067
  unsubscribeArtifactLogs?.();
891
1068
  if (!activeJobId) {