From 689a1cd21d00ee46b0e98326bb9f416c9ef4374a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 2 May 2026 03:57:49 +0100 Subject: [PATCH] fix: write media buffers atomically --- CHANGELOG.md | 1 + src/media/store.test.ts | 34 ++++++++++++++++++++++++++++++++++ src/media/store.ts | 19 ++++++++++++++++--- 3 files changed, 51 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d8e13cc3b9a..5d9def1532f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Control UI/chat: keep live replies visible when a raw session alias such as `main` sends the chat turn but Gateway emits events under the canonical session key for the same run. Fixes #73716. Thanks @teebes. +- Media: write inbound media buffers through same-directory temp files before rename, so failed disk writes do not leave zero-byte artifacts for later voice transcription. Fixes #55966. Thanks @OpenCodeEngineer. - TTS/Telegram: keep trusted local audio generated by the TTS tool queued for voice-note delivery even when the run-level built-in tool list omits the raw `tts` name. Fixes #74752. Thanks @Loveworld3033 and @andyliu. - TTS: require explicit user or config audio intent for the agent speech tool so dashboard chats stay text unless audio is requested. Fixes #69777. Thanks @alexandre-leng. - Plugins/config: keep bundled source-checkout plugins from being runtime-gated by install-only `minHostVersion` metadata, accept prerelease host floors, trim plugin-service startup failures to one log line, and avoid broad channel-runtime loading during base config parsing. Thanks @vincentkoc. diff --git a/src/media/store.test.ts b/src/media/store.test.ts index 5d6860a379c..bb536f00a88 100644 --- a/src/media/store.test.ts +++ b/src/media/store.test.ts @@ -307,6 +307,40 @@ describe("media store", () => { }); }, }, + { + name: "does not leave final media artifacts when buffer writes fail", + run: async () => { + await withTempStore(async (store) => { + const mediaDir = await store.ensureMediaDir(); + const originalWriteFile = fs.writeFile.bind(fs); + const attemptedPaths: string[] = []; + vi.spyOn(fs, "writeFile").mockImplementation(async (...args) => { + const [filePath] = args; + if ( + typeof filePath === "string" && + filePath.includes(`${path.sep}failed-buffer${path.sep}`) + ) { + attemptedPaths.push(filePath); + await originalWriteFile(filePath, Buffer.alloc(0), args[2]); + const err = new Error("no space left on device") as NodeJS.ErrnoException; + err.code = "ENOSPC"; + throw err; + } + return await originalWriteFile(...args); + }); + + await expect( + store.saveMediaBuffer(Buffer.from("voice"), "audio/ogg", "failed-buffer"), + ).rejects.toMatchObject({ code: "ENOSPC" }); + + const failedDir = path.join(mediaDir, "failed-buffer"); + const entries = await fs.readdir(failedDir).catch(() => []); + expect(attemptedPaths).toHaveLength(1); + expect(path.basename(attemptedPaths[0] ?? "")).toMatch(/^\..+\.tmp$/); + expect(entries).toEqual([]); + }); + }, + }, { name: "rejects traversal media subdirs before saving buffers", run: async () => { diff --git a/src/media/store.ts b/src/media/store.ts index 9055a31582d..0e1055fbb18 100644 --- a/src/media/store.ts +++ b/src/media/store.ts @@ -345,9 +345,22 @@ async function writeSavedMediaBuffer(params: { buffer: Buffer; }): Promise { const dest = path.join(params.dir, params.id); - await retryAfterRecreatingDir(params.dir, () => - fs.writeFile(dest, params.buffer, { mode: MEDIA_FILE_MODE }), - ); + await retryAfterRecreatingDir(params.dir, async () => { + const tempDest = path.join(params.dir, `.${params.id}.${crypto.randomUUID()}.tmp`); + try { + await fs.writeFile(tempDest, params.buffer, { mode: MEDIA_FILE_MODE }); + const handle = await fs.open(tempDest, "r"); + try { + await handle.sync(); + } finally { + await handle.close(); + } + await fs.rename(tempDest, dest); + } catch (err) { + await fs.rm(tempDest, { force: true }).catch(() => {}); + throw err; + } + }); return dest; }