Merge remote-tracking branch 'origin/main' into fazxes/zig-0.17-upgrade

# Conflicts:
#	src/core/images/png_downscale.zig
This commit is contained in:
Pranit committed 2026-10-05 21:52:33 -04:00
commit 08ee83b6a8
9 files changed
+460 -713

No files matched your search

+6 -1
View File
@@ -230,6 +230,11 @@ jobs:
with:
node-version: "24.18.0"
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.4.2"
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
pattern: libfx-native-*
@@ -276,7 +281,7 @@ jobs:
const names = new Set(report.files.map(({ path }) => path));
const required = [
"package.json", "README.md", "LICENSE", "browser.js", "node.js", "node.cjs", "fx-sdk.js", "wasm-module.js",
"core-output.js", "mcp.js", "skills.js", "skills-node.js",
"core-output.js", "fetch-cleanup.js", "mcp.js", "skills.js", "skills-node.js",
"fx-core.wasm", "fx-term.wasm",
"libfx.linux-x64.node", "libfx.linux-arm64.node",
"libfx.darwin-x64.node", "libfx.darwin-arm64.node",
+2
View File
@@ -118,6 +118,8 @@ Use `/ultrafast on`, `/ultrafast off`, or `/ultrafast status` in the shell. The
Ultra mode is available only through the Vercel AI Gateway's OpenAI service tier. Gateway metadata currently marks Astra eligible. fx does not select Ultra automatically, and switching models clears an existing Ultra request. Subagents inherit the parent turn's request; an explicit parent disable and capability checks override an existing child preference. Background side calls, including titles, reviews, and compaction, do not use Ultra mode.
ACP clients get the same per-session choice as the shell. The config options from `session/new`, `session/load`, `session/resume`, and `session/set_config_option` include `fast` only for models with a Fast lane and `ultrafast` only for Ultra-eligible models, each with the values `false` and `true`. Turning one on turns the other off, enabling a lane the model lacks returns an error, and switching models turns off a lane the new model lacks. The choice is saved with the session. An unknown config option id returns an error.
## Gateway provider routing
When the active model goes through the Vercel AI Gateway, one model is often served by several providers (for example Anthropic directly, AWS Bedrock, or Google Vertex). fx can tell the gateway which providers to use, in what order:
+26 -2
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env node
import { strict as assert } from "node:assert";
import { spawnSync } from "node:child_process";
import { cp, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { cp, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
@@ -24,6 +24,13 @@ function run(command, args, options = {}) {
}
try {
const workflow = await readFile(resolve(repoRoot, ".github/workflows/publish-libfx.yml"), "utf8");
const packageJob = workflow.split("\n package:\n")[1]?.split("\n publish:\n")[0];
assert.ok(packageJob, "publisher package job must exist");
const bunSetup = packageJob.indexOf("uses: oven-sh/setup-bun@");
const assembly = packageJob.indexOf("- name: Assemble package");
assert.ok(bunSetup >= 0 && bunSetup < assembly, "publisher must install Bun before building the CommonJS entry");
run(process.execPath, [resolve(repoRoot, "sdk/scripts/package-libfx.mjs"), packageDir]);
const manifest = JSON.parse(await readFile(join(packageDir, "package.json"), "utf8"));
@@ -39,6 +46,23 @@ try {
assert.equal(manifest.exports["./skills"], "./skills.js");
assert.equal(manifest.exports["./skills/node"], "./skills-node.js");
const archiveValidator = packageJob.match(/- name: Validate package archive\n\s+run: \|\n\s+node -e '([\s\S]*?)'/)?.[1];
assert.ok(archiveValidator, "publisher archive validation must exist");
const reportPath = join(temp, "libfx-pack.json");
const archiveFiles = new Set(await readdir(packageDir));
for (const platform of ["linux-x64", "linux-arm64", "darwin-x64", "darwin-arm64"]) {
archiveFiles.add(`libfx.${platform}.node`);
}
const archiveReport = [{ name: manifest.name, version: manifest.version, size: 0, files: [...archiveFiles].map(path => ({ path })) }];
await writeFile(reportPath, JSON.stringify(archiveReport));
run(process.execPath, ["-e", archiveValidator.replace("/tmp/libfx-pack.json", reportPath)]);
archiveReport[0].files = archiveReport[0].files.filter(({ path }) => path !== "fetch-cleanup.js");
await writeFile(reportPath, JSON.stringify(archiveReport));
assert.throws(
() => run(process.execPath, ["-e", archiveValidator.replace("/tmp/libfx-pack.json", reportPath)]),
/unexpected package file count|package is missing/,
);
const cjs = await readFile(join(packageDir, "node.cjs"), "utf8");
assert.doesNotMatch(cjs, new RegExp(repoRoot.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
for (const platform of ["linux-x64", "linux-arm64", "darwin-x64", "darwin-arm64"]) {
@@ -93,7 +117,7 @@ try {
const esm = run(process.execPath, ["esm.mjs"], { cwd: consumerDir });
const cjsResult = run(process.execPath, ["--no-experimental-require-module", "cjs.cjs"], { cwd: consumerDir });
assert.deepEqual(JSON.parse(cjsResult.stdout), JSON.parse(esm.stdout));
console.log("Node package passed: conditional exports, relocated CJS, literal native assets, and bare imports");
console.log("Node package passed: publisher prerequisites, archive validation, conditional exports, relocated CJS, literal native assets, and bare imports");
} finally {
await rm(temp, { recursive: true, force: true });
}
+184 -4
View File
@@ -2793,6 +2793,7 @@ fn handleSetConfigOption(state: *ServerState, alloc: Allocator, msg: *jsonrpc.Me
"Failed to persist session model",
});
};
if (!try dropUnsupportedSpeeds(state, alloc, msg, session)) return;
} else if (std.mem.eql(u8, config_id, "provider")) {
const parsed_provider = model_provider.parse(value) orelse
return state.writer.writeError(alloc, msg.id, .{
@@ -2918,6 +2919,7 @@ fn handleSetConfigOption(state: *ServerState, alloc: Allocator, msg: *jsonrpc.Me
session.api_key = &.{};
session.account_id = null;
}
if (!try dropUnsupportedSpeeds(state, alloc, msg, session)) return;
}
} else if (std.mem.eql(u8, config_id, "mode")) {
if (state.active_session) |*session| {
@@ -2936,6 +2938,17 @@ fn handleSetConfigOption(state: *ServerState, alloc: Allocator, msg: *jsonrpc.Me
.message = "Ultrafast mode must be true or false",
});
if (!try applyActiveSessionUltrafast(state, alloc, msg, session, ultrafast)) return;
} else if (std.mem.eql(u8, config_id, "fast")) {
const session = if (state.active_session) |*active| active else return state.writer.writeError(alloc, msg.id, .{
.code = ErrorCode.invalid_request,
.message = "No active session",
});
const fast = parseConfigBool(value) orelse
return state.writer.writeError(alloc, msg.id, .{
.code = ErrorCode.invalid_params,
.message = "Fast mode must be true or false",
});
if (!try applyActiveSessionFast(state, alloc, msg, session, fast)) return;
} else if (std.mem.eql(u8, config_id, "effort")) {
const session = if (state.active_session) |*active| active else return state.writer.writeError(alloc, msg.id, .{
.code = ErrorCode.invalid_request,
@@ -2963,6 +2976,11 @@ fn handleSetConfigOption(state: *ServerState, alloc: Allocator, msg: *jsonrpc.Me
.message = "Failed to persist session effort",
});
};
} else {
return state.writer.writeError(alloc, msg.id, .{
.code = ErrorCode.invalid_params,
.message = "Unknown config option",
});
}
try refreshModelCatalogForOptions(state);
@@ -2991,6 +3009,10 @@ fn handleSetConfigOption(state: *ServerState, alloc: Allocator, msg: *jsonrpc.Me
try out.writer.writeAll(",");
try sessions.writeEffortConfigOption(&out.writer, config.efforts, config.current);
}
if (sessions.fastConfigState(state)) |current| {
try out.writer.writeAll(",");
try sessions.writeFastConfigOption(&out.writer, current);
}
if (sessions.ultrafastConfigState(state)) |current| {
try out.writer.writeAll(",");
try sessions.writeUltrafastConfigOption(&out.writer, current);
@@ -3185,17 +3207,175 @@ fn applyActiveSessionUltrafast(
});
return false;
}
// Fast and Ultrafast are exclusive, as in the CLI.
if (session.fast_mode and !try storeSessionFast(state, alloc, msg, session, false)) return false;
}
return storeSessionUltrafast(state, alloc, msg, session, ultrafast);
}
/// Saves the session's ultrafast preference without admission checks.
/// Returns false after writing the error response.
fn storeSessionUltrafast(
state: *ServerState,
alloc: Allocator,
msg: *jsonrpc.Message,
session: *ActiveSessionState,
ultrafast: bool,
) !bool {
if (state.cfg.minimal_kernel and session.writable == null and session.v2 == null and session.wasm_state == null) {
session.ultrafast_mode = ultrafast;
} else {
commitActiveSessionUltrafast(alloc, session, ultrafast) catch {
return true;
}
commitActiveSessionUltrafast(alloc, session, ultrafast) catch {
try state.writer.writeError(alloc, msg.id, .{
.code = ErrorCode.internal_error,
.message = "Failed to persist session ultrafast preference",
});
return false;
};
return true;
}
/// Applies a Fast-mode transition to the active session, mirroring the CLI's
/// `/fast`: enabling requires the model's Fast lane and turns Ultrafast off;
/// disabling always succeeds. Returns false after writing the error response.
fn applyActiveSessionFast(
state: *ServerState,
alloc: Allocator,
msg: *jsonrpc.Message,
session: *ActiveSessionState,
fast: bool,
) !bool {
if (fast) {
if (!activeSessionCapabilities(state, session).supports_fast_mode) {
const message = try std.fmt.allocPrint(alloc, "Fast mode is not available for model \"{s}\"", .{session.model});
defer alloc.free(message);
try state.writer.writeError(alloc, msg.id, .{
.code = ErrorCode.internal_error,
.message = "Failed to persist session ultrafast preference",
.code = ErrorCode.invalid_params,
.message = message,
.data = .{
.code = "LIBFX_MODEL_UNSUPPORTED_FAST",
.model = session.model,
.capability = "fast",
},
});
return false;
}
if (session.ultrafast_mode and !try storeSessionUltrafast(state, alloc, msg, session, false)) return false;
}
return storeSessionFast(state, alloc, msg, session, fast);
}
/// Saves the session's fast preference without admission checks.
/// Returns false after writing the error response.
fn storeSessionFast(
state: *ServerState,
alloc: Allocator,
msg: *jsonrpc.Message,
session: *ActiveSessionState,
fast: bool,
) !bool {
if (state.cfg.minimal_kernel and session.writable == null and session.v2 == null and session.wasm_state == null) {
session.fast_mode = fast;
return true;
}
commitActiveSessionFast(alloc, session, fast) catch {
try state.writer.writeError(alloc, msg.id, .{
.code = ErrorCode.internal_error,
.message = "Failed to persist session fast preference",
});
return false;
};
return true;
}
fn commitActiveSessionFast(alloc: Allocator, session: *ActiveSessionState, fast: bool) !void {
if (host_target.is_wasm and session.writable == null) {
const previous = session.fast_mode;
session.fast_mode = fast;
sessions.commitWasmSession(alloc, session) catch |err| {
session.fast_mode = previous;
return err;
};
return;
}
session.session_write_mutex.lockUncancelable(io_mod.getIo());
defer session.session_write_mutex.unlock(io_mod.getIo());
if (session.v2) |v2| {
var preferences = try v2.currentPreferences(alloc);
defer preferences.deinit(alloc);
preferences.fast_mode = fast;
try v2.setPreferences(preferences);
} else {
const writable = if (session.writable) |*active| active else return error.SessionPersistenceUnavailable;
_ = try writable.appendEvent(
alloc,
.{ .preferences_changed = .{ .fast_mode = fast } },
io_mod.milliTimestamp(),
);
}
session.fast_mode = fast;
}
fn activeSessionCapabilities(state: *ServerState, session: *const ActiveSessionState) model_capabilities.Capabilities {
const bundle = state.cfg.provider_set.select(session.provider);
return state.capability_resolver.available(session.model, bundle.fallbackModelCapabilities(session.model));
}
const SessionSpeed = struct {
fast: bool,
ultrafast: bool,
};
/// Pure policy: the lanes a session keeps after switching to a model with
/// `capabilities` under `provider`. Ultrafast is a Gateway-only lane.
fn speedSupportedBy(
speed: SessionSpeed,
provider: model_provider.ProviderId,
capabilities: model_capabilities.Capabilities,
) SessionSpeed {
return .{
.fast = speed.fast and capabilities.supports_fast_mode,
.ultrafast = speed.ultrafast and provider == .gateway and capabilities.supports_ultrafast_mode,
};
}
test "speedSupportedBy keeps only the lanes the new model offers" {
const both = model_capabilities.Capabilities{ .supports_fast_mode = true, .supports_ultrafast_mode = true };
const fast_only = model_capabilities.Capabilities{ .supports_fast_mode = true };
const plain = model_capabilities.Capabilities{};
const on: SessionSpeed = .{ .fast = true, .ultrafast = true };
try std.testing.expectEqual(on, speedSupportedBy(on, .gateway, both));
try std.testing.expectEqual(SessionSpeed{ .fast = true, .ultrafast = false }, speedSupportedBy(on, .gateway, fast_only));
try std.testing.expectEqual(SessionSpeed{ .fast = false, .ultrafast = false }, speedSupportedBy(on, .gateway, plain));
try std.testing.expectEqual(SessionSpeed{ .fast = true, .ultrafast = false }, speedSupportedBy(on, .codex, both));
const standard: SessionSpeed = .{ .fast = false, .ultrafast = false };
try std.testing.expectEqual(standard, speedSupportedBy(standard, .gateway, both));
}
/// After a model or provider switch, turns off any lane the new model lacks,
/// like the CLI model picker. A hidden lane would otherwise stay on: Ultrafast
/// would fail the next prompt and Fast would return on a later switch back.
/// Returns false after writing the error response.
fn dropUnsupportedSpeeds(
state: *ServerState,
alloc: Allocator,
msg: *jsonrpc.Message,
session: *ActiveSessionState,
) !bool {
const kept = speedSupportedBy(
.{ .fast = session.fast_mode, .ultrafast = session.ultrafast_mode },
session.provider,
activeSessionCapabilities(state, session),
);
if (session.ultrafast_mode and !kept.ultrafast) {
debug_trace.logf("acp", "ultrafast mode turned off: model={s} does not offer it", .{session.model});
if (!try storeSessionUltrafast(state, alloc, msg, session, false)) return false;
}
if (session.fast_mode and !kept.fast) {
debug_trace.logf("acp", "fast mode turned off: model={s} does not offer it", .{session.model});
if (!try storeSessionFast(state, alloc, msg, session, false)) return false;
}
return true;
}
+43
View File
@@ -604,6 +604,10 @@ fn writeNewSessionResponse(
try out.writer.writeAll(",");
try writeEffortConfigOption(&out.writer, config.efforts, config.current);
}
if (fastConfigState(state)) |current| {
try out.writer.writeAll(",");
try writeFastConfigOption(&out.writer, current);
}
if (ultrafastConfigState(state)) |current| {
try out.writer.writeAll(",");
try writeUltrafastConfigOption(&out.writer, current);
@@ -1264,6 +1268,10 @@ fn writeLoadSessionResponse(
try out.writer.writeAll(",");
try writeEffortConfigOption(&out.writer, config.efforts, config.current);
}
if (fastConfigState(state)) |current| {
try out.writer.writeAll(",");
try writeFastConfigOption(&out.writer, current);
}
if (ultrafastConfigState(state)) |current| {
try out.writer.writeAll(",");
try writeUltrafastConfigOption(&out.writer, current);
@@ -2365,6 +2373,41 @@ pub fn effortConfigState(state: *server.ServerState) ?EffortConfigState {
return .{ .efforts = capabilities.reasoning_efforts, .current = active.effort };
}
/// Active-session Fast selector state. Like the CLI's speed picker, the option
/// is exposed only when the active model offers a Fast lane.
pub fn fastConfigState(state: *server.ServerState) ?bool {
const active = if (state.active_session) |*session| session else return null;
const bundle = state.cfg.provider_set.select(active.provider);
const capabilities = state.capability_resolver.available(
active.model,
bundle.fallbackModelCapabilities(active.model),
);
if (!capabilities.supports_fast_mode) return null;
return active.fast_mode;
}
pub fn writeFastConfigOption(w: *std.Io.Writer, current: bool) !void {
try w.writeAll("{\"id\":\"fast\",\"name\":\"Fast Mode\",\"description\":\"Uses the model's fast lane\",\"category\":\"model\",\"type\":\"select\",\"currentValue\":");
try writeJsonStr(if (current) "true" else "false", w);
try w.writeAll(",\"options\":[{\"value\":\"false\",\"name\":\"off\"},{\"value\":\"true\",\"name\":\"on\"}]}");
}
test "writeFastConfigOption produces an off/on select with the current value" {
const alloc = std.testing.allocator;
var out: std.Io.Writer.Allocating = .init(alloc);
defer out.deinit();
try writeFastConfigOption(&out.writer, true);
const parsed = try std.json.parseFromSlice(std.json.Value, alloc, out.writer.buffered(), .{});
defer parsed.deinit();
const option = parsed.value.object;
try std.testing.expectEqualStrings("fast", option.get("id").?.string);
try std.testing.expectEqualStrings("true", option.get("currentValue").?.string);
const choices = option.get("options").?.array.items;
try std.testing.expectEqual(@as(usize, 2), choices.len);
try std.testing.expectEqualStrings("false", choices[0].object.get("value").?.string);
try std.testing.expectEqualStrings("true", choices[1].object.get("value").?.string);
}
/// Active-session ultrafast selector state. The option is exposed only for a
/// verified Gateway catalog model with the explicit capability.
pub fn ultrafastConfigState(state: *server.ServerState) ?bool {
+1 -4
View File
@@ -3265,14 +3265,11 @@ test "capture keeps the original when a PNG conversion would grow" {
try std.testing.expectEqual(@as(usize, 1), try countSnapshotFiles(snapshot_dir));
}
test "in-memory capture keeps the original when its downscaled copy is over the byte limit" {
test "in-memory capture keeps the original of a palette PNG over 2000 pixels" {
const alloc = std.testing.allocator;
const png = try png_downscale.testRandomPalettePng(alloc, 2001, 1300);
defer alloc.free(png);
try std.testing.expect(image_data.fitsEncodedImageLimit(png.len));
const smaller = (try png_downscale.downscaleOversized(alloc, "image/png", png)).?;
defer alloc.free(smaller.png);
try std.testing.expect(!image_data.fitsEncodedImageLimit(smaller.png.len));
const attachment = try captureInlineImageBytesInMemory(alloc, 1, "image/png", png);
defer types.freeImageAttachment(alloc, attachment);
-21
View File
@@ -190,29 +190,8 @@ pub const Dimensions = struct {
pub fn exceeds(self: Dimensions, limit: u32) bool {
return self.width > limit or self.height > limit;
}
pub fn exceedsModelLimit(self: Dimensions) bool {
return self.exceeds(max_image_dimension);
}
};
/// Writes the model-visible note for an image downscaled from `original` to
/// `smaller`, including the factor that maps its coordinates back.
pub fn writeDownscaledNotice(writer: *std.Io.Writer, original: Dimensions, smaller: Dimensions) std.Io.Writer.Error!void {
try writer.print(
"[Image downscaled from {d}x{d} to {d}x{d} pixels to fit the {d}-pixel limit per side. Multiply coordinates in this image by {d:.2} to get original pixels.]\n",
.{ original.width, original.height, smaller.width, smaller.height, max_image_dimension, coordinateScale(original, smaller) },
);
}
/// Ratio from downscaled to original pixels along the longer side.
fn coordinateScale(original: Dimensions, smaller: Dimensions) f64 {
if (original.width >= original.height) {
return @as(f64, @floatFromInt(original.width)) / @as(f64, @floatFromInt(smaller.width));
}
return @as(f64, @floatFromInt(original.height)) / @as(f64, @floatFromInt(smaller.height));
}
/// Reads pixel dimensions from a PNG, JPEG, GIF, or WebP header without
/// decoding pixels. Returns null for unsupported, truncated, or malformed
/// headers, including a JPEG whose frame header lies beyond `bytes`.
+4 -681
View File
@@ -1,72 +1,11 @@
//! Shrinks PNG images so neither side exceeds a limit. Decoding streams one
//! source row at a time through a box filter, so working memory stays
//! proportional to the image width; only the smaller output is materialized.
//! Test fixtures that encode decodable PNG images from raw scanlines.
const std = @import("std");
const debug_trace = @import("../shared/debug_trace.zig");
const image_data = @import("image_data.zig");
const Allocator = std.mem.Allocator;
const flate = std.compress.flate;
/// Largest accepted source side. Bounds row buffers.
const max_source_side: u32 = 16384;
/// Largest accepted source pixel count, the largest image a provider accepts.
/// Bounds decompression work for inputs that claim huge dimensions.
const max_source_pixels: u64 = 8000 * 8000;
const png_signature = "\x89PNG\r\n\x1a\n";
const Error = Allocator.Error || error{ InvalidPng, UnsupportedPng };
/// Whether `downscale` accepts images of `media_type`.
pub fn supportsMediaType(media_type: []const u8) bool {
return std.mem.eql(u8, media_type, "image/png");
}
/// Shrinks `bytes` to the model pixel limit when it is a PNG over that limit.
/// Returns null for other formats, images within the limit, and PNGs this
/// decoder cannot handle, so callers can fall back. The result is owned by
/// `alloc`.
pub fn downscaleOversized(alloc: Allocator, media_type: []const u8, bytes: []const u8) Allocator.Error!?Downscaled {
if (!supportsMediaType(media_type)) return null;
const dimensions = image_data.imageDimensions(bytes) orelse return null;
if (!dimensions.exceedsModelLimit()) return null;
return downscale(alloc, bytes, image_data.max_image_dimension) catch |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory,
error.InvalidPng, error.UnsupportedPng => {
debug_trace.logf("images", "event=png_downscale_skipped reason={s} width={d} height={d}", .{ @errorName(err), dimensions.width, dimensions.height });
return null;
},
};
}
pub const Downscaled = struct {
/// PNG bytes owned by the allocator passed to `downscale`.
png: []u8,
width: u32,
height: u32,
};
/// Returns a copy of `png` scaled down, preserving aspect ratio, so neither
/// side exceeds `max_side`. Malformed input returns `error.InvalidPng`;
/// interlaced or oversized input returns `error.UnsupportedPng`.
fn downscale(alloc: Allocator, png: []const u8, max_side: u32) Error!Downscaled {
std.debug.assert(max_side > 0);
const parsed = try parse(alloc, png);
defer alloc.free(parsed.idat);
const header = parsed.header;
const target = targetSize(header.width, header.height, max_side);
const channels = outputChannels(header.color_type, parsed.transparency.len > 0);
var encoder = try RowEncoder.init(alloc, target.width, channels);
defer encoder.deinit(alloc);
try encoder.start();
try decodeInto(alloc, parsed, target, channels, &encoder);
const encoded = try encoder.finish(alloc, target.width, target.height, channels);
return .{ .png = encoded, .width = target.width, .height = target.height };
}
const ColorType = enum(u8) {
gray = 0,
rgb = 2,
@@ -75,270 +14,6 @@ const ColorType = enum(u8) {
rgba = 6,
};
const Header = struct {
width: u32,
height: u32,
bit_depth: u8,
color_type: ColorType,
};
const Parsed = struct {
header: Header,
/// Borrowed from the input PNG.
palette: []const u8,
/// Borrowed from the input PNG.
transparency: []const u8,
/// Concatenated IDAT payload, owned by the `downscale` allocator.
idat: []u8,
};
const Size = struct { width: u32, height: u32 };
fn parse(alloc: Allocator, png: []const u8) Error!Parsed {
if (png.len < png_signature.len or !std.mem.eql(u8, png[0..png_signature.len], png_signature)) return error.InvalidPng;
var offset: usize = png_signature.len;
var header: ?Header = null;
var palette: []const u8 = &.{};
var transparency: []const u8 = &.{};
var idat: std.ArrayList(u8) = .empty;
errdefer idat.deinit(alloc);
while (true) {
// Length, type, and CRC framing need 12 bytes around the payload.
if (png.len - offset < 12) return error.InvalidPng;
const length = std.mem.readInt(u32, png[offset..][0..4], .big);
const kind = png[offset + 4 ..][0..4];
const data_start = offset + 8;
if (length > png.len - data_start - 4) return error.InvalidPng;
const data = png[data_start..][0..length];
offset = data_start + length + 4;
if (header == null) {
if (!std.mem.eql(u8, kind, "IHDR")) return error.InvalidPng;
header = try parseHeader(data);
} else if (std.mem.eql(u8, kind, "PLTE")) {
palette = data;
} else if (std.mem.eql(u8, kind, "tRNS")) {
transparency = data;
} else if (std.mem.eql(u8, kind, "IDAT")) {
try idat.appendSlice(alloc, data);
} else if (std.mem.eql(u8, kind, "IEND")) {
break;
}
}
const parsed_header = header.?;
if (parsed_header.color_type == .palette) {
if (palette.len == 0 or palette.len % 3 != 0) return error.InvalidPng;
} else {
// tRNS only carries per-entry alpha for palette images; a single
// transparent color key for gray or RGB is rendered opaque.
transparency = &.{};
}
if (idat.items.len == 0) return error.InvalidPng;
return .{
.header = parsed_header,
.palette = palette,
.transparency = transparency,
.idat = try idat.toOwnedSlice(alloc),
};
}
fn parseHeader(data: []const u8) Error!Header {
if (data.len != 13) return error.InvalidPng;
const width = std.mem.readInt(u32, data[0..4], .big);
const height = std.mem.readInt(u32, data[4..8], .big);
const bit_depth = data[8];
const color_type = std.enums.fromInt(ColorType, data[9]) orelse return error.InvalidPng;
if (width == 0 or height == 0) return error.InvalidPng;
if (data[10] != 0 or data[11] != 0) return error.InvalidPng;
switch (data[12]) {
0 => {},
1 => return error.UnsupportedPng,
else => return error.InvalidPng,
}
if (width > max_source_side or height > max_source_side) return error.UnsupportedPng;
if (@as(u64, width) * height > max_source_pixels) return error.UnsupportedPng;
const depth_valid = switch (color_type) {
.gray => bit_depth == 1 or bit_depth == 2 or bit_depth == 4 or bit_depth == 8 or bit_depth == 16,
.palette => bit_depth == 1 or bit_depth == 2 or bit_depth == 4 or bit_depth == 8,
.rgb, .gray_alpha, .rgba => bit_depth == 8 or bit_depth == 16,
};
if (!depth_valid) return error.InvalidPng;
return .{ .width = width, .height = height, .bit_depth = bit_depth, .color_type = color_type };
}
fn samplesPerPixel(color_type: ColorType) u32 {
return switch (color_type) {
.gray, .palette => 1,
.gray_alpha => 2,
.rgb => 3,
.rgba => 4,
};
}
fn outputChannels(color_type: ColorType, has_palette_alpha: bool) u32 {
return switch (color_type) {
.gray => 1,
.gray_alpha => 2,
.rgb => 3,
.rgba => 4,
.palette => if (has_palette_alpha) 4 else 3,
};
}
fn outputColorType(channels: u32) u8 {
return switch (channels) {
1 => @backingInt(ColorType.gray),
2 => @backingInt(ColorType.gray_alpha),
3 => @backingInt(ColorType.rgb),
4 => @backingInt(ColorType.rgba),
else => unreachable,
};
}
/// Scales the longer side to `max_side` and rounds the shorter side, keeping
/// at least one pixel. Sizes already within the limit are unchanged.
fn targetSize(width: u32, height: u32, max_side: u32) Size {
if (width <= max_side and height <= max_side) return .{ .width = width, .height = height };
if (width >= height) {
const scaled = (@as(u64, height) * max_side + width / 2) / width;
return .{ .width = max_side, .height = @intCast(@max(1, scaled)) };
}
const scaled = (@as(u64, width) * max_side + height / 2) / height;
return .{ .width = @intCast(@max(1, scaled)), .height = max_side };
}
fn decodeInto(alloc: Allocator, parsed: Parsed, target: Size, channels: u32, encoder: *RowEncoder) Error!void {
const header = parsed.header;
const bits_per_pixel = samplesPerPixel(header.color_type) * header.bit_depth;
const row_bytes: usize = @intCast((@as(u64, header.width) * bits_per_pixel + 7) / 8);
const filter_stride: usize = @max(1, bits_per_pixel / 8);
var current = try alloc.alloc(u8, row_bytes);
defer alloc.free(current);
var previous = try alloc.alloc(u8, row_bytes);
defer alloc.free(previous);
@memset(previous, 0);
const pixels = try alloc.alloc(u8, @as(usize, header.width) * channels);
defer alloc.free(pixels);
const column_map = try alloc.alloc(u32, header.width);
defer alloc.free(column_map);
for (column_map, 0..) |*column, x| column.* = @intCast(@as(u64, x) * target.width / header.width);
// A box can cover the whole source, so sums need more than 32 bits.
const sums = try alloc.alloc(u64, @as(usize, target.width) * channels);
defer alloc.free(sums);
@memset(sums, 0);
const counts = try alloc.alloc(u32, target.width);
defer alloc.free(counts);
@memset(counts, 0);
const window = try alloc.alloc(u8, flate.max_window_len);
defer alloc.free(window);
var input: std.Io.Reader = .fixed(parsed.idat);
const decompress = try alloc.create(flate.Decompress);
defer alloc.destroy(decompress);
decompress.* = .init(&input, .zlib, window);
var target_row: u32 = 0;
for (0..header.height) |y| {
const filter = decompress.reader.takeByte() catch return error.InvalidPng;
decompress.reader.readSliceAll(current) catch return error.InvalidPng;
try unfilter(filter, current, previous, filter_stride);
expandRow(parsed, current, pixels);
const row: u32 = @intCast(@as(u64, y) * target.height / header.height);
if (row != target_row) {
try encoder.writeAveragedRow(sums, counts, channels);
target_row = row;
}
for (column_map, 0..) |column, x| {
counts[column] += 1;
for (0..channels) |channel| sums[column * channels + channel] += pixels[x * channels + channel];
}
std.mem.swap([]u8, &current, &previous);
}
try encoder.writeAveragedRow(sums, counts, channels);
}
fn paeth(left: u8, above: u8, upper_left: u8) u8 {
const estimate = @as(i16, left) + above - upper_left;
const to_left = @abs(estimate - left);
const to_above = @abs(estimate - above);
const to_upper_left = @abs(estimate - upper_left);
if (to_left <= to_above and to_left <= to_upper_left) return left;
if (to_above <= to_upper_left) return above;
return upper_left;
}
fn unfilter(filter: u8, row: []u8, previous: []const u8, stride: usize) Error!void {
switch (filter) {
0 => {},
1 => for (stride..row.len) |i| {
row[i] +%= row[i - stride];
},
2 => for (row, previous) |*byte, above| {
byte.* +%= above;
},
3 => for (row, 0..) |*byte, i| {
const left: u16 = if (i >= stride) row[i - stride] else 0;
byte.* +%= @intCast((left + previous[i]) / 2);
},
4 => for (row, 0..) |*byte, i| {
const left = if (i >= stride) row[i - stride] else 0;
const upper_left = if (i >= stride) previous[i - stride] else 0;
byte.* +%= paeth(left, previous[i], upper_left);
},
else => return error.InvalidPng,
}
}
/// Reads sample `index` of a row packed at `bit_depth` bits per sample.
fn packedSample(row: []const u8, index: usize, bit_depth: u8) u8 {
const bit = index * bit_depth;
const shift: u3 = @intCast(8 - bit_depth - bit % 8);
const mask: u8 = @intCast((@as(u16, 1) << @intCast(bit_depth)) - 1);
return (row[bit / 8] >> shift) & mask;
}
/// Expands one unfiltered row to 8-bit output channels.
fn expandRow(parsed: Parsed, row: []const u8, pixels: []u8) void {
const header = parsed.header;
const width: usize = header.width;
switch (header.color_type) {
.palette => {
const with_alpha = parsed.transparency.len > 0;
const channels: usize = if (with_alpha) 4 else 3;
for (0..width) |x| {
const index: usize = if (header.bit_depth == 8) row[x] else packedSample(row, x, header.bit_depth);
const out = pixels[x * channels ..][0..channels];
if (index * 3 + 2 < parsed.palette.len) {
@memcpy(out[0..3], parsed.palette[index * 3 ..][0..3]);
} else {
@memset(out[0..3], 0);
}
if (with_alpha) out[3] = if (index < parsed.transparency.len) parsed.transparency[index] else 255;
}
},
.gray => if (header.bit_depth < 8) {
const max_value: u16 = (@as(u16, 1) << @intCast(header.bit_depth)) - 1;
for (0..width) |x| {
pixels[x] = @intCast(@as(u16, packedSample(row, x, header.bit_depth)) * 255 / max_value);
}
} else {
copyHighBytes(row, pixels, header.bit_depth);
},
.rgb, .gray_alpha, .rgba => copyHighBytes(row, pixels, header.bit_depth),
}
}
/// Copies 8-bit samples, or the most significant byte of 16-bit samples.
fn copyHighBytes(row: []const u8, pixels: []u8, bit_depth: u8) void {
if (bit_depth == 8) {
@memcpy(pixels, row[0..pixels.len]);
return;
}
for (pixels, 0..) |*sample, i| sample.* = row[i * 2];
}
fn writeChunk(writer: *std.Io.Writer, kind: *const [4]u8, data: []const u8) std.Io.Writer.Error!void {
var length: [4]u8 = undefined;
std.mem.writeInt(u32, &length, @intCast(data.len), .big);
@@ -353,166 +28,6 @@ fn writeChunk(writer: *std.Io.Writer, kind: *const [4]u8, data: []const u8) std.
try writer.writeAll(&checksum);
}
/// Filters and compresses output rows as the box filter completes them.
const RowEncoder = struct {
idat: std.Io.Writer.Allocating,
window: []u8,
compress: *flate.Compress,
row: []u8,
previous: []u8,
filtered: []u8,
fn init(alloc: Allocator, width: u32, channels: u32) Error!RowEncoder {
const row_len = @as(usize, width) * channels;
var idat = try std.Io.Writer.Allocating.initCapacity(alloc, 64 * 1024);
errdefer idat.deinit();
const window = try alloc.alloc(u8, flate.max_window_len);
errdefer alloc.free(window);
const compress = try alloc.create(flate.Compress);
errdefer alloc.destroy(compress);
const row = try alloc.alloc(u8, row_len);
errdefer alloc.free(row);
const previous = try alloc.alloc(u8, row_len);
errdefer alloc.free(previous);
@memset(previous, 0);
const filtered = try alloc.alloc(u8, row_len);
errdefer alloc.free(filtered);
return .{
.idat = idat,
.window = window,
.compress = compress,
.row = row,
.previous = previous,
.filtered = filtered,
};
}
fn deinit(self: *RowEncoder, alloc: Allocator) void {
self.idat.deinit();
alloc.free(self.window);
alloc.destroy(self.compress);
alloc.free(self.row);
alloc.free(self.previous);
alloc.free(self.filtered);
}
/// Starts the zlib stream. The compressor keeps a pointer to `idat`, so
/// call this once the encoder is at its final address.
fn start(self: *RowEncoder) Error!void {
self.compress.* = flate.Compress.init(&self.idat.writer, self.window, .zlib, .default) catch return error.OutOfMemory;
}
/// Emits the averaged output row, then clears the accumulators.
fn writeAveragedRow(self: *RowEncoder, sums: []u64, counts: []u32, channels: u32) Error!void {
for (counts, 0..) |count, column| {
for (0..channels) |channel| {
const index = column * channels + channel;
self.row[index] = if (count == 0) 0 else @intCast((sums[index] + count / 2) / count);
}
}
@memset(sums, 0);
@memset(counts, 0);
const filter = chooseFilter(self.row, self.previous, channels, self.filtered);
self.compress.writer.writeByte(filter) catch return error.OutOfMemory;
self.compress.writer.writeAll(self.filtered) catch return error.OutOfMemory;
std.mem.swap([]u8, &self.row, &self.previous);
}
fn finish(self: *RowEncoder, alloc: Allocator, width: u32, height: u32, channels: u32) Error![]u8 {
self.compress.finish() catch return error.OutOfMemory;
var ihdr: [13]u8 = undefined;
std.mem.writeInt(u32, ihdr[0..4], width, .big);
std.mem.writeInt(u32, ihdr[4..8], height, .big);
ihdr[8] = 8;
ihdr[9] = outputColorType(channels);
@memset(ihdr[10..13], 0);
const idat = self.idat.written();
var out = try std.Io.Writer.Allocating.initCapacity(alloc, png_signature.len + idat.len + 3 * 12 + ihdr.len);
errdefer out.deinit();
out.writer.writeAll(png_signature) catch return error.OutOfMemory;
writeChunk(&out.writer, "IHDR", &ihdr) catch return error.OutOfMemory;
writeChunk(&out.writer, "IDAT", idat) catch return error.OutOfMemory;
writeChunk(&out.writer, "IEND", "") catch return error.OutOfMemory;
return out.toOwnedSlice();
}
};
fn filteredByte(filter: u8, row: []const u8, previous: []const u8, stride: usize, i: usize) u8 {
const left: u8 = if (i >= stride) row[i - stride] else 0;
const upper_left: u8 = if (i >= stride) previous[i - stride] else 0;
return switch (filter) {
0 => row[i],
1 => row[i] -% left,
2 => row[i] -% previous[i],
3 => row[i] -% @as(u8, @intCast((@as(u16, left) + previous[i]) / 2)),
4 => row[i] -% paeth(left, previous[i], upper_left),
else => unreachable,
};
}
/// Picks the filter with the smallest sum of signed residuals, the usual PNG
/// encoder heuristic, and writes the filtered row into `out`.
fn chooseFilter(row: []const u8, previous: []const u8, stride: usize, out: []u8) u8 {
var best_filter: u8 = 0;
var best_cost: u64 = std.math.maxInt(u64);
for (0..5) |candidate| {
const filter: u8 = @intCast(candidate);
var cost: u64 = 0;
for (0..row.len) |i| {
const residual: i8 = @bitCast(filteredByte(filter, row, previous, stride, i));
cost += @abs(residual);
}
if (cost < best_cost) {
best_cost = cost;
best_filter = filter;
}
}
for (out, 0..) |*byte, i| byte.* = filteredByte(best_filter, row, previous, stride, i);
return best_filter;
}
// Tests
const TestImage = struct {
width: u32,
height: u32,
channels: u32,
pixels: []u8,
fn deinit(self: TestImage, alloc: Allocator) void {
alloc.free(self.pixels);
}
};
/// Decodes a PNG produced by `downscale` back to 8-bit pixels.
fn testDecode(alloc: Allocator, png: []const u8) !TestImage {
const parsed = try parse(alloc, png);
defer alloc.free(parsed.idat);
const header = parsed.header;
try std.testing.expectEqual(@as(u8, 8), header.bit_depth);
const channels = samplesPerPixel(header.color_type);
const row_bytes = @as(usize, header.width) * channels;
const pixels = try alloc.alloc(u8, row_bytes * header.height);
errdefer alloc.free(pixels);
const previous = try alloc.alloc(u8, row_bytes);
defer alloc.free(previous);
@memset(previous, 0);
const window = try alloc.alloc(u8, flate.max_window_len);
defer alloc.free(window);
var input: std.Io.Reader = .fixed(parsed.idat);
const decompress = try alloc.create(flate.Decompress);
defer alloc.destroy(decompress);
decompress.* = .init(&input, .zlib, window);
for (0..header.height) |y| {
const row = pixels[y * row_bytes ..][0..row_bytes];
const filter = try decompress.reader.takeByte();
try decompress.reader.readSliceAll(row);
try unfilter(filter, row, previous, channels);
@memcpy(previous, row);
}
return .{ .width = header.width, .height = header.height, .channels = channels, .pixels = pixels };
}
/// Encodes raw scanlines (each already prefixed with its filter byte).
fn testEncode(alloc: Allocator, width: u32, height: u32, color_type: ColorType, bit_depth: u8, scanlines: []const u8, extra_chunks: []const [2][]const u8) ![]u8 {
var idat = try std.Io.Writer.Allocating.initCapacity(alloc, 1024);
@@ -561,9 +76,9 @@ pub fn testSolidGrayPng(alloc: Allocator, width: u32, height: u32, value: u8) ![
return testEncode(alloc, width, height, .gray, 8, scanlines, &.{});
}
/// Test fixture: an 8-bit palette PNG of random pixels. Its downscaled copy
/// stores each pixel as three RGB bytes instead of one palette index, so the
/// copy is larger than the source.
/// Test fixture: an 8-bit palette PNG of random pixels. A copy converted to
/// RGB stores three bytes per pixel instead of one palette index, so it is
/// larger than the source.
pub fn testRandomPalettePng(alloc: Allocator, width: u32, height: u32) ![]u8 {
var prng = std.Random.DefaultPrng.init(0x2000);
const random = prng.random();
@@ -591,195 +106,3 @@ fn testSolidScanlines(alloc: Allocator, width: u32, height: u32, pixel: []const
}
return scanlines;
}
test "downscale averages source pixels into each output pixel" {
const alloc = std.testing.allocator;
const scanlines = [_]u8{
0, 0, 100, 200, 250,
0, 10, 110, 210, 240,
};
const png = try testEncode(alloc, 4, 2, .gray, 8, &scanlines, &.{});
defer alloc.free(png);
const result = try downscale(alloc, png, 2);
defer alloc.free(result.png);
try std.testing.expectEqual(Size{ .width = 2, .height = 1 }, Size{ .width = result.width, .height = result.height });
const decoded = try testDecode(alloc, result.png);
defer decoded.deinit(alloc);
try std.testing.expectEqualSlices(u8, &.{ 55, 225 }, decoded.pixels);
}
test "downscale keeps the aspect ratio of a full-resolution frame" {
const alloc = std.testing.allocator;
const scanlines = try testSolidScanlines(alloc, 3420, 2224, &.{ 30, 144, 255 });
defer alloc.free(scanlines);
const png = try testEncode(alloc, 3420, 2224, .rgb, 8, scanlines, &.{});
defer alloc.free(png);
const result = try downscale(alloc, png, 2000);
defer alloc.free(result.png);
try std.testing.expectEqual(@as(u32, 2000), result.width);
try std.testing.expectEqual(@as(u32, 1301), result.height);
const decoded = try testDecode(alloc, result.png);
defer decoded.deinit(alloc);
try std.testing.expectEqual(@as(u32, 3), decoded.channels);
try std.testing.expectEqualSlices(u8, &.{ 30, 144, 255 }, decoded.pixels[0..3]);
try std.testing.expectEqualSlices(u8, &.{ 30, 144, 255 }, decoded.pixels[decoded.pixels.len - 3 ..]);
}
test "downscale reverses every scanline filter" {
const alloc = std.testing.allocator;
// Two RGBA pixels per row; each row uses a different filter over the
// same target pixels, so every reconstructed row must be identical.
const target = [_]u8{ 10, 20, 30, 255, 200, 100, 50, 128 };
var scanlines: [5 * 9]u8 = undefined;
var previous: [8]u8 = @splat(0);
for (0..5) |filter| {
const row = scanlines[filter * 9 ..][0..9];
row[0] = @intCast(filter);
for (0..8) |i| row[1 + i] = filteredByte(@intCast(filter), &target, &previous, 4, i);
previous = target;
}
const png = try testEncode(alloc, 2, 5, .rgba, 8, &scanlines, &.{});
defer alloc.free(png);
const result = try downscale(alloc, png, 16);
defer alloc.free(result.png);
const decoded = try testDecode(alloc, result.png);
defer decoded.deinit(alloc);
try std.testing.expectEqual(@as(u32, 5), decoded.height);
for (0..5) |row| try std.testing.expectEqualSlices(u8, &target, decoded.pixels[row * 8 ..][0..8]);
}
test "downscale expands palette, transparency, sub-byte gray, and 16-bit samples" {
const alloc = std.testing.allocator;
// Palette at 2 bits: indices 0,1,2,3 in one byte; index 3 has no entry.
const palette_png = try testEncode(alloc, 4, 1, .palette, 2, &.{ 0, 0b00_01_10_11 }, &.{
.{ "PLTE", "\xff\x00\x00\x00\xff\x00\x00\x00\xff" },
.{ "tRNS", "\x80" },
});
defer alloc.free(palette_png);
const palette_result = try downscale(alloc, palette_png, 8);
defer alloc.free(palette_result.png);
const palette_pixels = try testDecode(alloc, palette_result.png);
defer palette_pixels.deinit(alloc);
try std.testing.expectEqualSlices(u8, &.{
255, 0, 0, 128,
0, 255, 0, 255,
0, 0, 255, 255,
0, 0, 0, 255,
}, palette_pixels.pixels);
const gray_png = try testEncode(alloc, 4, 1, .gray, 2, &.{ 0, 0b00_01_10_11 }, &.{});
defer alloc.free(gray_png);
const gray_result = try downscale(alloc, gray_png, 8);
defer alloc.free(gray_result.png);
const gray_pixels = try testDecode(alloc, gray_result.png);
defer gray_pixels.deinit(alloc);
try std.testing.expectEqualSlices(u8, &.{ 0, 85, 170, 255 }, gray_pixels.pixels);
const deep_png = try testEncode(alloc, 1, 1, .rgb, 16, &.{ 0, 0x12, 0x34, 0xab, 0xcd, 0xfe, 0xdc }, &.{});
defer alloc.free(deep_png);
const deep_result = try downscale(alloc, deep_png, 8);
defer alloc.free(deep_result.png);
const deep_pixels = try testDecode(alloc, deep_result.png);
defer deep_pixels.deinit(alloc);
try std.testing.expectEqualSlices(u8, &.{ 0x12, 0xab, 0xfe }, deep_pixels.pixels);
}
test "downscale rejects malformed and unsupported images" {
const alloc = std.testing.allocator;
const scanlines = [_]u8{ 0, 1, 2, 3, 4 };
const png = try testEncode(alloc, 4, 1, .gray, 8, &scanlines, &.{});
defer alloc.free(png);
try std.testing.expectError(error.InvalidPng, downscale(alloc, "not a png", 2));
try std.testing.expectError(error.InvalidPng, downscale(alloc, png[0 .. png.len - 13], 2));
const bad_filter = try testEncode(alloc, 4, 1, .gray, 8, &.{ 9, 1, 2, 3, 4 }, &.{});
defer alloc.free(bad_filter);
try std.testing.expectError(error.InvalidPng, downscale(alloc, bad_filter, 2));
const short_data = try testEncode(alloc, 4, 2, .gray, 8, &scanlines, &.{});
defer alloc.free(short_data);
try std.testing.expectError(error.InvalidPng, downscale(alloc, short_data, 2));
const interlaced = try alloc.dupe(u8, png);
defer alloc.free(interlaced);
interlaced[png_signature.len + 8 + 12] = 1;
try std.testing.expectError(error.UnsupportedPng, downscale(alloc, interlaced, 2));
const huge = try alloc.dupe(u8, png);
defer alloc.free(huge);
std.mem.writeInt(u32, huge[png_signature.len + 8 ..][0..4], max_source_side + 1, .big);
try std.testing.expectError(error.UnsupportedPng, downscale(alloc, huge, 2));
const palette_without_entries = try testEncode(alloc, 4, 1, .palette, 8, &scanlines, &.{});
defer alloc.free(palette_without_entries);
try std.testing.expectError(error.InvalidPng, downscale(alloc, palette_without_entries, 2));
}
test "downscale stays bounded on arbitrary bytes" {
const alloc = std.testing.allocator;
const png = try testEncode(alloc, 4, 2, .gray, 8, &.{ 0, 0, 100, 200, 250, 0, 10, 110, 210, 240 }, &.{});
defer alloc.free(png);
const bad_filter = try testEncode(alloc, 4, 2, .gray, 8, &.{ 9, 0, 100, 200, 250, 0, 10, 110, 210, 240 }, &.{});
defer alloc.free(bad_filter);
const interlaced = try alloc.dupe(u8, png);
defer alloc.free(interlaced);
interlaced[png_signature.len + 8 + 12] = 1;
const huge = try alloc.dupe(u8, png);
defer alloc.free(huge);
@memset(huge[png_signature.len + 8 ..][0..8], 0xff);
// Signature, IHDR, and the start of IDAT, cut mid-stream.
const truncated = png[0 .. png.len - 20];
try std.testing.fuzz(png, fuzzDownscale, .{ .corpus = &.{ png, bad_filter, interlaced, huge, truncated } });
}
test "downscale stays bounded on mutated PNGs" {
const alloc = std.testing.allocator;
const gray = try testEncode(alloc, 4, 2, .gray, 8, &.{ 0, 0, 100, 200, 250, 0, 10, 110, 210, 240 }, &.{});
defer alloc.free(gray);
const palette = try testEncode(alloc, 3, 2, .palette, 2, &.{ 1, 0b00011011, 4, 0b11100100 }, &.{
.{ "PLTE", "\x00\x00\x00\xff\x00\x00\x00\xff\x00\x00\x00\xff" },
.{ "tRNS", "\x00\x80" },
});
defer alloc.free(palette);
const rgba = try testEncode(alloc, 2, 2, .rgba, 16, &([_]u8{3} ++ @as([16]u8, @splat(0x40)) ++ [_]u8{2} ++ @as([16]u8, @splat(0x90))), &.{});
defer alloc.free(rgba);
const seeds = [_][]const u8{ gray, palette, rgba };
var prng = std.Random.DefaultPrng.init(0x1049);
const random = prng.random();
var buffer: [256]u8 = undefined;
for (seeds) |seed| std.debug.assert(seed.len <= buffer.len);
for (0..3000) |round| {
const seed = seeds[round % seeds.len];
const bytes = buffer[0..seed.len];
@memcpy(bytes, seed);
// Leave the signature intact so mutations reach chunk parsing and inflate.
const flips = 1 + random.uintLessThan(usize, 4);
for (0..flips) |_| bytes[png_signature.len + random.uintLessThan(usize, seed.len - png_signature.len)] = random.int(u8);
const len = png_signature.len + random.uintAtMost(usize, seed.len - png_signature.len);
try fuzzDownscaleBytes(bytes[0..len]);
}
}
fn fuzzDownscale(_: []const u8, smith: *std.testing.Smith) !void {
var bytes: [2048]u8 = undefined;
const len: usize = @intCast(smith.slice(&bytes));
try fuzzDownscaleBytes(bytes[0..len]);
}
/// Any input either fails as malformed or unsupported, or yields a decodable
/// PNG within the requested bound.
fn fuzzDownscaleBytes(bytes: []const u8) !void {
const result = downscale(std.testing.allocator, bytes, 2) catch |err| switch (err) {
error.InvalidPng, error.UnsupportedPng => return,
error.OutOfMemory => return err,
};
defer std.testing.allocator.free(result.png);
try std.testing.expect(result.width <= 2 and result.height <= 2);
const decoded = try testDecode(std.testing.allocator, result.png);
defer decoded.deinit(std.testing.allocator);
try std.testing.expectEqual(result.width, decoded.width);
}
+194
View File
@@ -1304,3 +1304,197 @@ describe("ultrafast fake Gateway", () => {
);
});
});
const FAST_ONLY_MODEL = "provider/fast-lane-model";
const PLAIN_MODEL = "provider/plain-model";
function fastOnlyCatalogModel() {
return {
id: FAST_ONLY_MODEL,
type: "language" as const,
owned_by: "provider",
tags: ["tool-use"],
pricing: { fast: { input: "0.00003", output: "0.00015" } },
};
}
function plainCatalogModel() {
return { id: PLAIN_MODEL, type: "language" as const, owned_by: "provider", tags: ["tool-use"] };
}
function speedCatalog() {
return [ultrafastCatalogModel(), fastOnlyCatalogModel(), plainCatalogModel()];
}
function expectFastRequest(body: string) {
expectStandardRequest(body);
const request = JSON.parse(body) as { providerOptions?: { gateway?: { speed?: string } } };
expect(request.providerOptions?.gateway?.speed).toBe("fast");
}
function expectNoLaneRequest(body: string) {
expectStandardRequest(body);
const request = JSON.parse(body) as { providerOptions?: { gateway?: { speed?: string } } };
expect(request.providerOptions?.gateway?.speed).toBeUndefined();
}
function speedOptions(result: { configOptions?: Array<{ id: string; currentValue?: string }> } | undefined) {
const options = result?.configOptions ?? [];
const value = (id: string) => options.find((option) => option.id === id)?.currentValue ?? "absent";
return { fast: value("fast"), ultrafast: value("ultrafast") };
}
describe("ACP speed options", () => {
test(
"lists Fast and Ultrafast only for models that offer them and keeps them exclusive",
async () => {
const root = createIsolatedRoot();
const gateway = startFakeGateway(
[fakeGatewayFinalText("fast answer"), fakeGatewayFinalText("ultra answer"), fakeGatewayFinalText("plain answer")],
{ models: speedCatalog() },
);
const client = AcpClient.create(root.workspace, fakeGatewayEnv(root, gateway));
try {
await client.request("initialize", { protocolVersion: 1 }, 1);
const created = await client.request("session/new", { mcpServers: [] }, 2);
const sessionId = created.result.sessionId as string;
expect(speedOptions(created.result)).toEqual({ fast: "false", ultrafast: "false" });
const set = (id: number, configId: string, value: string) =>
client.request("session/set_config_option", { sessionId, configId, value }, id);
expect(speedOptions((await set(3, "fast", "true")).result)).toEqual({ fast: "true", ultrafast: "false" });
await client.prompt(sessionId, "Use Fast.", 4);
expectFastRequest(gateway.requests[0]!.body);
expect(speedOptions((await set(5, "ultrafast", "true")).result)).toEqual({ fast: "false", ultrafast: "true" });
await client.prompt(sessionId, "Use Ultrafast.", 6);
expectUltrafastRequest(gateway.requests[1]!.body);
expect(speedOptions((await set(7, "fast", "true")).result)).toEqual({ fast: "true", ultrafast: "false" });
expect(speedOptions((await set(8, "model", FAST_ONLY_MODEL)).result)).toEqual({ fast: "true", ultrafast: "absent" });
expect(speedOptions((await set(9, "model", PLAIN_MODEL)).result)).toEqual({ fast: "absent", ultrafast: "absent" });
await client.prompt(sessionId, "Plain model.", 10);
expectNoLaneRequest(gateway.requests[2]!.body);
expect(speedOptions((await set(11, "model", FAST_ONLY_MODEL)).result)).toEqual({ fast: "false", ultrafast: "absent" });
expect(gateway.requests).toHaveLength(3);
} finally {
try {
await client.close();
} finally {
gateway.stop();
rmSync(root.root, { recursive: true, force: true });
}
}
},
TIMEOUT,
);
test(
"rejects unsupported lanes and unknown config ids without changing the session",
async () => {
const root = createIsolatedRoot();
const gateway = startFakeGateway([fakeGatewayFinalText("standard answer")], { models: speedCatalog() });
const client = AcpClient.create(root.workspace, fakeGatewayEnv(root, gateway, { FX_MODEL: PLAIN_MODEL }));
try {
const sessionId = await startAcpSession(client);
const fast = await client.request("session/set_config_option", { sessionId, configId: "fast", value: "true" }, 3);
expect(fast.error).toMatchObject({
code: -32602,
data: { code: "LIBFX_MODEL_UNSUPPORTED_FAST", model: PLAIN_MODEL, capability: "fast" },
});
const off = await client.request("session/set_config_option", { sessionId, configId: "fast", value: "false" }, 4);
expect(off.error).toBeUndefined();
const unknown = await client.request("session/set_config_option", { sessionId, configId: "speed", value: "fast" }, 5);
expect(unknown.error).toMatchObject({ code: -32602, message: "Unknown config option" });
const invalid = await client.request("session/set_config_option", { sessionId, configId: "fast", value: "on" }, 6);
expect(invalid.error).toMatchObject({ code: -32602 });
expect(gateway.requests).toEqual([]);
await client.prompt(sessionId, "Still standard.", 7);
expectNoLaneRequest(gateway.requests[0]!.body);
} finally {
try {
await client.close();
} finally {
gateway.stop();
rmSync(root.root, { recursive: true, force: true });
}
}
},
TIMEOUT,
);
test(
"turns Ultrafast off when switching to a model without it so the next prompt runs",
async () => {
const root = createIsolatedRoot();
const gateway = startFakeGateway([fakeGatewayFinalText("plain after ultra")], { models: speedCatalog() });
const client = AcpClient.create(root.workspace, fakeGatewayEnv(root, gateway));
try {
const sessionId = await startAcpSession(client);
const set = (id: number, configId: string, value: string) =>
client.request("session/set_config_option", { sessionId, configId, value }, id);
expect((await set(3, "ultrafast", "true")).error).toBeUndefined();
expect(speedOptions((await set(4, "model", PLAIN_MODEL)).result)).toEqual({ fast: "absent", ultrafast: "absent" });
await client.prompt(sessionId, "Run on the plain model.", 5);
expectNoLaneRequest(gateway.requests[0]!.body);
expect(speedOptions((await set(6, "model", ULTRA_MODEL)).result)).toEqual({ fast: "false", ultrafast: "false" });
} finally {
try {
await client.close();
} finally {
gateway.stop();
rmSync(root.root, { recursive: true, force: true });
}
}
},
TIMEOUT,
);
for (const backend of ["0", "1"]) {
test(
`saves Fast across resume with FX_SESSIONS_V2=${backend}`,
async () => {
const root = createIsolatedRoot({ models: { gateway: FAST_ONLY_MODEL } });
const gateway = startFakeGateway(
[fakeGatewayFinalText("saved fast"), fakeGatewayFinalText("resumed fast"), fakeGatewayFinalText("resumed standard")],
{ models: speedCatalog() },
);
const env = fakeGatewayEnv(root, gateway, { FX_MODEL: undefined, FX_SESSIONS_V2: backend });
let client = AcpClient.create(root.workspace, env);
try {
const sessionId = await startAcpSession(client);
const enabled = await client.request("session/set_config_option", { sessionId, configId: "fast", value: "true" }, 3);
expect(speedOptions(enabled.result)).toEqual({ fast: "true", ultrafast: "absent" });
await client.prompt(sessionId, "Save Fast.", 4);
expectFastRequest(gateway.requests[0]!.body);
await client.close();
client = AcpClient.create(root.workspace, env);
expect((await client.request("initialize", { protocolVersion: 1 }, 1)).error).toBeUndefined();
const resumed = await client.request("session/resume", { sessionId, mcpServers: [] }, 2);
expect(speedOptions(resumed.result)).toEqual({ fast: "true", ultrafast: "absent" });
await client.prompt(sessionId, "Resume Fast.", 3);
expectFastRequest(gateway.requests[1]!.body);
const disabled = await client.request("session/set_config_option", { sessionId, configId: "fast", value: "false" }, 4);
expect(disabled.error).toBeUndefined();
await client.close();
client = AcpClient.create(root.workspace, env);
expect((await client.request("initialize", { protocolVersion: 1 }, 1)).error).toBeUndefined();
const standard = await client.request("session/resume", { sessionId, mcpServers: [] }, 2);
expect(speedOptions(standard.result)).toEqual({ fast: "false", ultrafast: "absent" });
await client.prompt(sessionId, "Resume standard.", 3);
expectNoLaneRequest(gateway.requests[2]!.body);
} finally {
try {
await client.close();
} finally {
gateway.stop();
rmSync(root.root, { recursive: true, force: true });
}
}
},
TIMEOUT,
);
}
});