Merge pull request #3183 from code-yeongyu/fix/issue-2932
fix: prevent background agent race condition in session prompt (#2932)
This commit is contained in:
@@ -1431,6 +1431,46 @@ describe("BackgroundManager.tryCompleteTask", () => {
|
||||
expect(task.concurrencyKey).toBeUndefined()
|
||||
})
|
||||
|
||||
test("should mark task as error when startTask throws after session creation", async () => {
|
||||
//#given - startTask creates session but fails before sending prompt
|
||||
const concurrencyKey = "anthropic/claude-opus-4-6"
|
||||
|
||||
const task = createMockTask({
|
||||
id: "task-zombie-session",
|
||||
parentSessionID: "parent-zombie",
|
||||
status: "pending",
|
||||
agent: "explore",
|
||||
})
|
||||
delete (task as Partial<BackgroundTask>).sessionID
|
||||
|
||||
const input = {
|
||||
description: task.description,
|
||||
prompt: task.prompt,
|
||||
agent: task.agent,
|
||||
parentSessionID: task.parentSessionID,
|
||||
parentMessageID: task.parentMessageID,
|
||||
model: { providerID: "anthropic", modelID: "claude-opus-4-6" },
|
||||
}
|
||||
getTaskMap(manager).set(task.id, task)
|
||||
getQueuesByKey(manager).set(concurrencyKey, [{ task, input }])
|
||||
|
||||
;(manager as unknown as { startTask: (item: { task: BackgroundTask; input: typeof input }) => Promise<void> }).startTask = async (item) => {
|
||||
item.task.status = "running"
|
||||
item.task.sessionID = "ses_zombie_child"
|
||||
item.task.startedAt = new Date()
|
||||
item.task.concurrencyKey = concurrencyKey
|
||||
throw new Error("crash between session creation and prompt send")
|
||||
}
|
||||
|
||||
//#when
|
||||
await processKeyForTest(manager, concurrencyKey)
|
||||
|
||||
//#then - task must be marked as error, not left in running zombie state
|
||||
expect(task.status).toBe("error")
|
||||
expect(task.error).toContain("crash between session creation and prompt send")
|
||||
expect(task.completedAt).toBeDefined()
|
||||
})
|
||||
|
||||
test("should release queue slot when queued task is already interrupt", async () => {
|
||||
// given
|
||||
const concurrencyKey = "anthropic/claude-opus-4-6"
|
||||
|
||||
@@ -371,7 +371,7 @@ export class BackgroundManager {
|
||||
this.markPreStartDescendantReservation(task)
|
||||
|
||||
// Trigger processing (fire-and-forget)
|
||||
this.processKey(key)
|
||||
void this.processKey(key)
|
||||
|
||||
return { ...task }
|
||||
} catch (error) {
|
||||
@@ -408,12 +408,35 @@ export class BackgroundManager {
|
||||
} catch (error) {
|
||||
log("[background-agent] Error starting task:", error)
|
||||
this.rollbackPreStartDescendantReservation(item.task)
|
||||
|
||||
// Mark task as error so the parent polling loop detects the failure
|
||||
// instead of leaving it in a zombie "running" state with no prompt sent
|
||||
item.task.status = "error"
|
||||
item.task.error = error instanceof Error ? error.message : String(error)
|
||||
item.task.completedAt = new Date()
|
||||
|
||||
if (item.task.concurrencyKey) {
|
||||
this.concurrencyManager.release(item.task.concurrencyKey)
|
||||
item.task.concurrencyKey = undefined
|
||||
} else {
|
||||
this.concurrencyManager.release(key)
|
||||
}
|
||||
|
||||
if (item.task.rootSessionID) {
|
||||
this.unregisterRootDescendant(item.task.rootSessionID)
|
||||
}
|
||||
|
||||
removeTaskToastTracking(item.task.id)
|
||||
|
||||
// Abort the orphaned session if one was created before the error
|
||||
if (item.task.sessionID) {
|
||||
await this.abortSessionWithLogging(item.task.sessionID, "startTask error cleanup")
|
||||
}
|
||||
|
||||
this.markForNotification(item.task)
|
||||
this.enqueueNotificationForParent(item.task.parentSessionID, () => this.notifyParentSession(item.task)).catch(err => {
|
||||
log("[background-agent] Failed to notify on startTask error:", err)
|
||||
})
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -234,7 +234,7 @@ describe("createReadImageResizerHook", () => {
|
||||
expect(output.output).toContain("resized")
|
||||
})
|
||||
|
||||
it("keeps original attachment URL and marks resize skipped when resize fails", async () => {
|
||||
it("removes oversized attachment when resize fails to prevent API error", async () => {
|
||||
//#given
|
||||
mockParseImageDimensions.mockReturnValue({ width: 3000, height: 2000 })
|
||||
mockCalculateTargetDimensions.mockReturnValue({ width: 1568, height: 1045 })
|
||||
@@ -252,8 +252,37 @@ describe("createReadImageResizerHook", () => {
|
||||
await hook["tool.execute.after"](createInput("Read"), output)
|
||||
|
||||
//#then
|
||||
expect(output.attachments?.[0]?.url).toBe("data:image/png;base64,old")
|
||||
expect(output.output).toContain("resize skipped")
|
||||
expect(output.attachments?.length ?? 0).toBe(0)
|
||||
expect(output.output).toContain("exceeds provider limits")
|
||||
expect(output.output).toContain("image removed to prevent API error")
|
||||
})
|
||||
|
||||
it("removes only oversized attachments and preserves valid ones in mixed batches", async () => {
|
||||
//#given
|
||||
mockParseImageDimensions
|
||||
.mockReturnValueOnce({ width: 800, height: 600 })
|
||||
.mockReturnValueOnce({ width: 4000, height: 3000 })
|
||||
mockCalculateTargetDimensions.mockReturnValueOnce(null).mockReturnValueOnce({ width: 1568, height: 1176 })
|
||||
mockResizeImage.mockResolvedValueOnce(null)
|
||||
|
||||
const hook = createReadImageResizerHook(createMockContext())
|
||||
const output: ToolOutput = {
|
||||
title: "Read",
|
||||
output: "original output",
|
||||
metadata: {},
|
||||
attachments: [
|
||||
{ mime: "image/png", url: "data:image/png;base64,small", filename: "small.png" },
|
||||
{ mime: "image/png", url: "data:image/png;base64,big", filename: "big.png" },
|
||||
],
|
||||
}
|
||||
|
||||
//#when
|
||||
await hook["tool.execute.after"](createInput("Read"), output)
|
||||
|
||||
//#then
|
||||
expect(output.attachments?.length).toBe(1)
|
||||
expect(output.attachments?.[0]?.filename).toBe("small.png")
|
||||
expect(output.output).toContain("exceeds provider limits")
|
||||
})
|
||||
|
||||
it("appends unknown-dimensions metadata when parsing fails", async () => {
|
||||
|
||||
@@ -86,7 +86,7 @@ function formatResizeAppendix(entries: ResizeEntry[]): string {
|
||||
}
|
||||
|
||||
if (entry.status === "resize-skipped") {
|
||||
lines.push(`- ${entry.filename}: ${originalText} (resize skipped, tokens: ${originalTokens})`)
|
||||
lines.push(`- ${entry.filename}: ${originalText} (exceeds provider limits, image removed to prevent API error)`)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -138,6 +138,7 @@ export function createReadImageResizerHook(_ctx: PluginInput) {
|
||||
}
|
||||
|
||||
const entries: ResizeEntry[] = []
|
||||
const attachmentsToRemove: ImageAttachment[] = []
|
||||
for (const [index, attachment] of attachments.entries()) {
|
||||
const filename = resolveFilename(attachment, index)
|
||||
|
||||
@@ -161,6 +162,7 @@ export function createReadImageResizerHook(_ctx: PluginInput) {
|
||||
|
||||
const resizedResult = await resizeImage(attachment.url, attachment.mime, targetDims)
|
||||
if (!resizedResult) {
|
||||
attachmentsToRemove.push(attachment)
|
||||
entries.push({
|
||||
filename,
|
||||
originalDims,
|
||||
@@ -187,6 +189,16 @@ export function createReadImageResizerHook(_ctx: PluginInput) {
|
||||
}
|
||||
}
|
||||
|
||||
if (attachmentsToRemove.length > 0) {
|
||||
const rawAttachments = outputRecord.attachments as unknown[]
|
||||
for (const toRemove of attachmentsToRemove) {
|
||||
const removeIndex = rawAttachments.indexOf(toRemove)
|
||||
if (removeIndex !== -1) {
|
||||
rawAttachments.splice(removeIndex, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
import { afterEach, describe, expect, it, mock } from "bun:test"
|
||||
import { deflateSync } from "node:zlib"
|
||||
|
||||
const PNG_1X1_DATA_URL =
|
||||
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
|
||||
@@ -11,6 +12,73 @@ async function importFreshImageResizerModule(): Promise<ImageResizerModule> {
|
||||
return import(`./image-resizer?test-${Date.now()}-${Math.random()}`)
|
||||
}
|
||||
|
||||
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
|
||||
|
||||
const CRC_TABLE = (() => {
|
||||
const table = new Uint32Array(256)
|
||||
for (let n = 0; n < 256; n++) {
|
||||
let c = n
|
||||
for (let k = 0; k < 8; k++) {
|
||||
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
|
||||
}
|
||||
table[n] = c
|
||||
}
|
||||
return table
|
||||
})()
|
||||
|
||||
function testCrc32(data: Buffer): number {
|
||||
let crc = 0xffffffff
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
crc = CRC_TABLE[(crc ^ data[i]) & 0xff] ^ (crc >>> 8)
|
||||
}
|
||||
return (crc ^ 0xffffffff) >>> 0
|
||||
}
|
||||
|
||||
function testCreateChunk(type: string, data: Buffer): Buffer {
|
||||
const typeBuffer = Buffer.from(type, "ascii")
|
||||
const lengthBuffer = Buffer.alloc(4)
|
||||
lengthBuffer.writeUInt32BE(data.length, 0)
|
||||
const crcInput = Buffer.concat([typeBuffer, data])
|
||||
const crcBuffer = Buffer.alloc(4)
|
||||
crcBuffer.writeUInt32BE(testCrc32(crcInput) >>> 0, 0)
|
||||
return Buffer.concat([lengthBuffer, typeBuffer, data, crcBuffer])
|
||||
}
|
||||
|
||||
function createOversizedPngDataUrl(width: number, height: number): string {
|
||||
const ihdr = Buffer.alloc(13)
|
||||
ihdr.writeUInt32BE(width, 0)
|
||||
ihdr.writeUInt32BE(height, 4)
|
||||
ihdr[8] = 8
|
||||
ihdr[9] = 6
|
||||
ihdr[10] = 0
|
||||
ihdr[11] = 0
|
||||
ihdr[12] = 0
|
||||
|
||||
const rowBytes = width * 4
|
||||
const rawData = Buffer.alloc(height * (rowBytes + 1))
|
||||
for (let y = 0; y < height; y++) {
|
||||
const rowOffset = y * (rowBytes + 1)
|
||||
rawData[rowOffset] = 0
|
||||
for (let x = 0; x < width; x++) {
|
||||
const pixelOffset = rowOffset + 1 + x * 4
|
||||
rawData[pixelOffset] = (x * 255) % 256
|
||||
rawData[pixelOffset + 1] = (y * 255) % 256
|
||||
rawData[pixelOffset + 2] = ((x + y) * 127) % 256
|
||||
rawData[pixelOffset + 3] = 255
|
||||
}
|
||||
}
|
||||
|
||||
const idat = deflateSync(rawData)
|
||||
const buffer = Buffer.concat([
|
||||
PNG_SIGNATURE,
|
||||
testCreateChunk("IHDR", ihdr),
|
||||
testCreateChunk("IDAT", idat),
|
||||
testCreateChunk("IEND", Buffer.alloc(0)),
|
||||
])
|
||||
|
||||
return `data:image/png;base64,${buffer.toString("base64")}`
|
||||
}
|
||||
|
||||
describe("calculateTargetDimensions", () => {
|
||||
it("returns null when dimensions are already within limits", async () => {
|
||||
//#given
|
||||
@@ -90,7 +158,28 @@ describe("resizeImage", () => {
|
||||
mock.restore()
|
||||
})
|
||||
|
||||
it("returns null when sharp import fails", async () => {
|
||||
it("falls back to pure-JS resizer for PNG when sharp is unavailable", async () => {
|
||||
//#given
|
||||
mock.module("sharp", () => {
|
||||
throw new Error("sharp unavailable")
|
||||
})
|
||||
const { resizeImage } = await importFreshImageResizerModule()
|
||||
const oversizedPng = createOversizedPngDataUrl(3000, 2000)
|
||||
|
||||
//#when
|
||||
const result = await resizeImage(oversizedPng, "image/png", {
|
||||
width: 1568,
|
||||
height: 1045,
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.resized).toEqual({ width: 1568, height: 1045 })
|
||||
expect(result?.original).toEqual({ width: 3000, height: 2000 })
|
||||
expect(result?.resizedDataUrl).toStartWith("data:image/png;base64,")
|
||||
})
|
||||
|
||||
it("returns null for non-PNG when sharp is unavailable", async () => {
|
||||
//#given
|
||||
mock.module("sharp", () => {
|
||||
throw new Error("sharp unavailable")
|
||||
@@ -98,7 +187,7 @@ describe("resizeImage", () => {
|
||||
const { resizeImage } = await importFreshImageResizerModule()
|
||||
|
||||
//#when
|
||||
const result = await resizeImage(PNG_1X1_DATA_URL, "image/png", {
|
||||
const result = await resizeImage(PNG_1X1_DATA_URL, "image/jpeg", {
|
||||
width: 1,
|
||||
height: 1,
|
||||
})
|
||||
@@ -107,6 +196,25 @@ describe("resizeImage", () => {
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("falls back to pure-JS resizer when sharp has unexpected shape", async () => {
|
||||
//#given
|
||||
mock.module("sharp", () => ({
|
||||
default: "not-a-function",
|
||||
}))
|
||||
const { resizeImage } = await importFreshImageResizerModule()
|
||||
const oversizedPng = createOversizedPngDataUrl(2000, 1000)
|
||||
|
||||
//#when
|
||||
const result = await resizeImage(oversizedPng, "image/png", {
|
||||
width: 1568,
|
||||
height: 784,
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.resized).toEqual({ width: 1568, height: 784 })
|
||||
})
|
||||
|
||||
it("returns null when sharp throws during resize", async () => {
|
||||
//#given
|
||||
const mockSharpFactory = mock(() => ({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ImageDimensions, ResizeResult } from "./types"
|
||||
import { extractBase64Data } from "../../tools/look-at/mime-type-inference"
|
||||
import { log } from "../../shared"
|
||||
import { resizeImageFallback } from "./png-fallback-resizer"
|
||||
|
||||
const ANTHROPIC_MAX_LONG_EDGE = 1568
|
||||
const ANTHROPIC_MAX_FILE_SIZE = 5 * 1024 * 1024
|
||||
@@ -114,14 +115,14 @@ export async function resizeImage(
|
||||
const sharpModuleName = "sharp"
|
||||
const sharpModule = await import(sharpModuleName).catch(() => null)
|
||||
if (!sharpModule) {
|
||||
log("[read-image-resizer] sharp unavailable, skipping resize")
|
||||
return null
|
||||
log("[read-image-resizer] sharp unavailable, attempting pure-JS fallback")
|
||||
return resizeImageFallback(base64DataUrl, mimeType, target)
|
||||
}
|
||||
|
||||
const sharpFactory = resolveSharpFactory(sharpModule)
|
||||
if (!sharpFactory) {
|
||||
log("[read-image-resizer] sharp import has unexpected shape")
|
||||
return null
|
||||
log("[read-image-resizer] sharp import has unexpected shape, attempting pure-JS fallback")
|
||||
return resizeImageFallback(base64DataUrl, mimeType, target)
|
||||
}
|
||||
|
||||
const rawBase64 = extractBase64Data(base64DataUrl)
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { deflateSync } from "node:zlib"
|
||||
|
||||
import { resizeImageFallback } from "./png-fallback-resizer"
|
||||
import { parseImageDimensions } from "./image-dimensions"
|
||||
|
||||
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
|
||||
|
||||
const CRC_TABLE = (() => {
|
||||
const table = new Uint32Array(256)
|
||||
for (let n = 0; n < 256; n++) {
|
||||
let c = n
|
||||
for (let k = 0; k < 8; k++) {
|
||||
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
|
||||
}
|
||||
table[n] = c
|
||||
}
|
||||
return table
|
||||
})()
|
||||
|
||||
function crc32(data: Buffer): number {
|
||||
let crc = 0xffffffff
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
crc = CRC_TABLE[(crc ^ data[i]) & 0xff] ^ (crc >>> 8)
|
||||
}
|
||||
return (crc ^ 0xffffffff) >>> 0
|
||||
}
|
||||
|
||||
function createChunk(type: string, data: Buffer): Buffer {
|
||||
const typeBuffer = Buffer.from(type, "ascii")
|
||||
const lengthBuffer = Buffer.alloc(4)
|
||||
lengthBuffer.writeUInt32BE(data.length, 0)
|
||||
const crcInput = Buffer.concat([typeBuffer, data])
|
||||
const crcBuffer = Buffer.alloc(4)
|
||||
crcBuffer.writeUInt32BE(crc32(crcInput) >>> 0, 0)
|
||||
return Buffer.concat([lengthBuffer, typeBuffer, data, crcBuffer])
|
||||
}
|
||||
|
||||
function createValidRgbaPng(width: number, height: number): string {
|
||||
const ihdr = Buffer.alloc(13)
|
||||
ihdr.writeUInt32BE(width, 0)
|
||||
ihdr.writeUInt32BE(height, 4)
|
||||
ihdr[8] = 8
|
||||
ihdr[9] = 6
|
||||
ihdr[10] = 0
|
||||
ihdr[11] = 0
|
||||
ihdr[12] = 0
|
||||
|
||||
const rowBytes = width * 4
|
||||
const rawData = Buffer.alloc(height * (rowBytes + 1))
|
||||
for (let y = 0; y < height; y++) {
|
||||
const rowOffset = y * (rowBytes + 1)
|
||||
rawData[rowOffset] = 0
|
||||
for (let x = 0; x < width; x++) {
|
||||
const pixelOffset = rowOffset + 1 + x * 4
|
||||
rawData[pixelOffset] = (x * 255) % 256
|
||||
rawData[pixelOffset + 1] = (y * 255) % 256
|
||||
rawData[pixelOffset + 2] = ((x + y) * 127) % 256
|
||||
rawData[pixelOffset + 3] = 255
|
||||
}
|
||||
}
|
||||
|
||||
const idat = deflateSync(rawData)
|
||||
const buffer = Buffer.concat([
|
||||
PNG_SIGNATURE,
|
||||
createChunk("IHDR", ihdr),
|
||||
createChunk("IDAT", idat),
|
||||
createChunk("IEND", Buffer.alloc(0)),
|
||||
])
|
||||
|
||||
return `data:image/png;base64,${buffer.toString("base64")}`
|
||||
}
|
||||
|
||||
describe("resizeImageFallback", () => {
|
||||
describe("#given a valid RGBA PNG larger than the target", () => {
|
||||
it("#when called #then returns a smaller PNG with target dimensions", () => {
|
||||
//#given
|
||||
const sourcePng = createValidRgbaPng(2000, 1500)
|
||||
|
||||
//#when
|
||||
const result = resizeImageFallback(sourcePng, "image/png", { width: 1568, height: 1176 })
|
||||
|
||||
//#then
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.original).toEqual({ width: 2000, height: 1500 })
|
||||
expect(result?.resized).toEqual({ width: 1568, height: 1176 })
|
||||
|
||||
const parsed = parseImageDimensions(result!.resizedDataUrl, "image/png")
|
||||
expect(parsed).toEqual({ width: 1568, height: 1176 })
|
||||
})
|
||||
|
||||
it("#when target is much smaller #then produces a valid PNG decodable by parser", () => {
|
||||
//#given
|
||||
const sourcePng = createValidRgbaPng(800, 800)
|
||||
|
||||
//#when
|
||||
const result = resizeImageFallback(sourcePng, "image/png", { width: 100, height: 100 })
|
||||
|
||||
//#then
|
||||
expect(result).not.toBeNull()
|
||||
const parsed = parseImageDimensions(result!.resizedDataUrl, "image/png")
|
||||
expect(parsed).toEqual({ width: 100, height: 100 })
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given a non-PNG mime type", () => {
|
||||
it("#when called #then returns null", () => {
|
||||
//#given
|
||||
const sourcePng = createValidRgbaPng(2000, 1500)
|
||||
|
||||
//#when
|
||||
const result = resizeImageFallback(sourcePng, "image/jpeg", { width: 1568, height: 1176 })
|
||||
|
||||
//#then
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given an invalid PNG buffer", () => {
|
||||
it("#when called #then returns null", () => {
|
||||
//#given
|
||||
const invalidPng = "data:image/png;base64,AAAA"
|
||||
|
||||
//#when
|
||||
const result = resizeImageFallback(invalidPng, "image/png", { width: 100, height: 100 })
|
||||
|
||||
//#then
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given empty base64 data", () => {
|
||||
it("#when called #then returns null", () => {
|
||||
//#given
|
||||
const empty = "data:image/png;base64,"
|
||||
|
||||
//#when
|
||||
const result = resizeImageFallback(empty, "image/png", { width: 100, height: 100 })
|
||||
|
||||
//#then
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,359 @@
|
||||
import { inflateSync, deflateSync } from "node:zlib"
|
||||
|
||||
import type { ImageDimensions, ResizeResult } from "./types"
|
||||
import { extractBase64Data } from "../../tools/look-at/mime-type-inference"
|
||||
import { log } from "../../shared"
|
||||
|
||||
interface PngChunk {
|
||||
type: string
|
||||
data: Buffer
|
||||
crc: Buffer
|
||||
}
|
||||
|
||||
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
|
||||
|
||||
function readPngChunks(buffer: Buffer): PngChunk[] {
|
||||
const chunks: PngChunk[] = []
|
||||
let offset = 8
|
||||
|
||||
while (offset < buffer.length) {
|
||||
if (offset + 8 > buffer.length) {
|
||||
break
|
||||
}
|
||||
|
||||
const length = buffer.readUInt32BE(offset)
|
||||
const type = buffer.toString("ascii", offset + 4, offset + 8)
|
||||
const dataStart = offset + 8
|
||||
const dataEnd = dataStart + length
|
||||
|
||||
if (dataEnd + 4 > buffer.length) {
|
||||
break
|
||||
}
|
||||
|
||||
const data = buffer.subarray(dataStart, dataEnd)
|
||||
const crc = buffer.subarray(dataEnd, dataEnd + 4)
|
||||
chunks.push({ type, data, crc })
|
||||
offset = dataEnd + 4
|
||||
}
|
||||
|
||||
return chunks
|
||||
}
|
||||
|
||||
function parseIhdr(data: Buffer): { width: number; height: number; bitDepth: number; colorType: number } | null {
|
||||
if (data.length < 13) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
width: data.readUInt32BE(0),
|
||||
height: data.readUInt32BE(4),
|
||||
bitDepth: data[8],
|
||||
colorType: data[9],
|
||||
}
|
||||
}
|
||||
|
||||
function getBytesPerPixel(colorType: number, bitDepth: number): number | null {
|
||||
const channels: Record<number, number> = {
|
||||
0: 1, // grayscale
|
||||
2: 3, // RGB
|
||||
4: 2, // grayscale + alpha
|
||||
6: 4, // RGBA
|
||||
}
|
||||
|
||||
const channelCount = channels[colorType]
|
||||
if (channelCount === undefined) {
|
||||
return null
|
||||
}
|
||||
|
||||
return channelCount * (bitDepth / 8)
|
||||
}
|
||||
|
||||
function paethPredictor(a: number, b: number, c: number): number {
|
||||
const p = a + b - c
|
||||
const pa = Math.abs(p - a)
|
||||
const pb = Math.abs(p - b)
|
||||
const pc = Math.abs(p - c)
|
||||
|
||||
if (pa <= pb && pa <= pc) {
|
||||
return a
|
||||
}
|
||||
|
||||
if (pb <= pc) {
|
||||
return b
|
||||
}
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
function unfilterRow(
|
||||
filterType: number,
|
||||
currentRow: Buffer,
|
||||
previousRow: Buffer | null,
|
||||
bytesPerPixel: number,
|
||||
): Buffer {
|
||||
const result = Buffer.alloc(currentRow.length)
|
||||
|
||||
for (let i = 0; i < currentRow.length; i++) {
|
||||
const raw = currentRow[i]
|
||||
const a = i >= bytesPerPixel ? result[i - bytesPerPixel] : 0
|
||||
const b = previousRow ? previousRow[i] : 0
|
||||
const c = i >= bytesPerPixel && previousRow ? previousRow[i - bytesPerPixel] : 0
|
||||
|
||||
switch (filterType) {
|
||||
case 0:
|
||||
result[i] = raw
|
||||
break
|
||||
case 1:
|
||||
result[i] = (raw + a) & 0xff
|
||||
break
|
||||
case 2:
|
||||
result[i] = (raw + b) & 0xff
|
||||
break
|
||||
case 3:
|
||||
result[i] = (raw + Math.floor((a + b) / 2)) & 0xff
|
||||
break
|
||||
case 4:
|
||||
result[i] = (raw + paethPredictor(a, b, c)) & 0xff
|
||||
break
|
||||
default:
|
||||
result[i] = raw
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function decodePngPixels(
|
||||
idatData: Buffer,
|
||||
width: number,
|
||||
height: number,
|
||||
bytesPerPixel: number,
|
||||
): Buffer | null {
|
||||
try {
|
||||
const decompressed = inflateSync(idatData)
|
||||
const rowBytes = width * bytesPerPixel
|
||||
const expectedLength = height * (rowBytes + 1)
|
||||
|
||||
if (decompressed.length < expectedLength) {
|
||||
return null
|
||||
}
|
||||
|
||||
const pixels = Buffer.alloc(width * height * bytesPerPixel)
|
||||
let previousRow: Buffer | null = null
|
||||
|
||||
for (let y = 0; y < height; y++) {
|
||||
const rowStart = y * (rowBytes + 1)
|
||||
const filterType = decompressed[rowStart]
|
||||
const filteredRow = decompressed.subarray(rowStart + 1, rowStart + 1 + rowBytes)
|
||||
const unfilteredRow = unfilterRow(filterType, filteredRow, previousRow, bytesPerPixel)
|
||||
|
||||
unfilteredRow.copy(pixels, y * rowBytes)
|
||||
previousRow = unfilteredRow
|
||||
}
|
||||
|
||||
return pixels
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function nearestNeighborResize(
|
||||
sourcePixels: Buffer,
|
||||
srcWidth: number,
|
||||
srcHeight: number,
|
||||
dstWidth: number,
|
||||
dstHeight: number,
|
||||
bytesPerPixel: number,
|
||||
): Buffer {
|
||||
const destPixels = Buffer.alloc(dstWidth * dstHeight * bytesPerPixel)
|
||||
|
||||
for (let dstY = 0; dstY < dstHeight; dstY++) {
|
||||
const srcY = Math.min(Math.floor((dstY * srcHeight) / dstHeight), srcHeight - 1)
|
||||
|
||||
for (let dstX = 0; dstX < dstWidth; dstX++) {
|
||||
const srcX = Math.min(Math.floor((dstX * srcWidth) / dstWidth), srcWidth - 1)
|
||||
const srcOffset = (srcY * srcWidth + srcX) * bytesPerPixel
|
||||
const dstOffset = (dstY * dstWidth + dstX) * bytesPerPixel
|
||||
|
||||
for (let b = 0; b < bytesPerPixel; b++) {
|
||||
destPixels[dstOffset + b] = sourcePixels[srcOffset + b]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return destPixels
|
||||
}
|
||||
|
||||
function encodePng(
|
||||
pixels: Buffer,
|
||||
width: number,
|
||||
height: number,
|
||||
bitDepth: number,
|
||||
colorType: number,
|
||||
bytesPerPixel: number,
|
||||
): Buffer {
|
||||
const rowBytes = width * bytesPerPixel
|
||||
const filteredData = Buffer.alloc(height * (rowBytes + 1))
|
||||
|
||||
for (let y = 0; y < height; y++) {
|
||||
const rowOffset = y * (rowBytes + 1)
|
||||
filteredData[rowOffset] = 0
|
||||
pixels.copy(filteredData, rowOffset + 1, y * rowBytes, (y + 1) * rowBytes)
|
||||
}
|
||||
|
||||
const compressedData = deflateSync(filteredData)
|
||||
|
||||
const ihdrData = Buffer.alloc(13)
|
||||
ihdrData.writeUInt32BE(width, 0)
|
||||
ihdrData.writeUInt32BE(height, 4)
|
||||
ihdrData[8] = bitDepth
|
||||
ihdrData[9] = colorType
|
||||
ihdrData[10] = 0
|
||||
ihdrData[11] = 0
|
||||
ihdrData[12] = 0
|
||||
|
||||
const ihdrChunk = createChunk("IHDR", ihdrData)
|
||||
const idatChunk = createChunk("IDAT", compressedData)
|
||||
const iendChunk = createChunk("IEND", Buffer.alloc(0))
|
||||
|
||||
return Buffer.concat([PNG_SIGNATURE, ihdrChunk, idatChunk, iendChunk])
|
||||
}
|
||||
|
||||
function createChunk(type: string, data: Buffer): Buffer {
|
||||
const typeBuffer = Buffer.from(type, "ascii")
|
||||
const lengthBuffer = Buffer.alloc(4)
|
||||
lengthBuffer.writeUInt32BE(data.length, 0)
|
||||
|
||||
const crcInput = Buffer.concat([typeBuffer, data])
|
||||
const crc = crc32(crcInput)
|
||||
const crcBuffer = Buffer.alloc(4)
|
||||
crcBuffer.writeUInt32BE(crc >>> 0, 0)
|
||||
|
||||
return Buffer.concat([lengthBuffer, typeBuffer, data, crcBuffer])
|
||||
}
|
||||
|
||||
const CRC_TABLE = buildCrcTable()
|
||||
|
||||
function buildCrcTable(): Uint32Array {
|
||||
const table = new Uint32Array(256)
|
||||
|
||||
for (let n = 0; n < 256; n++) {
|
||||
let c = n
|
||||
|
||||
for (let k = 0; k < 8; k++) {
|
||||
if (c & 1) {
|
||||
c = 0xedb88320 ^ (c >>> 1)
|
||||
} else {
|
||||
c = c >>> 1
|
||||
}
|
||||
}
|
||||
|
||||
table[n] = c
|
||||
}
|
||||
|
||||
return table
|
||||
}
|
||||
|
||||
function crc32(data: Buffer): number {
|
||||
let crc = 0xffffffff
|
||||
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
crc = CRC_TABLE[(crc ^ data[i]) & 0xff] ^ (crc >>> 8)
|
||||
}
|
||||
|
||||
return (crc ^ 0xffffffff) >>> 0
|
||||
}
|
||||
|
||||
export function resizeImageFallback(
|
||||
base64DataUrl: string,
|
||||
mimeType: string,
|
||||
target: ImageDimensions,
|
||||
): ResizeResult | null {
|
||||
if (mimeType.toLowerCase() !== "image/png") {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const rawBase64 = extractBase64Data(base64DataUrl)
|
||||
if (!rawBase64) {
|
||||
return null
|
||||
}
|
||||
|
||||
const inputBuffer = Buffer.from(rawBase64, "base64")
|
||||
if (inputBuffer.length < 8) {
|
||||
return null
|
||||
}
|
||||
|
||||
const signature = inputBuffer.subarray(0, 8)
|
||||
if (!signature.equals(PNG_SIGNATURE)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const chunks = readPngChunks(inputBuffer)
|
||||
const ihdrChunk = chunks.find((c) => c.type === "IHDR")
|
||||
if (!ihdrChunk) {
|
||||
return null
|
||||
}
|
||||
|
||||
const ihdr = parseIhdr(ihdrChunk.data)
|
||||
if (!ihdr) {
|
||||
return null
|
||||
}
|
||||
|
||||
const bytesPerPixel = getBytesPerPixel(ihdr.colorType, ihdr.bitDepth)
|
||||
if (!bytesPerPixel) {
|
||||
log("[png-fallback-resizer] unsupported color type or bit depth", {
|
||||
colorType: ihdr.colorType,
|
||||
bitDepth: ihdr.bitDepth,
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
if (ihdr.bitDepth !== 8) {
|
||||
log("[png-fallback-resizer] only 8-bit depth supported for fallback", {
|
||||
bitDepth: ihdr.bitDepth,
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
const idatChunks = chunks.filter((c) => c.type === "IDAT")
|
||||
if (idatChunks.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const idatData = Buffer.concat(idatChunks.map((c) => c.data))
|
||||
const sourcePixels = decodePngPixels(idatData, ihdr.width, ihdr.height, bytesPerPixel)
|
||||
if (!sourcePixels) {
|
||||
return null
|
||||
}
|
||||
|
||||
const resizedPixels = nearestNeighborResize(
|
||||
sourcePixels,
|
||||
ihdr.width,
|
||||
ihdr.height,
|
||||
target.width,
|
||||
target.height,
|
||||
bytesPerPixel,
|
||||
)
|
||||
|
||||
const outputBuffer = encodePng(
|
||||
resizedPixels,
|
||||
target.width,
|
||||
target.height,
|
||||
ihdr.bitDepth,
|
||||
ihdr.colorType,
|
||||
bytesPerPixel,
|
||||
)
|
||||
|
||||
return {
|
||||
resizedDataUrl: `data:image/png;base64,${outputBuffer.toString("base64")}`,
|
||||
original: { width: ihdr.width, height: ihdr.height },
|
||||
resized: { width: target.width, height: target.height },
|
||||
}
|
||||
} catch (error) {
|
||||
log("[png-fallback-resizer] resize failed", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -345,6 +345,54 @@ describeFn("executeBackgroundTask output/session metadata compatibility", () =>
|
||||
expectFn(result).toContain("interrupt")
|
||||
})
|
||||
|
||||
testFn("reports failure when manager marks task as error during session startup", async () => {
|
||||
//#given - session created but startTask throws before prompt is sent
|
||||
const metadataCalls: any[] = []
|
||||
let reads = 0
|
||||
const manager = {
|
||||
launch: async () => ({
|
||||
id: "bg_crash_before_prompt",
|
||||
sessionID: undefined,
|
||||
description: "Crash before prompt",
|
||||
agent: "explore",
|
||||
status: "pending",
|
||||
}),
|
||||
getTask: () => {
|
||||
reads += 1
|
||||
if (reads >= 2) {
|
||||
return { sessionID: "ses_orphan", status: "error", error: "crash between session creation and prompt send" }
|
||||
}
|
||||
return { sessionID: undefined, status: "pending" }
|
||||
},
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = await executeBackgroundTask(
|
||||
{
|
||||
description: "Crash before prompt",
|
||||
prompt: "check",
|
||||
run_in_background: true,
|
||||
load_skills: [],
|
||||
},
|
||||
{
|
||||
sessionID: "ses_parent",
|
||||
callID: "call_crash",
|
||||
metadata: async (value: any) => metadataCalls.push(value),
|
||||
abort: new AbortController().signal,
|
||||
},
|
||||
{ manager },
|
||||
{ sessionID: "ses_parent", messageID: "msg_crash" },
|
||||
"explore",
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
)
|
||||
|
||||
//#then - polling loop should detect terminal status and report failure
|
||||
expectFn(result).toContain("Task failed to start")
|
||||
expectFn(result).toContain("error")
|
||||
})
|
||||
|
||||
testFn("keeps sibling background launch alive when two tasks start concurrently", async () => {
|
||||
//#given - one aborted parent call should not interrupt a sibling launch from the same parent session
|
||||
const firstAbortController = new AbortController()
|
||||
|
||||
Reference in New Issue
Block a user