Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 138 additions & 0 deletions core/src/integrationTest/java/app/photofox/vipsffm/VBlobArenaTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
package app.photofox.vipsffm;

import app.photofox.vipsffm.jextract.VipsRaw;
import java.lang.foreign.Arena;
import java.lang.foreign.MemorySegment;
import java.lang.foreign.ValueLayout;
import java.nio.ByteBuffer;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.*;

class VBlobArenaTest {

@Test
void scopedBufferExpiresWithArena() {
ByteBuffer buffer;
try (var arena = Arena.ofConfined()) {
var blob = VBlob.newFromBytes(arena, new byte[]{1, 2, 3});
buffer = blob.asArenaScopedByteBuffer();
assertTrue(buffer.isDirect());
assertEquals(3, buffer.remaining());
assertEquals(1, buffer.get(0));
}
assertExpired(buffer);
}

@Test
void dataSegmentUsesBlobArena() {
try (var arena = Arena.ofConfined()) {
var blob = VBlob.newFromBytes(arena, new byte[]{1, 2, 3});
var data = blob.getUnsafeDataAddress();
assertEquals(3, data.byteSize());
assertEquals(arena.scope(), data.scope());
}
}

@Test
void confinedBufferRetainsThreadConfinement() {
try (var arena = Arena.ofConfined()) {
var buffer = VBlob.newFromBytes(arena, new byte[]{1}).asArenaScopedByteBuffer();
var otherThread = new Thread(() -> {});
assertFalse(MemorySegment.ofBuffer(buffer).isAccessibleBy(otherThread));
}
}

@Test
void sharedBufferCanBeReadByAnotherThreadUntilArenaCloses() throws InterruptedException {
ByteBuffer buffer;
try (var arena = Arena.ofShared()) {
buffer = VBlob.newFromBytes(arena, new byte[]{42}).asArenaScopedByteBuffer();
var value = new AtomicInteger();
var failure = new AtomicReference<Throwable>();
var thread = new Thread(() -> {
try {
value.set(buffer.get(0));
} catch (Throwable error) {
failure.set(error);
}
});
thread.start();
thread.join();
assertNull(failure.get());
assertEquals(42, value.get());
}
assertExpired(buffer);
}

@Test
void copiesRemainUsableAfterArenaCloses() {
byte[] bytes;
ByteBuffer clone;
try (var arena = Arena.ofConfined()) {
var blob = VBlob.newFromBytes(arena, new byte[]{1, 2, 3});
bytes = blob.getBytes();
clone = blob.asClonedByteBuffer();
}
assertArrayEquals(new byte[]{1, 2, 3}, bytes);
assertEquals(ByteBuffer.wrap(bytes), clone);
}

@Test
void encodedImageBufferAlsoExpiresWithArena() {
ByteBuffer buffer;
try (var arena = Arena.ofConfined()) {
var png = VImage.black(arena, 16, 16).pngsaveBuffer();
buffer = png.asArenaScopedByteBuffer();
assertEquals((byte) 137, buffer.get(0));
}
assertExpired(buffer);
}

@Test
void imageMetadataBlobExpiresWithArena() {
ByteBuffer buffer;
try (var arena = Arena.ofConfined()) {
var image = VImage.black(arena, 4, 4)
.set("test-blob", VBlob.newFromBytes(arena, new byte[]{7, 8, 9}));
buffer = image.getBlob("test-blob").asArenaScopedByteBuffer();
assertEquals(3, buffer.remaining());
assertEquals(9, buffer.get(2));
}
assertExpired(buffer);
}

@Test
void helperBlobGetIsBoundToArena() {
MemorySegment data;
try (var arena = Arena.ofConfined()) {
var blob = VBlob.newFromBytes(arena, new byte[]{4, 5, 6});
var lengthOut = arena.allocate(VipsRaw.C_LONG);
data = VipsHelper.blob_get(arena, blob.getUnsafeStructAddress(), lengthOut);
assertEquals(arena.scope(), data.scope());
assertEquals(3, lengthOut.get(VipsRaw.C_LONG, 0));
assertArrayEquals(new byte[]{4, 5, 6}, data.reinterpret(3).toArray(ValueLayout.JAVA_BYTE));
}
assertFalse(data.scope().isAlive());
}

@Test
void helperImageGetDataIsBoundToArena() {
MemorySegment data;
try (var arena = Arena.ofConfined()) {
var image = VImage.black(arena, 2, 2);
data = VipsHelper.image_get_data(arena, image.getUnsafeStructAddress());
assertEquals(arena.scope(), data.scope());
assertEquals(0, data.reinterpret(4).get(ValueLayout.JAVA_BYTE, 0));
}
assertFalse(data.scope().isAlive());
}

private static void assertExpired(ByteBuffer buffer) {
// Check metadata first: the unpatched implementation must never read freed memory.
assertFalse(MemorySegment.ofBuffer(buffer).scope().isAlive());
assertThrows(IllegalStateException.class, () -> buffer.get(0));
}
}
6 changes: 4 additions & 2 deletions core/src/main/java/app/photofox/vipsffm/VBlob.java
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ public MemorySegment getUnsafeStructAddress() throws VipsError {
///
/// Gets the raw [MemorySegment] (C pointer) for the data in this blob
///
/// Sliced to the length of the data, which isn't always null terminated
/// Sized to the length of the data, which isn't always null terminated, and scoped to this blob's arena
public MemorySegment getUnsafeDataAddress() throws VipsError {
var lengthOutPointer = arena.allocate(C_LONG);
var dataPointer = VipsRaw.vips_blob_get(
Expand All @@ -81,7 +81,9 @@ public MemorySegment getUnsafeDataAddress() throws VipsError {
if (length < 0) {
throw new VipsError("unexpected length of vblob data " + length);
}
return dataPointer.asSlice(0, length);
// Downcall pointers have global scope; slicing alone does not bind their lifetime to the arena.
// The blob already owns cleanup, so this view must not register another deallocator.
return dataPointer.reinterpret(length, arena, null);
}

/// Size of the data in this blob
Expand Down
2 changes: 1 addition & 1 deletion core/src/main/java/app/photofox/vipsffm/VImage.java
Original file line number Diff line number Diff line change
Expand Up @@ -10465,7 +10465,7 @@ public VBlob getBlob(String name) {
if (blobLength <= 0) {
throw new VipsError("failed to read length of type blob from field: " + name);
}
var dataSegment = outPointer.get(VipsRaw.C_POINTER, 0).reinterpret(blobLength);
var dataSegment = outPointer.get(VipsRaw.C_POINTER, 0).reinterpret(blobLength, arena, null);
return VBlob.newFromDataSegment(arena, dataSegment);
}

Expand Down
4 changes: 2 additions & 2 deletions core/src/main/java/app/photofox/vipsffm/VipsHelper.java
Original file line number Diff line number Diff line change
Expand Up @@ -410,7 +410,7 @@ public static MemorySegment blob_get(Arena arena, MemorySegment blob, MemorySegm
if(!VipsValidation.isValidPointer(result)) {
VipsValidation.throwInvalidOutputError("vips_blob_get", "result");
}
result = result.reinterpret(arena, VipsRaw::g_object_unref);
result = result.reinterpret(arena, null);
return result;
}

Expand Down Expand Up @@ -1645,7 +1645,7 @@ public static MemorySegment image_get_data(Arena arena, MemorySegment image) thr
if(!VipsValidation.isValidPointer(result)) {
VipsValidation.throwInvalidOutputError("vips_image_get_data", "result");
}
result = result.reinterpret(arena, VipsRaw::g_object_unref);
result = result.reinterpret(arena, null);
return result;
}

Expand Down
2 changes: 1 addition & 1 deletion docs/app.photofox.vipsffm/app/photofox/vipsffm/VBlob.html
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ <h3>getUnsafeDataAddress</h3>
throws <span class="exceptions"><a href="VipsError.html" title="class in app.photofox.vipsffm">VipsError</a></span></div>
<div class="block"><p>Not recommended for use, use <a href="#asArenaScopedByteBuffer()"><code>asArenaScopedByteBuffer()</code></a> or <a href="#asClonedByteBuffer()"><code>asClonedByteBuffer()</code></a> instead</p>
<p>Gets the raw <a href="https://docs.oracle.com/en/java/javase/23/docs/api/java.base/java/lang/foreign/MemorySegment.html" title="class or interface in java.lang.foreign" class="external-link"><code>MemorySegment</code></a> (C pointer) for the data in this blob</p>
<p>Sliced to the length of the data, which isn't always null terminated</p>
<p>Sized to the length of the data, which isn't always null terminated, and scoped to this blob's arena</p>
</div>
<dl class="notes">
<dt>Throws:</dt>
Expand Down
2 changes: 1 addition & 1 deletion docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ <h2 id="usage-heading">Usage</h2>
}

dependencies {
implementation(&quot;app.photofox.vips-ffm:vips-ffm-core:1.9.8&quot;)
implementation(&quot;app.photofox.vips-ffm:vips-ffm-core:1.9.9&quot;)
}
</code></pre>
<p>Figure out what you're trying to do by looking at the <a href="https://www.libvips.org/API/current/">libvips documentation</a>
Expand Down
2 changes: 1 addition & 1 deletion generator/src/main/java/vipsffm/GenerateVClasses.java
Original file line number Diff line number Diff line change
Expand Up @@ -1110,7 +1110,7 @@ private static List<MethodSpec> buildImageGetSetMethods() {
.endControlFlow()
.build()
);
getMethodBuilder.addStatement("var dataSegment = outPointer.get($T.C_POINTER, 0).reinterpret(blobLength)", vipsRawType);
getMethodBuilder.addStatement("var dataSegment = outPointer.get($T.C_POINTER, 0).reinterpret(blobLength, arena, null)", vipsRawType);
getMethodBuilder.addStatement("return VBlob.newFromDataSegment(arena, dataSegment)");
} else if (poetValueType.equals(vimageType)) {
// VImage **
Expand Down
15 changes: 13 additions & 2 deletions generator/src/main/java/vipsffm/GenerateVipsHelperClass.java
Original file line number Diff line number Diff line change
Expand Up @@ -358,10 +358,21 @@ private static boolean addDeallocCodeblockIfOutType(
return true;
}

var isReturnType = externType.name().isBlank() && externType.pointerDepth() == 1;

// const data pointers are borrowed from the object that owns them, so they are bound to the arena but never freed
if (isReturnType && externType.type().equals("void") && externType.isConst()) {
methodBuilder.addCode(
CodeBlock.builder()
.addStatement(name + " = " + name + ".reinterpret(arena, null)")
.build()
);
return true;
}

// newly allocated return types have a depth of 1
var isNewReturnAlloc =
(externType.name().isBlank() &&
externType.pointerDepth() == 1 && !externType.type().equals("char") && !externType.raw().equals("void *"));
(isReturnType && !externType.type().equals("char") && !externType.type().equals("void"));

if (isNewReturnAlloc) {
methodBuilder.addCode(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ public void run(Arena arena, Path workingDirectory) throws Exception {
var rawDataSegment = blob.getUnsafeDataAddress();
var rawByteSize = (int) blob.byteSize();

if (!rawDataSegment.scope().equals(arena.scope())) {
throw new RuntimeException("blob data view is not scoped to its arena");
}

var remainingBytes = bytes.remaining();
if (remainingBytes < 50000L || remainingBytes > 100000L) {
throw new RuntimeException("number of bytes in buffer out of range " + remainingBytes);
Expand Down
Loading