From 6361912c53619220dc7e624be955a7881ed0896f Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Fri, 31 Jul 2026 10:59:54 -0700 Subject: [PATCH 1/7] Forward registered images across the renderer channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by running the full widget roster under the cutover: packets carry only image id + fingerprint references — the pixels ride the in-process side channel (uploadGpuSurfaceImage, ahead of the packet that references them), which never crossed the process boundary. A device-less Noro rendered everything except its album art: text, strokes, and controls intact, the entire art region blank (captures archived in the weaver repo's receipts directory). - kWeaverRendererMachMsgImageUpload: one registered image per message, pixels as an out-of-line descriptor, pixels_len == 0 is a removal. The 1 MiB ceiling mirrors canvas_limits' max_registered_canvas_image_pixel_bytes (stock profile; the widget profile's 256 KiB is enforced client-side by the SDK). The reply is the completion signal, same send/reply tripwires as frames. - One storage implementation for both worlds: the store/remove/ texture-for-key bodies move to shared statics; NativeSdkAppKitHost's methods delegate, and the headless per-client renderer gets its own mirror stores. The view reads through activeCanvasImageStore / activeTextureForImageKey so in-process and render-host paths cannot drift. - The device-less client forwards uploads and removals instead of storing locally (its raster paths never run); the host stores into the client's renderer, constructing it on the spot when an image precedes the first frame — the export completion now binds lazily so that order works. - Fixes the frame-budget contains-check left pinning the pre-review wall-clock pattern by #24 (would have failed the next weaver pin bump's CI). Live receipts (Mac15,6, noro-shell through weaverd with the cutover): before, art region blank (stddev 0 across the top half); after, full album art with Noro's grain overlay and scrim intact. Noro's widget process: 43.1 MB phys_footprint flat, ZERO owned-unmapped-graphics regions (in-process baseline ~155-166 MB with the 85 MB arena). test-canvas 841/841; all five shared-renderer contains-checks pass. --- build.zig | 20 +- src/platform/macos/appkit_host.m | 270 +++++++++++++++----- src/platform/macos/renderer_protocol_mach.h | 49 ++++ 3 files changed, 275 insertions(+), 64 deletions(-) diff --git a/build.zig b/build.zig index 9dd0745e..d5bc1a38 100644 --- a/build.zig +++ b/build.zig @@ -599,9 +599,27 @@ pub fn build(b: *std.Build) void { .{ .path = "src/platform/macos/appkit_host.m", .pattern = "FRAME BUDGET EXCEEDED pid=%d took=%llums budget=%llums" }, // Every frame is measured (trace-gated per-frame line for future // receipts), on both the export and static/refusal paths. - .{ .path = "src/platform/macos/appkit_host.m", .pattern = "client.frameRenderBeginNs = NativeSdkTimestampNanoseconds();" }, + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "client.frameRenderBeginNs = NativeSdkRenderHostMonotonicNanoseconds();" }, .{ .path = "src/platform/macos/appkit_host.m", .pattern = "weaver-render-host: frame-trace pid=%d" }, }); + addFileContainsCheckStep(b, file_contains_checker, test_step, "test-macos-shared-renderer-images", "Verify registered images cross the renderer channel ahead of their packets", &.{ + // The protocol message and its validator (pixels_len == 0 is a + // removal; the ceiling mirrors canvas_limits). + .{ .path = "src/platform/macos/renderer_protocol_mach.h", .pattern = "kWeaverRendererMachMsgImageUpload" }, + .{ .path = "src/platform/macos/renderer_protocol_mach.h", .pattern = "weaverRendererMachImageUploadValid" }, + .{ .path = "src/platform/macos/renderer_protocol_mach.h", .pattern = "kWeaverRendererMachMaxImageBytes = 1024 * 1024" }, + // One storage implementation for both worlds: the app host's + // stores in-process, the per-client headless stores in the host. + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "static BOOL NativeSdkCanvasStoreImage(" }, + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "- (NSMutableDictionary *)activeCanvasImageStore" }, + // The device-less client forwards uploads (and removals) instead + // of storing locally; the host stores into the client's renderer. + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "uploadImageWithId:image_id width:width height:height rgba8:rgba8 byteLength:rgba8_len" }, + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "NativeSdkRenderHostHandleImageUpload(strongClient, &message.image.upload);" }, + // An image arriving before the first frame constructs the + // renderer; the export completion binds lazily so that order works. + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "if (!client.renderer.headlessExportCompletion) {" }, + }); addFileContainsCheckStep(b, file_contains_checker, test_step, "test-macos-shared-renderer-client", "Verify the device-less widget client keeps the shared-renderer contract", &.{ // No Metal object is ever created in client mode; availability is // device-less by design. diff --git a/src/platform/macos/appkit_host.m b/src/platform/macos/appkit_host.m index 690d8d8b..bea46c5a 100644 --- a/src/platform/macos/appkit_host.m +++ b/src/platform/macos/appkit_host.m @@ -101,6 +101,10 @@ static NSEventModifierFlags NativeSdkMenuModifierFlags(uint32_t modifiers); static uint32_t NativeSdkModifierFlagsForEvent(NSEvent *event); static uint64_t NativeSdkTimestampNanoseconds(void); +static NSImage *NativeSdkCreateRGBA8Image(NSUInteger width, NSUInteger height, NSData *pixelData); +static BOOL NativeSdkCanvasStoreImage(uint64_t imageId, NSUInteger width, NSUInteger height, const uint8_t *rgba8, NSUInteger byteLength, NSMutableDictionary *imageStore, NSMutableDictionary *pixelStore, NSMutableDictionary *textureStore); +static void NativeSdkCanvasRemoveImage(uint64_t imageId, NSMutableDictionary *imageStore, NSMutableDictionary *pixelStore, NSMutableDictionary *textureStore); +static id NativeSdkCanvasTextureForImageKey(NSString *key, NSMutableDictionary *pixelStore, NSMutableDictionary *textureStore); static BOOL NativeSdkRendererBakeoffTraceEnabled(void); static uint64_t NativeSdkRetainedFrameIntervalNanoseconds(NSScreen *screen); static NSAccessibilityRole NativeSdkAccessibilityRoleForNativeViewKind(NSInteger kind); @@ -846,6 +850,66 @@ static BOOL NativeSdkSharedRendererClientEnabled(void) { * treated as a crash, with the reconnect path taking over. */ static const mach_msg_timeout_t NativeSdkSharedRendererReplyTimeoutMs = 5000; +/* The one implementation of registered-image storage, shared by the app + * host's stores (in-process rendering) and the render host's per-client + * headless stores. Copies the caller's bytes: the runtime's slot pool is + * reused on register/unregister, while the stored NSImage lives until + * the id is removed or replaced. */ +static BOOL NativeSdkCanvasStoreImage(uint64_t imageId, NSUInteger width, NSUInteger height, const uint8_t *rgba8, NSUInteger byteLength, NSMutableDictionary *imageStore, NSMutableDictionary *pixelStore, NSMutableDictionary *textureStore) { + if (imageId == 0 || !rgba8 || width == 0 || height == 0) return NO; + if (width > NSUIntegerMax / height || width * height > NSUIntegerMax / 4) return NO; + if (byteLength != width * height * 4) return NO; + if (!imageStore || !pixelStore || !textureStore) return NO; + NSData *pixelData = [NSData dataWithBytes:rgba8 length:byteLength]; + NSImage *image = NativeSdkCreateRGBA8Image(width, height, pixelData); + if (!image) return NO; + NSString *key = [NSString stringWithFormat:@"%llu", (unsigned long long)imageId]; + imageStore[key] = image; + pixelStore[key] = @{ + @"width" : @(width), + @"height" : @(height), + @"pixels" : pixelData, + }; + [textureStore removeObjectForKey:key]; + if (NativeSdkRendererBakeoffTraceEnabled()) { + fprintf(stderr, "native-sdk: renderer-bakeoff stage=image_upload bytes=%lu store_entries=%lu\n", + (unsigned long)byteLength, + (unsigned long)imageStore.count); + } + return YES; +} + +static void NativeSdkCanvasRemoveImage(uint64_t imageId, NSMutableDictionary *imageStore, NSMutableDictionary *pixelStore, NSMutableDictionary *textureStore) { + if (imageId == 0) return; + NSString *key = [NSString stringWithFormat:@"%llu", (unsigned long long)imageId]; + [imageStore removeObjectForKey:key]; + [pixelStore removeObjectForKey:key]; + [textureStore removeObjectForKey:key]; +} + +static id NativeSdkCanvasTextureForImageKey(NSString *key, NSMutableDictionary *pixelStore, NSMutableDictionary *textureStore) { + if (key.length == 0 || !pixelStore || !textureStore) return nil; + id cached = textureStore[key]; + if (cached) return cached; + NSDictionary *source = pixelStore[key]; + NSData *pixels = [source[@"pixels"] isKindOfClass:[NSData class]] ? source[@"pixels"] : nil; + NSUInteger width = [source[@"width"] unsignedIntegerValue]; + NSUInteger height = [source[@"height"] unsignedIntegerValue]; + if (!pixels || width == 0 || height == 0 || width > NSUIntegerMax / height || + width * height > NSUIntegerMax / 4 || pixels.length != width * height * 4) return nil; + NativeSdkMetalProcessResources *resources = [NativeSdkMetalProcessResources sharedResources]; + id device = resources.device; + if (!device) return nil; + MTLTextureDescriptor *descriptor = [MTLTextureDescriptor texture2DDescriptorWithPixelFormat:MTLPixelFormatRGBA8Unorm width:width height:height mipmapped:NO]; + descriptor.usage = MTLTextureUsageShaderRead; + descriptor.storageMode = MTLStorageModeShared; + id texture = [device newTextureWithDescriptor:descriptor]; + if (!texture) return nil; + [texture replaceRegion:MTLRegionMake2D(0, 0, width, height) mipmapLevel:0 withBytes:pixels.bytes bytesPerRow:width * 4]; + textureStore[key] = texture; + return texture; +} + /// The widget process's one connection to the render host. Connection is /// lazy (widgets launch safely while weaverd is still bringing the host /// up) and reconnects after a host crash: any mach failure tears the @@ -1038,6 +1102,51 @@ - (NSInteger)presentPacket:(const uint8_t *)packet return 1; } +/* Registered-image upload (or removal, rgba8 == NULL): rides the session + * ahead of the packets that reference the image, exactly like the + * in-process side channel. One reply per request, same tripwires as + * frames. */ +- (BOOL)uploadImageWithId:(uint64_t)imageId width:(NSUInteger)width height:(NSUInteger)height rgba8:(const uint8_t *)rgba8 byteLength:(NSUInteger)byteLength { + if (![self ensureConnected]) return NO; + WeaverRendererMachImageUpload upload = {0}; + upload.header.msgh_bits = MACH_MSGH_BITS(MACH_MSG_TYPE_COPY_SEND, MACH_MSG_TYPE_MAKE_SEND_ONCE); + upload.header.msgh_remote_port = self.sessionPort; + upload.header.msgh_local_port = self.replyPort; + upload.header.msgh_size = sizeof(upload); + upload.header.msgh_id = kWeaverRendererMachMsgImageUpload; + upload.magic = kWeaverRendererMachMagic; + upload.version = kWeaverRendererMachVersion; + upload.struct_size = sizeof(upload); + upload.image_id = imageId; + if (rgba8 && byteLength > 0) { + upload.header.msgh_bits |= MACH_MSGH_BITS_COMPLEX; + upload.body.msgh_descriptor_count = 1; + upload.pixels.address = (void *)rgba8; + upload.pixels.size = (mach_msg_size_t)byteLength; + upload.pixels.copy = MACH_MSG_VIRTUAL_COPY; + upload.pixels.deallocate = FALSE; + upload.pixels.type = MACH_MSG_OOL_DESCRIPTOR; + upload.pixels_len = (uint32_t)byteLength; + upload.width = (uint32_t)width; + upload.height = (uint32_t)height; + } + kern_return_t kr = mach_msg(&upload.header, MACH_SEND_MSG | MACH_SEND_TIMEOUT, sizeof(upload), 0, MACH_PORT_NULL, NativeSdkSharedRendererReplyTimeoutMs, MACH_PORT_NULL); + if (kr != KERN_SUCCESS) { + fprintf(stderr, "weaver-shared-renderer: image upload send failed kr=0x%x — host gone or wedged, will reconnect\n", kr); + mach_msg_destroy(&upload.header); + [self disconnect]; + return NO; + } + struct { WeaverRendererMachImageUploadReply reply; mach_msg_trailer_t trailer; } uploadReply = {0}; + kr = mach_msg(&uploadReply.reply.header, MACH_RCV_MSG | MACH_RCV_TIMEOUT, 0, sizeof(uploadReply), self.replyPort, NativeSdkSharedRendererReplyTimeoutMs, MACH_PORT_NULL); + if (kr != KERN_SUCCESS) { + fprintf(stderr, "weaver-shared-renderer: image upload reply timed out (kr=0x%x) — treating host as crashed\n", kr); + [self disconnect]; + return NO; + } + return uploadReply.reply.status == kWeaverRendererMachStatusOk; +} + @end @interface NativeSdkWidgetAccessibilityElement : NSAccessibilityElement @@ -1269,6 +1378,12 @@ @interface NativeSdkMetalSurfaceView : NSView * view's UI machinery is inert without a window (no tracking areas, no * events, no accessibility queries). Measured host cost stays in the * Phase 1 spike's envelope (~1.75 MB per client). */ +/* Headless (render host) image stores: the widget's image uploads ride + * the renderer channel ahead of the packets that reference them, and + * land here — the per-client mirror of NativeSdkAppKitHost's stores. */ +@property(nonatomic, strong) NSMutableDictionary *headlessImageStore; +@property(nonatomic, strong) NSMutableDictionary *headlessImagePixelStore; +@property(nonatomic, strong) NSMutableDictionary> *headlessImageTextureStore; @property(nonatomic, assign) BOOL headlessExport; /* Set synchronously by the composite seam when an export was committed, * cleared when its completion fires: the host reads it right after the @@ -1278,6 +1393,8 @@ @interface NativeSdkMetalSurfaceView : NSView @property(nonatomic, assign) BOOL headlessExportInFlight; @property(nonatomic, copy) void (^headlessExportCompletion)(BOOL completed, IOSurfaceRef surface, NSUInteger pixelWidth, NSUInteger pixelHeight); - (instancetype)initHeadlessRendererWithFrame:(NSRect)frameRect; +- (NSMutableDictionary *)activeCanvasImageStore; +- (id)activeTextureForImageKey:(NSString *)key; - (NSInteger)sharedRendererPresentPacket:(const uint8_t *)packet byteLength:(NSUInteger)byteLength surfaceWidth:(CGFloat)surfaceWidth surfaceHeight:(CGFloat)surfaceHeight scale:(CGFloat)scale clearR:(uint8_t)clearR clearG:(uint8_t)clearG clearB:(uint8_t)clearB clearA:(uint8_t)clearA commandCount:(NSUInteger)commandCount; - (void)configureWithHost:(NativeSdkAppKitHost *)host windowId:(uint64_t)windowId label:(NSString *)label; - (void)renderFrameThroughIOSurfacePresenter; @@ -3904,6 +4021,9 @@ - (instancetype)initHeadlessRendererWithFrame:(NSRect)frameRect { if (!_device) return self; _commandQueue = resources.commandQueue; _iosurfacePresenter = [[NativeSdkIOSurfacePresenter alloc] initWithDevice:_device commandQueue:_commandQueue]; + _headlessImageStore = [NSMutableDictionary dictionary]; + _headlessImagePixelStore = [NSMutableDictionary dictionary]; + _headlessImageTextureStore = [NSMutableDictionary dictionary]; _canvasImageCache = [NSMutableDictionary dictionary]; _windowDragRegions = @[]; _windowDragExclusions = @[]; @@ -4068,6 +4188,19 @@ - (void)applySurfaceLayerOpacity { } } +/* One truth for where registered images live: the app host's stores + * in-process, the per-client headless stores in the render host. */ +- (NSMutableDictionary *)activeCanvasImageStore { + return self.headlessExport ? self.headlessImageStore : self.host.canvasImageStore; +} + +- (id)activeTextureForImageKey:(NSString *)key { + if (self.headlessExport) { + return NativeSdkCanvasTextureForImageKey(key, self.headlessImagePixelStore, self.headlessImageTextureStore); + } + return [self.host gpuSurfaceTextureForImageKey:key]; +} + - (void)viewDidMoveToWindow { [super viewDidMoveToWindow]; self.window.acceptsMouseMovedEvents = YES; @@ -5096,7 +5229,7 @@ - (NSInteger)compositePacketCommands:(NSArray *)commands keys:(NSArray *)keys ta packetImage[@"src"] == nil && NativeSdkPacketRadiusIsZero(packetImage[@"radius"]) && (command[@"clip"] == nil || clipArray != nil) && cacheKey && self.canvasImageCache[cacheKey]; if (directTile) { - id texture = [self.host gpuSurfaceTextureForImageKey:cacheKey]; + id texture = [self activeTextureForImageKey:cacheKey]; NSRect requested = CGRectStandardize(NativeSdkPacketRect(packetImage[@"dst"])); NSRect drawRect = requested; if (clipArray) drawRect = NSIntersectionRect(drawRect, CGRectStandardize(NativeSdkPacketRect(clipArray))); @@ -6117,7 +6250,7 @@ - (NSInteger)presentGpuPacketObject:(NSDictionary *)packet surfaceWidth:(CGFloat NSArray *images = NativeSdkPacketArray(packet[@"images"], 0) ?: @[]; NSArray *imageActions = NativeSdkPacketArray(packet[@"imageActions"], 0) ?: @[]; if (!self.canvasImageCache) self.canvasImageCache = [NSMutableDictionary dictionary]; - if (!NativeSdkPacketApplyImageActions(imageActions, images, self.canvasImageCache, self.host.canvasImageStore)) { + if (!NativeSdkPacketApplyImageActions(imageActions, images, self.canvasImageCache, [self activeCanvasImageStore])) { if (patchLoadAction) self.hasCanvasRetainedState = NO; return 0; } @@ -8939,65 +9072,19 @@ - (BOOL)showContextMenuInWindow:(uint64_t)windowId label:(NSString *)label x:(do } - (BOOL)uploadGpuSurfaceImageWithId:(uint64_t)imageId width:(NSUInteger)width height:(NSUInteger)height rgba8:(const uint8_t *)rgba8 byteLength:(NSUInteger)byteLength { - if (imageId == 0 || !rgba8 || width == 0 || height == 0) return NO; - if (width > NSUIntegerMax / height || width * height > NSUIntegerMax / 4) return NO; - if (byteLength != width * height * 4) return NO; - // Copy the caller's bytes: the runtime's slot pool is reused on - // register/unregister, while the store's NSImage lives until the id - // is removed or replaced. - NSData *pixelData = [NSData dataWithBytes:rgba8 length:byteLength]; - NSImage *image = NativeSdkCreateRGBA8Image(width, height, pixelData); - if (!image) return NO; if (!self.canvasImageStore) self.canvasImageStore = [[NSMutableDictionary alloc] init]; if (!self.canvasImagePixelStore) self.canvasImagePixelStore = [[NSMutableDictionary alloc] init]; if (!self.canvasImageTextureStore) self.canvasImageTextureStore = [[NSMutableDictionary alloc] init]; - NSString *key = [NSString stringWithFormat:@"%llu", (unsigned long long)imageId]; - self.canvasImageStore[key] = image; - self.canvasImagePixelStore[key] = @{ - @"width" : @(width), - @"height" : @(height), - @"pixels" : pixelData, - }; - [self.canvasImageTextureStore removeObjectForKey:key]; - if (NativeSdkRendererBakeoffTraceEnabled()) { - fprintf(stderr, "native-sdk: renderer-bakeoff stage=image_upload bytes=%lu store_entries=%lu\n", - (unsigned long)byteLength, - (unsigned long)self.canvasImageStore.count); - } - return YES; + return NativeSdkCanvasStoreImage(imageId, width, height, rgba8, byteLength, self.canvasImageStore, self.canvasImagePixelStore, self.canvasImageTextureStore); } - (BOOL)removeGpuSurfaceImageWithId:(uint64_t)imageId { - if (imageId == 0) return NO; - NSString *key = [NSString stringWithFormat:@"%llu", (unsigned long long)imageId]; - [self.canvasImageStore removeObjectForKey:key]; - [self.canvasImagePixelStore removeObjectForKey:key]; - [self.canvasImageTextureStore removeObjectForKey:key]; - return YES; + NativeSdkCanvasRemoveImage(imageId, self.canvasImageStore, self.canvasImagePixelStore, self.canvasImageTextureStore); + return imageId != 0; } - (id)gpuSurfaceTextureForImageKey:(NSString *)key { - if (key.length == 0) return nil; - id cached = self.canvasImageTextureStore[key]; - if (cached) return cached; - NSDictionary *source = self.canvasImagePixelStore[key]; - NSData *pixels = [source[@"pixels"] isKindOfClass:[NSData class]] ? source[@"pixels"] : nil; - NSUInteger width = [source[@"width"] unsignedIntegerValue]; - NSUInteger height = [source[@"height"] unsignedIntegerValue]; - if (!pixels || width == 0 || height == 0 || width > NSUIntegerMax / height || - width * height > NSUIntegerMax / 4 || pixels.length != width * height * 4) return nil; - NativeSdkMetalProcessResources *resources = [NativeSdkMetalProcessResources sharedResources]; - id device = resources.device; - if (!device) return nil; - MTLTextureDescriptor *descriptor = [MTLTextureDescriptor texture2DDescriptorWithPixelFormat:MTLPixelFormatRGBA8Unorm width:width height:height mipmapped:NO]; - descriptor.usage = MTLTextureUsageShaderRead; - descriptor.storageMode = MTLStorageModeShared; - id texture = [device newTextureWithDescriptor:descriptor]; - if (!texture) return nil; - [texture replaceRegion:MTLRegionMake2D(0, 0, width, height) mipmapLevel:0 withBytes:pixels.bytes bytesPerRow:width * 4]; - if (!self.canvasImageTextureStore) self.canvasImageTextureStore = [[NSMutableDictionary alloc] init]; - self.canvasImageTextureStore[key] = texture; - return texture; + return NativeSdkCanvasTextureForImageKey(key, self.canvasImagePixelStore, self.canvasImageTextureStore); } - (BOOL)setNativeViewCursorInWindow:(uint64_t)windowId label:(NSString *)label cursor:(NSInteger)cursor { @@ -11954,11 +12041,20 @@ int native_sdk_appkit_show_context_menu(native_sdk_appkit_host_t *host, uint64_t } int native_sdk_appkit_upload_gpu_surface_image(native_sdk_appkit_host_t *host, uint64_t image_id, size_t width, size_t height, const uint8_t *rgba8, size_t rgba8_len) { + if (NativeSdkSharedRendererClientEnabled()) { + /* Device-less widget: the pixels belong in the render host's + * per-client store, ahead of the packets that reference them. + * Nothing is kept locally — the local raster paths never run. */ + return [[NativeSdkSharedRendererConnection sharedConnection] uploadImageWithId:image_id width:width height:height rgba8:rgba8 byteLength:rgba8_len] ? 1 : 0; + } NativeSdkAppKitHost *object = (__bridge NativeSdkAppKitHost *)host; return [object uploadGpuSurfaceImageWithId:image_id width:width height:height rgba8:rgba8 byteLength:rgba8_len] ? 1 : 0; } int native_sdk_appkit_remove_gpu_surface_image(native_sdk_appkit_host_t *host, uint64_t image_id) { + if (NativeSdkSharedRendererClientEnabled()) { + return [[NativeSdkSharedRendererConnection sharedConnection] uploadImageWithId:image_id width:0 height:0 rgba8:NULL byteLength:0] ? 1 : 0; + } NativeSdkAppKitHost *object = (__bridge NativeSdkAppKitHost *)host; return [object removeGpuSurfaceImageWithId:image_id] ? 1 : 0; } @@ -12501,6 +12597,42 @@ static void NativeSdkRenderHostSendFrameReply(mach_port_t replyPort, int32_t sta } } +/* One registered image, into this client's headless store — ahead of the + * packets that will reference it, mirroring the in-process side channel. + * A renderer-less client (image before first frame) gets one constructed + * on the spot: uploads precede presents by contract. */ +static void NativeSdkRenderHostHandleImageUpload(NativeSdkRenderHostClient *client, WeaverRendererMachImageUpload *upload) { + const mach_port_t replyPort = upload->header.msgh_remote_port; + void *pixelBytes = upload->body.msgh_descriptor_count == 1 ? upload->pixels.address : NULL; + const mach_msg_size_t pixelSize = upload->body.msgh_descriptor_count == 1 ? upload->pixels.size : 0; + BOOL stored = NO; + if (weaverRendererMachImageUploadValid(upload)) { + if (!client.renderer) { + client.renderer = [[NativeSdkMetalSurfaceView alloc] initHeadlessRendererWithFrame:NSMakeRect(0, 0, 1, 1)]; + } + if (upload->pixels_len == 0) { + NativeSdkCanvasRemoveImage(upload->image_id, client.renderer.headlessImageStore, client.renderer.headlessImagePixelStore, client.renderer.headlessImageTextureStore); + stored = YES; + } else if (pixelBytes) { + stored = NativeSdkCanvasStoreImage(upload->image_id, upload->width, upload->height, (const uint8_t *)pixelBytes, upload->pixels_len, + client.renderer.headlessImageStore, client.renderer.headlessImagePixelStore, client.renderer.headlessImageTextureStore); + } + } else { + fprintf(stderr, "weaver-render-host: invalid image upload from pid=%d (id=%llu %ux%u len=%u)\n", + client.widgetPid, (unsigned long long)upload->image_id, upload->width, upload->height, upload->pixels_len); + } + if (pixelBytes) vm_deallocate(mach_task_self(), (vm_address_t)pixelBytes, pixelSize); + WeaverRendererMachImageUploadReply reply = {0}; + reply.header.msgh_bits = MACH_MSGH_BITS(MACH_MSG_TYPE_MOVE_SEND_ONCE, 0); + reply.header.msgh_remote_port = replyPort; + reply.header.msgh_size = sizeof(reply); + reply.magic = kWeaverRendererMachMagic; + reply.version = kWeaverRendererMachVersion; + reply.status = stored ? kWeaverRendererMachStatusOk : kWeaverRendererMachStatusRefused; + const kern_return_t kr = mach_msg(&reply.header, MACH_SEND_MSG, sizeof(reply), 0, MACH_PORT_NULL, MACH_MSG_TIMEOUT_NONE, MACH_PORT_NULL); + if (kr != KERN_SUCCESS) mach_msg_destroy(&reply.header); +} + static void NativeSdkRenderHostHandleFrame(NativeSdkRenderHostClient *client, WeaverRendererMachFrame *frame) { mach_port_t replyPort = frame->header.msgh_remote_port; void *packetBytes = frame->packet.address; @@ -12530,7 +12662,11 @@ static void NativeSdkRenderHostHandleFrame(NativeSdkRenderHostClient *client, We } if (!client.renderer) { client.renderer = [[NativeSdkMetalSurfaceView alloc] initHeadlessRendererWithFrame:NSMakeRect(0, 0, frame->surface_width, frame->surface_height)]; - /* Deliberately a STRONG capture: an export in flight must be able + } + if (!client.renderer.headlessExportCompletion) { + /* Bound here rather than at construction: an image upload may + * have created the renderer before the first frame arrived. + * Deliberately a STRONG capture: an export in flight must be able * to answer (or destroy) its reply right even if the client * disconnects mid-frame, so the completion keeps the client * object alive until it has run. The renderer->completion->client @@ -12664,14 +12800,18 @@ int native_sdk_appkit_render_host_run(const char *bootstrap_name) { * measured 180 KB/min leak. */ NativeSdkRenderHostClient *strongClient = weakClient; if (!strongClient) return; - struct { WeaverRendererMachFrame frame; mach_msg_trailer_t trailer; } frameMessage = {0}; - kern_return_t frameRcv = mach_msg(&frameMessage.frame.header, MACH_RCV_MSG, 0, sizeof(frameMessage), strongClient.sessionPort, MACH_MSG_TIMEOUT_NONE, MACH_PORT_NULL); + union { + struct { WeaverRendererMachFrame frame; mach_msg_trailer_t trailer; } framed; + struct { WeaverRendererMachImageUpload upload; mach_msg_trailer_t trailer; } image; + } message; + memset(&message, 0, sizeof(message)); + kern_return_t frameRcv = mach_msg(&message.framed.frame.header, MACH_RCV_MSG, 0, sizeof(message), strongClient.sessionPort, MACH_MSG_TIMEOUT_NONE, MACH_PORT_NULL); if (frameRcv == MACH_RCV_TOO_LARGE) { - NativeSdkRenderHostDrainOversized(strongClient.sessionPort, frameMessage.frame.header.msgh_size); + NativeSdkRenderHostDrainOversized(strongClient.sessionPort, message.framed.frame.header.msgh_size); return; } if (frameRcv != KERN_SUCCESS) return; - if (frameMessage.frame.header.msgh_id == MACH_NOTIFY_NO_SENDERS) { + if (message.framed.frame.header.msgh_id == MACH_NOTIFY_NO_SENDERS) { fprintf(stderr, "weaver-render-host: client pid=%d disconnected\n", strongClient.widgetPid); /* An export in flight still answers through its * captured completion (the send fails harmlessly @@ -12687,21 +12827,25 @@ int native_sdk_appkit_render_host_run(const char *bootstrap_name) { [clients removeObject:strongClient]; return; } + if (message.framed.frame.header.msgh_id == kWeaverRendererMachMsgImageUpload) { + NativeSdkRenderHostHandleImageUpload(strongClient, &message.image.upload); + return; + } /* Shape before content: a frame must be a complex * message carrying exactly one out-of-line descriptor. * Anything else — extra descriptors, port rights where * bytes belong — is destroyed WHOLE, so every * transferred right is disposed and a malformed peer * cannot exhaust the host's port space. */ - if (frameMessage.frame.header.msgh_id != kWeaverRendererMachMsgFrame || - (frameMessage.frame.header.msgh_bits & MACH_MSGH_BITS_COMPLEX) == 0 || - frameMessage.frame.body.msgh_descriptor_count != 1 || - frameMessage.frame.packet.type != MACH_MSG_OOL_DESCRIPTOR) { + if (message.framed.frame.header.msgh_id != kWeaverRendererMachMsgFrame || + (message.framed.frame.header.msgh_bits & MACH_MSGH_BITS_COMPLEX) == 0 || + message.framed.frame.body.msgh_descriptor_count != 1 || + message.framed.frame.packet.type != MACH_MSG_OOL_DESCRIPTOR) { fprintf(stderr, "weaver-render-host: malformed message from pid=%d destroyed\n", strongClient.widgetPid); - mach_msg_destroy(&frameMessage.frame.header); + mach_msg_destroy(&message.framed.frame.header); return; } - NativeSdkRenderHostHandleFrame(strongClient, &frameMessage.frame); + NativeSdkRenderHostHandleFrame(strongClient, &message.framed.frame); } }); /* The receive right dies only after dispatch guarantees the diff --git a/src/platform/macos/renderer_protocol_mach.h b/src/platform/macos/renderer_protocol_mach.h index 0c39c1f1..eae05b6c 100644 --- a/src/platform/macos/renderer_protocol_mach.h +++ b/src/platform/macos/renderer_protocol_mach.h @@ -37,8 +37,17 @@ static const uint32_t kWeaverRendererMachMaxPacket = 8 * 1024 * 1024; enum { kWeaverRendererMachMsgHello = 0x57520001, kWeaverRendererMachMsgFrame = 0x57520002, + kWeaverRendererMachMsgImageUpload = 0x57520003, }; +/* Image pixels ride a side channel, exactly like the in-process binary + * ABI (`uploadGpuSurfaceImage` runs BEFORE the packet referencing the + * image is presented): packets carry only id + fingerprint references. + * The ceiling mirrors canvas_limits.max_registered_canvas_image_pixel_bytes + * for the stock profile (1 MiB; the widget profile's 256 KiB is enforced + * client-side by the SDK before bytes ever reach this channel). */ +static const uint32_t kWeaverRendererMachMaxImageBytes = 1024 * 1024; + enum { kWeaverRendererMachStatusFailed = 0, kWeaverRendererMachStatusOk = 1, @@ -109,6 +118,46 @@ typedef struct { uint32_t pixel_height; } WeaverRendererMachFrameReply; +/* One registered image, uploaded (or removed) ahead of the packets that + * reference it. pixels_len == 0 (with a null descriptor size) is a + * removal; the reply is the completion signal, mirroring frames. */ +typedef struct { + mach_msg_header_t header; + mach_msg_body_t body; + mach_msg_ool_descriptor_t pixels; + uint32_t magic; + uint32_t version; + uint32_t struct_size; + uint32_t pixels_len; + uint64_t image_id; + uint32_t width; + uint32_t height; +} WeaverRendererMachImageUpload; + +typedef struct { + mach_msg_header_t header; + uint32_t magic; + uint32_t version; + uint32_t status; + uint32_t reserved; +} WeaverRendererMachImageUploadReply; + +static inline bool weaverRendererMachImageUploadValid(const WeaverRendererMachImageUpload *upload) { + if (upload->magic != kWeaverRendererMachMagic || + upload->version != kWeaverRendererMachVersion || + upload->struct_size != sizeof(WeaverRendererMachImageUpload) || + upload->image_id == 0) return false; + if (upload->pixels_len == 0) { + /* removal */ + return upload->width == 0 && upload->height == 0 && upload->pixels.size == 0; + } + return upload->width > 0 && upload->height > 0 && + upload->width <= 4096 && upload->height <= 4096 && + upload->pixels_len == upload->width * upload->height * 4 && + upload->pixels_len <= kWeaverRendererMachMaxImageBytes && + upload->pixels.size == upload->pixels_len; +} + static inline bool weaverRendererMachHelloValid(const WeaverRendererMachHello *hello) { return hello->magic == kWeaverRendererMachMagic && hello->version == kWeaverRendererMachVersion && From 6176383b184424f97661affb6fa5aafc2fd84ebf Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Fri, 31 Jul 2026 11:20:01 -0700 Subject: [PATCH 2/7] Replay registered images to a replacement host; install retained images from the store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review is right that a replacement render host started with an empty per-client store while the runtime's cache (correctly) kept emitting retain actions — the first post-crash frame referenced images the host never received, and the draws were silently skipped: art gone until the image changed. Two halves, both needed: - The client connection keeps each registered image's bytes and replays them after every reconnect (the mach analog of the Windows client re-supplying retained content to a replacement renderer). Bounded by the SDK's registry: 16 ids, 256 KiB each in the widget profile. - A retain action whose view-cache entry is missing but whose pixels are in the store installs from the store — a fresh renderer sees retain for content it never installed, and the draw survives instead of silently skipping. This also hardens the in-process path against the same cache/store drift. Live receipt: noro-shell through weaverd, host kill -9 mid-run, weaverd respawn — album art region byte-equivalent before and after (stddev 20.9/21.3/21.1 both sides). test-canvas 841/841; the images contains-check pins both halves. --- build.zig | 5 +++ src/platform/macos/appkit_host.m | 59 ++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/build.zig b/build.zig index d5bc1a38..281cb8ba 100644 --- a/build.zig +++ b/build.zig @@ -619,6 +619,11 @@ pub fn build(b: *std.Build) void { // An image arriving before the first frame constructs the // renderer; the export completion binds lazily so that order works. .{ .path = "src/platform/macos/appkit_host.m", .pattern = "if (!client.renderer.headlessExportCompletion) {" }, + // A replacement host gets every registered image replayed on + // reconnect, and a retain action installs from the store when the + // fresh view cache lacks the entry — art survives host crashes. + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "registeredImagesById" }, + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "if (cacheKey && !imageCache[cacheKey] && imageStore[cacheKey]) {" }, }); addFileContainsCheckStep(b, file_contains_checker, test_step, "test-macos-shared-renderer-client", "Verify the device-less widget client keeps the shared-renderer contract", &.{ // No Metal object is ever created in client mode; availability is diff --git a/src/platform/macos/appkit_host.m b/src/platform/macos/appkit_host.m index bea46c5a..46532672 100644 --- a/src/platform/macos/appkit_host.m +++ b/src/platform/macos/appkit_host.m @@ -919,6 +919,15 @@ @interface NativeSdkSharedRendererConnection : NSObject @property(nonatomic, assign) mach_port_t sessionPort; @property(nonatomic, assign) mach_port_t replyPort; @property(nonatomic, strong) NSMutableDictionary *surfacesById; +/* Replay source for host crashes: the runtime's image cache rightly + * believes an unchanged image is already registered (it emits `retain`, + * not `upload`), so a REPLACEMENT host would never receive the pixels. + * The connection therefore keeps each registered image's bytes and + * replays them after every reconnect — the mach analog of the Windows + * client re-supplying retained content to a replacement renderer. + * Bounded by the SDK's image registry (16 ids; 256 KiB each in the + * widget profile — <= 4 MiB worst case, CPU-side). */ +@property(nonatomic, strong) NSMutableDictionary *registeredImagesById; @property(nonatomic, assign) uint32_t surfacePixelWidth; @property(nonatomic, assign) uint32_t surfacePixelHeight; @property(nonatomic, assign) BOOL loggedWaitingForHost; @@ -933,6 +942,7 @@ + (instancetype)sharedConnection { dispatch_once(&once, ^{ connection = [[NativeSdkSharedRendererConnection alloc] init]; connection.surfacesById = [NSMutableDictionary dictionary]; + connection.registeredImagesById = [NSMutableDictionary dictionary]; }); return connection; } @@ -1004,6 +1014,22 @@ - (BOOL)ensureConnected { self.replyPort = replyPort; self.loggedWaitingForHost = NO; fprintf(stderr, "weaver-shared-renderer: connected to render host\n"); + /* A fresh host starts with an empty per-client image store, but the + * runtime's cache still (correctly) retains unchanged ids — replay + * every registered image so the first post-reconnect frame's retain + * actions resolve. */ + for (NSNumber *imageIdNumber in [self.registeredImagesById.allKeys sortedArrayUsingSelector:@selector(compare:)]) { + NSDictionary *entry = self.registeredImagesById[imageIdNumber]; + NSData *pixels = entry[@"pixels"]; + if (![self sendImageWithId:imageIdNumber.unsignedLongLongValue + width:[entry[@"width"] unsignedIntegerValue] + height:[entry[@"height"] unsignedIntegerValue] + rgba8:(const uint8_t *)pixels.bytes + byteLength:pixels.length]) { + fprintf(stderr, "weaver-shared-renderer: image replay failed for id=%llu\n", imageIdNumber.unsignedLongLongValue); + return NO; + } + } return YES; } @@ -1107,7 +1133,26 @@ - (NSInteger)presentPacket:(const uint8_t *)packet * in-process side channel. One reply per request, same tripwires as * frames. */ - (BOOL)uploadImageWithId:(uint64_t)imageId width:(NSUInteger)width height:(NSUInteger)height rgba8:(const uint8_t *)rgba8 byteLength:(NSUInteger)byteLength { + /* Record the replay copy FIRST: even if the host is down right now, + * the eventual reconnect replays it (ensureConnected refuses this + * call, the runtime keeps its pixel fallback, and recovery follows + * the usual retry cadence). */ + if (rgba8 && byteLength > 0) { + self.registeredImagesById[@(imageId)] = @{ + @"width" : @(width), + @"height" : @(height), + @"pixels" : [NSData dataWithBytes:rgba8 length:byteLength], + }; + } else { + [self.registeredImagesById removeObjectForKey:@(imageId)]; + } if (![self ensureConnected]) return NO; + return [self sendImageWithId:imageId width:width height:height rgba8:rgba8 byteLength:byteLength]; +} + +/* The wire round trip alone — used by uploads and by the reconnect + * replay (which must not run ensureConnected re-entrantly). */ +- (BOOL)sendImageWithId:(uint64_t)imageId width:(NSUInteger)width height:(NSUInteger)height rgba8:(const uint8_t *)rgba8 byteLength:(NSUInteger)byteLength { WeaverRendererMachImageUpload upload = {0}; upload.header.msgh_bits = MACH_MSGH_BITS(MACH_MSG_TYPE_COPY_SEND, MACH_MSG_TYPE_MAKE_SEND_ONCE); upload.header.msgh_remote_port = self.sessionPort; @@ -3153,6 +3198,20 @@ static BOOL NativeSdkPacketApplyImageActions(NSArray *actions, NSArray *images, for (id actionObject in actions ?: @[]) { NSDictionary *action = NativeSdkPacketDictionary(actionObject); NSString *kind = [action[@"kind"] isKindOfClass:[NSString class]] ? action[@"kind"] : @""; + if ([kind isEqualToString:@"retain"]) { + /* A retained image the view cache no longer holds is + * recoverable when the store still has the pixels: a fresh + * renderer (the render host after a crash, with the client's + * registered images replayed into the store) sees `retain` + * for content it has never installed. Installing from the + * store keeps the draw instead of silently skipping it. */ + NSDictionary *key = NativeSdkPacketDictionary(action[@"key"]); + NSString *cacheKey = NativeSdkPacketImageCacheKey(key[@"imageId"]); + if (cacheKey && !imageCache[cacheKey] && imageStore[cacheKey]) { + imageCache[cacheKey] = imageStore[cacheKey]; + } + continue; + } if (![kind isEqualToString:@"upload"]) continue; NSInteger imageIndex = [action[@"imageIndex"] respondsToSelector:@selector(integerValue)] ? [action[@"imageIndex"] integerValue] : -1; if (imageIndex < 0 || (NSUInteger)imageIndex >= images.count) return NO; From 1432fd7774d74924b0911922cfc2f5367b780fc9 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Fri, 31 Jul 2026 11:31:44 -0700 Subject: [PATCH 3/7] Shape-check image uploads and tear down half-replayed sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Image messages now pass the same shape-before-content rule as frames — non-complex (a removal) or complex with exactly one out-of-line descriptor; anything else is destroyed whole, so a smuggled port right where pixels belong is disposed instead of leaked. And a replay failure mid-loop disconnects rather than stranding a session the host half knows: the next attempt reconnects and replays from the top. --- src/platform/macos/appkit_host.m | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/platform/macos/appkit_host.m b/src/platform/macos/appkit_host.m index 46532672..a82fbc3d 100644 --- a/src/platform/macos/appkit_host.m +++ b/src/platform/macos/appkit_host.m @@ -1026,7 +1026,11 @@ - (BOOL)ensureConnected { height:[entry[@"height"] unsignedIntegerValue] rgba8:(const uint8_t *)pixels.bytes byteLength:pixels.length]) { - fprintf(stderr, "weaver-shared-renderer: image replay failed for id=%llu\n", imageIdNumber.unsignedLongLongValue); + /* A half-replayed session must not survive: the next attempt + * reconnects and replays from the top, or the host would + * render retained draws without their pixels. */ + fprintf(stderr, "weaver-shared-renderer: image replay failed for id=%llu; reconnecting from scratch\n", imageIdNumber.unsignedLongLongValue); + [self disconnect]; return NO; } } @@ -12887,6 +12891,21 @@ int native_sdk_appkit_render_host_run(const char *bootstrap_name) { return; } if (message.framed.frame.header.msgh_id == kWeaverRendererMachMsgImageUpload) { + /* Same shape-before-content rule as frames: an + * upload is either non-complex (a removal) or + * complex with exactly one out-of-line descriptor. + * Anything else — a smuggled port right where + * pixels belong — is destroyed whole. */ + const BOOL uploadComplex = (message.image.upload.header.msgh_bits & MACH_MSGH_BITS_COMPLEX) != 0; + const BOOL uploadShapeOk = uploadComplex + ? (message.image.upload.body.msgh_descriptor_count == 1 && + message.image.upload.pixels.type == MACH_MSG_OOL_DESCRIPTOR) + : message.image.upload.body.msgh_descriptor_count == 0; + if (!uploadShapeOk) { + fprintf(stderr, "weaver-render-host: malformed image upload from pid=%d destroyed\n", strongClient.widgetPid); + mach_msg_destroy(&message.image.upload.header); + return; + } NativeSdkRenderHostHandleImageUpload(strongClient, &message.image.upload); return; } From 4d5ab040c8b340733b3364eceb40596b96b87591 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Fri, 31 Jul 2026 11:42:30 -0700 Subject: [PATCH 4/7] Drop host-refused images from the reconnect replay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A refused image (invalid by the host's validator) stayed in the replay set and poisoned every reconnect into a disconnect loop. The image send is now tri-state: accepted; refused by a live host (the entry is dropped — it can never be accepted, and the draws referencing it skip exactly like an unregistered image in-process); or transport failure (session torn down, entry kept for the next reconnect's replay). Crash drill re-verified on this build: art region byte-equivalent across host kill -9 + weaverd respawn. --- src/platform/macos/appkit_host.m | 44 +++++++++++++++++++++++--------- 1 file changed, 32 insertions(+), 12 deletions(-) diff --git a/src/platform/macos/appkit_host.m b/src/platform/macos/appkit_host.m index a82fbc3d..827925e7 100644 --- a/src/platform/macos/appkit_host.m +++ b/src/platform/macos/appkit_host.m @@ -1021,15 +1021,24 @@ - (BOOL)ensureConnected { for (NSNumber *imageIdNumber in [self.registeredImagesById.allKeys sortedArrayUsingSelector:@selector(compare:)]) { NSDictionary *entry = self.registeredImagesById[imageIdNumber]; NSData *pixels = entry[@"pixels"]; - if (![self sendImageWithId:imageIdNumber.unsignedLongLongValue - width:[entry[@"width"] unsignedIntegerValue] - height:[entry[@"height"] unsignedIntegerValue] - rgba8:(const uint8_t *)pixels.bytes - byteLength:pixels.length]) { + const NSInteger replayed = [self sendImageWithId:imageIdNumber.unsignedLongLongValue + width:[entry[@"width"] unsignedIntegerValue] + height:[entry[@"height"] unsignedIntegerValue] + rgba8:(const uint8_t *)pixels.bytes + byteLength:pixels.length]; + if (replayed == 0) { + /* Refused by a live host: the image can never be accepted and + * would poison every future replay. Drop it and keep going — + * the draws referencing it skip, like in-process. */ + fprintf(stderr, "weaver-shared-renderer: replay refused for image id=%llu; dropping it\n", imageIdNumber.unsignedLongLongValue); + [self.registeredImagesById removeObjectForKey:imageIdNumber]; + continue; + } + if (replayed < 0) { /* A half-replayed session must not survive: the next attempt * reconnects and replays from the top, or the host would * render retained draws without their pixels. */ - fprintf(stderr, "weaver-shared-renderer: image replay failed for id=%llu; reconnecting from scratch\n", imageIdNumber.unsignedLongLongValue); + fprintf(stderr, "weaver-shared-renderer: image replay transport failure for id=%llu; reconnecting from scratch\n", imageIdNumber.unsignedLongLongValue); [self disconnect]; return NO; } @@ -1151,12 +1160,23 @@ - (BOOL)uploadImageWithId:(uint64_t)imageId width:(NSUInteger)width height:(NSUI [self.registeredImagesById removeObjectForKey:@(imageId)]; } if (![self ensureConnected]) return NO; - return [self sendImageWithId:imageId width:width height:height rgba8:rgba8 byteLength:byteLength]; + const NSInteger sent = [self sendImageWithId:imageId width:width height:height rgba8:rgba8 byteLength:byteLength]; + if (sent == 0) { + /* The host refused the image outright (it can never be accepted); + * keeping it would poison every reconnect's replay. Drop it — the + * draws referencing it skip, exactly like an unregistered image + * in-process. */ + [self.registeredImagesById removeObjectForKey:@(imageId)]; + } + return sent == 1; } /* The wire round trip alone — used by uploads and by the reconnect - * replay (which must not run ensureConnected re-entrantly). */ -- (BOOL)sendImageWithId:(uint64_t)imageId width:(NSUInteger)width height:(NSUInteger)height rgba8:(const uint8_t *)rgba8 byteLength:(NSUInteger)byteLength { + * replay (which must not run ensureConnected re-entrantly). Returns 1 + * accepted, 0 refused by the host (the image can never be accepted — + * callers drop it), -1 transport failure (the session is torn down and + * the entry stays for the next reconnect's replay). */ +- (NSInteger)sendImageWithId:(uint64_t)imageId width:(NSUInteger)width height:(NSUInteger)height rgba8:(const uint8_t *)rgba8 byteLength:(NSUInteger)byteLength { WeaverRendererMachImageUpload upload = {0}; upload.header.msgh_bits = MACH_MSGH_BITS(MACH_MSG_TYPE_COPY_SEND, MACH_MSG_TYPE_MAKE_SEND_ONCE); upload.header.msgh_remote_port = self.sessionPort; @@ -1184,16 +1204,16 @@ - (BOOL)sendImageWithId:(uint64_t)imageId width:(NSUInteger)width height:(NSUInt fprintf(stderr, "weaver-shared-renderer: image upload send failed kr=0x%x — host gone or wedged, will reconnect\n", kr); mach_msg_destroy(&upload.header); [self disconnect]; - return NO; + return -1; } struct { WeaverRendererMachImageUploadReply reply; mach_msg_trailer_t trailer; } uploadReply = {0}; kr = mach_msg(&uploadReply.reply.header, MACH_RCV_MSG | MACH_RCV_TIMEOUT, 0, sizeof(uploadReply), self.replyPort, NativeSdkSharedRendererReplyTimeoutMs, MACH_PORT_NULL); if (kr != KERN_SUCCESS) { fprintf(stderr, "weaver-shared-renderer: image upload reply timed out (kr=0x%x) — treating host as crashed\n", kr); [self disconnect]; - return NO; + return -1; } - return uploadReply.reply.status == kWeaverRendererMachStatusOk; + return uploadReply.reply.status == kWeaverRendererMachStatusOk ? 1 : 0; } @end From 97b85a634457af8084efc2107497e0c94452f059 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Fri, 31 Jul 2026 12:04:39 -0700 Subject: [PATCH 5/7] Forward registered fonts across the renderer channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found live by Dara running the cutover side by side with an in-process Noro: text drew in the system face instead of the widget's registered font (Cozette), wider and truncated. Same class as images — native_sdk_appkit_register_font is the other pre-packet side channel, and text rasterizes in the render host, which never saw the face. - The image message generalizes to a resource upload (image | font), same ool payload, same removal convention, same tripwires; the font ceiling matches the packet tripwire (faces are megabyte-scale; Noro's subset is ~100 KiB). - Host side, faces land in a PER-CLIENT font table swapped in around each present (NativeSdkRenderHostSetActiveFontTable): the process table would let two widgets' font ids collide in the shared host. The font-size cache becomes a bounded NSCache with client-tagged keys. - Client side, registration is record-and-best-effort: fonts register at widget startup, often before the host is reachable, and the reconnect replay (fonts first, then images) delivers them the moment a session exists. The widget also keeps its local registration so text MEASUREMENT resolves the same face the host draws with. Live receipts: shared-renderer Noro's title/time strip now renders in Cozette, matching the in-process instance glyph for glyph (was system face + truncation); after a host kill -9 + weaverd respawn the title band is byte-identical — faces replay with the session. test-canvas 841/841; the resource contains-check pins the font half. --- build.zig | 6 + src/platform/macos/appkit_host.m | 174 +++++++++++++++----- src/platform/macos/renderer_protocol_mach.h | 59 ++++--- 3 files changed, 176 insertions(+), 63 deletions(-) diff --git a/build.zig b/build.zig index 281cb8ba..bedd09a7 100644 --- a/build.zig +++ b/build.zig @@ -624,6 +624,12 @@ pub fn build(b: *std.Build) void { // fresh view cache lacks the entry — art survives host crashes. .{ .path = "src/platform/macos/appkit_host.m", .pattern = "registeredImagesById" }, .{ .path = "src/platform/macos/appkit_host.m", .pattern = "if (cacheKey && !imageCache[cacheKey] && imageStore[cacheKey]) {" }, + // Fonts ride the same resource channel into a PER-CLIENT table, + // swapped in around each present so widgets' font ids can never + // collide in the shared host; faces replay after reconnect. + .{ .path = "src/platform/macos/renderer_protocol_mach.h", .pattern = "kWeaverRendererMachResourceFont" }, + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "NativeSdkRenderHostSetActiveFontTable" }, + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "registeredFontsById" }, }); addFileContainsCheckStep(b, file_contains_checker, test_step, "test-macos-shared-renderer-client", "Verify the device-less widget client keeps the shared-renderer contract", &.{ // No Metal object is ever created in client mode; availability is diff --git a/src/platform/macos/appkit_host.m b/src/platform/macos/appkit_host.m index 827925e7..1dbec6ff 100644 --- a/src/platform/macos/appkit_host.m +++ b/src/platform/macos/appkit_host.m @@ -928,6 +928,7 @@ @interface NativeSdkSharedRendererConnection : NSObject * Bounded by the SDK's image registry (16 ids; 256 KiB each in the * widget profile — <= 4 MiB worst case, CPU-side). */ @property(nonatomic, strong) NSMutableDictionary *registeredImagesById; +@property(nonatomic, strong) NSMutableDictionary *registeredFontsById; @property(nonatomic, assign) uint32_t surfacePixelWidth; @property(nonatomic, assign) uint32_t surfacePixelHeight; @property(nonatomic, assign) BOOL loggedWaitingForHost; @@ -943,6 +944,7 @@ + (instancetype)sharedConnection { connection = [[NativeSdkSharedRendererConnection alloc] init]; connection.surfacesById = [NSMutableDictionary dictionary]; connection.registeredImagesById = [NSMutableDictionary dictionary]; + connection.registeredFontsById = [NSMutableDictionary dictionary]; }); return connection; } @@ -1014,18 +1016,39 @@ - (BOOL)ensureConnected { self.replyPort = replyPort; self.loggedWaitingForHost = NO; fprintf(stderr, "weaver-shared-renderer: connected to render host\n"); - /* A fresh host starts with an empty per-client image store, but the - * runtime's cache still (correctly) retains unchanged ids — replay - * every registered image so the first post-reconnect frame's retain - * actions resolve. */ + /* A fresh host starts with empty per-client stores, but the + * runtime's caches still (correctly) treat unchanged resources as + * registered — replay every font and image so the first + * post-reconnect frame resolves. Fonts first: text rasters draw with + * them. */ + for (NSNumber *fontIdNumber in [self.registeredFontsById.allKeys sortedArrayUsingSelector:@selector(compare:)]) { + NSData *face = self.registeredFontsById[fontIdNumber]; + const NSInteger fontReplayed = [self sendResourceWithKind:kWeaverRendererMachResourceFont + resourceId:fontIdNumber.unsignedLongLongValue + width:0 + height:0 + bytes:(const uint8_t *)face.bytes + byteLength:face.length]; + if (fontReplayed == 0) { + fprintf(stderr, "weaver-shared-renderer: font replay refused for id=%llu; dropping it\n", fontIdNumber.unsignedLongLongValue); + [self.registeredFontsById removeObjectForKey:fontIdNumber]; + continue; + } + if (fontReplayed < 0) { + fprintf(stderr, "weaver-shared-renderer: font replay transport failure for id=%llu; reconnecting from scratch\n", fontIdNumber.unsignedLongLongValue); + [self disconnect]; + return NO; + } + } for (NSNumber *imageIdNumber in [self.registeredImagesById.allKeys sortedArrayUsingSelector:@selector(compare:)]) { NSDictionary *entry = self.registeredImagesById[imageIdNumber]; NSData *pixels = entry[@"pixels"]; - const NSInteger replayed = [self sendImageWithId:imageIdNumber.unsignedLongLongValue - width:[entry[@"width"] unsignedIntegerValue] - height:[entry[@"height"] unsignedIntegerValue] - rgba8:(const uint8_t *)pixels.bytes - byteLength:pixels.length]; + const NSInteger replayed = [self sendResourceWithKind:kWeaverRendererMachResourceImage + resourceId:imageIdNumber.unsignedLongLongValue + width:[entry[@"width"] unsignedIntegerValue] + height:[entry[@"height"] unsignedIntegerValue] + bytes:(const uint8_t *)pixels.bytes + byteLength:pixels.length]; if (replayed == 0) { /* Refused by a live host: the image can never be accepted and * would poison every future replay. Drop it and keep going — @@ -1145,6 +1168,23 @@ - (NSInteger)presentPacket:(const uint8_t *)packet * ahead of the packets that reference the image, exactly like the * in-process side channel. One reply per request, same tripwires as * frames. */ +- (BOOL)registerFontWithId:(uint64_t)fontId bytes:(const uint8_t *)bytes byteLength:(NSUInteger)byteLength { + if (!bytes || byteLength == 0) return NO; + /* Fonts register at widget startup, often before the host is + * reachable — record the replay copy and treat delivery as + * best-effort: the reconnect replay carries it the moment a session + * exists. Only a live host's refusal fails the registration (the + * face can never be accepted). */ + self.registeredFontsById[@(fontId)] = [NSData dataWithBytes:bytes length:byteLength]; + if (![self ensureConnected]) return YES; + const NSInteger sent = [self sendResourceWithKind:kWeaverRendererMachResourceFont resourceId:fontId width:0 height:0 bytes:bytes byteLength:byteLength]; + if (sent == 0) { + [self.registeredFontsById removeObjectForKey:@(fontId)]; + return NO; + } + return YES; +} + - (BOOL)uploadImageWithId:(uint64_t)imageId width:(NSUInteger)width height:(NSUInteger)height rgba8:(const uint8_t *)rgba8 byteLength:(NSUInteger)byteLength { /* Record the replay copy FIRST: even if the host is down right now, * the eventual reconnect replays it (ensureConnected refuses this @@ -1160,7 +1200,7 @@ - (BOOL)uploadImageWithId:(uint64_t)imageId width:(NSUInteger)width height:(NSUI [self.registeredImagesById removeObjectForKey:@(imageId)]; } if (![self ensureConnected]) return NO; - const NSInteger sent = [self sendImageWithId:imageId width:width height:height rgba8:rgba8 byteLength:byteLength]; + const NSInteger sent = [self sendResourceWithKind:kWeaverRendererMachResourceImage resourceId:imageId width:width height:height bytes:rgba8 byteLength:byteLength]; if (sent == 0) { /* The host refused the image outright (it can never be accepted); * keeping it would poison every reconnect's replay. Drop it — the @@ -1176,26 +1216,27 @@ - (BOOL)uploadImageWithId:(uint64_t)imageId width:(NSUInteger)width height:(NSUI * accepted, 0 refused by the host (the image can never be accepted — * callers drop it), -1 transport failure (the session is torn down and * the entry stays for the next reconnect's replay). */ -- (NSInteger)sendImageWithId:(uint64_t)imageId width:(NSUInteger)width height:(NSUInteger)height rgba8:(const uint8_t *)rgba8 byteLength:(NSUInteger)byteLength { - WeaverRendererMachImageUpload upload = {0}; +- (NSInteger)sendResourceWithKind:(uint32_t)kind resourceId:(uint64_t)imageId width:(NSUInteger)width height:(NSUInteger)height bytes:(const uint8_t *)rgba8 byteLength:(NSUInteger)byteLength { + WeaverRendererMachResourceUpload upload = {0}; upload.header.msgh_bits = MACH_MSGH_BITS(MACH_MSG_TYPE_COPY_SEND, MACH_MSG_TYPE_MAKE_SEND_ONCE); upload.header.msgh_remote_port = self.sessionPort; upload.header.msgh_local_port = self.replyPort; upload.header.msgh_size = sizeof(upload); - upload.header.msgh_id = kWeaverRendererMachMsgImageUpload; + upload.header.msgh_id = kWeaverRendererMachMsgResourceUpload; upload.magic = kWeaverRendererMachMagic; upload.version = kWeaverRendererMachVersion; upload.struct_size = sizeof(upload); - upload.image_id = imageId; + upload.resource_id = imageId; + upload.resource_kind = kind; if (rgba8 && byteLength > 0) { upload.header.msgh_bits |= MACH_MSGH_BITS_COMPLEX; upload.body.msgh_descriptor_count = 1; - upload.pixels.address = (void *)rgba8; - upload.pixels.size = (mach_msg_size_t)byteLength; - upload.pixels.copy = MACH_MSG_VIRTUAL_COPY; - upload.pixels.deallocate = FALSE; - upload.pixels.type = MACH_MSG_OOL_DESCRIPTOR; - upload.pixels_len = (uint32_t)byteLength; + upload.payload.address = (void *)rgba8; + upload.payload.size = (mach_msg_size_t)byteLength; + upload.payload.copy = MACH_MSG_VIRTUAL_COPY; + upload.payload.deallocate = FALSE; + upload.payload.type = MACH_MSG_OOL_DESCRIPTOR; + upload.payload_len = (uint32_t)byteLength; upload.width = (uint32_t)width; upload.height = (uint32_t)height; } @@ -1206,7 +1247,7 @@ - (NSInteger)sendImageWithId:(uint64_t)imageId width:(NSUInteger)width height:(N [self disconnect]; return -1; } - struct { WeaverRendererMachImageUploadReply reply; mach_msg_trailer_t trailer; } uploadReply = {0}; + struct { WeaverRendererMachResourceUploadReply reply; mach_msg_trailer_t trailer; } uploadReply = {0}; kr = mach_msg(&uploadReply.reply.header, MACH_RCV_MSG | MACH_RCV_TIMEOUT, 0, sizeof(uploadReply), self.replyPort, NativeSdkSharedRendererReplyTimeoutMs, MACH_PORT_NULL); if (kr != KERN_SUCCESS) { fprintf(stderr, "weaver-shared-renderer: image upload reply timed out (kr=0x%x) — treating host as crashed\n", kr); @@ -2701,23 +2742,40 @@ static NSTextAlignment NativeSdkPacketTextAlignment(NSString *align) { // their cache so a registered id can never be masked by a font resolved // for that id earlier (ids are engine-validated and permanent, so cached // NSFonts here never go stale). +/* Render-host scoping: while a client's frame is being presented, its + * per-client font table overrides the process table so font ids from + * different widgets never collide in the shared host. Set and cleared + * synchronously around the present on the main queue (raster fills + * complete within the present call); in-process rendering never sets it. */ +static NSMutableDictionary *gWeaverActiveClientFontTable = nil; +static uint64_t gWeaverActiveClientFontTag = 0; + +static void NativeSdkRenderHostSetActiveFontTable(NSMutableDictionary *table, uint64_t tag) { + gWeaverActiveClientFontTable = table; + gWeaverActiveClientFontTag = table ? tag : 0; +} + static NSFont *NativeSdkRegisteredFontForId(unsigned long long value, CGFloat size) { - NSMutableDictionary *table = NativeSdkRegisteredFontDescriptors(); - static NSMutableDictionary *sizeCache = nil; + NSMutableDictionary *table = gWeaverActiveClientFontTable ?: NativeSdkRegisteredFontDescriptors(); + const uint64_t tag = gWeaverActiveClientFontTable ? gWeaverActiveClientFontTag : 0; + /* NSCache: bounded and thread-safe, so per-client tags cannot grow the + * cache without limit across client generations. */ + static NSCache *sizeCache = nil; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ - sizeCache = [[NSMutableDictionary alloc] init]; + sizeCache = [[NSCache alloc] init]; + sizeCache.countLimit = 512; }); @synchronized (table) { id descriptorObject = table[@(value)]; if (!descriptorObject) return nil; - NSString *key = [NSString stringWithFormat:@"%llu/%.3f", value, (double)size]; - NSFont *cached = sizeCache[key]; + NSString *key = [NSString stringWithFormat:@"%llu/%llu/%.3f", (unsigned long long)tag, value, (double)size]; + NSFont *cached = [sizeCache objectForKey:key]; if (cached) return cached; CTFontRef created = CTFontCreateWithFontDescriptor((__bridge CTFontDescriptorRef)descriptorObject, size, NULL); if (!created) return nil; NSFont *font = (__bridge_transfer NSFont *)created; - sizeCache[key] = font; + [sizeCache setObject:font forKey:key]; return font; } } @@ -2730,6 +2788,15 @@ static NSTextAlignment NativeSdkPacketTextAlignment(NSString *align) { // error engine-side, never a silent fallback at draw time. int native_sdk_appkit_register_font(uint64_t font_id, const uint8_t *bytes, size_t bytes_len) { if (font_id == 0 || !bytes || bytes_len == 0) return 0; + if (NativeSdkSharedRendererClientEnabled()) { + /* Device-less widget: text rasterizes in the render host, so the + * face belongs in the host's per-client font table — ahead of + * the packets whose text draws with it. Registered locally too: + * the widget's own text MEASUREMENT (native_sdk_appkit_measure_text) + * resolves through the same table, and layout must measure with + * the same face the host draws. */ + if (![[NativeSdkSharedRendererConnection sharedConnection] registerFontWithId:font_id bytes:bytes byteLength:bytes_len]) return 0; + } @autoreleasepool { NSData *data = [NSData dataWithBytes:bytes length:bytes_len]; CTFontDescriptorRef descriptor = CTFontManagerCreateFontDescriptorFromData((__bridge CFDataRef)data); @@ -12567,6 +12634,11 @@ @interface NativeSdkRenderHostClient : NSObject @property(nonatomic, assign) mach_port_t pendingReplyPort; /* The most recently exported surface, re-sent for static frames * (requiresRender == 0 never reaches the composite pass). */ +/* This client's registered font faces, keyed by the widget's font ids. + * Scoped per client (not the process table) so two widgets' font ids + * can never collide in the shared host; swapped in around each present + * via NativeSdkRenderHostSetActiveFontTable. */ +@property(nonatomic, strong) NSMutableDictionary *fontDescriptorsById; @property(nonatomic, assign) IOSurfaceRef lastSurface; @property(nonatomic, assign) uint32_t lastPixelWidth; @property(nonatomic, assign) uint32_t lastPixelHeight; @@ -12684,28 +12756,40 @@ static void NativeSdkRenderHostSendFrameReply(mach_port_t replyPort, int32_t sta * packets that will reference it, mirroring the in-process side channel. * A renderer-less client (image before first frame) gets one constructed * on the spot: uploads precede presents by contract. */ -static void NativeSdkRenderHostHandleImageUpload(NativeSdkRenderHostClient *client, WeaverRendererMachImageUpload *upload) { +static void NativeSdkRenderHostHandleResourceUpload(NativeSdkRenderHostClient *client, WeaverRendererMachResourceUpload *upload) { const mach_port_t replyPort = upload->header.msgh_remote_port; - void *pixelBytes = upload->body.msgh_descriptor_count == 1 ? upload->pixels.address : NULL; - const mach_msg_size_t pixelSize = upload->body.msgh_descriptor_count == 1 ? upload->pixels.size : 0; + void *pixelBytes = upload->body.msgh_descriptor_count == 1 ? upload->payload.address : NULL; + const mach_msg_size_t pixelSize = upload->body.msgh_descriptor_count == 1 ? upload->payload.size : 0; BOOL stored = NO; - if (weaverRendererMachImageUploadValid(upload)) { + if (weaverRendererMachResourceUploadValid(upload)) { if (!client.renderer) { client.renderer = [[NativeSdkMetalSurfaceView alloc] initHeadlessRendererWithFrame:NSMakeRect(0, 0, 1, 1)]; } - if (upload->pixels_len == 0) { - NativeSdkCanvasRemoveImage(upload->image_id, client.renderer.headlessImageStore, client.renderer.headlessImagePixelStore, client.renderer.headlessImageTextureStore); + if (!client.fontDescriptorsById) client.fontDescriptorsById = [NSMutableDictionary dictionary]; + if (upload->payload_len == 0) { + if (upload->resource_kind == kWeaverRendererMachResourceFont) { + [client.fontDescriptorsById removeObjectForKey:@(upload->resource_id)]; + } else { + NativeSdkCanvasRemoveImage(upload->resource_id, client.renderer.headlessImageStore, client.renderer.headlessImagePixelStore, client.renderer.headlessImageTextureStore); + } stored = YES; + } else if (pixelBytes && upload->resource_kind == kWeaverRendererMachResourceFont) { + NSData *face = [NSData dataWithBytes:pixelBytes length:upload->payload_len]; + CTFontDescriptorRef descriptor = CTFontManagerCreateFontDescriptorFromData((__bridge CFDataRef)face); + if (descriptor) { + client.fontDescriptorsById[@(upload->resource_id)] = (__bridge_transfer id)descriptor; + stored = YES; + } } else if (pixelBytes) { - stored = NativeSdkCanvasStoreImage(upload->image_id, upload->width, upload->height, (const uint8_t *)pixelBytes, upload->pixels_len, + stored = NativeSdkCanvasStoreImage(upload->resource_id, upload->width, upload->height, (const uint8_t *)pixelBytes, upload->payload_len, client.renderer.headlessImageStore, client.renderer.headlessImagePixelStore, client.renderer.headlessImageTextureStore); } } else { fprintf(stderr, "weaver-render-host: invalid image upload from pid=%d (id=%llu %ux%u len=%u)\n", - client.widgetPid, (unsigned long long)upload->image_id, upload->width, upload->height, upload->pixels_len); + client.widgetPid, (unsigned long long)upload->resource_id, upload->width, upload->height, upload->payload_len); } if (pixelBytes) vm_deallocate(mach_task_self(), (vm_address_t)pixelBytes, pixelSize); - WeaverRendererMachImageUploadReply reply = {0}; + WeaverRendererMachResourceUploadReply reply = {0}; reply.header.msgh_bits = MACH_MSGH_BITS(MACH_MSG_TYPE_MOVE_SEND_ONCE, 0); reply.header.msgh_remote_port = replyPort; reply.header.msgh_size = sizeof(reply); @@ -12777,6 +12861,7 @@ static void NativeSdkRenderHostHandleFrame(NativeSdkRenderHostClient *client, We client.widgetPid, frame->surface_width, frame->surface_height, frame->scale); } client.pendingReplyPort = replyPort; + NativeSdkRenderHostSetActiveFontTable(client.fontDescriptorsById, (uint64_t)client.widgetPid); const NSInteger result = [client.renderer presentGpuPacketBinaryWithSurfaceWidth:frame->surface_width height:frame->surface_height @@ -12788,6 +12873,7 @@ static void NativeSdkRenderHostHandleFrame(NativeSdkRenderHostClient *client, We representable:frame->representable != 0 packet:(const uint8_t *)packetBytes byteLength:frame->packet_len]; + NativeSdkRenderHostSetActiveFontTable(nil, 0); if (packetBytes) vm_deallocate(mach_task_self(), (vm_address_t)packetBytes, packetSize); if (client.renderer.headlessExportInFlight) return; /* the export completion sends the reply (and notes the duration) */ NativeSdkRenderHostNoteFrameDuration(client); @@ -12885,7 +12971,7 @@ int native_sdk_appkit_render_host_run(const char *bootstrap_name) { if (!strongClient) return; union { struct { WeaverRendererMachFrame frame; mach_msg_trailer_t trailer; } framed; - struct { WeaverRendererMachImageUpload upload; mach_msg_trailer_t trailer; } image; + struct { WeaverRendererMachResourceUpload upload; mach_msg_trailer_t trailer; } resource; } message; memset(&message, 0, sizeof(message)); kern_return_t frameRcv = mach_msg(&message.framed.frame.header, MACH_RCV_MSG, 0, sizeof(message), strongClient.sessionPort, MACH_MSG_TIMEOUT_NONE, MACH_PORT_NULL); @@ -12910,23 +12996,23 @@ int native_sdk_appkit_render_host_run(const char *bootstrap_name) { [clients removeObject:strongClient]; return; } - if (message.framed.frame.header.msgh_id == kWeaverRendererMachMsgImageUpload) { + if (message.framed.frame.header.msgh_id == kWeaverRendererMachMsgResourceUpload) { /* Same shape-before-content rule as frames: an * upload is either non-complex (a removal) or * complex with exactly one out-of-line descriptor. * Anything else — a smuggled port right where * pixels belong — is destroyed whole. */ - const BOOL uploadComplex = (message.image.upload.header.msgh_bits & MACH_MSGH_BITS_COMPLEX) != 0; + const BOOL uploadComplex = (message.resource.upload.header.msgh_bits & MACH_MSGH_BITS_COMPLEX) != 0; const BOOL uploadShapeOk = uploadComplex - ? (message.image.upload.body.msgh_descriptor_count == 1 && - message.image.upload.pixels.type == MACH_MSG_OOL_DESCRIPTOR) - : message.image.upload.body.msgh_descriptor_count == 0; + ? (message.resource.upload.body.msgh_descriptor_count == 1 && + message.resource.upload.payload.type == MACH_MSG_OOL_DESCRIPTOR) + : message.resource.upload.body.msgh_descriptor_count == 0; if (!uploadShapeOk) { fprintf(stderr, "weaver-render-host: malformed image upload from pid=%d destroyed\n", strongClient.widgetPid); - mach_msg_destroy(&message.image.upload.header); + mach_msg_destroy(&message.resource.upload.header); return; } - NativeSdkRenderHostHandleImageUpload(strongClient, &message.image.upload); + NativeSdkRenderHostHandleResourceUpload(strongClient, &message.resource.upload); return; } /* Shape before content: a frame must be a complex diff --git a/src/platform/macos/renderer_protocol_mach.h b/src/platform/macos/renderer_protocol_mach.h index eae05b6c..a209169e 100644 --- a/src/platform/macos/renderer_protocol_mach.h +++ b/src/platform/macos/renderer_protocol_mach.h @@ -37,7 +37,12 @@ static const uint32_t kWeaverRendererMachMaxPacket = 8 * 1024 * 1024; enum { kWeaverRendererMachMsgHello = 0x57520001, kWeaverRendererMachMsgFrame = 0x57520002, - kWeaverRendererMachMsgImageUpload = 0x57520003, + kWeaverRendererMachMsgResourceUpload = 0x57520003, +}; + +enum { + kWeaverRendererMachResourceImage = 0, + kWeaverRendererMachResourceFont = 1, }; /* Image pixels ride a side channel, exactly like the in-process binary @@ -47,6 +52,11 @@ enum { * for the stock profile (1 MiB; the widget profile's 256 KiB is enforced * client-side by the SDK before bytes ever reach this channel). */ static const uint32_t kWeaverRendererMachMaxImageBytes = 1024 * 1024; +/* Font faces ride the same channel (native_sdk_appkit_register_font is + * the other pre-packet side channel). The ceiling matches the packet + * tripwire: full font files are megabyte-scale — Noro's subset face is + * ~100 KiB; whole CJK families would exceed this deliberately. */ +static const uint32_t kWeaverRendererMachMaxFontBytes = 8 * 1024 * 1024; enum { kWeaverRendererMachStatusFailed = 0, @@ -118,21 +128,24 @@ typedef struct { uint32_t pixel_height; } WeaverRendererMachFrameReply; -/* One registered image, uploaded (or removed) ahead of the packets that - * reference it. pixels_len == 0 (with a null descriptor size) is a - * removal; the reply is the completion signal, mirroring frames. */ +/* One registered resource, uploaded (or removed) ahead of the packets + * that reference it. payload_len == 0 (with a null descriptor size) is a + * removal; the reply is the completion signal, mirroring frames. Images + * carry width/height; fonts leave them zero. */ typedef struct { mach_msg_header_t header; mach_msg_body_t body; - mach_msg_ool_descriptor_t pixels; + mach_msg_ool_descriptor_t payload; uint32_t magic; uint32_t version; uint32_t struct_size; - uint32_t pixels_len; - uint64_t image_id; + uint32_t payload_len; + uint64_t resource_id; + uint32_t resource_kind; uint32_t width; uint32_t height; -} WeaverRendererMachImageUpload; + uint32_t reserved; +} WeaverRendererMachResourceUpload; typedef struct { mach_msg_header_t header; @@ -140,22 +153,30 @@ typedef struct { uint32_t version; uint32_t status; uint32_t reserved; -} WeaverRendererMachImageUploadReply; +} WeaverRendererMachResourceUploadReply; -static inline bool weaverRendererMachImageUploadValid(const WeaverRendererMachImageUpload *upload) { +static inline bool weaverRendererMachResourceUploadValid(const WeaverRendererMachResourceUpload *upload) { if (upload->magic != kWeaverRendererMachMagic || upload->version != kWeaverRendererMachVersion || - upload->struct_size != sizeof(WeaverRendererMachImageUpload) || - upload->image_id == 0) return false; - if (upload->pixels_len == 0) { + upload->struct_size != sizeof(WeaverRendererMachResourceUpload) || + upload->resource_id == 0) return false; + if (upload->payload_len == 0) { /* removal */ - return upload->width == 0 && upload->height == 0 && upload->pixels.size == 0; + return upload->width == 0 && upload->height == 0 && upload->payload.size == 0; + } + if (upload->payload.size != upload->payload_len) return false; + switch (upload->resource_kind) { + case kWeaverRendererMachResourceImage: + return upload->width > 0 && upload->height > 0 && + upload->width <= 4096 && upload->height <= 4096 && + upload->payload_len == upload->width * upload->height * 4 && + upload->payload_len <= kWeaverRendererMachMaxImageBytes; + case kWeaverRendererMachResourceFont: + return upload->width == 0 && upload->height == 0 && + upload->payload_len <= kWeaverRendererMachMaxFontBytes; + default: + return false; } - return upload->width > 0 && upload->height > 0 && - upload->width <= 4096 && upload->height <= 4096 && - upload->pixels_len == upload->width * upload->height * 4 && - upload->pixels_len <= kWeaverRendererMachMaxImageBytes && - upload->pixels.size == upload->pixels_len; } static inline bool weaverRendererMachHelloValid(const WeaverRendererMachHello *hello) { From 9097a817c327d846fc5e8456d640f749f7038142 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Fri, 31 Jul 2026 12:12:38 -0700 Subject: [PATCH 6/7] Point the resource contains-check at the generalized names --- build.zig | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/build.zig b/build.zig index bedd09a7..731671f5 100644 --- a/build.zig +++ b/build.zig @@ -605,8 +605,8 @@ pub fn build(b: *std.Build) void { addFileContainsCheckStep(b, file_contains_checker, test_step, "test-macos-shared-renderer-images", "Verify registered images cross the renderer channel ahead of their packets", &.{ // The protocol message and its validator (pixels_len == 0 is a // removal; the ceiling mirrors canvas_limits). - .{ .path = "src/platform/macos/renderer_protocol_mach.h", .pattern = "kWeaverRendererMachMsgImageUpload" }, - .{ .path = "src/platform/macos/renderer_protocol_mach.h", .pattern = "weaverRendererMachImageUploadValid" }, + .{ .path = "src/platform/macos/renderer_protocol_mach.h", .pattern = "kWeaverRendererMachMsgResourceUpload" }, + .{ .path = "src/platform/macos/renderer_protocol_mach.h", .pattern = "weaverRendererMachResourceUploadValid" }, .{ .path = "src/platform/macos/renderer_protocol_mach.h", .pattern = "kWeaverRendererMachMaxImageBytes = 1024 * 1024" }, // One storage implementation for both worlds: the app host's // stores in-process, the per-client headless stores in the host. @@ -615,7 +615,7 @@ pub fn build(b: *std.Build) void { // The device-less client forwards uploads (and removals) instead // of storing locally; the host stores into the client's renderer. .{ .path = "src/platform/macos/appkit_host.m", .pattern = "uploadImageWithId:image_id width:width height:height rgba8:rgba8 byteLength:rgba8_len" }, - .{ .path = "src/platform/macos/appkit_host.m", .pattern = "NativeSdkRenderHostHandleImageUpload(strongClient, &message.image.upload);" }, + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "NativeSdkRenderHostHandleResourceUpload(strongClient, &message.resource.upload);" }, // An image arriving before the first frame constructs the // renderer; the export completion binds lazily so that order works. .{ .path = "src/platform/macos/appkit_host.m", .pattern = "if (!client.renderer.headlessExportCompletion) {" }, From 8ca5b4b52b21278c4188a595639cfa9ec8d4bcf4 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Fri, 31 Jul 2026 12:21:29 -0700 Subject: [PATCH 7/7] Reject unknown resource kinds before the removal branch A removal with an unrecognized kind validated and fell into the image removal path; kind membership is now checked before anything else. --- src/platform/macos/renderer_protocol_mach.h | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/platform/macos/renderer_protocol_mach.h b/src/platform/macos/renderer_protocol_mach.h index a209169e..524c6c2c 100644 --- a/src/platform/macos/renderer_protocol_mach.h +++ b/src/platform/macos/renderer_protocol_mach.h @@ -160,6 +160,8 @@ static inline bool weaverRendererMachResourceUploadValid(const WeaverRendererMac upload->version != kWeaverRendererMachVersion || upload->struct_size != sizeof(WeaverRendererMachResourceUpload) || upload->resource_id == 0) return false; + if (upload->resource_kind != kWeaverRendererMachResourceImage && + upload->resource_kind != kWeaverRendererMachResourceFont) return false; if (upload->payload_len == 0) { /* removal */ return upload->width == 0 && upload->height == 0 && upload->payload.size == 0;