Skip to content

Commit 780feed

Browse files
maleadtclaude
andcommitted
Defer pointer extraction to unsafe_convert (fixes #41 properly).
The old `MDBValue(::String)` / `MDBValue(::AbstractArray)` / `MDBValue(::Base.RefValue)` constructors called `Base.unsafe_convert` to take a raw data pointer eagerly, then stored it in the returned `MDB_val` struct. The pointer was correctly rooted *in practice* because the caller's `MDBArg` wrapper held the buffer, but the call to `unsafe_convert` happened outside of any preservation scope — violating the Julia GC contract that `unsafe_convert` should only produce a pointer while ccall is preserving the source. Push the extraction into a small set of `unsafe_convert(::Ptr{MDB_val}, ::MDBArg{D})` methods (one per `D`). These run while ccall is preserving the `MDBArg`, so the buffer is provably alive at the moment we ask it for a pointer. Drop the unsafe `MDBValue(::String)` etc. constructors; the iterator's buffer-to-MDB_val needs are served by a private `_mdb_val_for(::Vector)` that's only called inside an explicit `GC.@preserve`. The cursor iterator now fills `mdb_key_ref` lazily inside the same preserve block that wraps the ccall. Rewrites `test/common.jl` to test the cconvert / unsafe_convert chain — and along the way demonstrates the unsafety by example: the test helper has to pass the loaded `MDB_val` to a callback inside `GC.@preserve`, because reading through `mv_data` after leaving the preserve scope is exactly the bug the carrier exists to prevent. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent aa68ae7 commit 780feed

3 files changed

Lines changed: 127 additions & 89 deletions

File tree

src/common.jl

Lines changed: 53 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,59 +1,76 @@
11
const EnvironmentFlags = Unsigned
22

3-
# Build an `MDB_val` (size + raw data pointer) from a heap-rooted Julia value.
4-
# The pointer is taken via `Base.unsafe_convert` — the same primitive ccall
5-
# uses internally to lower `Ref{T}`/`Vector{T}`/`String` arguments. The caller
6-
# is responsible for keeping `val` alive across the ccall (via `GC.@preserve`),
7-
# since the resulting `MDB_val` is opaque to GC.
3+
# Zero-valued `MDB_val` sentinels — used as out-parameters and for the
4+
# "no value" form of `delete!`. Constructing a non-empty `MDB_val` from a
5+
# Julia value requires taking a raw pointer into that value, which is only
6+
# safe when the value is GC-preserved across the eventual ccall. We keep
7+
# that pointer extraction confined to `unsafe_convert(::Ptr{MDB_val}, ::MDBArg)`
8+
# below, where ccall's automatic preservation covers the carrier.
89
MDBValue() = MDB_val(zero(Csize_t), C_NULL)
910
MDBValue(::Nothing) = MDBValue()
10-
MDBValue(val::String) =
11-
MDB_val(Csize_t(sizeof(val)),
12-
Ptr{Cvoid}(Base.unsafe_convert(Ptr{UInt8}, val)))
13-
MDBValue(val::AbstractArray{T}) where {T} =
14-
MDB_val(Csize_t(sizeof(T) * length(val)),
15-
Ptr{Cvoid}(Base.unsafe_convert(Ptr{T}, val)))
16-
MDBValue(val::Base.RefValue{T}) where {T} =
17-
MDB_val(Csize_t(sizeof(T)),
18-
Ptr{Cvoid}(Base.unsafe_convert(Ptr{T}, val)))
1911

20-
# Self-rooted argument for `Ptr{MDB_val}` ccall sites: holds the
21-
# `Ref{MDB_val}` box and a reference to the data buffer the box's `mv_data`
22-
# field aliases. Returned from `cconvert` below so ccall's automatic
23-
# `GC.@preserve` covers both the box and the data — callers never need
24-
# explicit `Ref(...)`, `MDBValue(...)`, or `GC.@preserve` for input args.
12+
# Self-rooted argument for `Ptr{MDB_val}` ccall sites. `box` is an
13+
# uninitialized `Ref{MDB_val}`; `data` is the Julia-owned buffer whose
14+
# pointer the C call needs to see. `cconvert` returns an `MDBArg`, ccall
15+
# preserves it across the call, and `unsafe_convert` (below) is the one
16+
# place pointer extraction happens — that means `data` is provably alive
17+
# at the moment its pointer is taken.
2518
struct MDBArg{D}
2619
box::Base.RefValue{MDB_val}
2720
data::D
21+
MDBArg(data::D) where {D} = new{D}(Ref{MDB_val}(), data)
22+
end
23+
24+
# Lazy pointer extraction. Runs while ccall is preserving `m`; therefore
25+
# `m.data` is alive at the point we ask it for a pointer, satisfying the
26+
# Julia GC contract for `Base.unsafe_convert`.
27+
@inline function Base.unsafe_convert(::Type{Ptr{MDB_val}}, m::MDBArg{String})
28+
m.box[] = MDB_val(Csize_t(sizeof(m.data)),
29+
Ptr{Cvoid}(Base.unsafe_convert(Ptr{UInt8}, m.data)))
30+
return Base.unsafe_convert(Ptr{MDB_val}, m.box)
31+
end
32+
@inline function Base.unsafe_convert(::Type{Ptr{MDB_val}}, m::MDBArg{<:AbstractArray{T}}) where {T}
33+
m.box[] = MDB_val(Csize_t(sizeof(T) * length(m.data)),
34+
Ptr{Cvoid}(Base.unsafe_convert(Ptr{T}, m.data)))
35+
return Base.unsafe_convert(Ptr{MDB_val}, m.box)
36+
end
37+
@inline function Base.unsafe_convert(::Type{Ptr{MDB_val}}, m::MDBArg{<:Base.RefValue{T}}) where {T}
38+
m.box[] = MDB_val(Csize_t(sizeof(T)),
39+
Ptr{Cvoid}(Base.unsafe_convert(Ptr{T}, m.data)))
40+
return Base.unsafe_convert(Ptr{MDB_val}, m.box)
2841
end
29-
Base.unsafe_convert(::Type{Ptr{MDB_val}}, m::MDBArg) =
30-
Base.unsafe_convert(Ptr{MDB_val}, m.box)
3142

3243
# Bare `MDB_val` (used for `delete!`'s empty val): heap-box it.
3344
Base.cconvert(::Type{Ptr{MDB_val}}, x::MDB_val) = Ref(x)
34-
# Pre-built `Ref{MDB_val}` (used by iterator state, and as the out-param
35-
# for `get`/`mdb_cursor_get`): ccall reads/writes the box directly.
45+
# Pre-built `Ref{MDB_val}` (iterator state, `get` out-param): passthrough;
46+
# ccall reads/writes the box directly.
3647
Base.cconvert(::Type{Ptr{MDB_val}}, x::Base.RefValue{MDB_val}) = x
37-
# User input — heap-rooted forms with stable data pointers.
38-
Base.cconvert(::Type{Ptr{MDB_val}}, x::String) = MDBArg(Ref(MDBValue(x)), x)
39-
Base.cconvert(::Type{Ptr{MDB_val}}, x::Array) = MDBArg(Ref(MDBValue(x)), x)
48+
# User input — package the data, defer pointer extraction.
49+
Base.cconvert(::Type{Ptr{MDB_val}}, x::String) = MDBArg(x)
50+
Base.cconvert(::Type{Ptr{MDB_val}}, x::Array) = MDBArg(x)
4051
# Other AbstractArrays that support `unsafe_convert(Ptr{T}, x)` flow through —
4152
# contiguous `SubArray`, `ReinterpretArray`, etc. Non-contiguous inputs
4253
# surface the standard "cannot take pointer" error from `unsafe_convert`.
43-
# Matches the scope of `MDBValue(::AbstractArray)` above. The `Array`
44-
# method above stays as a more-specific overload to break the ambiguity
45-
# with `Base.cconvert(::Type{<:Ptr}, ::Array)`.
46-
Base.cconvert(::Type{Ptr{MDB_val}}, x::AbstractArray) = MDBArg(Ref(MDBValue(x)), x)
47-
Base.cconvert(::Type{Ptr{MDB_val}}, x::Base.RefValue) = MDBArg(Ref(MDBValue(x)), x)
48-
# User input — bare bitstype scalar. Wrap in a `Ref` to give it a heap
49-
# address, then build the `MDBArg`. The `Ref` lives in `MDBArg.data` and
50-
# is rooted by ccall's preserve.
54+
# The `Array` method above stays as a more-specific overload to break the
55+
# ambiguity with `Base.cconvert(::Type{<:Ptr}, ::Array)`.
56+
Base.cconvert(::Type{Ptr{MDB_val}}, x::AbstractArray) = MDBArg(x)
57+
Base.cconvert(::Type{Ptr{MDB_val}}, x::Base.RefValue) = MDBArg(x)
58+
# Bare bitstype scalar: heap-box via `Ref` so it has a stable address.
5159
function Base.cconvert(::Type{Ptr{MDB_val}}, x::T) where {T}
5260
isbitstype(T) || throw(MethodError(Base.cconvert, (Ptr{MDB_val}, x)))
53-
rx = Ref(x)
54-
MDBArg(Ref(MDBValue(rx)), rx)
61+
MDBArg(Ref(x))
5562
end
5663

64+
# Private: build an `MDB_val` whose `mv_data` aliases `buf`. The pointer
65+
# is taken via `unsafe_convert`; the caller MUST keep `buf` alive across
66+
# any ccall that reads the resulting `MDB_val` (in practice, by wrapping
67+
# the call site in `GC.@preserve buf ...`). Used by the cursor iterator,
68+
# which already threads its key buffer through the iteration state for
69+
# exactly this reason.
70+
@inline _mdb_val_for(buf::Vector{T}) where {T} =
71+
MDB_val(Csize_t(sizeof(T) * length(buf)),
72+
Ptr{Cvoid}(Base.unsafe_convert(Ptr{T}, buf)))
73+
5774
mbd_unpack(::Type{T}, mdb_val_ref::Ref{MDB_val}) where {T} = _mbd_unpack(T, mdb_val_ref[])
5875
function _mbd_unpack(::Type{T}, mdb_val::MDB_val) where {T <: String}
5976
unsafe_string(convert(Ptr{UInt8}, mdb_val.mv_data), mdb_val.mv_size)

src/cur.jl

Lines changed: 29 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -82,22 +82,24 @@ struct ReturnValueSize end
8282

8383
arcopy(x::Array) = copy(x)
8484
arcopy(x) = x
85-
# process_returns returns (retval, next_op, key_buf). key_buf is whatever the
86-
# iterator wrote into mdb_key_ref via mdb_key_ref[] = MDBValue(buf); it must
87-
# be GC-preserved across the next mdb_cursor_get call. Variants that don't
88-
# rewrite the key return `nothing` for key_buf.
85+
# process_returns returns (retval, next_op, key_buf). key_buf is the Julia
86+
# buffer the next iteration will fill `mdb_key_ref` from; it must outlive
87+
# the next mdb_cursor_get call. Variants that don't seed a SET_RANGE
88+
# return `nothing` for key_buf.
8989
process_returns(::ReturnKeys{K}, mdb_key_ref, _) where K = arcopy(mbd_unpack(K, mdb_key_ref)), MDB_NEXT, nothing
9090
process_returns(::ReturnValues{V}, _, mdb_val_ref) where V = arcopy(mbd_unpack(V, mdb_val_ref)), MDB_NEXT, nothing
9191
process_returns(::ReturnBoth{K,V}, mdb_key_ref, mdb_val_ref) where {K,V} = arcopy((mbd_unpack(K, mdb_key_ref)) => arcopy(mbd_unpack(V, mdb_val_ref))), MDB_NEXT, nothing
9292
process_returns(::ReturnValueSize, _, mdb_val_ref) = mdb_val_ref[].mv_size, MDB_NEXT, nothing
9393
function init_values(d::LMDBIterator)
94-
k, op, key_buf = if !isempty(d.prefix)
95-
Ref(MDBValue(d.prefix)), MDB_SET_RANGE, d.prefix
94+
if !isempty(d.prefix)
95+
# The Ref is initialised lazily inside `iterate`, under
96+
# `GC.@preserve key_buf`, so the pointer into `d.prefix` is
97+
# only taken when the buffer is provably alive.
98+
k = Ref(MDBValue())
99+
return k, Ref(MDBValue()), MDB_SET_RANGE, d.prefix
96100
else
97-
Ref(MDBValue()), MDB_FIRST, nothing
101+
return Ref(MDBValue()), Ref(MDBValue()), MDB_FIRST, nothing
98102
end
99-
v = Ref(MDBValue())
100-
return k, v, op, key_buf
101103
end
102104

103105
Base.iterate(iter::LMDBIterator) = Base.iterate(iter, init_values(iter))
@@ -106,10 +108,15 @@ Base.iterate(iter::LMDBIterator) = Base.iterate(iter, init_values(iter))
106108
function Base.iterate(iter::LMDBIterator, refs)
107109
mdb_key_ref, mdb_val_ref, cursor_op, key_buf = refs
108110

109-
# key_buf may be aliased by mdb_key_ref (e.g. DirectoryLister or
110-
# SET_RANGE seed) and must outlive the C call. unchecked_* because the
111-
# iterator branches on MDB_NOTFOUND itself.
112-
ret = GC.@preserve key_buf unchecked_mdb_cursor_get(iter.cur, mdb_key_ref, mdb_val_ref, cursor_op)
111+
# If we have a key buffer (SET_RANGE seed or DirectoryLister rewrite),
112+
# fill `mdb_key_ref` with its pointer under GC.@preserve — the pointer
113+
# is only valid while `key_buf` is rooted, and ccall extends that
114+
# rooting through the call. unchecked_* because the iterator branches
115+
# on MDB_NOTFOUND itself.
116+
ret = GC.@preserve key_buf begin
117+
key_buf === nothing || (mdb_key_ref[] = _mdb_val_for(key_buf))
118+
unchecked_mdb_cursor_get(iter.cur, mdb_key_ref, mdb_val_ref, cursor_op)
119+
end
113120

114121
if ret == 0
115122
#Check if we are still in key prefix
@@ -146,11 +153,16 @@ function process_returns(l::DirectoryLister{K}, mdb_key_ref, _) where K
146153
else
147154
k = copy(k)
148155
resize!(k,nextsep)
149-
kout = arcopy(mbd_unpack(K, Ref(MDBValue(k))))
156+
# Decode `k` under GC.@preserve — `_mdb_val_for(k)` takes a raw
157+
# pointer into `k` that must outlive the `mbd_unpack` read.
158+
local kout
159+
GC.@preserve k begin
160+
kref = Ref(_mdb_val_for(k))
161+
kout = arcopy(mbd_unpack(K, kref))
162+
end
150163
k[end] = k[end]+1
151-
mdb_key_ref[] = MDBValue(k)
152-
# Return k so the next iterate() can GC.@preserve it across the
153-
# MDB_SET_RANGE call that reads through mdb_key_ref.
164+
# Return `k` as the next iteration's key_buf; `iterate` will
165+
# fill `mdb_key_ref` from it under its own GC.@preserve.
154166
return kout, MDB_SET_RANGE, k
155167
end
156168
end

test/common.jl

Lines changed: 45 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import LMDB.MDBValue
21
using LMDB
32
using Test
43

@@ -9,49 +8,59 @@ using Test
98
@test_throws LMDBError throw(ex)
109
@test ex.code == 0
1110

12-
# MDBValue
13-
val = "abcd" # string
14-
mdb_val_ref = Ref(MDBValue(val));
15-
mdb_val = mdb_val_ref[]
16-
# @test val == unsafe_string(convert(Ptr{UInt8}, mdb_val.data), mdb_val.size)
17-
@test val == LMDB.mbd_unpack(String, mdb_val_ref)
11+
# cconvert / unsafe_convert dispatch. Mirrors what ccall does: takes
12+
# an input value, runs the cconvert chain to get a carrier, then
13+
# passes the carrier + the resulting `MDB_val` to `f` inside a
14+
# `GC.@preserve` so pointer extraction is correctly scoped. We can't
15+
# return the `MDB_val` and read its `mv_data` afterwards — that's
16+
# exactly the unsafety the carrier exists to prevent.
17+
function with_mdb_val(f, x)
18+
arg = Base.cconvert(Ptr{LMDB.MDB_val}, x)
19+
GC.@preserve arg begin
20+
ptr = Base.unsafe_convert(Ptr{LMDB.MDB_val}, arg)
21+
f(unsafe_load(ptr))
22+
end
23+
end
24+
25+
# String
26+
val = "abcd"
27+
with_mdb_val(val) do mdb_val
28+
@test mdb_val.mv_size == sizeof(val)
29+
@test unsafe_string(Ptr{UInt8}(mdb_val.mv_data), mdb_val.mv_size) == val
30+
end
1831

19-
val = [1233] # dense array
32+
# Int array
33+
val = [1233]
2034
T = eltype(val)
21-
val_size = sizeof(val)
22-
mdb_val_ref = Ref(MDBValue(val));
23-
mdb_val = mdb_val_ref[]
24-
@test val_size == mdb_val.mv_size
25-
nvals = floor(Int, mdb_val.mv_size/sizeof(T))
26-
value = unsafe_wrap(Array, convert(Ptr{T}, mdb_val.mv_data), nvals)
27-
@test val == value
28-
@test val == LMDB.mbd_unpack(Vector{Int}, mdb_val_ref)
35+
with_mdb_val(val) do mdb_val
36+
@test mdb_val.mv_size == sizeof(val)
37+
nvals = floor(Int, mdb_val.mv_size / sizeof(T))
38+
value = unsafe_wrap(Array, convert(Ptr{T}, mdb_val.mv_data), nvals)
39+
@test val == value
40+
end
2941

42+
# UInt16 array
3043
val = [0x0003, 0xff45]
31-
val_size = sizeof(val)
3244
T = eltype(val)
33-
mdb_val_ref = Ref(MDBValue(val));
34-
mdb_val = mdb_val_ref[]
35-
@test val_size == mdb_val.mv_size
36-
nvals = floor(Int, mdb_val.mv_size/sizeof(T))
37-
value = unsafe_wrap(Array, convert(Ptr{T}, mdb_val.mv_data), nvals)
38-
@test val == value
39-
@test val == LMDB.mbd_unpack(Vector{UInt16}, mdb_val_ref)
45+
with_mdb_val(val) do mdb_val
46+
@test mdb_val.mv_size == sizeof(val)
47+
nvals = floor(Int, mdb_val.mv_size / sizeof(T))
48+
value = unsafe_wrap(Array, convert(Ptr{T}, mdb_val.mv_data), nvals)
49+
@test val == value
50+
end
4051

52+
# Isbits struct: flows through the bitstype cconvert branch
53+
# (heap-boxed via `Ref(x)` so it has a stable address).
4154
struct TestType
4255
i::Int
4356
j::Char
4457
end
45-
val = TestType(1,'a') # struct
46-
val_size = sizeof(val)
58+
val = TestType(1, 'a')
4759
T = typeof(val)
48-
@test_throws MethodError MDBValue(val)
49-
val = [val]
50-
mdb_val_ref = Ref(MDBValue(val));
51-
mdb_val = mdb_val_ref[]
52-
@test val_size == mdb_val.mv_size
53-
nvals = floor(Int, mdb_val.mv_size/sizeof(T))
54-
value = unsafe_wrap(Array, convert(Ptr{T}, mdb_val.mv_data), nvals)
55-
@test val == value
56-
@test val == LMDB.mbd_unpack(Vector{T}, mdb_val_ref)
57-
@test val[1] == LMDB.mbd_unpack(T, mdb_val_ref)
60+
with_mdb_val(val) do mdb_val
61+
@test mdb_val.mv_size == sizeof(T)
62+
@test unsafe_load(Ptr{T}(mdb_val.mv_data)) == val
63+
end
64+
65+
# Non-isbits values have no pointer-stable shape.
66+
@test_throws MethodError with_mdb_val(_ -> nothing, (1, [2]))

0 commit comments

Comments
 (0)