diff --git a/source/dcompute/driver/cuda/buffer.d b/source/dcompute/driver/cuda/buffer.d index b1c7b13..11bc732 100644 --- a/source/dcompute/driver/cuda/buffer.d +++ b/source/dcompute/driver/cuda/buffer.d @@ -9,11 +9,20 @@ struct Buffer(T) // Host memory associated with this buffer T[] hostMemory; + // Shared atomic reference count for the owned CUdeviceptr (see rcCreate/ + // rcRetain/rcRelease in dcompute.driver.cuda). null when this Buffer does + // not own a handle (e.g. a non-owning view created via + // UnifiedBuffer.asBuffer). Copies share the same counter so the underlying + // device allocation is freed exactly once, when the last owner dies. + private shared(int)* _rc; + this(size_t elems) { status = cast(Status)cuMemAlloc(&raw,elems * T.sizeof); checkErrors(); hostMemory = null; + if (raw != 0) + _rc = rcCreate(); } this(T[] arr) @@ -21,6 +30,29 @@ struct Buffer(T) status = cast(Status)cuMemAlloc(&raw,arr.length * T.sizeof); checkErrors(); hostMemory = arr; + if (raw != 0) + _rc = rcCreate(); + } + + // Copies bump the shared refcount so the handle survives until the last + // copy is destroyed. + this(this) + { + rcRetain(_rc); + } + + // Last owner frees the device allocation. The CUresult is deliberately + // ignored: throwing from a destructor (which may run as a GC finalizer, on + // a thread without a current CUDA context, or after driver teardown at + // program exit) is unsafe — in those cases cuMemFree simply reports an + // error we cannot act on anyway. + ~this() + { + if (rcRelease(_rc) && raw != 0) + cuMemFree(raw); + raw = 0; + // hostMemory is a GC-owned slice: never free it, just drop the reference. + hostMemory = null; } void copy(Copy c)() { @@ -34,13 +66,31 @@ struct Buffer(T) } checkErrors(); } - alias hostArgOf(U : GlobalPointer!T) = raw; + alias hostArgOf(U : GlobalPointer!T) = raw; void release() { - status = cast(Status)cuMemFree(raw); - checkErrors(); + if (_rc !is null) + { + // Owning buffer: drop this copy's ownership. Only the LAST owner + // actually frees — with other copies still alive the free is + // deferred to the last copy's release()/~this(), so the surviving + // copies keep a valid handle and nothing is freed twice. + if (rcRelease(_rc) && raw != 0) + { + status = cast(Status)cuMemFree(raw); + checkErrors(); + } + } + else if (raw != 0) + { + // Non-owning / manually assembled buffer: legacy behaviour, free + // immediately (the caller manages the lifetime by hand). + status = cast(Status)cuMemFree(raw); + checkErrors(); + } raw = 0; hostMemory = null; + // Handle and ownership are both gone; ~this is now a guaranteed no-op. } } diff --git a/source/dcompute/driver/cuda/context.d b/source/dcompute/driver/cuda/context.d index f879885..12cc4a5 100644 --- a/source/dcompute/driver/cuda/context.d +++ b/source/dcompute/driver/cuda/context.d @@ -5,12 +5,38 @@ import dcompute.driver.cuda; struct Context { CUcontext raw; + + // Shared atomic refcount for an OWNED CUcontext (one created via + // cuCtxCreate); see rcCreate/rcRetain/rcRelease in dcompute.driver.cuda. + // null = non-owning view. The static factory paths pop() and current() + // intentionally leave _rc null: they reference an existing context owned + // elsewhere (e.g. the runtime's _defaultContext), so a transient copy + // must NOT destroy it. + private shared(int)* _rc; + this(Device dev, uint flags = 0) { status = cast(Status)cuCtxCreate(&raw, flags,dev.raw); checkErrors(); + if (raw !is null) + _rc = rcCreate(); } - + + this(this) + { + rcRetain(_rc); + } + + // Last owner destroys the context. The CUresult is deliberately ignored: + // a destructor must not throw (it may run as a GC finalizer or during + // shutdown after the driver is already torn down). + ~this() + { + if (rcRelease(_rc) && raw !is null) + cuCtxDestroy(raw); + raw = null; + } + static void push(Context ctx) { status = cast(Status)cuCtxPushCurrent(ctx.raw); @@ -19,6 +45,8 @@ struct Context static Context pop() { + // ret._rc stays null: this references an EXISTING context being popped + // off the stack, owned elsewhere. A non-owning view must not destroy it. Context ret; status = cast(Status)cuCtxPopCurrent(&ret.raw); checkErrors(); @@ -26,6 +54,8 @@ struct Context } static @property Context current() { + // ret._rc stays null: non-owning view of the current context (owned by + // whoever created it, e.g. the runtime). Its destructor is a no-op. Context ret; status = cast(Status)cuCtxGetCurrent(&ret.raw); checkErrors(); @@ -104,9 +134,27 @@ struct Context checkErrors(); } + // Deprecated: cuCtxDetach is a legacy refcount-decrement API. The RAII + // destructor (~this) uses cuCtxDestroy for owned contexts. For an OWNED + // context this drops one owner and only the last owner actually detaches + // (so surviving copies never see a destroyed handle); the handle and + // ownership are always cleared here, making a later ~this a no-op. void detach() { - status = cast(Status)cuCtxDetach(raw); - checkErrors(); + if (_rc !is null) + { + if (rcRelease(_rc) && raw !is null) + { + status = cast(Status)cuCtxDetach(raw); + checkErrors(); + } + } + else if (raw !is null) + { + // Non-owning view: legacy behaviour, detach immediately. + status = cast(Status)cuCtxDetach(raw); + checkErrors(); + } + raw = null; } } diff --git a/source/dcompute/driver/cuda/event.d b/source/dcompute/driver/cuda/event.d index 3cc1367..7ef4a38 100644 --- a/source/dcompute/driver/cuda/event.d +++ b/source/dcompute/driver/cuda/event.d @@ -5,5 +5,40 @@ import dcompute.driver.cuda; struct Event { CUevent raw; - + + // Shared atomic refcount for an OWNED CUevent (see rcCreate/rcRetain/ + // rcRelease in dcompute.driver.cuda). null = non-owning view. + // Default-constructed Events keep _rc null, so ~this is a no-op for them. + private shared(int)* _rc; + + /** + * Create an event. + * + * Params: + * flags = CU_EVENT_* creation flags (pass 0 / CU_EVENT_DEFAULT for the + * default behaviour; CU_EVENT_BLOCKING_SYNC, + * CU_EVENT_DISABLE_TIMING, ... to opt in to other modes). + */ + this(uint flags) + { + status = cast(Status)cuEventCreate(&raw, flags); + checkErrors(); + if (raw !is null) + _rc = rcCreate(); + } + + this(this) + { + rcRetain(_rc); + } + + // Last owner destroys the event. The CUresult is deliberately ignored: + // a destructor must not throw (it may run as a GC finalizer or during + // shutdown after the driver is already torn down). + ~this() + { + if (rcRelease(_rc) && raw !is null) + cuEventDestroy(raw); + raw = null; + } } diff --git a/source/dcompute/driver/cuda/package.d b/source/dcompute/driver/cuda/package.d index ab71724..2afabe1 100644 --- a/source/dcompute/driver/cuda/package.d +++ b/source/dcompute/driver/cuda/package.d @@ -17,6 +17,59 @@ public import dcompute.driver.cuda.queue; public import dcompute.driver.cuda.unified_buffer; public import dcompute.driver.cuda.runtime; +// --------------------------------------------------------------------------- +// Shared atomic refcount helpers for the RAII driver wrappers (Buffer, Queue, +// Context, Program, Event). +// +// The counter is allocated with C malloc, NOT the GC: struct destructors also +// run as GC finalizers, and during a GC sweep other GC blocks (which a +// GC-allocated counter would be) may already have been freed — touching them +// from a finalizer is undefined behaviour. malloc'd memory stays valid until +// we free() it ourselves, which happens exactly once, when the count hits 0. +// --------------------------------------------------------------------------- + +/// Allocate a new refcount initialised to 1 (a single owner). +/// Returns null on out-of-memory; callers treat null as "non-owning", so the +/// handle is then never auto-destroyed (a leak, never a crash/double-free). +package shared(int)* rcCreate() nothrow @nogc +{ + import core.stdc.stdlib : malloc; + auto rc = cast(shared(int)*) malloc(int.sizeof); + if (rc !is null) + { + import core.atomic : atomicStore; + atomicStore(*rc, 1); + } + return rc; +} + +/// Postblit helper: one more owner shares the handle. Safe on null. +package void rcRetain(shared(int)* rc) nothrow @nogc +{ + import core.atomic : atomicOp; + if (rc !is null) + atomicOp!"+="(*rc, 1); +} + +/// Destructor/release helper: drop one owner. Returns true iff this was the +/// LAST owner — the caller must then destroy the CUDA handle. Frees the +/// counter and nulls the caller's pointer either way, so any subsequent +/// ~this()/release() on the same struct is a guaranteed no-op. +package bool rcRelease(ref shared(int)* rc) nothrow @nogc +{ + import core.atomic : atomicOp; + if (rc is null) + return false; + immutable last = atomicOp!"-="(*rc, 1) == 0; + if (last) + { + import core.stdc.stdlib : free; + free(cast(void*) rc); + } + rc = null; + return last; +} + enum Copy { hostToDevice, diff --git a/source/dcompute/driver/cuda/program.d b/source/dcompute/driver/cuda/program.d index f0c3e08..1cb90f0 100644 --- a/source/dcompute/driver/cuda/program.d +++ b/source/dcompute/driver/cuda/program.d @@ -6,7 +6,29 @@ import std.string; struct Program { CUmodule raw; - + + // Shared atomic refcount for an OWNED CUmodule (see rcCreate/rcRetain/ + // rcRelease in dcompute.driver.cuda). null = non-owning. The + // fromFile/fromString/fromModule factories DO create a module and set + // _rc, so the returned Program owns it. Copies share the counter. + private shared(int)* _rc; + + this(this) + { + rcRetain(_rc); + } + + // Last owner unloads the module. The CUresult is deliberately ignored (a + // destructor must not throw) — this matters for globalProgram-style + // usage, where the dtor may run at shutdown after the CUDA context is + // torn down; a cuModuleUnload error there is harmless. + ~this() + { + if (rcRelease(_rc) && raw !is null) + cuModuleUnload(raw); + raw = null; + } + Kernel!void getKernelByName(immutable(char)* name) { Kernel!void ret; @@ -28,6 +50,8 @@ struct Program Program ret; status = cast(Status)cuModuleLoad(&ret.raw,name.toStringz); checkErrors(); + if (ret.raw !is null) + ret._rc = rcCreate(); // freshly loaded module: take ownership return ret; } @@ -36,6 +60,8 @@ struct Program Program ret; status = cast(Status)cuModuleLoadData(&ret.raw,name.toStringz); checkErrors(); + if (ret.raw !is null) + ret._rc = rcCreate(); // freshly loaded module: take ownership return ret; } @@ -76,6 +102,8 @@ struct Program Program ret; mixin("status = cast(Status)cuModuleLoadData(&ret.raw, &" ~ symbolName ~ ");"); checkErrors(); + if (ret.raw !is null) + ret._rc = rcCreate(); // freshly loaded module: take ownership return ret; } else @@ -94,10 +122,27 @@ struct Program //cuModuleLoadDataEx //cuModuleLoadFatBinary + // Drops this copy's ownership; only the LAST owner actually unloads the + // module, so surviving copies never see an unloaded handle and nothing is + // unloaded twice. Handle and ownership are cleared either way, making a + // later ~this a guaranteed no-op. void unload() { - status = cast(Status)cuModuleUnload(raw); - checkErrors(); + if (_rc !is null) + { + if (rcRelease(_rc) && raw !is null) + { + status = cast(Status)cuModuleUnload(raw); + checkErrors(); + } + } + else if (raw !is null) + { + // Non-owning view: legacy behaviour, unload immediately. + status = cast(Status)cuModuleUnload(raw); + checkErrors(); + } + raw = null; } //TODO: linkstate diff --git a/source/dcompute/driver/cuda/queue.d b/source/dcompute/driver/cuda/queue.d index 09e9f6b..fd7ecbc 100644 --- a/source/dcompute/driver/cuda/queue.d +++ b/source/dcompute/driver/cuda/queue.d @@ -5,17 +5,43 @@ import dcompute.driver.cuda; struct Queue { CUstream raw; + + // Shared atomic refcount for the owned CUstream (see rcCreate/rcRetain/ + // rcRelease in dcompute.driver.cuda). null = non-owning view (e.g. + // Queue.init). Copies (enqueue copies a Queue by value into Call) share + // the counter so the stream is destroyed exactly once. + private shared(int)* _rc; + this (bool async) { status = cast(Status)cuStreamCreate(&raw, async ? 1 : 0); checkErrors(); + if (raw !is null) + _rc = rcCreate(); } this (bool async, int priority) { status = cast(Status)cuStreamCreateWithPriority(&raw, async ? 1 : 0, priority); checkErrors(); + if (raw !is null) + _rc = rcCreate(); } - + + this(this) + { + rcRetain(_rc); + } + + // Last owner destroys the stream. The CUresult is deliberately ignored: + // a destructor must not throw (it may run as a GC finalizer or during + // shutdown after the driver is already torn down). + ~this() + { + if (rcRelease(_rc) && raw !is null) + cuStreamDestroy(raw); + raw = null; + } + @property bool async() { uint ret; diff --git a/source/dcompute/driver/cuda/unified_buffer.d b/source/dcompute/driver/cuda/unified_buffer.d index 9f47765..b2e3f6c 100644 --- a/source/dcompute/driver/cuda/unified_buffer.d +++ b/source/dcompute/driver/cuda/unified_buffer.d @@ -149,6 +149,9 @@ struct UnifiedBuffer(T) Buffer!T b; b.raw = raw; b.hostMemory = hostSlice; + // Intentionally leave b._rc null: this is a non-owning view. The + // transient Buffer must NOT cuMemFree the UnifiedBuffer's allocation + // when it dies — UnifiedBuffer.release() owns that lifetime. return b; } diff --git a/source/dcompute/tests/main.d b/source/dcompute/tests/main.d index c32d33d..8179602 100644 --- a/source/dcompute/tests/main.d +++ b/source/dcompute/tests/main.d @@ -115,6 +115,15 @@ int main(string[] args) version(DComputeTestCUDA) { + // RAII destructor / refcount tests. These exercise the shared-atomic + // refcount postblits + destructors added to Buffer/Queue/Context/ + // UnifiedBuffer. They need only a live CUDA context (set up by + // dcompute.driver.cuda.runtime's module constructors) and never launch a + // kernel, so they run on ANY LDC -independent of the embedded-PTX gate + // below. They use their own local arrays and never touch res/x/y, so the + // saxpy validation at the end of main is unaffected. + runRaiiDestructorTests(); + // The injected-PTX feature (Program.fromModule / launch!) needs an LDC that // embeds device PTX into the binary: D frontend __VERSION__ >= 2113 (LDC >= 1.43). // On older compilers these tests are skipped rather than failing to build. @@ -206,4 +215,355 @@ int main(string[] args) return 0; } +version(DComputeTestCUDA) +{ + // RAII destructor / refcount tests + // + // These exercise the shared-atomic refcount (this(this) + ~this) added to + // Buffer, Queue, Context and the non-owning UnifiedBuffer.asBuffer view. + // They only need a live CUDA context (created by + // dcompute.driver.cuda.runtime's module constructors before main) and never + // launch a kernel, so they run independently of the embedded-PTX __VERSION__ + // gate. Each test uses its own local arrays — none touch the saxpy res/x/y. + // + // Detection model: a destructor SWALLOWS its CUresult (it must not throw), + // so a double cuMemFree/cuStreamDestroy/cuCtxDestroy does not surface from + // the dtor itself. Instead it poisons the CUDA context, so each "must not + // double free" test allocates/uses a fresh handle AFTERWARDS — that follow-up + // op goes through checkErrors() and throws if the context was corrupted. + + // Receive a by-value copy and let it die at function return. The postblit + // bumped the shared refcount on the way in; ~this decrements it on the way + // out. With the original still alive the count must not hit zero here. + private void dropBufferCopy(T)(Buffer!T copy) {} + private void dropQueueCopy(Queue copy) {} + + // [1] Refcount keeps the device allocation alive across copies. + private void testBufferRefcountSurvivesCopy() + { + enum size_t M = 64; + float[M] src, back; + foreach (i; 0 .. M) src[i] = cast(float)(i * 3 + 1); + back[] = 0.0f; + + auto b = Buffer!float(src[]); + enforce(b.raw != 0, "Buffer allocation failed"); + + // Copy by value into a function; the copy is destroyed at return. + dropBufferCopy(b); + + // Copy into an array element, then drop it via the inner scope's dtor. + { + Buffer!float[1] arr; + arr[0] = b; // postblit: refcount++ + } // arr[0].~this: refcount-- (still > 0) + + // The original handle must still be live: round-trip data through it. + b.copy!(Copy.hostToDevice); + b.hostMemory = back[]; // read back into a different host buffer + b.copy!(Copy.deviceToHost); + foreach (i; 0 .. M) + enforce(back[i] == src[i], + "Buffer H2D/D2H mismatch after copy dtor at " ~ i.to!string); + } + + // [1] Refcount keeps the CUstream alive across copies. + private void testQueueRefcountSurvivesCopy() + { + auto q = Queue(false); + enforce(q.raw !is null, "Queue creation failed"); + + dropQueueCopy(q); // by-value copy created and destroyed + + { + Queue[1] arr; + arr[0] = q; // postblit: refcount++ + } // arr[0].~this: refcount-- (still > 0) + + // Stream must still be live: cuStreamGetFlags on a destroyed stream + // would error through checkErrors(). + q.async; + enforce(q.raw !is null, "Queue handle nulled unexpectedly"); + } + + // [2] Last owner frees exactly once; nested copies destroyed in sequence. + private void testLastOwnerFreesNoDoubleFree() + { + enum size_t M = 32; + float[M] src; + foreach (i; 0 .. M) src[i] = cast(float) i; + { + auto a = Buffer!float(src[]); // refcount 1 + { + auto bb = a; // refcount 2 + { + auto cc = bb; // refcount 3 + } // cc.~this -> 2 + } // bb.~this -> 1 + a.copy!(Copy.hostToDevice); // 'a' still owns a live handle + } // a.~this -> 0: a single cuMemFree + + // A double free above would corrupt the context; this fresh allocation + // goes through checkErrors() and throws if so. + auto d = Buffer!float(M); + enforce(d.raw != 0, "post-free allocation failed (context may be corrupt)"); + } + + // [3] Manual Buffer.release() then the automatic ~this must not double free. + private void testBufferReleaseThenDtorSafe() + { + enum size_t M = 16; + float[M] src; + foreach (i; 0 .. M) src[i] = cast(float)(i + 1); + { + auto b = Buffer!float(src[]); + b.release(); // sole owner: cuMemFree + raw = 0 + ownership dropped + enforce(b.raw == 0, "release() did not null raw"); + } // ~this: no ownership, raw == 0 -> guaranteed no-op + + auto after = Buffer!float(M); + enforce(after.raw != 0, "allocation after release+dtor failed (double free?)"); + } + + // [3] Program.unload() then ~this must not double-unload. Uses a minimal PTX + // module (JIT-compiled by the driver). Gracefully skips if the driver + // rejects the PTX (e.g. very old/locked-down driver). + private void testProgramUnloadThenDtorSafe() + { + enum string ptx = + ".version 6.0\n" ~ + ".target sm_52\n" ~ + ".address_size 64\n" ~ + ".visible .entry _dcompute_raii_noop() { ret; }\n"; + try + { + { + auto p = Program.fromString(ptx); + enforce(p.raw !is null, "module load returned null"); + p.unload(); // cuModuleUnload + raw = null + enforce(p.raw is null, "unload() did not null raw"); + } // ~this: raw==null guard skips a second cuModuleUnload + writeln(" [3] Program.unload then dtor safe: PASS"); + } + catch (Exception e) + { + writeln(" [3] Program.unload test SKIPPED (PTX load unsupported here): ", + e.msg); + } + } + + // [4] Context.current / Context.pop() return NON-owning views (_rc == null). + // Letting such a view die must not cuCtxDestroy the runtime's default context. + private void testContextViewsAreNonOwning() + { + { + auto view = Context.current; // non-owning copy of default context + enforce(view.raw !is null, "no current context"); + auto view2 = view; // another non-owning copy + } // both dtors: _rc null -> no cuCtxDestroy + + // If the default context had been destroyed, allocating (which needs a + // live current context) would throw via checkErrors(). + auto b = Buffer!float(8); + enforce(b.raw != 0, + "allocation failed -> current context was wrongly destroyed by a view"); + } + + // [4] UnifiedBuffer.asBuffer() is a non-owning Buffer view (_rc == null). + // Letting the view die must not cuMemFree the managed allocation. + private void testUnifiedBufferViewIsNonOwning() + { + if (!defaultDevice().supportsUnifiedMemory) + { + writeln(" [4] UnifiedBuffer view test SKIPPED (no Unified Memory)"); + return; + } + enum size_t M = 24; + float[M] src; + foreach (i; 0 .. M) src[i] = i * 2.0f; + + auto ub = UnifiedBuffer!float(src[]); + scope(exit) ub.release(); + { + auto view = ub.asBuffer; // _rc == null, non-owning + enforce(view.raw == ub.raw, "asBuffer view raw mismatch"); + } // view.~this: _rc null -> no cuMemFree + + // The managed memory must still be valid and unchanged. + foreach (i; 0 .. M) + enforce(ub.hostSlice[i] == src[i], + "UnifiedBuffer corrupted after non-owning view dtor at " ~ i.to!string); + writeln(" [4] UnifiedBuffer.asBuffer view is non-owning: PASS"); + } + + // cuMemGetInfo — bound by bindbc-cuda (to the driver's cuMemGetInfo_v2) + // as part of the normal loadCUDA() the runtime already performs. Used by + // the [5] tests below to verify that destructors REALLY return device + // memory to the driver. + private size_t deviceFreeBytes() + { + size_t freeB, totalB; + immutable r = cuMemGetInfo(&freeB, &totalB); + enforce(r == 0, "cuMemGetInfo failed with CUresult " ~ r.to!string); + return freeB; + } + + private enum size_t MiB = 1024 * 1024; + // Big enough that the free-memory delta is unambiguous, small enough for + // any real GPU (the RTX 2050 this was validated on has 4 GiB). + private enum size_t BIG_BYTES = 128 * MiB; + private enum size_t BIG_ELEMS = BIG_BYTES / float.sizeof; + // Driver-internal pools/page tables cause small persistent shifts in the + // free-memory reading; anything within this slack of the baseline counts + // as "returned". A leaked 128 MiB allocation is way outside it. + private enum size_t MEM_SLACK = 32 * MiB; + + // [5] Scope exit alone (NO release()) must return the device memory. + private void testScopedDtorReturnsDeviceMemory() + { + // Warm-up cycle so first-touch driver allocations don't skew the baseline. + { auto warm = Buffer!float(BIG_ELEMS); } + + immutable before = deviceFreeBytes(); + size_t during; + { + auto big = Buffer!float(BIG_ELEMS); + enforce(big.raw != 0, "128 MiB device allocation failed"); + during = deviceFreeBytes(); + } // NO release(): ~this alone must cuMemFree + immutable after = deviceFreeBytes(); + + enforce(before >= during + BIG_BYTES, + "allocation not visible in cuMemGetInfo (free before=" ~ before.to!string + ~ " during=" ~ during.to!string ~ ")"); + enforce(after + MEM_SLACK >= before, + "dtor did NOT return device memory: free before=" ~ before.to!string + ~ " after=" ~ after.to!string); + } + + // [5] Copies share ONE allocation; releasing one copy must not free it + // while another copy is live; the LAST owner frees it exactly once + // (free memory returns to baseline — a double free would corrupt the + // context, a missed free would leave 128 MiB missing). + private void testCopiesFreeExactlyOnceMemVerified() + { + { auto warm = Buffer!float(BIG_ELEMS); } // warm-up, as above + immutable before = deviceFreeBytes(); + { + auto a = Buffer!float(BIG_ELEMS); + auto b = a; // postblit: shares the allocation + enforce(b.raw == a.raw, "copy must alias the same device pointer"); + + immutable during = deviceFreeBytes(); + enforce(before >= during + BIG_BYTES, + "allocation missing from cuMemGetInfo"); + enforce(during + BIG_BYTES + MEM_SLACK >= before, + "copy duplicated the device allocation (~2x memory in use)"); + + b.release(); // drops ONE owner; must NOT free yet + enforce(b.raw == 0, "release() did not null raw"); + immutable afterRelease = deviceFreeBytes(); + enforce(before >= afterRelease + BIG_BYTES, + "release() of one copy freed memory while another copy is live"); + + float[16] probe = 1.5f; // 'a' must still own a live handle: + a.hostMemory = probe[]; // write a few bytes through it — + a.copy!(Copy.hostToDevice); // errors via checkErrors() if freed + } // a.~this: LAST owner -> single cuMemFree + immutable after = deviceFreeBytes(); + enforce(after + MEM_SLACK >= before, + "last owner did not free: free before=" ~ before.to!string + ~ " after=" ~ after.to!string); + } + + // [6] Queue + Event RAII churn: create and drop in a tight loop with NO + // manual cleanup. If ~this leaked them, 1000 leaked CUstreams/CUevents + // would pile up in the driver; everything still working afterwards (and + // no crash along the way) is the pass signal. + private void testQueueEventChurn() + { + foreach (i; 0 .. 1000) + { + auto q = Queue(false); + enforce(q.raw !is null, "cuStreamCreate failed at iteration " ~ i.to!string); + auto e = Event(0); + enforce(e.raw !is null, "cuEventCreate failed at iteration " ~ i.to!string); + q.wait(e, 0); // also exercises a by-value Event copy + } // q.~this + e.~this destroy the stream/event every iteration + auto q2 = Queue(false); + enforce(q2.raw !is null, "stream creation failed after churn (leak?)"); + } + + // [3] Context.detach() nulls raw so a later ~this skips cuCtxDestroy. + // cuCtxDetach is a deprecated legacy API that modern drivers may reject; + // on rejection we report and skip rather than fail. Runs last and restores + // the default context as current so later code is unaffected. + private void testContextDetachThenDtorSafe() + { + auto saved = Context.current; // remember the runtime's default context + try + { + { + auto ctx = Context(defaultDevice()); // owns a fresh context (now current) + enforce(ctx.raw !is null, "context creation failed"); + ctx.detach(); // legacy detach; nulls raw on success + enforce(ctx.raw is null, "detach() did not null raw"); + } // ~this: raw==null guard skips cuCtxDestroy + writeln(" [3] Context.detach then dtor safe: PASS"); + } + catch (Exception e) + { + writeln(" [3] Context.detach test SKIPPED (driver rejected deprecated API): ", + e.msg); + } + finally + { + Context.current = saved; // restore default context as current + } + } + + void runRaiiDestructorTests() + { + ensureInit(); // guarantee platform/context/queue exist for this thread + writeln("RAII destructor / refcount tests:"); + + testBufferRefcountSurvivesCopy(); + writeln(" [1] Buffer refcount survives copies: PASS"); + + testQueueRefcountSurvivesCopy(); + writeln(" [1] Queue refcount survives copies: PASS"); + + testLastOwnerFreesNoDoubleFree(); + writeln(" [2] last-owner-frees / no double free: PASS"); + + testBufferReleaseThenDtorSafe(); + writeln(" [3] Buffer.release then dtor safe: PASS"); + + testProgramUnloadThenDtorSafe(); + + testContextViewsAreNonOwning(); + writeln(" [4] Context.current views are non-owning: PASS"); + + testUnifiedBufferViewIsNonOwning(); + + // Memory-verified tests use cuMemGetInfo, bound by bindbc-cuda in the + // same loadCUDA() every other driver call here depends on — if it had + // failed, nothing above could have run either. + testScopedDtorReturnsDeviceMemory(); + writeln(" [5] scoped dtor returns device memory (cuMemGetInfo): PASS"); + + testCopiesFreeExactlyOnceMemVerified(); + writeln(" [5] copies free exactly once, last owner frees (cuMemGetInfo): PASS"); + + testQueueEventChurn(); + writeln(" [6] Queue+Event create/destroy churn x1000, no manual cleanup: PASS"); + + // Mutating context test runs last so the read-only tests above are + // already validated before we poke the deprecated detach path. + testContextDetachThenDtorSafe(); + + writeln("RAII destructor tests complete.\n"); + } +} +