#5184·libvips

vips_sink_screen: crash, background render thread can use a freed Render

Author: octopus-kkkCreated Aug 13, 2026Updated Sep 3, 2026
Labelsbug

Bug report

Description

vips_sink_screen() can crash: the background render thread can pick up a Render whose reference count has already reached zero, and then use it while the thread that dropped the last reference is freeing it.

render_dirty_all is a worklist of raw Render * pointers which holds no reference of its own. render_dirty_get() takes the reference when it pulls one off, and render_free() unlinks — both under render_dirty_lock. But render_unref() drops the count outside that lock:

c
:315   kill = g_atomic_ref_count_dec(&render->ref_count);   /* not under the lock */
:325   if (kill) render_free(render);                       /* takes it only at :253 */

Between :315 and :253 the render has reached zero but is still on the worklist:

T_user                                      T_render (background render thread)
──────────────────────────────────────────  ────────────────────────────────────
render_close_cb() -> render_unref()
  :315 dec -> 1->0, kill = TRUE
  :325 render_free()
  :253 g_mutex_lock(&render_dirty_lock)
       --- blocks: the render thread holds it ---
                                            render_dirty_get()
                                            :1017 g_mutex_lock(&render_dirty_lock)
                                            :1023 head of render_dirty_all
                                                  (still linked)
                                            :1028 render_ref()   <-- 0 -> 1
                                            :1030 unlink
                                            :1033 unlock
       --- acquires the lock ---
  :254 g_slist_find() -> no-op, already gone
  :278 VIPS_UNREF(render->in)
  :280 g_free(render)
                                            vips_threadpool_run(render->in, ...)
                                                                 ^ freed

The window is not a few instructions wide: T_user blocks on the lock at :253, so it stays open for as long as the render thread holds render_dirty_lock.

This needs notify_fn != NULL. tile_queue() only calls render_dirty_put() on the if (render->notify) branch; with a NULL notify the tile is painted synchronously and the render never reaches the worklist.

Reproducible example

Self-contained, no image files, no sanitizer needed.

c
/* gcc -g -o mre mre.c `pkg-config vips --cflags --libs` */

#include <stdio.h>
#include <glib.h>
#include <vips/vips.h>

/* A non-NULL notify is what selects the asynchronous path.
 */
static void
notify_cb(VipsImage *im, VipsRect *rect, void *a)
{
}

int
main(int argc, char **argv)
{
	if (VIPS_INIT(argv[0]))
		vips_error_exit(NULL);

	for (int i = 0; i < 4000; i++) {
		VipsImage *in, *out;
		VipsRegion *region;
		VipsRect r = { 0, 0, 64, 64 };

		if (vips_gaussnoise(&in, 2048, 2048, NULL))
			vips_error_exit(NULL);

		out = vips_image_new();
		if (vips_sink_screen(in, out, NULL, 64, 64, 32, 0, notify_cb, NULL))
			vips_error_exit(NULL);

		/* Dirty a tile: this queues the render for the background thread.
		 */
		if ((region = vips_region_new(out))) {
			(void) vips_region_prepare(region, &r);
			g_object_unref(region);
		}

		/* ... and drop it again straight away.
		 */
		g_object_unref(out);
		g_object_unref(in);
	}

	printf("survived\n");

	return 0;
}
bash
for i in $(seq 20); do ./mre >/dev/null 2>&1 || echo crash; done

Expected result

All 20 runs print survived.

Actual result

Most runs die — 13 and 16 out of 20 in two batches here — almost all with SIGSEGV, occasionally with SIGABRT and glibc reporting corrupted double-linked list.

Environment

  • OS: Linux x86_64, glibc 2.39, glib 2.80.0, gcc 13
  • libvips version: master 8805627b, meson setup build -Doptimization=2 -Ddebug=false
  • 8.18.5 carries the identical render_unref() and the same raw-pointer worklist, so it looks affected too.