From 23728ba02bb7d3326aff08f6d662d565fb54a49c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B4=AE=E7=94=9F=EF=BC=88=E5=AD=90=E8=99=9A=EF=BC=89?= <2234839456@qq.com> Date: Thu, 30 Jul 2026 23:11:54 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E5=9B=9E=E9=80=80=E5=86=85?= =?UTF-8?q?=E5=AD=98=E9=97=B8=E9=97=A8=E4=B8=BA=E5=9B=BA=E5=AE=9A=E5=B9=B6?= =?UTF-8?q?=E5=8F=91(=E4=BF=9D=E7=95=99=E5=88=86=E7=BB=84=E8=B0=83?= =?UTF-8?q?=E5=BA=A6)=20+=20=E5=89=8D=E7=AB=AF=E6=94=B9=E8=BF=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端: - subset_queue.ts: 移除基于内存的门控(LLRT GC 不释放 RSS), 回退到固定并发模型,保留分组调度优化(相同字体批处理重用缓存) - config.ts: 恢复 subsetConcurrency / subsetQueueTimeoutSeconds, 移除 subsetMemSoftLimitMB - routes/subset.ts, app.ts, routes/config.ts: 同步恢复 - api.ts, README: 同步更新 前端: - FontSelector.vue: 下拉项和触发器标记临时字体(橙色徽章) - CodeBlock.vue: 新增轻量代码块组件,内置 CSS/HTML/JS 分词高亮(0依赖) - Home.vue: 集成 CodeBlock 替换原始
 代码块
---
 README.en.md                 |   4 +-
 README.md                    |   4 +-
 backend/app.ts               |   6 +-
 backend/config.ts            |  12 +-
 backend/routes/config.ts     |   6 +-
 backend/routes/subset.ts     |   4 +-
 backend/subset_queue.ts      | 141 ++++------------
 src/FontSelector.vue         |  21 ++-
 src/api.ts                   |   4 +-
 src/components/CodeBlock.vue | 306 +++++++++++++++++++++++++++++++++++
 src/pages/Home.vue           |  17 +-
 11 files changed, 387 insertions(+), 138 deletions(-)
 create mode 100644 src/components/CodeBlock.vue

diff --git a/README.en.md b/README.en.md
index d3c16c3..c48a5ca 100644
--- a/README.en.md
+++ b/README.en.md
@@ -125,8 +125,8 @@ services:
       - SUBSET_CACHE_MAX_SIZE=10485760
       # Temp font retention (seconds), auto-deleted if unused. Default: 10800 (3h)
       - TEMP_RETENTION_SECONDS=10800
-      # Memory soft limit (MB) for subsetting queue. Default: 600
-      - SUBSET_MEM_SOFT_LIMIT_MB=600
+      # Max concurrent font subsetting. Default: 4
+      - SUBSET_CONCURRENCY=4
 ```
 
 ## API Reference
diff --git a/README.md b/README.md
index 82138a5..09fb06e 100644
--- a/README.md
+++ b/README.md
@@ -125,8 +125,8 @@ services:
       - SUBSET_CACHE_MAX_SIZE=10485760
       # 临时字体保留时限(秒),超时未使用自动删除,默认 10800(3小时)
       - TEMP_RETENTION_SECONDS=10800
-      # 子集化内存水位阈值(MB),RSS 超此值时排队等待,默认 600
-      - SUBSET_MEM_SOFT_LIMIT_MB=600
+      # 字体裁剪最大并发数,默认 4(内存受限环境建议 2-3)
+      - SUBSET_CONCURRENCY=4
 ```
 
 ## API
diff --git a/backend/app.ts b/backend/app.ts
index 82c5a36..3e3da9e 100644
--- a/backend/app.ts
+++ b/backend/app.ts
@@ -14,7 +14,7 @@ import { handleFontDetail } from "./routes/font_detail";
 import { handleFontMeta } from "./routes/font_meta";
 import { startTempCleaner } from "./temp_cleaner";
 import { initMemoryGate } from "./subset_queue";
-import { subsetMemSoftLimitMB } from "./config";
+import { subsetConcurrency } from "./config";
 import "./server/node";
 import "./server/llrt";
 
@@ -209,8 +209,8 @@ async function main() {
   console.log("[config] temp upload:", enableTempUpload);
   console.log("[config] admin upload:", !!adminApiKey);
 
-  /** 初始化内存水位闸门(子集化排队控制) */
-  initMemoryGate(subsetMemSoftLimitMB);
+  /** 初始化子集化并发队列(含字体分组调度) */
+  initMemoryGate(0, subsetConcurrency);
 
   /** 启动临时字体定时清理器 */
   startTempCleaner();
diff --git a/backend/config.ts b/backend/config.ts
index 578b13f..3e4e1b2 100644
--- a/backend/config.ts
+++ b/backend/config.ts
@@ -22,16 +22,16 @@ export const tempRetentionSeconds = parseInt(env.TEMP_RETENTION_SECONDS ?? "1080
 export const subsetCacheMaxSize = parseInt(env.SUBSET_CACHE_MAX_SIZE ?? `${10 * 1024 * 1024}`, 10) || 10 * 1024 * 1024;
 
 /**
- * 字体子集化内存水位阈值(MB)
+ * 字体子集化最大并发数
  *
- * RSS 超过此值时,新的子集化请求排队等待,
- * 直到前面的请求完成 + GC 释放内存后 RSS 回落。
- * 默认 600:容器限制 900M 时留 300M 余量给峰值。
+ * 字体裁剪是 CPU/内存密集操作,并发过多会导致 LLRT OOM 崩溃。
+ * 默认 4:在 900M 内存限制下安全运行。
+ * 内存充裕可调大,内存紧张可调小到 2。
  */
-export const subsetMemSoftLimitMB = parseInt(env.SUBSET_MEM_SOFT_LIMIT_MB ?? "600", 10) || 600;
+export const subsetConcurrency = Math.max(1, parseInt(env.SUBSET_CONCURRENCY ?? "4", 10) || 4);
 
 /**
- * 队列等待超时(秒)—— 排队超过此时间返回 503,客户端可重试
+ * 队列等待超时(秒)—— 排队超过此时间返回 503,避免请求无限堆积
  */
 export const subsetQueueTimeoutSeconds = Math.max(5, parseInt(env.SUBSET_QUEUE_TIMEOUT ?? "30", 10) || 30);
 
diff --git a/backend/routes/config.ts b/backend/routes/config.ts
index 9d21942..e630de1 100644
--- a/backend/routes/config.ts
+++ b/backend/routes/config.ts
@@ -1,5 +1,5 @@
 import { jsonResponse } from "../shared";
-import { enableTempUpload, adminApiKey, tempRetentionSeconds, subsetMemSoftLimitMB, subsetQueueTimeoutSeconds } from "../config";
+import { enableTempUpload, adminApiKey, tempRetentionSeconds, subsetConcurrency, subsetQueueTimeoutSeconds } from "../config";
 
 /** GET /api/config — 返回公开配置 */
 export async function handleGetConfig(req: Request, _res: Response) {
@@ -11,8 +11,8 @@ export async function handleGetConfig(req: Request, _res: Response) {
       supportedOutTypes: ["woff2", "ttf"],
       /** 临时字体保留时限(秒) */
       tempRetentionSeconds,
-      /** 子集化内存水位阈值(MB),RSS 超此值时排队 */
-      subsetMemSoftLimitMB,
+      /** 字体子集化最大并发数 */
+      subsetConcurrency,
       /** 队列等待超时(秒) */
       subsetQueueTimeoutSeconds,
     }),
diff --git a/backend/routes/subset.ts b/backend/routes/subset.ts
index 013ddce..ba46a38 100644
--- a/backend/routes/subset.ts
+++ b/backend/routes/subset.ts
@@ -3,7 +3,7 @@ import type { FontEditor } from "../../vendor/fonteditor-core/lib/ttf/font.js";
 import { parseUrl, stats, subsetCache, findFontPath, readFontBuffer, markStatsDirty } from "../shared";
 import { markFontUsed } from "../temp_cleaner";
 import { withMemoryGate } from "../subset_queue";
-import { subsetMemSoftLimitMB, subsetQueueTimeoutSeconds } from "../config";
+import { subsetConcurrency, subsetQueueTimeoutSeconds } from "../config";
 
 /**
  * 进程启动时戳(模块加载时取一次,进程重启即变化)
@@ -116,7 +116,7 @@ export async function handleFontSubset(req: Request, res: Response) {
    * 缓存未命中的请求才进入闸门;RSS 超 softLimit 时排队等待,
    * 前面请求完成 + GC 释放内存后 RSS 回落才执行。避免 OOM 崩溃。
    */
-  const subsetResult = await withMemoryGate(subsetMemSoftLimitMB, async () => {
+  const subsetResult = await withMemoryGate(subsetConcurrency, async () => {
     return fontSubset(oldFontBuffer, text, {
       outType: outType,
       sourceType: fontType,
diff --git a/backend/subset_queue.ts b/backend/subset_queue.ts
index 175921b..88a826e 100644
--- a/backend/subset_queue.ts
+++ b/backend/subset_queue.ts
@@ -1,107 +1,58 @@
 /**
- * 字体子集化内存水位闸门
+ * 字体子集化并发队列(带字体分组调度)
  *
  * LLRT 运行时内存受限(~900M),字体裁剪是内存密集操作。
- * 不用固定并发数,而是实时监控进程 RSS:
- *  - RSS < softLimit:直接执行(小请求可高并发)
- *  - RSS ≥ softLimit:排队等待,直到前面的请求完成 + GC 释放内存
- *  - 无硬限制/拒绝:所有请求最终都会执行
+ * 通过固定并发数限制同时执行的子集化任务,防止 OOM 崩溃。
  *
- * 内存监控通过 /proc/self/statm(Linux 唯一可用方式,LLRT 无 process.memoryUsage)。
- * GC 通过 LLRT 内置的 __gc() 主动触发(请求完成后调用,加速内存回收)。
+ * 分组优化:排队中的请求按字体(groupKey)分组,同字体的请求优先连续处理,
+ * 使字体 buffer / 解析对象在缓存窗口内被下一个请求复用,降低内存峰值。
  */
 
-/** /proc/self/statm 文件描述符(启动时打开,反复读取不需每次 open/close) */
-let statmFd: number | null = null;
-
-/**
- * 读取当前进程 RSS(常驻内存),单位 MB
- *
- * Node 环境使用 process.memoryUsage().rss;LLRT 无此 API,改用 /proc/self/statm。
- * /proc/self/statm 格式:size resident shared text lib data dt(单位:页)
- * resident 字段 × 页大小(4096) = RSS 字节数。
- */
-function getRssMB(): number {
-  try {
-    /** Node 环境优先使用 process.memoryUsage() */
-    if (typeof process !== "undefined" && process.memoryUsage) {
-      return Math.round(process.memoryUsage().rss / 1024 / 1024);
-    }
-    /** LLRT 环境:读取 /proc/self/statm */
-    if (statmFd === null) {
-      const { openSync } = require("fs");
-      statmFd = openSync("/proc/self/statm", "r");
-    }
-    const { readSync } = require("fs");
-    const buf = new Uint8Array(256);
-    const n = readSync(statmFd, buf, 0, 256, 0);
-    const parts = new TextDecoder().decode(buf.subarray(0, n)).trim().split(" ");
-    return Math.round((parseInt(parts[1]) * 4096) / 1024 / 1024);
-  } catch {
-    /** 两个方案都不可用,返回 0 表示「无限制」 */
-    return 0;
-  }
-}
-
-/**
- * 主动触发垃圾回收
- *
- * LLRT 内置 __gc(),请求完成后调用可立即释放字体解析产生的大对象,
- * 而非等待 LLRT 引擎自动 GC(可能延迟数秒)。
- */
-function gc(): void {
-  try {
-    (globalThis as any).__gc?.();
-  } catch {
-    /** __gc 不存在(Node 环境)时静默跳过 */
-  }
-}
-
 /** 等待队列项:resolver + groupKey(用于同字体分组连续唤醒) */
 interface QueueItem {
-  /** 唤醒函数 */
   resolve: () => void;
   /** 分组键(通常为 fontPath),同组优先连续处理以复用字体缓存 */
   groupKey: string;
 }
 
-/** 等待队列:checkAndNotify 按分组优先级唤醒 */
+/** 等待队列:release 按分组优先级唤醒 */
 const waitQueue: QueueItem[] = [];
 
-/** 当前正在执行的子集化数量(用于 stats 展示) */
+/** 当前正在执行的子集化数量 */
 let activeCount = 0;
 
+/** 最大并发数(由 initConcurrency 设置) */
+let maxConcurrency = 4;
+
 /** 最近一次执行的 groupKey——同组请求优先连续唤醒 */
 let lastGroupKey = "";
 
-/** softLimit 引用(checkAndNotify 需要用) */
-let softLimitRef = 0;
-
 /**
- * 通过内存水位闸门执行子集化任务
+ * 通过并发队列执行子集化任务
  *
- * - RSS 未超 softLimit → 立即执行
- * - RSS 超 softLimit → 排队等待,前面的请求完成后 GC → RSS 回落 → 唤醒
+ * - active < maxConcurrency → 立即执行
+ * - active ≥ maxConcurrency → 排队等待,前面的完成后唤醒(同 groupKey 优先)
  * - 队列超时 → 返回 null(调用方返回 503,客户端可重试)
  *
  * 分组优化:同 groupKey(同一字体)的排队请求优先连续唤醒,
- * 使字体 buffer / 解析对象在 GC 窗口内被下一个请求复用,降低内存峰值。
+ * 使字体 buffer / 解析对象在缓存窗口内被下一个请求复用,降低内存峰值。
  *
- * @param softLimitMB 内存软限制(MB),RSS 超过此值时新请求排队
+ * 函数名保留 withMemoryGate 以减少调用方改动(subset.ts 等)。
+ *
+ * @param _softLimitMB 废弃保留(兼容签名),不再使用
  * @param task 实际的子集化异步任务
  * @param queueTimeoutMs 排队超时(毫秒)
  * @param groupKey 分组键(通常为 fontPath),同组连续处理以复用缓存
  * @returns 任务结果,或 null 表示排队超时
  */
 export async function withMemoryGate(
-  softLimitMB: number,
+  _softLimitMB: number,
   task: () => Promise,
   queueTimeoutMs: number,
   groupKey = "",
 ): Promise {
-  /** RSS=0 表示无法读取(开发环境),跳过闸门直接执行 */
-  if (softLimitMB > 0 && getRssMB() >= softLimitMB) {
-    /** 内存超阈值,进入排队 */
+  /** 并发已满,进入排队 */
+  if (activeCount >= maxConcurrency) {
     const acquired = await waitForSlot(queueTimeoutMs, groupKey);
     if (!acquired) return null;
   }
@@ -112,17 +63,12 @@ export async function withMemoryGate(
     return await task();
   } finally {
     activeCount--;
-    /**
-     * 任务完成后主动 GC,加速释放字体解析的大对象。
-     * 然后检查队列:如果 RSS 已回落,唤醒下一个等待者(同 groupKey 优先)。
-     */
-    gc();
-    checkAndNotify();
+    release();
   }
 }
 
 /**
- * 排队等待内存释放
+ * 排队等待并发槽位
  *
  * 加入队列,等待前面的请求完成后唤醒(同 groupKey 优先)。
  * 超时则从队列移除自己,返回 false。
@@ -138,7 +84,6 @@ function waitForSlot(timeoutMs: number, groupKey: string): Promise {
       if (idx !== -1) waitQueue.splice(idx, 1);
       resolve(false);
     }, timeoutMs);
-    /** 覆盖 resolve 以便唤醒时清 timer */
     item.resolve = () => {
       clearTimeout(timer);
       resolve(true);
@@ -163,50 +108,27 @@ function pickNext(): QueueItem | undefined {
 }
 
 /**
- * 检查内存并唤醒队列
- *
- * 每个子集化任务完成后调用:
- * 1. 如果 RSS < softLimit 且队列非空 → 唤醒下一个(同 groupKey 优先)
- * 2. RSS 仍超限 → 不唤醒(等待引擎自动 GC 后下次再检查)
- * 3. 兜底:5 秒后如果 RSS 没超 softLimit×1.2,强制唤醒(防饿死)
+ * 释放一个并发槽位,唤醒下一个等待者(同 groupKey 优先)
  */
-function checkAndNotify(): void {
+function release(): void {
   if (waitQueue.length === 0) return;
-  /** RSS 未知(开发环境)→ 直接唤醒 */
-  if (softLimitRef === 0) {
+  if (activeCount < maxConcurrency) {
     pickNext()?.resolve();
-    return;
   }
-  /** RSS 已回落到阈值以下 → 唤醒下一个 */
-  if (getRssMB() < softLimitRef) {
-    pickNext()?.resolve();
-    /** 唤醒后递归检查:可能还有内存余量给更多等待者 */
-    checkAndNotify();
-    return;
-  }
-  /**
-   * RSS 仍超限时不立即唤醒——设置兜底定时器:
-   * 5 秒后如果 RSS 降到 softLimit×1.2 以内,唤醒一个(防极端饿死)。
-   */
-  setTimeout(() => {
-    if (waitQueue.length > 0 && getRssMB() < softLimitRef * 1.2) {
-      pickNext()?.resolve();
-    }
-  }, 5000);
 }
 
 /**
- * 初始化闸门参数(由 config.ts 的值设置)
+ * 初始化并发参数(由 config 设置)
  *
  * 必须在第一次 withMemoryGate 调用前执行。
- * @param softLimitMB 内存软限制(MB)
+ * @param _softLimitMB 废弃(兼容签名)
+ * @param concurrency 最大并发数
  */
-export function initMemoryGate(softLimitMB: number): void {
-  softLimitRef = softLimitMB;
-  const rss = getRssMB();
-  if (rss > 0) {
-    console.log(`[memgate] RSS=${rss}MB, softLimit=${softLimitMB}MB`);
+export function initMemoryGate(_softLimitMB: number, concurrency?: number): void {
+  if (concurrency && concurrency > 0) {
+    maxConcurrency = concurrency;
   }
+  console.log(`[subset-queue] maxConcurrency=${maxConcurrency}`);
 }
 
 /** 获取当前队列状态(用于 stats / 日志) */
@@ -214,6 +136,5 @@ export function getQueueStats() {
   return {
     active: activeCount,
     waiting: waitQueue.length,
-    rssMB: getRssMB(),
   };
 }
diff --git a/src/FontSelector.vue b/src/FontSelector.vue
index 1575c4a..afe4425 100644
--- a/src/FontSelector.vue
+++ b/src/FontSelector.vue
@@ -43,6 +43,9 @@ const filteredFonts = computed(() => {
   });
 });
 
+/** 当前选中的字体对象(用于判断是否临时字体) */
+const selectedFontInfo = computed(() => props.fonts.find((f) => f.name === props.selectedFont));
+
 /** 当前选中字体的显示名(无选中时显示占位文字) */
 const selectedLabel = computed(() => props.selectedFont || t("pleaseSelect"));
 
@@ -135,8 +138,12 @@ function handleOutTypeChange(e: Event) {
           style="width: 100%; border: none; outline: none; font-size: 14px; background: transparent; padding: 0; color: #000"
         />
         
-        
-          {{ selectedLabel }}
+        
+          {{ selectedLabel }}
+          临时
         
         
         
@@ -161,11 +168,15 @@ function handleOutTypeChange(e: Event) {
               :key="f.name"
               @click="selectFont(f.name)"
               @mouseenter="($event.currentTarget as HTMLElement).style.background = '#f5f5f5'"
-              @mouseleave="($event.currentTarget as HTMLElement).style.background = '#fff'"
-              style="padding: 8px 12px; font-size: 14px; cursor: pointer; overflow: hidden; text-overflow: ellipsis; white-space: nowrap"
+              @mouseleave="($event.currentTarget as HTMLElement).style.background = f.name === selectedFont ? '#e6f4ff' : '#fff'"
+              style="padding: 8px 12px; font-size: 14px; cursor: pointer; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: flex; align-items: center; gap: 6px"
               :style="{ background: f.name === selectedFont ? '#e6f4ff' : '#fff', color: f.name === selectedFont ? '#1677ff' : '#333', fontWeight: f.name === selectedFont ? '500' : 'normal' }"
             >
-              {{ f.name }}
+              {{ f.name }}
+              临时
             
           
         
diff --git a/src/api.ts b/src/api.ts
index 7c5bfbe..695adaf 100644
--- a/src/api.ts
+++ b/src/api.ts
@@ -11,8 +11,8 @@ export interface ServerConfig {
   supportedOutTypes: ("woff2" | "ttf")[];
   /** 临时字体保留时限(秒) */
   tempRetentionSeconds?: number;
-  /** 子集化内存水位阈值(MB),RSS 超此值时排队 */
-  subsetMemSoftLimitMB?: number;
+  /** 字体子集化最大并发数 */
+  subsetConcurrency?: number;
   /** 队列等待超时(秒) */
   subsetQueueTimeoutSeconds?: number;
 }
diff --git a/src/components/CodeBlock.vue b/src/components/CodeBlock.vue
new file mode 100644
index 0000000..96b1825
--- /dev/null
+++ b/src/components/CodeBlock.vue
@@ -0,0 +1,306 @@
+
+
+
diff --git a/src/pages/Home.vue b/src/pages/Home.vue
index bcec730..989be52 100644
--- a/src/pages/Home.vue
+++ b/src/pages/Home.vue
@@ -35,6 +35,7 @@ import UploadSection from "../UploadSection.vue";
 import StatsPanel from "../StatsPanel.vue";
 import SelectorRow from "../FontSelector.vue";
 import FontDebugPreview from "../FontDebugPreview.vue";
+import CodeBlock from "../components/CodeBlock.vue";
 
 const text = ref("天地无极,乾坤借法");
 const fonts = ref([]);
@@ -102,6 +103,16 @@ const cssStyle = computed(() => {
 }`;
 });
 
+/** 基础用法代码示例(依赖 origin,需 computed) */
+const basicUsageCode = computed(() => {
+  return '\n

\u4f60\u7684\u6587\u5b57

'; +}); + +/** JS SDK 代码示例 */ +const jsSdkCode = computed(() => { + return '