mirror of
https://github.com/luxfi/chains.git
synced 2026-09-24 00:26:00 +00:00
The three GPU suites compiled a library exporting launcher names and nothing else, which is exactly what the identity check refuses, so eighteen tests failed against a loader that had started doing its job. The stubs now export lux_gpu_backend_init answering with this build's ABI version and vtable size, compiled against the real header so an ABI bump moves them with it. Three assertions pinned the behaviour that was removed — aivm expecting the bare filename, mpcvm the bare leaves, bridgevm the working directory — and now pin its absence. TestALibraryThatOnlyExportsTheNames is new in each package: it plants the unidentified build, watches the loader refuse it, then opens the identified build from the same place so the refusal is the identity check rather than a stub that never worked. Co-authored-by: Hanzo Dev <[email protected]>
460 lines
18 KiB
Go
460 lines
18 KiB
Go
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
//go:build cgo
|
|
|
|
package mpcvm
|
|
|
|
// gpu_plugin_test.go — the dlopen bridge, driven against a real plugin.
|
|
//
|
|
// The GPU bridge is a runtime overlay: it dlopens a backend plugin, dlsyms four
|
|
// launchers, and hands them host pointers to the wire structs. What can be
|
|
// wrong here is not the arithmetic inside a kernel — that is the kernel's
|
|
// business — but the BRIDGE: whether a missing symbol is noticed, whether the
|
|
// counts the launcher receives are the ones the caller passed, and whether a
|
|
// launcher's non-zero return reaches the caller as an error instead of as a
|
|
// zero count the caller reads as success.
|
|
//
|
|
// So the plugin under test is a real shared object, compiled here, whose
|
|
// launchers ECHO what they were handed back through the output buffers the ABI
|
|
// already gives them. That exercises the same dlopen, the same dlsym and the
|
|
// same call shims a CUDA plugin would, with the kernel replaced by a mirror. No
|
|
// GPU is required and none is simulated: the arithmetic is not what this file
|
|
// is about, and pretending otherwise would be a test that passes when the
|
|
// bridge and the mirror are wrong in the same way.
|
|
//
|
|
// The mirror's contract, which the tests below read back:
|
|
//
|
|
// rc — every launcher returns desc->Mode (compute_leaves, which gets
|
|
// no descriptor, returns the low half of ceremonies[0].CeremonyID)
|
|
// *capp — ceremony_count *napp — next_contribution_id (truncated)
|
|
// *ra — ceremony_count *fin — key_share_count
|
|
// *fai — next_share_id (truncated)
|
|
// result — Status=7, ActiveCeremonyCount=ceremony_count,
|
|
// KeyShareCount=key_share_count; state.CurrentEpoch += 1
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
const stubSource = `
|
|
#include <stdint.h>
|
|
#include <string.h>
|
|
|
|
// The descriptor's Mode field, at offset 32, carries the return code the
|
|
// caller wants back — so one plugin covers the success and the failure path
|
|
// without a second build.
|
|
static int rc_of(const void* desc) { return (int)*(const uint32_t*)((const char*)desc + 32); }
|
|
|
|
int lux_cuda_mpcvm_ceremony_apply(
|
|
const void* desc, const void* cops, const void* nops,
|
|
void* cer, void* con, void* capp, void* napp,
|
|
uint32_t cc, uint32_t nc, uint64_t next_cont, void* stream)
|
|
{
|
|
(void)cops; (void)nops; (void)cer; (void)con; (void)stream;
|
|
*(uint32_t*)capp = cc;
|
|
*(uint32_t*)napp = (uint32_t)next_cont;
|
|
return rc_of(desc);
|
|
}
|
|
|
|
int lux_cuda_mpcvm_ceremony_sweep(
|
|
const void* desc, void* cer, void* ks, void* con,
|
|
void* ra, void* fin, void* fai,
|
|
uint32_t cc, uint32_t kc, uint32_t nc, uint64_t next_share, void* stream)
|
|
{
|
|
(void)cer; (void)ks; (void)con; (void)nc; (void)stream;
|
|
*(uint32_t*)ra = cc;
|
|
*(uint32_t*)fin = kc;
|
|
*(uint32_t*)fai = (uint32_t)next_share;
|
|
return rc_of(desc);
|
|
}
|
|
|
|
int lux_cuda_mpcvm_compute_leaves(
|
|
const void* cer, const void* ks, const void* con,
|
|
void* cl, void* sl, void* nl,
|
|
void* ac, void* fc, void* fac, void* sc,
|
|
void* cm, void* sm, void* nm,
|
|
uint32_t cc, uint32_t kc, uint32_t nc, void* stream)
|
|
{
|
|
(void)ks; (void)con; (void)stream;
|
|
memset(cl, 0xAA, (size_t)cc * 32);
|
|
memset(sl, 0xBB, (size_t)kc * 32);
|
|
memset(nl, 0xCC, (size_t)nc * 32);
|
|
memset(cm, 1, cc); memset(sm, 1, kc); memset(nm, 1, nc);
|
|
*(uint32_t*)ac = cc; *(uint32_t*)fc = kc; *(uint32_t*)fac = nc; *(uint32_t*)sc = kc;
|
|
// No descriptor reaches this launcher; the first ceremony slot carries the
|
|
// return code instead.
|
|
return (int)*(const uint32_t*)cer;
|
|
}
|
|
|
|
int lux_cuda_mpcvm_compose_root(
|
|
const void* desc,
|
|
const void* cl, const void* sl, const void* nl,
|
|
const void* cm, const void* sm, const void* nm,
|
|
const void* ac, const void* fc, const void* fac, const void* sc,
|
|
void* state, void* result,
|
|
uint32_t cc, uint32_t kc, uint32_t nc, void* stream)
|
|
{
|
|
(void)cl; (void)sl; (void)nl; (void)cm; (void)sm; (void)nm;
|
|
(void)fc; (void)fac; (void)cc; (void)kc; (void)nc; (void)stream;
|
|
uint32_t* out = (uint32_t*)result;
|
|
out[0] = 7; // Status
|
|
out[5] = *(const uint32_t*)ac; // ActiveCeremonyCount
|
|
out[6] = *(const uint32_t*)sc; // KeyShareCount
|
|
((uint64_t*)state)[0] += 1; // CurrentEpoch advances in place
|
|
return rc_of(desc);
|
|
}
|
|
`
|
|
|
|
// stubIdentity is what makes a compiled stub a plugin tryLoadPlugin will
|
|
// trust: the init symbol the header requires, answering with the ABI version
|
|
// and vtable size this build was compiled against. A stub built without it
|
|
// exports the four launcher names and nothing else, which is precisely the
|
|
// library the loader must refuse — see TestALibraryThatOnlyExportsTheNames.
|
|
const stubIdentity = `
|
|
#include <stdbool.h>
|
|
#include "lux/gpu/backend_plugin.h"
|
|
|
|
bool lux_gpu_backend_init(lux_gpu_backend_desc* out) {
|
|
out->abi_version = LUX_GPU_BACKEND_ABI_VERSION;
|
|
out->vtbl_size = (uint32_t)sizeof(lux_gpu_backend_vtbl);
|
|
out->backend_name = "cuda";
|
|
out->backend_version = "0.0.0-mirror";
|
|
out->capabilities = 0;
|
|
out->vtbl = 0;
|
|
return true;
|
|
}
|
|
`
|
|
|
|
// pluginInclude is where the plugin header lives, relative to this package.
|
|
const pluginInclude = "-I../internal/luxgpu/include"
|
|
|
|
var (
|
|
stubOnce sync.Once
|
|
stubPath string
|
|
stubAnon string
|
|
stubErr error
|
|
)
|
|
|
|
// stubPlugin compiles the mirror plugin once. A C compiler is present by
|
|
// construction: this file only builds under cgo, and cgo needed one.
|
|
func stubPlugin(t *testing.T) string {
|
|
t.Helper()
|
|
stubOnce.Do(func() {
|
|
dir, err := os.MkdirTemp("", "mpcvm-gpu-stub")
|
|
if err != nil {
|
|
stubErr = err
|
|
return
|
|
}
|
|
// Two builds from one mirror: one that identifies itself, and one that
|
|
// exports the launcher names and nothing else.
|
|
for name, identity := range map[string]string{
|
|
"named": stubIdentity,
|
|
"anon": "",
|
|
} {
|
|
src := filepath.Join(dir, "stub_"+name+".c")
|
|
if err := os.WriteFile(src, []byte(stubSource+identity), 0o600); err != nil {
|
|
stubErr = err
|
|
return
|
|
}
|
|
out := filepath.Join(dir, name, "libluxgpu_backend_cuda.so")
|
|
if err := os.MkdirAll(filepath.Dir(out), 0o755); err != nil {
|
|
stubErr = err
|
|
return
|
|
}
|
|
if combined, err := exec.Command(compiler(), pluginInclude, "-shared", "-fPIC", "-o", out, src).CombinedOutput(); err != nil {
|
|
stubErr = fmt.Errorf("%s: %w: %s", compiler(), err, combined)
|
|
return
|
|
}
|
|
if name == "anon" {
|
|
stubAnon = out
|
|
} else {
|
|
stubPath = out
|
|
}
|
|
}
|
|
})
|
|
require.NoError(t, stubErr)
|
|
return stubPath
|
|
}
|
|
|
|
// stubUnidentified is the same mirror without the init symbol.
|
|
func stubUnidentified(t *testing.T) string {
|
|
t.Helper()
|
|
stubPlugin(t)
|
|
return stubAnon
|
|
}
|
|
|
|
// A library exporting the four launcher names and nothing else is what an
|
|
// attacker plants. It is refused before a single launcher is resolved: the
|
|
// name was the only thing being matched, and a name is not a contract.
|
|
func TestALibraryThatOnlyExportsTheNames(t *testing.T) {
|
|
anon := stubUnidentified(t)
|
|
require.FileExists(t, anon, "the stub is built; it just does not identify itself")
|
|
require.Nil(t, tryLoadPlugin(GPUBackendCUDA, anon),
|
|
"an unidentified library is not a backend")
|
|
|
|
// The identified build opens, so the refusal above is the identity check
|
|
// and not a broken stub.
|
|
b := tryLoadPlugin(GPUBackendCUDA, stubPlugin(t))
|
|
require.NotNil(t, b)
|
|
require.True(t, b.IsAvailable())
|
|
}
|
|
|
|
func compiler() string {
|
|
if cc := os.Getenv("CC"); cc != "" {
|
|
return cc
|
|
}
|
|
return "cc"
|
|
}
|
|
|
|
// loadStub resolves the mirror plugin through the production loader.
|
|
func loadStub(t *testing.T) *GPUBackend {
|
|
t.Helper()
|
|
b := tryLoadPlugin(GPUBackendCUDA, stubPlugin(t))
|
|
require.NotNil(t, b, "the loader did not resolve a plugin exporting all four launchers")
|
|
require.Equal(t, GPUBackendCUDA, b.Kind)
|
|
require.Equal(t, stubPlugin(t), b.Path)
|
|
require.True(t, b.IsAvailable())
|
|
return b
|
|
}
|
|
|
|
// round is a descriptor whose Mode carries the return code the mirror echoes.
|
|
func round(rc uint32) *GPUMPCVMRoundDescriptor {
|
|
return &GPUMPCVMRoundDescriptor{ChainID: 1, Round: 2, Mode: rc}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Resolution
|
|
// -----------------------------------------------------------------------------
|
|
|
|
// A plugin is used only when the launchers it must export actually resolve. A
|
|
// library that dlopens but exports nothing is not a backend, and adopting one
|
|
// would call a null pointer on the first round.
|
|
func TestALibraryWithoutTheLaunchersIsNotABackend(t *testing.T) {
|
|
require.Nil(t, tryLoadPlugin(GPUBackendCUDA, "libc.so.6", "libSystem.B.dylib"),
|
|
"a library missing ceremony_apply/ceremony_sweep must not be adopted as a backend")
|
|
}
|
|
|
|
func TestAPluginThatIsNotThereIsNotABackend(t *testing.T) {
|
|
require.Nil(t, tryLoadPlugin(GPUBackendCUDA, "/nonexistent/a.so", "/nonexistent/b.so", "/nonexistent/c.so"))
|
|
require.Nil(t, tryLoadPlugin(GPUBackendCUDA))
|
|
require.Nil(t, tryLoadPlugin(GPUBackendCUDA, "", "", ""))
|
|
require.Nil(t, tryLoadPlugin(GPUBackendNone, "anything"),
|
|
"a backend with no launcher prefix names no symbols to resolve")
|
|
}
|
|
|
|
// The probe walks the substrate priority order and adopts the first backend
|
|
// that resolves. A host with no plugin adopts none and runs on the CPU
|
|
// reference, rather than adopting a broken one.
|
|
func TestTheProbeAdoptsTheFirstBackendThatResolves(t *testing.T) {
|
|
t.Setenv("LUX_GPU_PLUGIN_DIR", filepath.Dir(stubPlugin(t)))
|
|
t.Setenv("LUXCPP_PREFIX", "/nonexistent")
|
|
|
|
got := probeGPUBackend()
|
|
require.NotNil(t, got)
|
|
require.Equal(t, GPUBackendCUDA, got.Kind, "cuda is first in the substrate priority order")
|
|
|
|
t.Setenv("LUX_GPU_PLUGIN_DIR", "/nonexistent")
|
|
require.Nil(t, probeGPUBackend(), "a host with no plugin runs on the CPU reference")
|
|
}
|
|
|
|
// Every backend has candidate filenames, ordered: install path, then build
|
|
// tree, then the bare leaf for the dynamic linker's own search.
|
|
func TestEveryBackendHasCandidateFilenames(t *testing.T) {
|
|
t.Setenv("LUXCPP_PREFIX", "/opt/luxcpp")
|
|
t.Setenv("LUX_GPU_PLUGIN_DIR", "/build/plugins")
|
|
|
|
for _, kind := range []GPUBackendKind{
|
|
GPUBackendCUDA, GPUBackendHIP, GPUBackendMetal, GPUBackendVulkan, GPUBackendWebGPU,
|
|
} {
|
|
got := candidatesFor(kind)
|
|
require.NotEmptyf(t, got, "%s has no candidate filenames", kind)
|
|
require.Containsf(t, got[0], "/opt/luxcpp/lib/", "%s must try the install path first", kind)
|
|
require.Truef(t, hasPrefixDir(got, "/build/plugins"), "%s must try the build tree", kind)
|
|
require.NotContainsf(t, got, filepath.Base(got[0]),
|
|
"%s must not offer a bare leaf: the loader would search its own path, "+
|
|
"and a GPU plugin mints threshold-signing custody", kind)
|
|
require.NotEmptyf(t, kind.String(), "%s has no launcher prefix", kind)
|
|
}
|
|
require.Empty(t, candidatesFor(GPUBackendNone))
|
|
require.Empty(t, backendDylibLeaves(GPUBackendNone))
|
|
|
|
// With no explicit prefix, the home-relative install tree stands in.
|
|
t.Setenv("LUXCPP_PREFIX", "")
|
|
t.Setenv("LUX_GPU_PLUGIN_DIR", "")
|
|
t.Setenv("HOME", "/home/somebody")
|
|
require.Contains(t, candidatesFor(GPUBackendCUDA)[0], "/home/somebody/work/luxcpp/install/lib/")
|
|
|
|
// With neither, nothing remains. Every candidate came from somewhere an
|
|
// operator named, so when nobody named anywhere there is nowhere to look.
|
|
t.Setenv("HOME", "")
|
|
require.Empty(t, candidatesFor(GPUBackendCUDA))
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// The four launchers
|
|
// -----------------------------------------------------------------------------
|
|
|
|
// Each method hands the launcher the counts and buffers the caller passed, and
|
|
// returns what the launcher wrote back into them.
|
|
func TestTheBridgePassesTheCallersCountsAndReadsBackTheResult(t *testing.T) {
|
|
b := loadStub(t)
|
|
desc := round(0)
|
|
ceremonies := make([]GPUCeremony, 4)
|
|
contributions := make([]GPUContribution, 3)
|
|
shares := make([]GPUKeyShare, 2)
|
|
|
|
applied, err := b.CeremonyApply(desc, []GPUCeremonyOp{{CeremonyID: 9}}, ceremonies)
|
|
require.NoError(t, err)
|
|
require.Equal(t, uint32(len(ceremonies)), applied, "the ceremony table's length reaches the launcher")
|
|
|
|
applied, err = b.ContributionApply(desc, []GPUContributionOp{{CeremonyID: 9}}, ceremonies, contributions, 41)
|
|
require.NoError(t, err)
|
|
require.Equal(t, uint32(41), applied, "the contribution-id counter reaches the launcher")
|
|
|
|
advance, finalized, failed, err := b.KeyShareApply(desc, ceremonies, shares, contributions, 77)
|
|
require.NoError(t, err)
|
|
require.Equal(t, uint32(len(ceremonies)), advance)
|
|
require.Equal(t, uint32(len(shares)), finalized)
|
|
require.Equal(t, uint32(77), failed, "the share-id counter reaches the launcher")
|
|
|
|
state := &GPUMPCVMState{CurrentEpoch: 5}
|
|
result, err := b.MPCTransition(desc, ceremonies, shares, contributions, state)
|
|
require.NoError(t, err)
|
|
require.Equal(t, uint32(7), result.Status)
|
|
require.Equal(t, uint32(len(ceremonies)), result.ActiveCeremonyCount,
|
|
"the leaf pass's counts reach the fold pass")
|
|
require.Equal(t, uint32(len(shares)), result.KeyShareCount)
|
|
require.Equal(t, uint64(6), state.CurrentEpoch, "the substrate state advances in place")
|
|
}
|
|
|
|
// An empty op stream is a legal call: the ceremony-admin and contribution
|
|
// dispatches share one launcher and differ only in which stream is populated.
|
|
func TestAnEmptyOpStreamStillDispatches(t *testing.T) {
|
|
b := loadStub(t)
|
|
desc := round(0)
|
|
ceremonies := make([]GPUCeremony, 2)
|
|
contributions := make([]GPUContribution, 2)
|
|
|
|
_, err := b.CeremonyApply(desc, nil, ceremonies)
|
|
require.NoError(t, err)
|
|
_, err = b.ContributionApply(desc, nil, ceremonies, contributions, 0)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
// A launcher that fails is an error, not a zero count. A caller that read the
|
|
// count without the error would apply nothing and believe it applied
|
|
// everything.
|
|
func TestALauncherFailureIsAnErrorNotAZeroCount(t *testing.T) {
|
|
b := loadStub(t)
|
|
desc := round(4) // 4 = dispatch, in the launcher's own vocabulary
|
|
ceremonies := make([]GPUCeremony, 1)
|
|
contributions := make([]GPUContribution, 1)
|
|
shares := make([]GPUKeyShare, 1)
|
|
|
|
_, err := b.CeremonyApply(desc, []GPUCeremonyOp{{}}, ceremonies)
|
|
require.ErrorContains(t, err, "rc=4")
|
|
_, err = b.ContributionApply(desc, []GPUContributionOp{{}}, ceremonies, contributions, 0)
|
|
require.ErrorContains(t, err, "rc=4")
|
|
_, _, _, err = b.KeyShareApply(desc, ceremonies, shares, contributions, 0)
|
|
require.ErrorContains(t, err, "rc=4")
|
|
|
|
// Both halves of the transition report separately, so an operator knows
|
|
// which pass failed.
|
|
_, err = b.MPCTransition(desc, ceremonies, shares, contributions, &GPUMPCVMState{})
|
|
require.ErrorContains(t, err, "compose_root: launcher rc=4")
|
|
|
|
ceremonies[0].CeremonyID = 5 // the leaf pass's return code
|
|
_, err = b.MPCTransition(round(0), ceremonies, shares, contributions, &GPUMPCVMState{})
|
|
require.ErrorContains(t, err, "compute_leaves: launcher rc=5")
|
|
}
|
|
|
|
// An empty arena is refused before any pointer is taken. Taking &slice[0] of an
|
|
// empty slice panics, so this is what stands between an empty round and a
|
|
// crash.
|
|
func TestAnEmptyArenaIsRefusedBeforeAPointerIsTaken(t *testing.T) {
|
|
b := loadStub(t)
|
|
desc := round(0)
|
|
ceremonies := make([]GPUCeremony, 1)
|
|
contributions := make([]GPUContribution, 1)
|
|
shares := make([]GPUKeyShare, 1)
|
|
|
|
_, err := b.CeremonyApply(nil, nil, ceremonies)
|
|
require.ErrorContains(t, err, "nil desc")
|
|
_, err = b.CeremonyApply(desc, nil, nil)
|
|
require.ErrorContains(t, err, "empty ceremonies")
|
|
|
|
_, err = b.ContributionApply(nil, nil, ceremonies, contributions, 0)
|
|
require.ErrorContains(t, err, "empty arena")
|
|
_, err = b.ContributionApply(desc, nil, nil, contributions, 0)
|
|
require.ErrorContains(t, err, "empty arena")
|
|
_, err = b.ContributionApply(desc, nil, ceremonies, nil, 0)
|
|
require.ErrorContains(t, err, "empty arena")
|
|
|
|
_, _, _, err = b.KeyShareApply(nil, ceremonies, shares, contributions, 0)
|
|
require.ErrorContains(t, err, "empty arena")
|
|
_, _, _, err = b.KeyShareApply(desc, nil, shares, contributions, 0)
|
|
require.ErrorContains(t, err, "empty arena")
|
|
_, _, _, err = b.KeyShareApply(desc, ceremonies, nil, contributions, 0)
|
|
require.ErrorContains(t, err, "empty arena")
|
|
_, _, _, err = b.KeyShareApply(desc, ceremonies, shares, nil, 0)
|
|
require.ErrorContains(t, err, "empty arena")
|
|
|
|
_, err = b.MPCTransition(nil, ceremonies, shares, contributions, &GPUMPCVMState{})
|
|
require.ErrorContains(t, err, "empty arena")
|
|
_, err = b.MPCTransition(desc, ceremonies, shares, contributions, nil)
|
|
require.ErrorContains(t, err, "empty arena")
|
|
_, err = b.MPCTransition(desc, nil, shares, contributions, &GPUMPCVMState{})
|
|
require.ErrorContains(t, err, "empty arena")
|
|
_, err = b.MPCTransition(desc, ceremonies, nil, contributions, &GPUMPCVMState{})
|
|
require.ErrorContains(t, err, "empty arena")
|
|
_, err = b.MPCTransition(desc, ceremonies, shares, nil, &GPUMPCVMState{})
|
|
require.ErrorContains(t, err, "empty arena")
|
|
}
|
|
|
|
// A plugin exporting the two required launchers but not the two the whole
|
|
// transition needs is adopted for what it can do and refuses the rest by name,
|
|
// so an early port is usable rather than silently wrong.
|
|
func TestAPartialPluginIsUsedForWhatItExports(t *testing.T) {
|
|
b := loadStub(t)
|
|
|
|
partial := *b
|
|
partial.fnComputeLeaves = nil
|
|
require.True(t, partial.IsAvailable())
|
|
_, err := partial.MPCTransition(round(0),
|
|
make([]GPUCeremony, 1), make([]GPUKeyShare, 1), make([]GPUContribution, 1), &GPUMPCVMState{})
|
|
require.ErrorIs(t, err, ErrGPUNotAvailable)
|
|
require.ErrorContains(t, err, "compute_leaves or compose_root missing")
|
|
|
|
partial = *b
|
|
partial.fnComposeRoot = nil
|
|
_, err = partial.MPCTransition(round(0),
|
|
make([]GPUCeremony, 1), make([]GPUKeyShare, 1), make([]GPUContribution, 1), &GPUMPCVMState{})
|
|
require.ErrorIs(t, err, ErrGPUNotAvailable)
|
|
}
|
|
|
|
// hasPrefixDir reports whether any candidate lives under dir.
|
|
func hasPrefixDir(candidates []string, dir string) bool {
|
|
for _, c := range candidates {
|
|
if strings.HasPrefix(c, dir+string(filepath.Separator)) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// A caller holding no backend at all asks the same question and gets the same
|
|
// answer, rather than dereferencing nothing.
|
|
func TestNoBackendIsNotAnAvailableOne(t *testing.T) {
|
|
require.False(t, (*GPUBackend)(nil).IsAvailable())
|
|
require.False(t, (&GPUBackend{}).IsAvailable(), "a zero backend has dlopened nothing")
|
|
}
|