diff --git a/sdk_v2/js/native/src/catalog.cc b/sdk_v2/js/native/src/catalog.cc index 8372187a2..d8a210b90 100644 --- a/sdk_v2/js/native/src/catalog.cc +++ b/sdk_v2/js/native/src/catalog.cc @@ -5,6 +5,7 @@ #include "addon_data.h" #include "errors.h" #include "model.h" +#include "manager.h" #include @@ -16,10 +17,24 @@ namespace foundry_local_node { namespace { +std::shared_ptr LockManagerOrThrow( + const std::weak_ptr& manager_keepalive, + const std::shared_ptr& lifecycle) { + if (!lifecycle || lifecycle->disposed.load(std::memory_order_acquire)) { + throw foundry_local::Error("Manager has been disposed", FOUNDRY_LOCAL_ERROR_INVALID_USAGE); + } + auto manager = manager_keepalive.lock(); + if (!manager) { + throw foundry_local::Error("Manager has been disposed", FOUNDRY_LOCAL_ERROR_INVALID_USAGE); + } + return manager; +} + // Wrap a ModelList (rvalue) into a JS array of Model handles, each pinning the // passed-in manager reference. -Napi::Value WrapModelList(Napi::Env env, foundry_local::ModelList ml, - Napi::ObjectReference manager) { +Napi::Value WrapModelList(Napi::Env env, foundry_local::ModelList ml, Napi::ObjectReference manager, + std::weak_ptr manager_keepalive, + std::shared_ptr lifecycle) { auto list = std::make_shared(std::move(ml)); auto models = list->Models(); Napi::Array arr = Napi::Array::New(env, models.size()); @@ -28,6 +43,8 @@ Napi::Value WrapModelList(Napi::Env env, foundry_local::ModelList ml, token.impl = models[i].get(); token.keepalive = list; token.manager = Napi::Reference::New(manager.Value(), 1); + token.manager_keepalive = manager_keepalive; + token.lifecycle = lifecycle; arr.Set(static_cast(i), Model::NewInstance(env, std::move(token))); } return arr; @@ -35,7 +52,9 @@ Napi::Value WrapModelList(Napi::Env env, foundry_local::ModelList ml, // Wrap an owning unique_ptr into a JS Model (or undefined when null). Napi::Value WrapOwnedModelOrUndefined(Napi::Env env, std::unique_ptr owned, - Napi::ObjectReference manager) { + Napi::ObjectReference manager, + std::weak_ptr manager_keepalive, + std::shared_ptr lifecycle) { if (!owned) { return env.Undefined(); } @@ -46,11 +65,13 @@ Napi::Value WrapOwnedModelOrUndefined(Napi::Env env, std::unique_ptr>(std::move(owned)); token.keepalive = holder; token.manager = std::move(manager); + token.manager_keepalive = std::move(manager_keepalive); + token.lifecycle = std::move(lifecycle); return Model::NewInstance(env, std::move(token)); } -// Extract IModel* from a JS Model arg, or return nullptr if not a Model. -foundry_local::IModel* ExtractIModel(const Napi::Value& v) { +// Extract Model* from a JS Model arg, or return nullptr if not a Model. +Model* ExtractModel(const Napi::Value& v) { if (!v.IsObject()) { return nullptr; } @@ -63,8 +84,7 @@ foundry_local::IModel* ExtractIModel(const Napi::Value& v) { if (!obj.InstanceOf(ctor)) { return nullptr; } - Model* m = Napi::ObjectWrap::Unwrap(obj); - return m != nullptr ? m->native_impl() : nullptr; + return Napi::ObjectWrap::Unwrap(obj); } Napi::ObjectReference CloneManager(const Napi::ObjectReference& mgr) { @@ -109,11 +129,15 @@ Catalog::Catalog(const Napi::CallbackInfo& info) : Napi::ObjectWrap(inf } impl_ = token->impl; manager_ = std::move(token->manager); + manager_keepalive_ = std::move(token->manager_keepalive); + lifecycle_ = std::move(token->lifecycle); } Napi::Value Catalog::GetName(const Napi::CallbackInfo& info) { Napi::Env env = info.Env(); return CallChecked(env, [&]() -> Napi::Value { + auto manager_alive = LockManagerOrThrow(manager_keepalive_, lifecycle_); + (void)manager_alive; std::string_view name = impl_->GetName(); return Napi::String::New(env, std::string(name)); }); @@ -125,14 +149,20 @@ Napi::Value Catalog::GetModels(const Napi::CallbackInfo& info) { Napi::Env env = info.Env(); Napi::ObjectReference mgr = CloneManager(manager_); return CallChecked( - env, [&]() -> Napi::Value { return WrapModelList(env, impl_->GetModels(), std::move(mgr)); }); + env, [&]() -> Napi::Value { + auto manager_alive = LockManagerOrThrow(manager_keepalive_, lifecycle_); + (void)manager_alive; + return WrapModelList(env, impl_->GetModels(), std::move(mgr), manager_keepalive_, lifecycle_); + }); } Napi::Value Catalog::GetCachedModels(const Napi::CallbackInfo& info) { Napi::Env env = info.Env(); Napi::ObjectReference mgr = CloneManager(manager_); return CallChecked(env, [&]() -> Napi::Value { - return WrapModelList(env, impl_->GetCachedModels(), std::move(mgr)); + auto manager_alive = LockManagerOrThrow(manager_keepalive_, lifecycle_); + (void)manager_alive; + return WrapModelList(env, impl_->GetCachedModels(), std::move(mgr), manager_keepalive_, lifecycle_); }); } @@ -140,7 +170,9 @@ Napi::Value Catalog::GetLoadedModels(const Napi::CallbackInfo& info) { Napi::Env env = info.Env(); Napi::ObjectReference mgr = CloneManager(manager_); return CallChecked(env, [&]() -> Napi::Value { - return WrapModelList(env, impl_->GetLoadedModels(), std::move(mgr)); + auto manager_alive = LockManagerOrThrow(manager_keepalive_, lifecycle_); + (void)manager_alive; + return WrapModelList(env, impl_->GetLoadedModels(), std::move(mgr), manager_keepalive_, lifecycle_); }); } @@ -155,8 +187,10 @@ Napi::Value Catalog::GetModel(const Napi::CallbackInfo& info) { std::string alias = info[0].As(); Napi::ObjectReference mgr = CloneManager(manager_); return CallChecked(env, [&]() -> Napi::Value { + auto manager_alive = LockManagerOrThrow(manager_keepalive_, lifecycle_); + (void)manager_alive; auto owned = impl_->GetModel(alias); - return WrapOwnedModelOrUndefined(env, std::move(owned), std::move(mgr)); + return WrapOwnedModelOrUndefined(env, std::move(owned), std::move(mgr), manager_keepalive_, lifecycle_); }); } @@ -169,8 +203,10 @@ Napi::Value Catalog::GetModelVariant(const Napi::CallbackInfo& info) { std::string model_id = info[0].As(); Napi::ObjectReference mgr = CloneManager(manager_); return CallChecked(env, [&]() -> Napi::Value { + auto manager_alive = LockManagerOrThrow(manager_keepalive_, lifecycle_); + (void)manager_alive; auto owned = impl_->GetModelVariant(model_id); - return WrapOwnedModelOrUndefined(env, std::move(owned), std::move(mgr)); + return WrapOwnedModelOrUndefined(env, std::move(owned), std::move(mgr), manager_keepalive_, lifecycle_); }); } @@ -180,15 +216,20 @@ Napi::Value Catalog::GetLatestVersion(const Napi::CallbackInfo& info) { Napi::TypeError::New(env, "getLatestVersion(model: Model)").ThrowAsJavaScriptException(); return env.Undefined(); } - foundry_local::IModel* arg = ExtractIModel(info[0]); - if (arg == nullptr) { + Model* arg = ExtractModel(info[0]); + if (arg == nullptr || arg->native_impl() == nullptr) { Napi::TypeError::New(env, "getLatestVersion: argument must be a Model").ThrowAsJavaScriptException(); return env.Undefined(); } Napi::ObjectReference mgr = CloneManager(manager_); return CallChecked(env, [&]() -> Napi::Value { - auto owned = impl_->GetLatestVersion(*arg); - return WrapOwnedModelOrUndefined(env, std::move(owned), std::move(mgr)); + auto manager_alive = LockManagerOrThrow(manager_keepalive_, lifecycle_); + if (arg->manager_disposed() || !arg->manager_keepalive()) { + throw foundry_local::Error("Manager has been disposed", FOUNDRY_LOCAL_ERROR_INVALID_USAGE); + } + (void)manager_alive; + auto owned = impl_->GetLatestVersion(*arg->native_impl()); + return WrapOwnedModelOrUndefined(env, std::move(owned), std::move(mgr), manager_keepalive_, lifecycle_); }); } diff --git a/sdk_v2/js/native/src/catalog.h b/sdk_v2/js/native/src/catalog.h index 29ff89cd9..6d8ce8fa3 100644 --- a/sdk_v2/js/native/src/catalog.h +++ b/sdk_v2/js/native/src/catalog.h @@ -16,13 +16,18 @@ #include +#include #include namespace foundry_local_node { +struct ManagerLifecycle; + struct CatalogCtorToken { foundry_local::ICatalog* impl = nullptr; Napi::ObjectReference manager; // pins the owning Manager + std::weak_ptr manager_keepalive; + std::shared_ptr lifecycle; }; class Catalog : public Napi::ObjectWrap { @@ -43,6 +48,8 @@ class Catalog : public Napi::ObjectWrap { foundry_local::ICatalog* impl_ = nullptr; Napi::ObjectReference manager_; + std::weak_ptr manager_keepalive_; + std::shared_ptr lifecycle_; }; } // namespace foundry_local_node diff --git a/sdk_v2/js/native/src/manager.cc b/sdk_v2/js/native/src/manager.cc index 6d359b588..d132e5e16 100644 --- a/sdk_v2/js/native/src/manager.cc +++ b/sdk_v2/js/native/src/manager.cc @@ -20,6 +20,24 @@ namespace foundry_local_node { namespace { +struct WorkerLease { + explicit WorkerLease(std::shared_ptr lifecycle) : lifecycle_(std::move(lifecycle)) { + if (lifecycle_) { + lifecycle_->active_workers.fetch_add(1, std::memory_order_acq_rel); + } + } + ~WorkerLease() { + if (lifecycle_) { + lifecycle_->active_workers.fetch_sub(1, std::memory_order_acq_rel); + } + } + std::shared_ptr lifecycle_; +}; + +std::shared_ptr MakeWorkerLease(std::shared_ptr lifecycle) { + return std::make_shared(std::move(lifecycle)); +} + Napi::Value ConvertEndpoints(Napi::Env env, std::vector& endpoints) { Napi::Array out = Napi::Array::New(env, endpoints.size()); for (size_t i = 0; i < endpoints.size(); ++i) { @@ -88,6 +106,21 @@ Manager::Manager(const Napi::CallbackInfo& info) : Napi::ObjectWrap(inf return true; }; + auto read_optional_bool = [&](const char* key, bool& out, bool& has) -> bool { + if (opts.Has(key) && !opts.Get(key).IsUndefined() && !opts.Get(key).IsNull()) { + if (!opts.Get(key).IsBoolean()) { + std::string msg = "options."; + msg += key; + msg += " must be a boolean"; + Napi::TypeError::New(env, msg).ThrowAsJavaScriptException(); + return false; + } + out = opts.Get(key).As(); + has = true; + } + return true; + }; + std::string model_cache_dir; bool has_model_cache_dir = false; if (!read_optional_string("modelCacheDir", model_cache_dir, has_model_cache_dir)) return; @@ -193,7 +226,7 @@ Manager::Manager(const Napi::CallbackInfo& info) : Napi::ObjectWrap(inf } config.SetAdditionalOptions(kvp); } - impl_ = std::make_unique(std::move(config)); + impl_ = std::make_shared(std::move(config)); }); } @@ -227,6 +260,8 @@ Napi::Value Manager::GetCatalog(const Napi::CallbackInfo& info) { CatalogCtorToken token; token.impl = &cat; token.manager = std::move(owner); + token.manager_keepalive = impl_; + token.lifecycle = lifecycle_; return Catalog::NewInstance(env, std::move(token)); }); } @@ -234,6 +269,17 @@ Napi::Value Manager::GetCatalog(const Napi::CallbackInfo& info) { Napi::Value Manager::Dispose(const Napi::CallbackInfo& info) { Napi::Env env = info.Env(); // Idempotent — releasing an already-null unique_ptr is a no-op. + if (lifecycle_->active_sessions.load(std::memory_order_acquire) > 0) { + ThrowFoundryLocalError(env, FOUNDRY_LOCAL_ERROR_INVALID_USAGE, + "Manager has active sessions; dispose sessions before disposing the manager"); + return env.Undefined(); + } + if (lifecycle_->active_workers.load(std::memory_order_acquire) > 0) { + ThrowFoundryLocalError(env, FOUNDRY_LOCAL_ERROR_INVALID_USAGE, + "Manager has active native workers; await them before disposing the manager"); + return env.Undefined(); + } + lifecycle_->disposed.store(true, std::memory_order_release); impl_.reset(); return env.Undefined(); } @@ -288,11 +334,13 @@ namespace { // progress callback. Mirrors the pattern in model.cc's DownloadWorker. class EpDownloadWorker : public Napi::AsyncWorker { public: - EpDownloadWorker(Napi::Env env, foundry_local::Manager* impl, std::vector ep_names, - Napi::ObjectReference owner, Napi::ThreadSafeFunction tsfn) + EpDownloadWorker(Napi::Env env, std::shared_ptr impl, std::shared_ptr worker_lease, + std::vector ep_names, Napi::ObjectReference owner, + Napi::ThreadSafeFunction tsfn) : Napi::AsyncWorker(env), deferred_(Napi::Promise::Deferred::New(env)), impl_(impl), + worker_lease_(std::move(worker_lease)), ep_names_(std::move(ep_names)), owner_(std::move(owner)), tsfn_(std::move(tsfn)) {} @@ -356,7 +404,8 @@ class EpDownloadWorker : public Napi::AsyncWorker { } Napi::Promise::Deferred deferred_; - foundry_local::Manager* impl_; + std::shared_ptr impl_; + std::shared_ptr worker_lease_; std::vector ep_names_; Napi::ObjectReference owner_; Napi::ThreadSafeFunction tsfn_; @@ -413,7 +462,8 @@ Napi::Value Manager::DownloadAndRegisterEps(const Napi::CallbackInfo& info) { } Napi::ObjectReference owner = Napi::Reference::New(info.This().As(), 1); - auto* w = new EpDownloadWorker(env, impl_.get(), std::move(ep_names), std::move(owner), std::move(tsfn)); + auto* w = new EpDownloadWorker(env, impl_, MakeWorkerLease(lifecycle_), std::move(ep_names), std::move(owner), + std::move(tsfn)); Napi::Promise p = w->Promise(); w->Queue(); return p; diff --git a/sdk_v2/js/native/src/manager.h b/sdk_v2/js/native/src/manager.h index 0cdd35207..c321a1c12 100644 --- a/sdk_v2/js/native/src/manager.h +++ b/sdk_v2/js/native/src/manager.h @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. // -// Napi::ObjectWrap over std::unique_ptr. +// Napi::ObjectWrap over std::shared_ptr. // // Surface: // - ctor accepts { appName, modelCacheDir?, serviceEndpoint? } @@ -15,10 +15,17 @@ #include +#include #include namespace foundry_local_node { +struct ManagerLifecycle { + std::atomic disposed{false}; + std::atomic active_sessions{0}; + std::atomic active_workers{0}; +}; + class Manager : public Napi::ObjectWrap { public: static Napi::Function Init(Napi::Env env); @@ -55,7 +62,8 @@ class Manager : public Napi::ObjectWrap { // on env and returns true. Callers should return env.Undefined() when true. bool ThrowIfDisposed(Napi::Env env); - std::unique_ptr impl_; + std::shared_ptr impl_; + std::shared_ptr lifecycle_ = std::make_shared(); }; } // namespace foundry_local_node diff --git a/sdk_v2/js/native/src/model.cc b/sdk_v2/js/native/src/model.cc index c7164778f..a6605acdc 100644 --- a/sdk_v2/js/native/src/model.cc +++ b/sdk_v2/js/native/src/model.cc @@ -4,6 +4,7 @@ #include "addon_data.h" #include "errors.h" +#include "manager.h" #include "promise_worker.h" #include @@ -18,6 +19,24 @@ namespace foundry_local_node { namespace { +struct WorkerLease { + explicit WorkerLease(std::shared_ptr lifecycle) : lifecycle_(std::move(lifecycle)) { + if (lifecycle_) { + lifecycle_->active_workers.fetch_add(1, std::memory_order_acq_rel); + } + } + ~WorkerLease() { + if (lifecycle_) { + lifecycle_->active_workers.fetch_sub(1, std::memory_order_acq_rel); + } + } + std::shared_ptr lifecycle_; +}; + +std::shared_ptr MakeWorkerLease(std::shared_ptr lifecycle) { + return std::make_shared(std::move(lifecycle)); +} + const char* DeviceTypeToString(flDeviceType dt) { switch (dt) { case FOUNDRY_LOCAL_DEVICE_CPU: @@ -73,6 +92,46 @@ void SetPromptTemplate(Napi::Env env, Napi::Object obj, const foundry_local::Mod obj.Set("promptTemplate", template_obj); } +std::shared_ptr LockManager( + const std::weak_ptr& manager_keepalive) { + return manager_keepalive.lock(); +} + +void ThrowFoundryLocalError(Napi::Env env, int code, const std::string& msg) { + Napi::Error err = Napi::Error::New(env, msg); + Napi::Object value = err.Value(); + value.Set("name", Napi::String::New(env, "FoundryLocalError")); + value.Set("code", Napi::Number::New(env, code)); + err.ThrowAsJavaScriptException(); +} + +std::shared_ptr LockManagerOrThrow( + const std::weak_ptr& manager_keepalive, + const std::shared_ptr& lifecycle) { + if (!lifecycle || lifecycle->disposed.load(std::memory_order_acquire)) { + throw foundry_local::Error("Manager has been disposed", FOUNDRY_LOCAL_ERROR_INVALID_USAGE); + } + auto manager = LockManager(manager_keepalive); + if (!manager) { + throw foundry_local::Error("Manager has been disposed", FOUNDRY_LOCAL_ERROR_INVALID_USAGE); + } + return manager; +} + +std::shared_ptr LockManagerOrThrowJs( + Napi::Env env, const std::weak_ptr& manager_keepalive, + const std::shared_ptr& lifecycle) { + if (!lifecycle || lifecycle->disposed.load(std::memory_order_acquire)) { + ThrowFoundryLocalError(env, FOUNDRY_LOCAL_ERROR_INVALID_USAGE, "Manager has been disposed"); + return nullptr; + } + auto manager = LockManager(manager_keepalive); + if (!manager) { + ThrowFoundryLocalError(env, FOUNDRY_LOCAL_ERROR_INVALID_USAGE, "Manager has been disposed"); + } + return manager; +} + void SetModelSettings(Napi::Env env, Napi::Object obj, const foundry_local::ModelInfo& info) { auto settings = info.GetModelSettings(); if (!settings.has_value()) { @@ -144,7 +203,9 @@ Napi::Object SnapshotModelInfo(Napi::Env env, const foundry_local::ModelInfo& in // Drain a ModelList into a JS array, with each entry wrapped as a JS Model // whose keepalive holds the shared ModelList. Napi::Array WrapModelList(Napi::Env env, std::shared_ptr list, - Napi::ObjectReference manager) { + Napi::ObjectReference manager, + std::weak_ptr manager_keepalive, + std::shared_ptr lifecycle) { auto models = list->Models(); Napi::Array arr = Napi::Array::New(env, models.size()); for (size_t i = 0; i < models.size(); ++i) { @@ -153,6 +214,8 @@ Napi::Array WrapModelList(Napi::Env env, std::shared_ptr::New(manager.Value(), 1); + token.manager_keepalive = manager_keepalive; + token.lifecycle = lifecycle; arr.Set(static_cast(i), Model::NewInstance(env, std::move(token))); } return arr; @@ -198,12 +261,20 @@ Model::Model(const Napi::CallbackInfo& info) : Napi::ObjectWrap(info) { } impl_ = token->impl; keepalive_ = std::move(token->keepalive); + manager_keepalive_ = std::move(token->manager_keepalive); + lifecycle_ = std::move(token->lifecycle); manager_ = std::move(token->manager); } +bool Model::manager_disposed() const noexcept { + return !lifecycle_ || lifecycle_->disposed.load(std::memory_order_acquire); +} + Napi::Value Model::GetInfo(const Napi::CallbackInfo& info) { Napi::Env env = info.Env(); return CallChecked(env, [&]() -> Napi::Value { + auto manager_alive = LockManagerOrThrow(manager_keepalive_, lifecycle_); + (void)manager_alive; foundry_local::ModelInfo mi = impl_->GetInfo(); Napi::Object snapshot = SnapshotModelInfo(env, mi); snapshot.Set("cached", Napi::Boolean::New(env, impl_->IsCached())); @@ -214,6 +285,8 @@ Napi::Value Model::GetInfo(const Napi::CallbackInfo& info) { Napi::Value Model::IsCached(const Napi::CallbackInfo& info) { Napi::Env env = info.Env(); return CallChecked(env, [&]() -> Napi::Value { + auto manager_alive = LockManagerOrThrow(manager_keepalive_, lifecycle_); + (void)manager_alive; return Napi::Boolean::New(env, impl_->IsCached()); }); } @@ -221,6 +294,8 @@ Napi::Value Model::IsCached(const Napi::CallbackInfo& info) { Napi::Value Model::IsLoaded(const Napi::CallbackInfo& info) { Napi::Env env = info.Env(); return CallChecked(env, [&]() -> Napi::Value { + auto manager_alive = LockManagerOrThrow(manager_keepalive_, lifecycle_); + (void)manager_alive; return Napi::Boolean::New(env, impl_->IsLoaded()); }); } @@ -228,6 +303,8 @@ Napi::Value Model::IsLoaded(const Napi::CallbackInfo& info) { Napi::Value Model::GetPath(const Napi::CallbackInfo& info) { Napi::Env env = info.Env(); return CallChecked(env, [&]() -> Napi::Value { + auto manager_alive = LockManagerOrThrow(manager_keepalive_, lifecycle_); + (void)manager_alive; std::string_view p = impl_->GetPath(); return Napi::String::New(env, std::string(p)); }); @@ -237,18 +314,19 @@ Napi::Value Model::GetVariants(const Napi::CallbackInfo& info) { Napi::Env env = info.Env(); Napi::ObjectReference owner_clone = Napi::Reference::New(manager_.Value(), 1); return CallChecked(env, [&]() -> Napi::Value { + auto manager_alive = LockManagerOrThrow(manager_keepalive_, lifecycle_); + (void)manager_alive; auto list = std::make_shared(impl_->GetVariants()); - return WrapModelList(env, std::move(list), std::move(owner_clone)); + return WrapModelList(env, std::move(list), std::move(owner_clone), manager_keepalive_, lifecycle_); }); } // ── Async lifecycle ───────────────────────────────────────────────────────── // // Load/Unload/Download dispatch the underlying virtual call onto a libuv -// worker so the event loop stays responsive. The Model itself is pinned -// against GC for the duration of the worker via an ObjectReference to the -// parent Manager (the Manager owns the catalog whose ModelList views the -// IModel*). +// worker so the event loop stays responsive. Each worker captures the model's +// native keepalives so explicit Manager.dispose() or JS GC cannot release the +// underlying Manager/ModelList/owned IModel before the worker finishes. Napi::Value Model::Load(const Napi::CallbackInfo& info) { Napi::Env env = info.Env(); @@ -258,8 +336,16 @@ Napi::Value Model::Load(const Napi::CallbackInfo& info) { } Napi::ObjectReference owner = Napi::Reference::New(manager_.Value(), 1); foundry_local::IModel* m = impl_; + auto keepalive = keepalive_; + auto manager_keepalive = LockManagerOrThrowJs(env, manager_keepalive_, lifecycle_); + if (!manager_keepalive) { + return env.Undefined(); + } + auto worker_lease = MakeWorkerLease(lifecycle_); return PromiseWorkerVoid::Run( - env, [m]() { m->Load(); }, std::move(owner)); + env, [m, keepalive = std::move(keepalive), manager_keepalive = std::move(manager_keepalive), + worker_lease = std::move(worker_lease)]() { m->Load(); }, + std::move(owner)); } Napi::Value Model::Unload(const Napi::CallbackInfo& info) { @@ -270,8 +356,16 @@ Napi::Value Model::Unload(const Napi::CallbackInfo& info) { } Napi::ObjectReference owner = Napi::Reference::New(manager_.Value(), 1); foundry_local::IModel* m = impl_; + auto keepalive = keepalive_; + auto manager_keepalive = LockManagerOrThrowJs(env, manager_keepalive_, lifecycle_); + if (!manager_keepalive) { + return env.Undefined(); + } + auto worker_lease = MakeWorkerLease(lifecycle_); return PromiseWorkerVoid::Run( - env, [m]() { m->Unload(); }, std::move(owner)); + env, [m, keepalive = std::move(keepalive), manager_keepalive = std::move(manager_keepalive), + worker_lease = std::move(worker_lease)]() { m->Unload(); }, + std::move(owner)); } namespace { @@ -282,11 +376,15 @@ namespace { // worker queues and released in OnOK/OnError. class DownloadWorker : public Napi::AsyncWorker { public: - DownloadWorker(Napi::Env env, foundry_local::IModel* impl, Napi::ObjectReference owner, - Napi::ThreadSafeFunction tsfn) + DownloadWorker(Napi::Env env, foundry_local::IModel* impl, std::shared_ptr keepalive, + std::shared_ptr manager_keepalive, std::shared_ptr worker_lease, + Napi::ObjectReference owner, Napi::ThreadSafeFunction tsfn) : Napi::AsyncWorker(env), deferred_(Napi::Promise::Deferred::New(env)), impl_(impl), + keepalive_(std::move(keepalive)), + manager_keepalive_(std::move(manager_keepalive)), + worker_lease_(std::move(worker_lease)), owner_(std::move(owner)), tsfn_(std::move(tsfn)) {} @@ -350,6 +448,9 @@ class DownloadWorker : public Napi::AsyncWorker { Napi::Promise::Deferred deferred_; foundry_local::IModel* impl_; + std::shared_ptr keepalive_; + std::shared_ptr manager_keepalive_; + std::shared_ptr worker_lease_; Napi::ObjectReference owner_; Napi::ThreadSafeFunction tsfn_; std::string err_msg_; @@ -366,6 +467,11 @@ Napi::Value Model::Download(const Napi::CallbackInfo& info) { return env.Undefined(); } + auto manager_keepalive = LockManagerOrThrowJs(env, manager_keepalive_, lifecycle_); + if (!manager_keepalive) { + return env.Undefined(); + } + Napi::ThreadSafeFunction tsfn; if (info.Length() >= 1 && info[0].IsFunction()) { tsfn = Napi::ThreadSafeFunction::New(env, info[0].As(), @@ -379,7 +485,9 @@ Napi::Value Model::Download(const Napi::CallbackInfo& info) { } Napi::ObjectReference owner = Napi::Reference::New(manager_.Value(), 1); - auto* w = new DownloadWorker(env, impl_, std::move(owner), std::move(tsfn)); + auto* w = new DownloadWorker(env, impl_, keepalive_, std::move(manager_keepalive), MakeWorkerLease(lifecycle_), + std::move(owner), + std::move(tsfn)); Napi::Promise p = w->Promise(); w->Queue(); return p; @@ -395,6 +503,8 @@ Napi::Value Model::RemoveFromCache(const Napi::CallbackInfo& info) { // V1's contract is `removeFromCache(): void` so we do not bounce to a // worker. return CallChecked(env, [&]() -> Napi::Value { + auto manager_alive = LockManagerOrThrow(manager_keepalive_, lifecycle_); + (void)manager_alive; impl_->RemoveFromCache(); return env.Undefined(); }); @@ -422,6 +532,10 @@ Napi::Value Model::SelectVariant(const Napi::CallbackInfo& info) { } return CallChecked(env, [&]() -> Napi::Value { + auto manager_alive = LockManagerOrThrow(manager_keepalive_, lifecycle_); + auto variant_manager_alive = LockManagerOrThrow(variant->manager_keepalive_, variant->lifecycle_); + (void)manager_alive; + (void)variant_manager_alive; impl_->SelectVariant(*variant->impl_); return env.Undefined(); }); diff --git a/sdk_v2/js/native/src/model.h b/sdk_v2/js/native/src/model.h index 3ae4b6e63..e90dc3977 100644 --- a/sdk_v2/js/native/src/model.h +++ b/sdk_v2/js/native/src/model.h @@ -32,6 +32,8 @@ namespace foundry_local_node { +struct ManagerLifecycle; + struct ModelCtorToken { // The IModel accessor. Never null when the token is constructed. foundry_local::IModel* impl = nullptr; @@ -39,6 +41,8 @@ struct ModelCtorToken { // or a std::shared_ptr) alive for the JS Model's // lifetime. std::shared_ptr keepalive; + std::weak_ptr manager_keepalive; + std::shared_ptr lifecycle; // Pins the parent Manager so its native handle (and the Catalog's flCatalog* // which the IModel views into) cannot be released first. Napi::ObjectReference manager; @@ -62,6 +66,9 @@ class Model : public Napi::ObjectWrap { // Internal accessor used by Session / ChatSession ctors so they can clone // the parent Manager ObjectReference and pin it for the session lifetime. const Napi::ObjectReference& manager() const noexcept { return manager_; } + std::shared_ptr manager_keepalive() const noexcept { return manager_keepalive_.lock(); } + std::shared_ptr manager_lifecycle() const noexcept { return lifecycle_; } + bool manager_disposed() const noexcept; private: Napi::Value GetInfo(const Napi::CallbackInfo& info); @@ -78,6 +85,8 @@ class Model : public Napi::ObjectWrap { foundry_local::IModel* impl_ = nullptr; std::shared_ptr keepalive_; + std::weak_ptr manager_keepalive_; + std::shared_ptr lifecycle_; Napi::ObjectReference manager_; }; diff --git a/sdk_v2/js/native/src/session.cc b/sdk_v2/js/native/src/session.cc index 0d1247463..d71f2896e 100644 --- a/sdk_v2/js/native/src/session.cc +++ b/sdk_v2/js/native/src/session.cc @@ -5,6 +5,7 @@ #include "addon_data.h" #include "errors.h" #include "items.h" +#include "manager.h" #include "model.h" #include "promise_worker.h" #include "request.h" @@ -22,6 +23,36 @@ namespace foundry_local_node { namespace { +struct SessionLease { + explicit SessionLease(std::shared_ptr lifecycle) + : lifecycle_(std::move(lifecycle)) { + if (lifecycle_) { + lifecycle_->active_sessions.fetch_add(1, std::memory_order_acq_rel); + } + } + + ~SessionLease() { + if (lifecycle_) { + lifecycle_->active_sessions.fetch_sub(1, std::memory_order_acq_rel); + } + } + + std::shared_ptr lifecycle_; +}; + +std::shared_ptr MakeSessionLease(std::shared_ptr lifecycle) { + return std::make_shared(std::move(lifecycle)); +} + +template +void ReleaseSessionState(std::shared_ptr& impl, + std::shared_ptr& manager_keepalive, + std::shared_ptr& session_lease) { + impl.reset(); + manager_keepalive.reset(); + session_lease.reset(); +} + const char* FinishReasonToString(flFinishReason r) { switch (r) { case FOUNDRY_LOCAL_FINISH_STOP: @@ -97,8 +128,10 @@ foundry_local::Request* UnwrapRequest(Napi::Env env, const Napi::Value& v) { // Pins both the Manager (so the Model handle the Session holds stays alive) // and the Request (so the C++ Request the worker reads stays alive). template -Napi::Value ProcessRequestOn(Napi::Env env, SessT* sess, const Napi::Value& request_arg, - Napi::ObjectReference manager_ref) { +Napi::Value ProcessRequestOn(Napi::Env env, std::shared_ptr sess, const Napi::Value& request_arg, + Napi::ObjectReference manager_ref, + std::shared_ptr manager_keepalive, + std::shared_ptr session_lease) { foundry_local::Request* req = UnwrapRequest(env, request_arg); if (req == nullptr) return env.Undefined(); // pending exception Napi::ObjectReference req_pin = Napi::Reference::New(request_arg.As(), 1); @@ -107,12 +140,15 @@ Napi::Value ProcessRequestOn(Napi::Env env, SessT* sess, const Napi::Value& requ struct Pins { Napi::ObjectReference manager; Napi::ObjectReference request; + std::shared_ptr native_manager; + std::shared_ptr session_lease; }; - auto pins = std::make_shared(Pins{std::move(manager_ref), std::move(req_pin)}); + auto pins = std::make_shared( + Pins{std::move(manager_ref), std::move(req_pin), std::move(manager_keepalive), std::move(session_lease)}); return PromiseWorker::Run( env, - [sess, req, pins]() -> Result { + [sess = std::move(sess), req, pins]() -> Result { (void)pins; // keepalive captured by reference count return std::make_shared(sess->ProcessRequest(*req)); }, @@ -148,6 +184,8 @@ struct StreamCtx { Napi::Promise::Deferred deferred; Napi::ObjectReference manager; Napi::ObjectReference request; + std::shared_ptr native_manager; + std::shared_ptr session_lease; std::shared_ptr response; std::string err_msg; int err_code = 0; @@ -180,9 +218,9 @@ void FinalizeStream(Napi::Env env, void* /*data*/, StreamCtx* ctx) { template class StreamWorker : public Napi::AsyncWorker { public: - static Napi::Promise Run(Napi::Env env, SessT* sess, foundry_local::Request* req, + static Napi::Promise Run(Napi::Env env, std::shared_ptr sess, foundry_local::Request* req, Napi::Function jsCallback, StreamCtx* ctx) { - auto* w = new StreamWorker(env, sess, req, jsCallback, ctx); + auto* w = new StreamWorker(env, std::move(sess), req, jsCallback, ctx); Napi::Promise p = ctx->deferred.Promise(); w->Queue(); return p; @@ -215,9 +253,6 @@ class StreamWorker : public Napi::AsyncWorker { return 0; }); ctx_->response = std::make_shared(sess_->ProcessRequest(*req_)); - // Drop the callback so any stale shared state in the lambda is released - // before the Session is re-used for a follow-up request. - sess_->SetStreamingCallback(nullptr); } catch (const foundry_local::Error& e) { ctx_->errored = true; ctx_->err_code = static_cast(e.Code()); @@ -230,6 +265,9 @@ class StreamWorker : public Napi::AsyncWorker { ctx_->errored = true; ctx_->err_msg = "Unknown native exception"; } + // Drop the callback so any stale shared state in the lambda is released + // before the Session is re-used for a follow-up request. + sess_->SetStreamingCallback(nullptr); } // Promise resolution happens in FinalizeStream — overriding OnOK/OnError @@ -239,10 +277,10 @@ class StreamWorker : public Napi::AsyncWorker { void OnError(const Napi::Error& /*unused*/) override { tsfn_.Release(); } private: - StreamWorker(Napi::Env env, SessT* sess, foundry_local::Request* req, + StreamWorker(Napi::Env env, std::shared_ptr sess, foundry_local::Request* req, Napi::Function jsCallback, StreamCtx* ctx) : Napi::AsyncWorker(env), - sess_(sess), + sess_(std::move(sess)), req_(req), ctx_(ctx), tsfn_(Napi::ThreadSafeFunction::New(env, jsCallback, "foundry_local_stream", @@ -250,15 +288,17 @@ class StreamWorker : public Napi::AsyncWorker { FinalizeStream, static_cast(nullptr))) {} - SessT* sess_; + std::shared_ptr sess_; foundry_local::Request* req_; StreamCtx* ctx_; Napi::ThreadSafeFunction tsfn_; }; template -Napi::Value ProcessStreamingRequestOn(Napi::Env env, SessT* sess, const Napi::CallbackInfo& info, - Napi::ObjectReference manager_ref) { +Napi::Value ProcessStreamingRequestOn(Napi::Env env, std::shared_ptr sess, const Napi::CallbackInfo& info, + Napi::ObjectReference manager_ref, + std::shared_ptr manager_keepalive, + std::shared_ptr session_lease) { if (info.Length() < 2 || !info[1].IsFunction()) { Napi::TypeError::New(env, "processStreamingRequest(request: Request, onItem: (item) => void)") .ThrowAsJavaScriptException(); @@ -272,12 +312,14 @@ Napi::Value ProcessStreamingRequestOn(Napi::Env env, SessT* sess, const Napi::Ca auto* ctx = new StreamCtx{Napi::Promise::Deferred::New(env), std::move(manager_ref), std::move(req_pin), + std::move(manager_keepalive), + std::move(session_lease), nullptr, "", 0, false, false}; - return StreamWorker::Run(env, sess, req, info[1].As(), ctx); + return StreamWorker::Run(env, std::move(sess), req, info[1].As(), ctx); } } // namespace @@ -322,7 +364,15 @@ ChatSession::ChatSession(const Napi::CallbackInfo& info) : Napi::ObjectWrap(*native); + auto manager_keepalive = model->manager_keepalive(); + if (model->manager_disposed() || !manager_keepalive) { + ThrowFoundryLocalError(env, FOUNDRY_LOCAL_ERROR_INVALID_USAGE, "Manager has been disposed"); + return; + } + impl_ = std::make_shared(*native); + lifecycle_ = model->manager_lifecycle(); + session_lease_ = MakeSessionLease(lifecycle_); + manager_keepalive_ = std::move(manager_keepalive); } catch (const foundry_local::Error& e) { ThrowFoundryLocalError(env, static_cast(e.Code()), e.what()); return; @@ -333,6 +383,10 @@ ChatSession::ChatSession(const Napi::CallbackInfo& info) : Napi::ObjectWrap::New(model->manager().Value(), 1); } +ChatSession::~ChatSession() { + ReleaseSessionState(impl_, manager_keepalive_, session_lease_); +} + bool ChatSession::ThrowIfDisposed(Napi::Env env) { if (impl_ == nullptr) { ThrowFoundryLocalError(env, FOUNDRY_LOCAL_ERROR_INVALID_USAGE, @@ -350,14 +404,14 @@ Napi::Value ChatSession::ProcessRequest(const Napi::CallbackInfo& info) { return env.Undefined(); } Napi::ObjectReference owner = Napi::Reference::New(manager_.Value(), 1); - return ProcessRequestOn(env, impl_.get(), info[0], std::move(owner)); + return ProcessRequestOn(env, impl_, info[0], std::move(owner), manager_keepalive_, session_lease_); } Napi::Value ChatSession::ProcessStreamingRequest(const Napi::CallbackInfo& info) { Napi::Env env = info.Env(); if (ThrowIfDisposed(env)) return env.Undefined(); Napi::ObjectReference owner = Napi::Reference::New(manager_.Value(), 1); - return ProcessStreamingRequestOn(env, impl_.get(), info, std::move(owner)); + return ProcessStreamingRequestOn(env, impl_, info, std::move(owner), manager_keepalive_, session_lease_); } Napi::Value ChatSession::SetOptions(const Napi::CallbackInfo& info) { @@ -437,7 +491,7 @@ Napi::Value ChatSession::UndoTurns(const Napi::CallbackInfo& info) { } Napi::Value ChatSession::Dispose(const Napi::CallbackInfo& info) { - impl_.reset(); + ReleaseSessionState(impl_, manager_keepalive_, session_lease_); return info.Env().Undefined(); } @@ -479,7 +533,15 @@ EmbeddingsSession::EmbeddingsSession(const Napi::CallbackInfo& info) return; } try { - impl_ = std::make_unique(*native); + auto manager_keepalive = model->manager_keepalive(); + if (model->manager_disposed() || !manager_keepalive) { + ThrowFoundryLocalError(env, FOUNDRY_LOCAL_ERROR_INVALID_USAGE, "Manager has been disposed"); + return; + } + impl_ = std::make_shared(*native); + lifecycle_ = model->manager_lifecycle(); + session_lease_ = MakeSessionLease(lifecycle_); + manager_keepalive_ = std::move(manager_keepalive); } catch (const foundry_local::Error& e) { ThrowFoundryLocalError(env, static_cast(e.Code()), e.what()); return; @@ -490,6 +552,10 @@ EmbeddingsSession::EmbeddingsSession(const Napi::CallbackInfo& info) manager_ = Napi::Reference::New(model->manager().Value(), 1); } +EmbeddingsSession::~EmbeddingsSession() { + ReleaseSessionState(impl_, manager_keepalive_, session_lease_); +} + bool EmbeddingsSession::ThrowIfDisposed(Napi::Env env) { if (impl_ == nullptr) { ThrowFoundryLocalError(env, FOUNDRY_LOCAL_ERROR_INVALID_USAGE, @@ -507,7 +573,7 @@ Napi::Value EmbeddingsSession::ProcessRequest(const Napi::CallbackInfo& info) { return env.Undefined(); } Napi::ObjectReference owner = Napi::Reference::New(manager_.Value(), 1); - return ProcessRequestOn(env, impl_.get(), info[0], std::move(owner)); + return ProcessRequestOn(env, impl_, info[0], std::move(owner), manager_keepalive_, session_lease_); } Napi::Value EmbeddingsSession::SetOptions(const Napi::CallbackInfo& info) { @@ -526,7 +592,7 @@ Napi::Value EmbeddingsSession::SetOptions(const Napi::CallbackInfo& info) { } Napi::Value EmbeddingsSession::Dispose(const Napi::CallbackInfo& info) { - impl_.reset(); + ReleaseSessionState(impl_, manager_keepalive_, session_lease_); return info.Env().Undefined(); } @@ -573,7 +639,15 @@ AudioSession::AudioSession(const Napi::CallbackInfo& info) return; } try { - impl_ = std::make_unique(*native); + auto manager_keepalive = model->manager_keepalive(); + if (model->manager_disposed() || !manager_keepalive) { + ThrowFoundryLocalError(env, FOUNDRY_LOCAL_ERROR_INVALID_USAGE, "Manager has been disposed"); + return; + } + impl_ = std::make_shared(*native); + lifecycle_ = model->manager_lifecycle(); + session_lease_ = MakeSessionLease(lifecycle_); + manager_keepalive_ = std::move(manager_keepalive); } catch (const foundry_local::Error& e) { ThrowFoundryLocalError(env, static_cast(e.Code()), e.what()); return; @@ -584,6 +658,10 @@ AudioSession::AudioSession(const Napi::CallbackInfo& info) manager_ = Napi::Reference::New(model->manager().Value(), 1); } +AudioSession::~AudioSession() { + ReleaseSessionState(impl_, manager_keepalive_, session_lease_); +} + bool AudioSession::ThrowIfDisposed(Napi::Env env) { if (impl_ == nullptr) { ThrowFoundryLocalError(env, FOUNDRY_LOCAL_ERROR_INVALID_USAGE, @@ -601,14 +679,14 @@ Napi::Value AudioSession::ProcessRequest(const Napi::CallbackInfo& info) { return env.Undefined(); } Napi::ObjectReference owner = Napi::Reference::New(manager_.Value(), 1); - return ProcessRequestOn(env, impl_.get(), info[0], std::move(owner)); + return ProcessRequestOn(env, impl_, info[0], std::move(owner), manager_keepalive_, session_lease_); } Napi::Value AudioSession::ProcessStreamingRequest(const Napi::CallbackInfo& info) { Napi::Env env = info.Env(); if (ThrowIfDisposed(env)) return env.Undefined(); Napi::ObjectReference owner = Napi::Reference::New(manager_.Value(), 1); - return ProcessStreamingRequestOn(env, impl_.get(), info, std::move(owner)); + return ProcessStreamingRequestOn(env, impl_, info, std::move(owner), manager_keepalive_, session_lease_); } Napi::Value AudioSession::SetOptions(const Napi::CallbackInfo& info) { @@ -627,7 +705,7 @@ Napi::Value AudioSession::SetOptions(const Napi::CallbackInfo& info) { } Napi::Value AudioSession::Dispose(const Napi::CallbackInfo& info) { - impl_.reset(); + ReleaseSessionState(impl_, manager_keepalive_, session_lease_); return info.Env().Undefined(); } diff --git a/sdk_v2/js/native/src/session.h b/sdk_v2/js/native/src/session.h index 2b1db7b86..796439e54 100644 --- a/sdk_v2/js/native/src/session.h +++ b/sdk_v2/js/native/src/session.h @@ -32,11 +32,14 @@ namespace foundry_local_node { +struct ManagerLifecycle; + class ChatSession : public Napi::ObjectWrap { public: static Napi::Function Init(Napi::Env env); explicit ChatSession(const Napi::CallbackInfo& info); + ~ChatSession(); private: Napi::Value ProcessRequest(const Napi::CallbackInfo& info); @@ -51,8 +54,11 @@ class ChatSession : public Napi::ObjectWrap { bool ThrowIfDisposed(Napi::Env env); - std::unique_ptr impl_; + std::shared_ptr impl_; Napi::ObjectReference manager_; + std::shared_ptr manager_keepalive_; + std::shared_ptr lifecycle_; + std::shared_ptr session_lease_; }; // Napi::ObjectWrap over foundry_local::EmbeddingsSession. @@ -73,6 +79,7 @@ class EmbeddingsSession : public Napi::ObjectWrap { static Napi::Function Init(Napi::Env env); explicit EmbeddingsSession(const Napi::CallbackInfo& info); + ~EmbeddingsSession(); private: Napi::Value ProcessRequest(const Napi::CallbackInfo& info); @@ -82,8 +89,11 @@ class EmbeddingsSession : public Napi::ObjectWrap { bool ThrowIfDisposed(Napi::Env env); - std::unique_ptr impl_; + std::shared_ptr impl_; Napi::ObjectReference manager_; + std::shared_ptr manager_keepalive_; + std::shared_ptr lifecycle_; + std::shared_ptr session_lease_; }; // Napi::ObjectWrap over foundry_local::AudioSession. @@ -101,6 +111,7 @@ class AudioSession : public Napi::ObjectWrap { static Napi::Function Init(Napi::Env env); explicit AudioSession(const Napi::CallbackInfo& info); + ~AudioSession(); private: Napi::Value ProcessRequest(const Napi::CallbackInfo& info); @@ -111,8 +122,11 @@ class AudioSession : public Napi::ObjectWrap { bool ThrowIfDisposed(Napi::Env env); - std::unique_ptr impl_; + std::shared_ptr impl_; Napi::ObjectReference manager_; + std::shared_ptr manager_keepalive_; + std::shared_ptr lifecycle_; + std::shared_ptr session_lease_; }; } // namespace foundry_local_node diff --git a/sdk_v2/js/src/catalog.ts b/sdk_v2/js/src/catalog.ts index 978a0f359..c08c8d579 100644 --- a/sdk_v2/js/src/catalog.ts +++ b/sdk_v2/js/src/catalog.ts @@ -4,6 +4,7 @@ // // The underlying native catalog operations are synchronous; the async surface here is for parity with the C# / // Python SDKs. `getModel`, `getModelVariant`, and `getLatestVersion` throw when the alias / id is not found. +// After the parent manager is disposed, native catalog methods throw a FoundryLocalError. import type { NativeCatalog, NativeModel } from "./detail/native.js"; import type { IModel } from "./imodel.js"; diff --git a/sdk_v2/js/test/_fixtures/realModelManager.ts b/sdk_v2/js/test/_fixtures/realModelManager.ts index 2c9214453..391639ce0 100644 --- a/sdk_v2/js/test/_fixtures/realModelManager.ts +++ b/sdk_v2/js/test/_fixtures/realModelManager.ts @@ -46,6 +46,8 @@ export interface RealModelManagerOptions { * "qwen2.5-0.5b" — alias of the smallest chat model we ship. */ readonly namePreference?: string; + /** Skip in CI when catalog data cannot identify a matching cached model. */ + readonly skipUnavailableInCi?: boolean; } export interface RealModelManagerFixture { @@ -107,9 +109,11 @@ export async function setupRealModelManager(opts: RealModelManagerOptions = {}): }); if (matching.length === 0) { manager.dispose(); - throw new Error( - `No catalog model matches task='${task}' deviceType='CPU' (and preference '${namePref}' missing)`, - ); + const message = `No catalog model matches task='${task}' deviceType='CPU' (and preference '${namePref}' missing)`; + if (isCi && opts.skipUnavailableInCi === true) { + throw new SkipFixture(`[CI] ${message}`); + } + throw new Error(message); } matching.sort( (a, b) => diff --git a/sdk_v2/js/test/manager-dispose.test.ts b/sdk_v2/js/test/manager-dispose.test.ts index fe7c7ba19..3f46579d7 100644 --- a/sdk_v2/js/test/manager-dispose.test.ts +++ b/sdk_v2/js/test/manager-dispose.test.ts @@ -62,6 +62,21 @@ describeIfBuilt("FoundryLocalManager.dispose", () => { } }); + it("a cached catalog handle throws after manager disposal and does not block a new manager", () => { + const mgr = freshManager("cached-catalog"); + const catalog = mgr.catalog; + mgr.dispose(); + + expect(() => catalog.name).toThrow(/disposed/i); + + const next = freshManager("after-cached-catalog"); + try { + expect(next.disposed).toBe(false); + } finally { + next.dispose(); + } + }); + it("Symbol.dispose is wired and idempotent", () => { const mgr = freshManager("symbol-dispose"); mgr[Symbol.dispose](); @@ -70,6 +85,20 @@ describeIfBuilt("FoundryLocalManager.dispose", () => { expect(mgr.disposed).toBe(true); }); + it("dispose() rejects while a native EP worker is in flight", async () => { + const mgr = freshManager("async-worker"); + const pending = mgr.downloadAndRegisterEps(["__not-a-provider__"]); + try { + expect(() => mgr.dispose()).toThrow(/active native workers/i); + await pending.catch(() => undefined); + } finally { + if (!mgr.disposed) { + mgr.dispose(); + } + } + expect(mgr.disposed).toBe(true); + }); + it("`using` declaration disposes at scope exit", () => { let captured: FoundryLocalManager | undefined; { diff --git a/sdk_v2/js/test/openai-client-dispose.test.ts b/sdk_v2/js/test/openai-client-dispose.test.ts index 0717c3f1e..5ba005f24 100644 --- a/sdk_v2/js/test/openai-client-dispose.test.ts +++ b/sdk_v2/js/test/openai-client-dispose.test.ts @@ -10,7 +10,7 @@ // paths against the chat fixture too (a client that never inferred has no // session-type dependency) and rely on their respective integration test // files for the with-session paths. -import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { AudioClient } from "../src/openai/audioClient.js"; import { ChatClient } from "../src/openai/chatClient.js"; @@ -19,6 +19,7 @@ import { EmbeddingClient } from "../src/openai/embeddingClient.js"; import { type RealModelManagerFixture, haveTestModelCache, + SkipFixture, setupRealModelManager, teardownRealModelManager, testModelCacheDiagnostic, @@ -30,11 +31,27 @@ if (!haveTestModelCache) { describe.skipIf(!haveTestModelCache)("OpenAI client dispose()", () => { let fixture: RealModelManagerFixture | undefined; + let skipReason: string | undefined; beforeAll(async () => { - fixture = await setupRealModelManager(); + try { + fixture = await setupRealModelManager({ skipUnavailableInCi: true }); + } catch (error) { + if (error instanceof SkipFixture) { + skipReason = error.message; + console.warn(error.message); + return; + } + throw error; + } }, 5 * 60_000); + beforeEach((context) => { + if (skipReason !== undefined) { + context.skip(skipReason); + } + }); + afterAll(() => { teardownRealModelManager(fixture); });