Skip to content

Commit 07a7a6c

Browse files
chaxusclaude
andcommitted
fix(v9): images no longer hang the save pipeline -- serverless image registration
Root cause, proven with an offline Node repro of the exact x2t.wasm call: with no Document Server, sendImgUrls registers nothing, the DOCY writers embed the image's raw external URL (getImageLocal misses), and x2t.wasm loops forever on such a path -- the same DOCY converts in about 100ms once the path is a registered media name. prepareEditorIframe gains a fourth guard: a self-healing getImageLocal that registers external ids at serialization time, a serverless sendImgUrls honoring the server response contract, and a convertFromBin medias fallback that materializes the registered bytes into the WASM FS. The formerly-hanging insert-image-then-save flow now completes in ~130ms with the image bytes present in the output, and a permanent embed-regression case guards it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 54f9b82 commit 07a7a6c

5 files changed

Lines changed: 362 additions & 259 deletions

File tree

‎CLAUDE.md‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,8 @@ test/setup/vitest.ts # 全局 mock:matchMedia、URL.createObjectURL
145145
锁定期保存被拒、解锁后保存往返完整
146146
- docx open-buffer 打开 + 存回 docx(页内零依赖手拼最小 OOXML zip,#113 直接钉死)
147147
- PDF 打开:真实挂载 pdfeditor(断言 iframe URL 路由),页内手拼合法最小 PDF
148+
- URL 插图后保存:产物 zip 含 media 条目且字节完整(守护 serverless image
149+
pipeline;此前该场景主线程永久卡死)
148150

149151
**这套用例的调试教训(2026-08-13)**:它在首次落地时就抓出两个只在
150152
"全新浏览器 profile 首次访问" 下复现的生产级 bug(SharedWorker 拼写引擎挂起、
@@ -373,8 +375,13 @@ v7 代码分支(OO_VARIANT、页面级 x2t 打开转换、empty_bin 模板、v
373375
iframe patch 与混淆符号 hook 已全部删除。
374376
- **关键代码**:`lib/onlyoffice-editor.ts` 的 `createPersonalEditorInstance` /
375377
`handleFileStreamMessage` / `triggerPersonalDownloadAs` / `prepareEditorIframe`
376-
(最后一个含三个冷启动守卫:品牌元素隐藏、SharedWorker 遮蔽、fetchFonts
377-
字体竞态守卫——都是首次访问才会踩中的生产 bug,别删)。
378+
(最后一个含四个运行时守卫:品牌元素隐藏、SharedWorker 遮蔽、fetchFonts
379+
字体竞态守卫、**serverless image pipeline**——第四个修的是"文档含图片
380+
时保存令主线程永久卡死":无服务器时 sendImgUrls 注册不了图片,DOCY
381+
被写入裸外部 URL,x2t.wasm 对此死循环。自愈 getImageLocal + 本地
382+
sendImgUrls + convertFromBin medias 兜底三件套,见
383+
docs/explorations/2026-08-15-image-save-hang-root-cause-fix.md。全部
384+
都是真实生产 bug,别删)。
378385
- **部署约束**:x2t.wasm 只发布 gzip(9.4 MB,x2t_helper 里浏览器端解压
379386
预置 `Module.wasmBinary`),裸 40 MB 文件超 CF Pages 25 MB 限制、不入库。
380387
- **CSV**:新 vendor 编辑器不能直接吃 CSV——打开前用 SheetJS 转 XLSX、保存
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# 图片保存卡死:根因定位与修复(serverless image pipeline)
2+
3+
日期:2026-08-15
4+
前情:[2026-08-15-image-save-hang-and-verification-gap.md](2026-08-15-image-save-hang-and-verification-gap.md)
5+
(现象确认:文档含图片时保存令主线程永久阻塞,线上 4 次复现)
6+
7+
## 根因链(每一环都有实证)
8+
9+
1. **SDK 期望服务器完成图片登记**:外链/粘贴图片本应经
10+
`AscCommon.sendImgUrls` 发 `c:"imgurls"` 命令给 Document Server,
11+
服务器下载图片、返回 `media/imageN.png` 本地名,SDK 注册进
12+
`g_oDocumentUrls`。我们无服务器,这条命令发进虚空,注册表恒空
13+
(实测 `g_oDocumentUrls.urls = {}`)。
14+
2. **DOCY 写入器落进裸 URL**:序列化时写入器调
15+
`g_oDocumentUrls.getImageLocal(RasterImageId)`——未注册 → 返回 null →
16+
**把原始外部 URL 原样写进 DOCY**(对捕获的 DOCY 二进制做 utf16
17+
字符串扫描,找到 `http://127.0.0.1:4173/img/64.png`)。另外
18+
`getImageLocal` 对 data:URL 有硬性 null 守卫,data 形态一样落裸串。
19+
3. **x2t.wasm 对外部 URL 图片路径死循环**:离线复现(见下)确认同一个
20+
DOCY——裸 URL 路径永不返回;把该字符串**等长替换**为
21+
`media/xxx.png` 后 **100ms 转换成功**。媒体文件缺失时也只是优雅
22+
降级(100ms 完成、图片缺失),不会卡死。
23+
4. 空的 `medias`(保存胶水层传 `medias: []`)是并发的第二处缺口:
24+
即便 DOCY 正确,字节也进不了 x2t 的 FS。
25+
26+
## 离线复现方法(本次最值钱的调试资产)
27+
28+
浏览器内迭代一轮 3 分钟且主线程死锁难观测,改为:
29+
30+
1. **捕获现场**:E2E 里 wrap `AscCommon.x2t.convertFromBin`,拿到
31+
DOCY 二进制与 medias 后返回永不 resolve 的 Promise(故意弃养保存),
32+
页面保持存活,payload base64 导出到文件。
33+
2. **Node 复现台**(scratchpad `x2t-node-repro.mjs`):gunzip
34+
`x2t.wasm.gz` → 以同一份 glue(拷为 .cjs)+ 同一 params.xml 跑
35+
`main1`。坑:glue 里 `Module` 不是 globalThis.Module(模块作用域
36+
遮蔽),需在拷贝里改一行采用注入对象。
37+
3. **等长字符串替换实验**:DOCY 字符串带长度前缀,替换必须等长;
38+
借此做"只改图片路径、其余全同"的对照,10 秒一轮迭代出结论。
39+
40+
## 修复(lib/onlyoffice-editor.ts,prepareEditorIframe 第 4 号守卫)
41+
42+
三个协同 patch,全部页面侧运行时注入,vendor 零改动:
43+
44+
- **4a 自愈 getImageLocal**:查不到且 id 是外部形态
45+
(http/https/blob/data)→ 当场 `addImageUrl` 注册 → 返回新本地名。
46+
写入器调它的时机恰好拿到模型真实 RasterImageId,因此覆盖一切写入
47+
路径(word/cell/slide 共用),不需要遍历文档模型。
48+
- **4b 无服务器 sendImgUrls**:按服务器响应契约(`{url, path}`)本地
49+
实现,粘贴等主动调它的流程直接走通。
50+
- **4c convertFromBin medias 兜底**:胶水层传空 medias 时从
51+
`g_oDocumentUrls` 注册表重建,x2t_helper 的 `writeMediaFiles` 负责
52+
把 data:URL 解码 / blob、http fetch 成字节写入 WASM FS。
53+
54+
首版曾用"保存前扫 `ImageLoader.map_image_index` 注册"的方案,被实测
55+
推翻:ImageLoader 的 key 是展示用 data:URL,而模型里的 RasterImageId
56+
是原始 http URL——注册错了对象,DOCY 依旧落裸串。教训:**注册点必须
57+
选在拿得到"将被写入的那个 id"的位置**,即 getImageLocal 本身。
58+
59+
## 验证
60+
61+
- 原卡死场景(打开 docx → URL 插图 → 保存):**129ms 完成**,产物含
62+
`word/media/image1.png`(字节完整)。
63+
- 新增 E2E 永久守护:embed-regression "saves a docx after inserting an
64+
image by URL"(校验产物 zip 的 media 条目与体积)。
65+
- 全套:oxlint + tsc、单测 298 全绿、E2E 18 全绿。
66+
67+
## 遗留与后续
68+
69+
- **Save 按钮灰色之谜**(用户报告)已解释一半:插图后
70+
`isDocumentModified` 为 false,按钮语义为"无未保存修改"。修复后
71+
插入链路走通,按钮行为待部署后顺手复核;不阻塞本修复。
72+
- 粘贴带链接图片(#72 原始场景)与本地文件插图共享同一序列化管线,
73+
理论上同时修复;粘贴路径无法自动化,部署后人工确认再更新 #72。
74+
- x2t.wasm"外部 URL 路径 → 死循环"本身是引擎级问题,我们只能在喂入
75+
侧保证不出现该形态;换底座时此守卫必须保留并重验。

‎lib/onlyoffice-editor.ts‎

Lines changed: 115 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -381,7 +381,121 @@ function prepareEditorIframe(): boolean {
381381
console.log('[OO] AscCommon.fetchFonts guarded against uninitialized font system');
382382
}
383383

384-
if (win.__ooSharedWorkerShadowed && ooWin.__ooFetchFontsGuarded) {
384+
// 4. Serverless image pipeline. The SDK expects a Document Server to
385+
// turn pasted/URL-inserted images into registered local media names
386+
// (sendImgUrls posts an "imgurls" command and waits for the server
387+
// to answer). Without a server nothing registers, the document
388+
// model keeps a raw data:/blob:/https: src, the DOCY writer embeds
389+
// that raw string as the image path (getImageLocal hard-rejects
390+
// data: URLs), and x2t.wasm blocks the main thread forever trying
391+
// to resolve it. Three coordinated patches:
392+
// 4a. a self-healing getImageLocal that registers external ids on
393+
// lookup miss, so the writers always serialize a local name;
394+
// 4b. a serverless sendImgUrls that registers the source directly;
395+
// 4c. a medias fallback on convertFromBin, since the vendor's save
396+
// glue passes an empty media map.
397+
const imgWin = win as unknown as {
398+
AscCommon?: {
399+
x2t?: { convertFromBin?: unknown };
400+
sendImgUrls?: unknown;
401+
g_oDocumentUrls?: {
402+
mediaPrefix?: string;
403+
addImageUrl: (name: string, url: string) => void;
404+
getLocal: (url: string) => string | null;
405+
getUrl: (path: string) => string | null;
406+
getUrls: () => Record<string, string>;
407+
getImageLocal: (url: string) => string | null;
408+
};
409+
};
410+
Asc?: {
411+
editor?: { ImageLoader?: { map_image_index?: Record<string, unknown> }; _downloadAsFromLocal?: unknown };
412+
};
413+
editor?: { ImageLoader?: { map_image_index?: Record<string, unknown> }; _downloadAsFromLocal?: unknown };
414+
__ooImagePipelinePatched?: boolean;
415+
};
416+
const ac = imgWin.AscCommon;
417+
const editorApi = imgWin.Asc?.editor || imgWin.editor;
418+
if (ac?.g_oDocumentUrls && ac.x2t && editorApi && !imgWin.__ooImagePipelinePatched) {
419+
const docUrls = ac.g_oDocumentUrls;
420+
const MEDIA = docUrls.mediaPrefix || 'media/';
421+
let imgSeq = 0;
422+
423+
const extFromSrc = (src: string): string => {
424+
const dataMime = /^data:image\/([a-z0-9+.-]+)/i.exec(src);
425+
if (dataMime) {
426+
const sub = dataMime[1].toLowerCase();
427+
return sub === 'svg+xml' ? 'svg' : sub === 'jpeg' ? 'jpg' : sub;
428+
}
429+
const fromPath = /\.([a-z0-9]{2,5})(?:[?#]|$)/i.exec(src);
430+
return fromPath ? fromPath[1].toLowerCase() : 'png';
431+
};
432+
433+
const registerSrc = (src: unknown): string | null => {
434+
if (typeof src !== 'string' || !/^(data:image|blob:|https?:)/i.test(src)) return null;
435+
const existing = docUrls.getLocal(src);
436+
if (existing) return existing;
437+
let name: string;
438+
do {
439+
name = `image_oo${imgSeq++}.${extFromSrc(src)}`;
440+
} while (docUrls.getUrl(MEDIA + name));
441+
docUrls.addImageUrl(name, src);
442+
return MEDIA + name;
443+
};
444+
445+
// 4a. Self-healing resolver. The DOCY writers call getImageLocal
446+
// with the model's RasterImageId right before serializing it; a
447+
// miss makes them embed the raw external URL into the DOCY,
448+
// which is exactly what x2t.wasm loops forever on (verified
449+
// offline: the same DOCY converts in ~100ms once the path is a
450+
// local media name). So on a miss for an external id, register
451+
// it on the spot and return the fresh local name.
452+
docUrls.getImageLocal = function (url: string) {
453+
let local = this.getLocal(url) || registerSrc(url);
454+
if (local && local.indexOf(MEDIA) === 0) local = local.substring(MEDIA.length);
455+
return local || null;
456+
};
457+
458+
// 4b. The callback contract mirrors the server response: url is what
459+
// the editor displays, path is the document-relative media name.
460+
ac.sendImgUrls = function (
461+
_api: unknown,
462+
images: string[],
463+
callback: (r: Array<{ url: string; path: string }>) => void,
464+
) {
465+
const out = (images || []).map((src) => {
466+
const path = registerSrc(src);
467+
return path ? { url: src, path } : { url: 'error', path: 'error' };
468+
});
469+
setTimeout(() => callback(out), 0);
470+
};
471+
472+
// 4c. The vendor's save glue passes medias: [] even when the
473+
// document references media -- refill it from the registry so
474+
// x2t_helper's writeMediaFiles materializes the bytes (it
475+
// decodes data: URLs and fetches blob:/http(s): sources). A
476+
// fetch failure degrades to a missing image in the output,
477+
// never a hang (verified offline).
478+
const x2tProto = Object.getPrototypeOf(ac.x2t) as {
479+
convertFromBin: (obj: { medias?: Record<string, string> }) => unknown;
480+
};
481+
const origConvertFromBin = x2tProto.convertFromBin;
482+
x2tProto.convertFromBin = function (obj: { medias?: Record<string, string> }) {
483+
if (obj && (!obj.medias || Object.keys(obj.medias).length === 0)) {
484+
const urls = docUrls.getUrls() || {};
485+
const medias: Record<string, string> = {};
486+
for (const key of Object.keys(urls)) {
487+
if (key.indexOf(MEDIA) === 0) medias[key] = urls[key];
488+
}
489+
if (Object.keys(medias).length > 0) obj.medias = medias;
490+
}
491+
return origConvertFromBin.call(this, obj);
492+
};
493+
494+
imgWin.__ooImagePipelinePatched = true;
495+
console.log('[OO] serverless image pipeline installed (sendImgUrls, media registry, convertFromBin medias)');
496+
}
497+
498+
if (win.__ooSharedWorkerShadowed && ooWin.__ooFetchFontsGuarded && imgWin.__ooImagePipelinePatched) {
385499
fullyApplied = true;
386500
}
387501
} catch {

‎test/e2e/embed-regression.spec.ts‎

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,169 @@ test.describe('embed regression (real editor)', () => {
330330
expect(result.size).toBeGreaterThan(500);
331331
});
332332

333+
test('saves a docx after inserting an image by URL, with the image bytes in the output', async ({ page }) => {
334+
// Guards the serverless image pipeline (prepareEditorIframe patch 4).
335+
// Without it the SDK serializes the raw external URL into the DOCY and
336+
// x2t.wasm blocks the main thread forever (the save never returns and
337+
// the whole tab freezes) -- see
338+
// docs/explorations/2026-08-15-image-save-hang-and-verification-gap.md.
339+
const result = await page.evaluate(async () => {
340+
const crcTable = (() => {
341+
const t = new Uint32Array(256);
342+
for (let n = 0; n < 256; n++) {
343+
let c = n;
344+
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
345+
t[n] = c >>> 0;
346+
}
347+
return t;
348+
})();
349+
const crc32 = (b: Uint8Array) => {
350+
let c = 0xffffffff;
351+
for (let i = 0; i < b.length; i++) c = crcTable[(c ^ b[i]) & 0xff] ^ (c >>> 8);
352+
return (c ^ 0xffffffff) >>> 0;
353+
};
354+
const enc = new TextEncoder();
355+
const makeZip = (entries: Array<{ name: string; text: string }>) => {
356+
const ch: number[] = [];
357+
const cen: number[] = [];
358+
const u16 = (a: number[], v: number) => a.push(v & 0xff, (v >> 8) & 0xff);
359+
const u32 = (a: number[], v: number) => a.push(v & 0xff, (v >> 8) & 0xff, (v >> 16) & 0xff, (v >> 24) & 0xff);
360+
for (const { name, text } of entries) {
361+
const nb = enc.encode(name);
362+
const d = enc.encode(text);
363+
const crc = crc32(d);
364+
const off = ch.length;
365+
u32(ch, 0x04034b50);
366+
u16(ch, 20);
367+
u16(ch, 0);
368+
u16(ch, 0);
369+
u32(ch, 0);
370+
u32(ch, crc);
371+
u32(ch, d.length);
372+
u32(ch, d.length);
373+
u16(ch, nb.length);
374+
u16(ch, 0);
375+
ch.push(...nb, ...d);
376+
u32(cen, 0x02014b50);
377+
u16(cen, 20);
378+
u16(cen, 20);
379+
u16(cen, 0);
380+
u16(cen, 0);
381+
u32(cen, 0);
382+
u32(cen, crc);
383+
u32(cen, d.length);
384+
u32(cen, d.length);
385+
u16(cen, nb.length);
386+
u16(cen, 0);
387+
u16(cen, 0);
388+
u16(cen, 0);
389+
u16(cen, 0);
390+
u32(cen, 0);
391+
u32(cen, off);
392+
cen.push(...nb);
393+
}
394+
const co = ch.length;
395+
ch.push(...cen);
396+
u32(ch, 0x06054b50);
397+
u16(ch, 0);
398+
u16(ch, 0);
399+
u16(ch, entries.length);
400+
u16(ch, entries.length);
401+
u32(ch, cen.length);
402+
u32(ch, co);
403+
u16(ch, 0);
404+
return new Uint8Array(ch);
405+
};
406+
const docx = makeZip([
407+
{
408+
name: '[Content_Types].xml',
409+
text:
410+
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
411+
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">' +
412+
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>' +
413+
'<Default Extension="xml" ContentType="application/xml"/>' +
414+
'<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>' +
415+
'</Types>',
416+
},
417+
{
418+
name: '_rels/.rels',
419+
text:
420+
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
421+
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
422+
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>' +
423+
'</Relationships>',
424+
},
425+
{
426+
name: 'word/document.xml',
427+
text:
428+
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
429+
'<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">' +
430+
'<w:body><w:p><w:r><w:t>image save</w:t></w:r></w:p></w:body></w:document>',
431+
},
432+
]);
433+
await post('document:open-buffer', { fileName: 'imgsave.docx', buffer: docx.buffer, readonly: false });
434+
435+
// Locate the editor API and insert an image by URL (the flow that
436+
// used to freeze the tab on save).
437+
const findApi = (): any => {
438+
const visit = (win: Window): any => {
439+
try {
440+
const scope = win as any;
441+
const api = scope.Asc?.editor || scope.editor;
442+
if (api && typeof api.AddImageUrlAction === 'function') return api;
443+
} catch {
444+
/* cross-origin frame */
445+
}
446+
for (let i = 0; i < win.frames.length; i++) {
447+
const found = visit(win.frames[i]);
448+
if (found) return found;
449+
}
450+
return null;
451+
};
452+
return visit(window);
453+
};
454+
const start = Date.now();
455+
let api = findApi();
456+
while ((!api || !api.isDocumentLoadComplete) && Date.now() - start < 60_000) {
457+
await new Promise((r) => setTimeout(r, 500));
458+
api = api || findApi();
459+
}
460+
api.AddImageUrlAction(`${location.origin}/img/64.png`);
461+
await new Promise((r) => setTimeout(r, 6000));
462+
463+
const saved = await Promise.race([
464+
post('document:save', { targetExt: 'DOCX' }),
465+
new Promise<never>((_, rej) =>
466+
setTimeout(
467+
() => rej(new Error('image save timed out (the historical symptom was a permanent hang)')),
468+
60_000,
469+
),
470+
),
471+
]);
472+
const bytes = new Uint8Array(await (saved as any).file.arrayBuffer());
473+
const dec = new TextDecoder();
474+
const mediaEntries: Array<{ name: string; size: number }> = [];
475+
for (let i = 0; i < bytes.length - 4; i++) {
476+
if (bytes[i] === 0x50 && bytes[i + 1] === 0x4b && bytes[i + 2] === 0x01 && bytes[i + 3] === 0x02) {
477+
const nameLen = bytes[i + 28] | (bytes[i + 29] << 8);
478+
const size = bytes[i + 24] | (bytes[i + 25] << 8) | (bytes[i + 26] << 16) | (bytes[i + 27] << 24);
479+
const name = dec.decode(bytes.slice(i + 46, i + 46 + nameLen));
480+
if (/media\//i.test(name)) mediaEntries.push({ name, size });
481+
}
482+
}
483+
return {
484+
savedName: (saved as any).file.name as string,
485+
isZip: bytes[0] === 0x50 && bytes[1] === 0x4b,
486+
mediaEntries,
487+
};
488+
});
489+
490+
expect(result.isZip).toBe(true);
491+
expect(result.savedName).toBe('imgsave.docx');
492+
expect(result.mediaEntries.length).toBeGreaterThan(0);
493+
expect(result.mediaEntries[0].size).toBeGreaterThan(100);
494+
});
495+
333496
test('opens a PDF through the vendor pdf editor', async ({ page }) => {
334497
await page.evaluate(async () => {
335498
// Build a minimal but structurally valid single-page PDF, computing the

0 commit comments

Comments
 (0)