Introduction
xlfn is a Rust framework for building Excel XLL add-ins against the Excel 12/XLOPER12 C API. It keeps the Excel ABI, raw pointers, panic containment, return-value ownership, function registration, RTD transport, and lifecycle exports inside generated framework boundaries. Add-in authors work primarily with safe Rust functions and typed values.
A minimal exported function looks like ordinary Rust:
#![allow(unused)]
fn main() {
use xlfn::prelude::*;
#[excel_function(name = "EXAMPLE.ADD", thread_safe)]
pub fn add(left: f64, right: f64) -> f64 {
left + right
}
}
The attribute generates the Excel ABI wrapper and registration descriptor. Arguments use FromExcel; ordinary scalar results and matrix cells use IntoExcel. Runtime dispatch and return ownership remain behind the framework boundary. Conversion behavior follows Rust trait resolution, so aliases and re-exports do not require macro-specific type-name recognition.
What the framework provides
- one typed add-in lifecycle with
Addin::open,Addin::quiesce, andAddin::cleanup; - function registration generated from Rust attributes;
- strict scalar, string, error, array, date-serial, and reference conversion;
- main-thread, thread-safe, macro-sheet, and asynchronous capability contexts;
- formula-owned, type-checked object handles;
- native Excel asynchronous UDFs behind an optional feature;
- generic push-based RTD subscriptions;
- bounded diagnostic delivery and structured
tracingevents; - linked-artifact, PE architecture, export, dependency, and optional sidecar-package validation;
- transactional x86/x64 packaging with best-effort rollback through
cargo xlfn.
Framework boundary
xlfn owns the Excel-facing boundary of an XLL. Once arguments have been converted to Rust values and a function has obtained the capabilities allowed by its execution context, the rest of the call is ordinary application code.
Dependencies below that boundary are application concerns. They may be Rust crates, native libraries, COM components, local processes, IPC endpoints, or remote services. xlfn does not generate bindings for them, load them at runtime, choose their ABI or transport, create their worker pools, or define their object identity. Optional bundle metadata can stage and validate sidecar files for distribution, but packaging does not create a runtime integration API.
xlfn also does not infer business semantics, cache keys, or application-specific cancellation policy. Keep those contracts explicit in ordinary Rust code.
Documentation map
Use this guide for workflows, mental models, constraints, and operational practices. Use generated rustdoc for exhaustive signatures:
cargo doc --package xlfn --all-features --open
Start with Requirements and compatibility, then complete Create your first add-in. Continue with Add-in lifecycle and state and Execution modes and contexts before using stateful facilities such as Formula-owned handles, Asynchronous functions, or Streaming RTD. For packaging and release behavior, use Deployment and distribution.
Status and release claims
The repository distinguishes implemented behavior from real-Excel qualification. A successful Rust unit test or PE inspection is not, by itself, evidence that a release candidate has passed both 32-bit and 64-bit Excel scenarios. Before distributing a release, follow Testing and release qualification and record the exact Windows and Excel environment used.
Requirements and compatibility
Build host
Release XLLs target the Microsoft Visual C++ ABI and are expected to be built and linked on Windows with:
- Windows 10 or Windows 11;
- Rust 1.98.1 or a compatible toolchain for this source snapshot;
- the
i686-pc-windows-msvcand/orx86_64-pc-windows-msvcRust targets; - Visual Studio Build Tools with Desktop development with C++;
- Cargo and
cargo-xlfn.
The repository pins the toolchain and both targets in rust-toolchain.toml:
[toolchain]
channel = "1.98.1"
profile = "minimal"
components = ["clippy", "rustfmt"]
targets = ["i686-pc-windows-msvc", "x86_64-pc-windows-msvc"]
Host-side tests that do not link an XLL may run on other operating systems. Packaging and release qualification still require Windows MSVC artifacts and real Excel.
Source-snapshot installation: the workspace is configured with publishable crates, but an official crates.io release may not yet be available. Until it is published, install
cargo-xlfnfrom an audited Git revision or local checkout and replace generatedversion = "0.2"dependencies with the same Git revision or a localpath. The version-based dependency examples in this guide show the intended form for a published release.
Excel bitness
Match the XLL to the Excel process, not to the operating system:
| Excel process | Rust target | Package directory |
|---|---|---|
| 32-bit Excel | i686-pc-windows-msvc | package/win-x86/ |
| 64-bit Excel | x86_64-pc-windows-msvc | package/win-x64/ |
A 64-bit edition of Windows can run 32-bit Excel. In that case, use the x86 XLL.
Excel API level
xlfn uses the Excel 12/XLOPER12 interface. Asynchronous worksheet functions use Excel’s native asynchronous UDF ABI and are intended for Excel versions that provide that ABI; the project documentation uses Excel 2010 or later as the operational baseline for this feature.
Do not convert this implementation target into an unqualified compatibility claim. Qualify each release candidate against the exact Excel channels, bitnesses, and Windows versions that you intend to support.
Rust crate features
The xlfn crate has no default features:
[dependencies]
xlfn = "0.2"
Enable only what the add-in uses:
[dependencies]
xlfn = { version = "0.2", features = ["async"] }
| Feature | Adds |
|---|---|
async | native asynchronous UDF runtime, AsyncContext, cancellation tokens, and calculation-event exports |
handles | formula-owned typed objects, aliases, and scoped handle inputs |
rtd | streaming sources, subscriptions, and RTD configuration |
unstable-cache | lower-level calculation-cache API with an explicitly unstable contract |
unstable-output | lower-level array-output API with an explicitly unstable contract |
handles and rtd are independent public capabilities. Use both async and
handles for asynchronous handle consumers. The refinement and
bench-internals features are for repository verification, not application
development. See the compatibility policy for stability scope.
Project shape
An XLL package must contain exactly one cdylib target and exactly one crate-root type attributed with #[excel_addin]. The generated lifecycle and COM exports are part of that definition. Do not hand-write competing xlAutoOpen, xlAutoClose, xlAutoRemove, xlAutoFree12, DllGetClassObject, or related exports.
Create your first add-in
1. Setup compilation targets and optional tooling
Add the MSVC target for your target Excel bitness:
rustup target add i686-pc-windows-msvc x86_64-pc-windows-msvc
xlfn is a library and framework crate. To perform automated PE artifact inspection and directory packaging, install the optional cargo-xlfn CLI tool:
cargo install cargo-xlfn --locked
From a local checkout, the equivalent command is:
cargo install --path crates/cargo-xlfn --locked --force
2. Create a library crate
Create a standard Rust library project:
cargo new --lib hello-xlfn
cd hello-xlfn
In Cargo.toml, set the crate type to cdylib and add xlfn as a dependency:
[package]
name = "hello-xlfn"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["cdylib"]
[dependencies]
xlfn = "0.2"
For a local checkout during development, use a path dependency instead:
[dependencies]
xlfn = { path = "../xlfn/crates/xlfn" }
Define the add-in in src/lib.rs:
#![allow(unused)]
#![deny(unsafe_op_in_unsafe_fn)]
fn main() {
use xlfn::prelude::*;
mod udf;
pub struct State;
#[excel_addin(
name = "Hello Xll",
id = "hello-xlfn",
category = "HelloXll"
)]
pub struct HelloXll;
impl Addin for HelloXll {
type SharedState = State;
type LifecycleState = ();
type Error = XllError;
type Layers = ();
fn open(context: &OpenContext) -> Result<Opened<Self::SharedState, Self::LifecycleState, Self::Layers>, Self::Error> {
context
.diagnostics()
.install_file_sink()
.map_err(|error| XllError::Native {
code: -1,
message: error.to_string(),
})?;
Ok(Opened::new(State, (), ()))
}
}
}
3. Add a function
Create src/udf.rs:
#![allow(unused)]
fn main() {
use xlfn::prelude::*;
/// Adds two finite numbers.
#[excel_function(
name = "HELLO.ADD",
category = "Hello",
help_topic = "https://example.invalid/hello/add",
thread_safe
)]
pub fn add(
#[excel_arg(name = "Left", description = "First addend.")] left: f64,
#[excel_arg(name = "Right", description = "Second addend.")] right: f64,
) -> f64 {
left + right
}
}
The doc comment becomes the function description unless description = "..." is supplied explicitly.
4. Validate linked artifacts (Optional)
cargo-xlfn provides artifact validation for Excel XLLs beyond standard cargo check:
cargo xlfn check
Without --target, check builds and validates both Windows targets. During development, validate one target explicitly:
cargo xlfn check --target x86_64-pc-windows-msvc
This links the DLL, stages an XLL package, verifies the .xllexp manifest, compares required exports with the PE export table, checks architecture, and resolves packaged imports.
5. Create a package
cargo xlfn package --all
The x86 and x64 packages are staged and validated before the output root is replaced. A failure in either target leaves the previous package directory in place.
For one target:
cargo xlfn package --target x86_64-pc-windows-msvc
(Note: You can also build directly using cargo build --target x86_64-pc-windows-msvc, but cargo xlfn package provides automated XLL PE validation, sidecar handling, and transactional output staging.)
6. Load the XLL in Excel
In Excel, open:
File → Options → Add-ins → Manage: Excel Add-ins → Go → Browse
Select the .xll in the directory matching the Excel process bitness (package/win-x64 or package/win-x86). Keep the complete package directory together, including every packaged sidecar and build-manifest.json.
Enter:
=HELLO.ADD(2, 3)
The result should be 5. If loading or invocation fails, inspect the diagnostic log and follow Troubleshooting.
Project anatomy
A production add-in is normally one Rust crate. Separate crates are optional architectural choices, not framework requirements.
hello-xlfn/
├── Cargo.toml
└── src/
├── lib.rs # one add-in definition and lifecycle state
└── udf.rs # exported worksheet functions
This is the framework-level shape, not a required application architecture. Add any modules, workspace crates, generated bindings, data directories, or sidecar directories that the application itself needs. xlfn only gives such files special meaning when they are referenced by an xlfn build or packaging contract.
src/lib.rs: lifecycle and shared state
Place exactly one #[excel_addin] type at crate root. Its Addin implementation creates immutable or internally synchronized state that exported functions access through typed contexts.
#![allow(unused)]
fn main() {
use xlfn::prelude::*;
use std::sync::Arc;
pub struct State {
pub configuration: Arc<Configuration>,
}
pub struct Configuration {
pub environment: String,
}
#[excel_addin(name = "App Tools", id = "app-tools", category = "App")]
pub struct AppTools;
impl Addin for AppTools {
type SharedState = State;
type LifecycleState = ();
type Error = XllError;
type Layers = ();
fn open(context: &OpenContext) -> XllResult<Opened<Self::SharedState, Self::LifecycleState, Self::Layers>> {
let configuration = load_configuration(context.module_directory())?;
Ok(Opened::new(State {
configuration: Arc::new(configuration),
}, (), ()))
}
}
fn load_configuration(_: &std::path::Path) -> XllResult<Configuration> {
Ok(Configuration {
environment: "Production".to_owned(),
})
}
}
State must be Send + Sync + 'static. Anything stored in it must independently satisfy the concurrency contract of every worksheet function that can access it. If an application resource is thread-affine, keep its owner in LifecycleState and expose only a safe thread-compatible client through State; xlfn binds lifecycle-state access to the lifecycle thread for the open generation. See Add-in lifecycle and state and Execution modes and contexts.
UDF modules
Worksheet functions may be organized across ordinary Rust modules. The macro uses inventory-based registration, so there is no central handwritten function table.
#![allow(unused)]
fn main() {
mod data;
mod math;
mod text;
}
Each Excel-visible function remains a non-generic, safe, free Rust function. Internal helpers may use any appropriate Rust design.
Cargo metadata
Cargo metadata controls output names and optional sidecar-file placement. It is not a second source of truth for worksheet signatures or application runtime behavior.
#[excel_function]is the source of truth for worksheet metadata.#[excel_addin]is the source of truth for add-in identity and lifecycle exports.- Ordinary application code is the source of truth for domain behavior and downstream dependency contracts.
[package.metadata.xlfn]controls distribution artifact naming and bundle staging.
Generated boundary
At compile time, the macros generate:
- lifecycle and COM exports;
- one ABI wrapper and one registration descriptor per function;
- x86 decorated export directives where required;
- a
.xllexpmanifest section for linked-artifact validation; - conversion, panic, and return-ownership boundaries;
- optional asynchronous calculation-event exports.
Application code should not call the generated symbols directly. Test application logic through ordinary Rust functions and integration behavior through cargo xlfn check and real Excel.
Build, validate, and load
An XLL is not complete when cargo check succeeds. Excel loads a linked PE image, resolves imports, calls a fixed set of exports, and expects the image architecture to match the Excel process. xlfn therefore supplies cargo xlfn check and cargo xlfn package as the supported artifact workflows.
Development validation
From the add-in package directory:
cargo xlfn check
With no target selection, check validates both supported Windows targets. Select one during a focused development loop:
cargo xlfn check --target x86_64-pc-windows-msvc
cargo xlfn check --target i686-pc-windows-msvc
cargo xlfn check does more than type checking. It:
- builds and links the selected
cdylib; - creates an isolated staging package;
- reads the generated
.xllexpmanifest; - compares required lifecycle, COM, calculation-event, and UDF exports with the PE export table;
- verifies the PE machine type against the requested target;
- stages configured bundle files;
- checks the import closure using the package’s system-import policy.
Use Cargo build-selection flags normally:
cargo xlfn check `
--target x86_64-pc-windows-msvc `
--features async `
--locked
The default is --crt static, which reduces deployment dependence on a separately installed VC runtime. The command reports that default rather than changing the profile silently. Use --crt dynamic when the linked application and its binary dependencies require /MD, or --crt inherit to preserve Cargo, environment, and toolchain CRT settings exactly.
static and dynamic are enforced by an internal rustc wrapper only for the selected target; host build scripts and proc macros are unchanged. The linked XLL contains an effective-policy marker which check and package verify. The CRT observer recognizes an exact, case-insensitive allowlist of release/debug MSVC runtime DLLs and Universal CRT API-set DLLs; lookalike names are not classified. Under static, an observed dynamic CRT import is rejected because it commonly indicates that a prebuilt static library used /MD. Under inherit, the same static-Rust/dynamic-import combination is recorded and warned as potentially mixed.
CRT observation does not approve an external dependency. Any runtime DLL not included in the package must be listed explicitly in external-imports, where it remains a deliberate deployment exception and is not validated as part of the package closure.
The policy cannot recompile an existing .lib. Build linked binary components with a matching MSVC runtime: /MT (or /MTd) for static, and /MD (or /MDd) for dynamic. Matching CRT settings also do not make cross-module allocator ownership safe: allocate and free an object in the same module, or expose an explicit paired deallocator/caller-owned buffer contract.
Release packaging
Build one target:
cargo xlfn package --target x86_64-pc-windows-msvc
Build both bitnesses as one output transaction:
cargo xlfn package --all
The default output root is package/. A typical result is:
package/
├── win-x86/
│ ├── AppTools.xll
│ ├── build-manifest.json
│ └── NativeEngine.dll
└── win-x64/
├── AppTools.xll
├── build-manifest.json
└── NativeEngine.dll
For --all, every target is built, staged, and verified before the previous output root is transactionally replaced. The replacement is not reader-visible atomicity; the journal provides crash recovery on the next invocation, while rollback remains best effort. If the final replacement and its rollback both fail, the tool preserves the previous package in a recovery directory and reports that path. Do not delete the recovery directory until the failure has been investigated.
Loading in Excel
Use File -> Options -> Add-ins -> Manage: Excel Add-ins -> Go -> Browse, then choose the XLL whose architecture matches the Excel process.
Keep the entire target directory together. The package verifier validates relative imports and
bundle files in that directory; moving only the .xll invalidates that deployment assumption.
When Excel reports that a file cannot be opened or is not a valid add-in, check these in order:
- Excel process bitness versus XLL bitness;
- whether the complete package directory was copied;
- Windows file blocking and code-signing policy;
- missing or wrong-architecture binary dependencies;
- diagnostics emitted during
xlAutoOpen; - endpoint protection or application-control policy.
See Troubleshooting for a symptom-oriented procedure.
What successful validation proves
cargo xlfn check proves properties of the linked and staged bytes. It does not prove that:
- a worksheet function has correct business semantics;
- an application-defined ABI declaration matches an external binary;
- an external implementation advertised as thread-safe actually is thread-safe;
- cancellation is timely;
- every supported Excel channel behaves identically;
- installation ACLs or code signatures are correct.
Treat linked-artifact validation, Rust tests, application-adapter tests, and real-Excel qualification as separate release gates. The Testing and release qualification chapter defines a complete matrix.
Add-in lifecycle and state
The Addin trait defines one open generation of the XLL:
#![allow(unused)]
fn main() {
pub trait Addin: Send + Sync + 'static {
type SharedState: Send + Sync + 'static;
type LifecycleState: 'static;
type Error: IntoXllError;
/// `()` or a tuple of one or more `UdfLayer` values.
type Layers;
fn open(
context: &OpenContext,
) -> Result<Opened<Self::SharedState, Self::LifecycleState, Self::Layers>, Self::Error>;
fn quiesce(
shared: &mut Self::SharedState,
lifecycle: &mut Self::LifecycleState,
) -> Result<(), Self::Error>;
fn cleanup(lifecycle: &mut Self::LifecycleState, reporter: &mut CleanupReporter<'_>);
}
}
Opened returns shared state, lifecycle-local state, execution layers, and the
runtime policy as one open transaction. SharedState is borrowed by UDF calls
and must be Send + Sync; LifecycleState is retained by xlfn in thread-local
storage and bound to the Excel lifecycle thread for the open generation, so it
may own thread-affine resources. RuntimeConfig can
select RTD limits and, with the async feature, the async worker count.
The stable default uses type Layers = ();. Custom UDF layers are part of the
stable execution contract and are documented separately in UDF execution
layers. Other lower-level APIs remain behind the explicit
unstable-cache and unstable-output are independent experimental API
features; internal lifecycle refinement is enabled only by refinement and
is not required by add-in authors.
Open
Addin::open runs on Excel’s main lifecycle thread. OpenContext provides:
module_path()— the full path reported for the loaded XLL;module_directory()— the directory containing the XLL;build_info()— add-in ID, crate version, and target triple.rtd().register_source(...)— an opaque RTD source identity for later subscriptions.
Use this hook to load bounded configuration, install diagnostics, and create application-owned resources needed by later calls. Return an error rather than panicking. The framework converts the error through IntoXllError, records diagnostics, and fails the open operation safely.
Do not perform unbounded I/O or long-running initialization in open. Excel is waiting synchronously.
Shared state
Synchronous contexts borrow &SharedState. The framework-owned asynchronous
future keeps the current execution generation (ExecutionLease) alive, while
AsyncContext<'_, A> borrows the shared generation state and per-call
cancellation token for the invocation. Shared state therefore needs explicit
synchronization for mutable data:
#![allow(unused)]
fn main() {
use std::sync::RwLock;
pub struct SharedState {
settings: RwLock<Settings>,
}
}
Prefer immutable snapshots or narrowly scoped locks. Never hold an application lock while calling Excel, invoking a user-supplied callback, waiting for a worker, or shutting down another subsystem.
UDF layers
Opened returns process-local execution middleware together with state. Layers
are installed for the open generation and receive call metadata before
argument conversion. See UDF execution layers.
Async worker count
With the async feature:
#![allow(unused)]
fn main() {
impl Addin for ServiceAddin {
type SharedState = State;
type LifecycleState = ();
type Error = XllError;
type Layers = ();
fn open(_: &OpenContext) -> XllResult<Opened<Self::SharedState, Self::LifecycleState, Self::Layers>> {
Ok(Opened::new(State::new(), (), ()).with_runtime_config(
RuntimeConfig::new().with_async_worker_count(
AsyncWorkerCount::new(4).expect("4 is within the supported range"),
),
))
}
}
}
AsyncWorkerCount accepts only values in 1..=32; values outside that range
are rejected, and the default is four. This pool executes Rust futures and is
separate from any executor, worker, connection pool, or other runtime created
by the application.
Quiescence, cleanup, and unload safety
Addin::quiesce runs on the same main lifecycle thread as open, after the framework has stopped accepting new calls and drained active framework calls and asynchronous tasks. It must synchronously stop every application-owned thread, callback, task, queue, and producer that could execute XLL code or require add-in state after logical teardown. An add-in that opts into physical DLL unload must additionally implement the unsafe PhysicallyUnloadableAddin contract and stop every executable source before the stronger hook returns.
The formula-handle registry is closed after quiesce returns. Formula-owned Rust objects can therefore still exist while application quiescence is being established. If a handle object refers to an application-owned resource, quiesce must leave its later Drop safe after workers, connections, or owner threads have stopped. Prefer releasing or invalidating such resources while their owners are still available, then make the later wrapper drop local or idempotent. See Formula-owned handles.
#![allow(unused)]
fn main() {
fn quiesce(
shared: &mut SharedState,
lifecycle: &mut LifecycleState,
) -> Result<(), Error> {
shared.request_application_shutdown();
shared.join_application_workers()?;
lifecycle.release_thread_affine_resources();
Ok(())
}
}
Excel’s xlAutoClose export is an ambiguous deactivation or shutdown hint. It
does not tear down the runtime and does not release the DLL’s physical
residency lease. UDFs remain callable after the hint while the generation is
still Open.
xlAutoRemove is the explicit terminal-removal boundary. It is the only
boundary that runs quiesce, unregisters Excel callbacks, stops framework
producers, closes RTD/COM state, reclaims the generation, and publishes the
logical Closed phase. A normal safe Addin retains the module residency
lease after this transition, so the framework does not claim that arbitrary
application-created executable sources have stopped. Only an add-in using the
physical_unload attribute option and the unsafe
PhysicallyUnloadableAddin contract permits the following xlAutoClose to
release that lease. DllCanUnloadNow remains S_FALSE while the lease is
held.
If quiesce fails or panics, or any other teardown hazard prevents a complete
certificate, the runtime enters Quarantined. It rejects new UDF calls and
opens, retains the module residency lease, and retains resources whose
destruction was not proven safe. Ordinary xlAutoClose hints never clear this
state.
If Excel requests xlAutoOpen while a generation is still open, xlfn performs
a controlled terminal teardown of the old generation and then opens a new
generation. A failed reload is quarantined. Normal Excel process termination
does not provide the same quiesce guarantee; process exit is therefore not
used as the logical lifecycle boundary.
For application-owned concurrent or thread-affine resources, a safe shutdown sequence is:
- reject new application submissions;
- signal cancellation or shutdown;
- resolve or reject queued requests according to the application’s contract;
- release thread-affine resources on the thread that owns them;
- join every application-owned worker or coordinator;
- release remaining application roots before
quiescereturns.
xlfn cannot prove those application-level properties; quiesce is the boundary at which the add-in must establish them.
After quiescence, Addin::cleanup performs best-effort disposal of
LifecycleState. It cannot return an arbitrary business error. Report
recoverable failures explicitly; they are logged without preventing safe
unload:
#![allow(unused)]
fn main() {
fn cleanup(lifecycle: &mut LifecycleState, reporter: &mut CleanupReporter<'_>) {
if let Err(error) = lifecycle.remove_cached_metadata() {
reporter.warn("metadata cache", CleanupIssueKind::HostMetadata, error);
}
}
}
A cleanup panic is contained after quiescence. The framework retains or leaks
the lifecycle state rather than invoking more unknown destructor code, records
the issue, and quarantines the runtime with its module residency lease held.
cleanup must not start work or register callbacks. Only a completed cleanup,
an explicit lifecycle-state take and drop, and an empty thread-affine binding
permit the runtime to publish Closed and release its lifecycle-thread
binding.
Consequences:
- cancellation must be cooperative;
- background callbacks must be quiescent before
quiescereturns; - every application-owned worker or coordinator must be joined;
- in-process work that cannot be interrupted should be isolated out of process when safe unload requires a hard stop;
- do not implement a timeout that abandons in-process code and then permits unload.
Application-owned lifecycle resources
Addin::open, Addin::quiesce, and Addin::cleanup for a generation run on
the same lifecycle thread. xlfn records that affinity as an internal
capability; a wrong-thread open or removal boundary is rejected and
quarantined before lifecycle state is accessed. An application may use this property for
lifecycle-owned registries or other resources that are not exposed to
worksheet calls. SharedState remains Send + Sync + 'static; expose only
safe, thread-compatible clients through it. LifecycleState is the place for
thread-affine owners.
For a thread-affine application resource, lifecycle code may own the resource
and its worker while SharedState exposes only a safe client. Do not let
submitted work capture and destroy the owner responsible for joining its own
worker. xlfn constrains the Excel boundary and unload ordering; the
application’s internal dispatch design remains ordinary Rust code.
Worksheet functions
Apply #[excel_function] to a safe, ordinary, non-generic free function:
#![allow(unused)]
fn main() {
#[excel_function(name = "MATH.HYPOT", thread_safe)]
pub fn hypot(x: f64, y: f64) -> f64 {
x.hypot(y)
}
}
The function may be synchronous or, with the async feature, an async fn. The macro rejects unsafe, extern, const, generic, variadic, and method declarations.
Inputs and results are trait-driven
An ordinary input type implements FromExcel. An ordinary scalar output or matrix cell implements IntoExcel; the framework keeps runtime return dispatch, ownership, and execution-mode checks internal.
Application errors use Result<T, E> where E: IntoXllError:
#![allow(unused)]
fn main() {
#[derive(Debug)]
enum DataProcessingError {
InvalidValue,
}
impl IntoXllError for DataProcessingError {
fn into_xll_error(self) -> XllError {
match self {
Self::InvalidValue => XllError::input(
"limit",
xlfn::error::InputError::OutOfRange,
),
}
}
}
#[excel_function(name = "DATA.EVALUATE", thread_safe)]
fn evaluate(base: f64, limit: f64) -> Result<f64, DataProcessingError> {
if limit < 0.0 {
return Err(DataProcessingError::InvalidValue);
}
Ok((base - limit).max(0.0))
}
}
Argument limits
- synchronous functions support at most 255 Excel-visible arguments;
- asynchronous functions support at most 254 because Excel supplies an additional async handle;
- the optional context argument is injected by the framework and does not count as an Excel-visible argument;
- argument patterns must be simple identifiers.
Large positional APIs are difficult to use even below these hard limits. Prefer domain objects, arrays, handles, or a small coherent worksheet surface.
Thread safety is an explicit claim
Add thread_safe only when the full call path is safe under Excel multi-threaded recalculation:
#![allow(unused)]
fn main() {
#[excel_function(name = "DATASET.EVALUATE", thread_safe)]
fn evaluate(dataset: Handle<'_, Dataset>, time: f64) -> XllResult<f64> {
dataset.evaluate(time)
}
}
This includes application state, external adapters, caches, logging, and destruction paths. The attribute is not a performance hint; it is a contract with Excel.
Return ownership
Return values are converted into framework-owned XLOPER12 storage. Excel eventually calls the generated xlAutoFree12 export. Do not allocate or free XLOPER12 values in ordinary add-in code.
The framework catches panics at its ABI boundaries and returns a safe Excel error while reporting the detailed failure. Panics remain bugs: containment protects Excel; it does not make a partially completed business operation transactional.
Registration conflicts
Registration names are conflict-denying. xlfn does not replace another XLL’s hidden or public name because it cannot safely reconstruct another add-in’s ownership and visibility state. Choose stable, namespaced Excel names such as ACME.DATA.COMPUTE.
Execution modes and contexts
xlfn separates Excel-visible arguments from injected capabilities. A context, when present, must be the first parameter and must be passed by value with exactly one #[excel_context(...)] role.
Main-thread context
#![allow(unused)]
fn main() {
#[excel_function(name = "APP.ENVIRONMENT")]
fn environment(
#[excel_context(main_thread)] context: MainThreadContext<'_, AppTools>,
) -> String {
context.state().environment.clone()
}
}
MainThreadContext is neither Send nor Sync. It has one inferred lifetime tied to the current Excel-call scope; the context keeps the open generation alive while exposing state and callback capability. With the rtd feature, context.rtd() returns the narrower RTD capability that establishes a streaming subscription. Formula-owned object producers use main-thread return semantics, even when they do not explicitly request a context.
Do not combine a main-thread context with thread_safe.
Thread-safe context
#![allow(unused)]
fn main() {
#[excel_function(name = "APP.VERSION", thread_safe)]
fn version(
#[excel_context(thread_safe)] context: ThreadSafeContext<'_, AppTools>,
) -> String {
context.state().version.clone()
}
}
ThreadSafeContext is Copy, Send, and Sync when the referenced state permits it. Its presence marks the function as thread-safe even if the function attribute omits the flag. Use an explicit attribute as well when it improves readability, but do not treat duplicate declaration as additional safety.
What thread_safe guarantees
thread_safe declares that Excel may invoke the generated boundary concurrently on calculation threads. It does not make State or anything reached through State thread-safe. Every application resource used by the function must independently support concurrent access or be protected by an application synchronization or dispatch policy.
Do not move call-scoped Excel values, raw references, or callback capabilities to another thread. Thread affinity below the Rust worksheet-function boundary is a separate application concern from Excel’s execution mode.
Macro-sheet context
#![allow(unused)]
fn main() {
#[excel_function(name = "APP.RANGE.NAME")]
fn range_name(
#[excel_context(macro_sheet)] context: MacroSheetContext<'_, AppTools>,
#[excel_arg(reference)] reference: ExcelReference<'_>,
) -> XllResult<String> {
context.sheet_name(&reference)
}
}
A macro-sheet context permits Excel callback operations that are not allowed in thread-safe functions. It is neither Send nor Sync; its one inferred lifetime is the current Excel-call scope. It provides:
coercefor an ownedExcelValue;coerce_matrix<T>for an owned matrix;sheet_name.
The macro_sheet function flag selects the same registration capability without injecting state access. It is incompatible with thread_safe and asynchronous functions.
Asynchronous context
#![allow(unused)]
fn main() {
#[excel_function(name = "APP.SLOW")]
async fn slow(
#[excel_context(asynchronous)] context: AsyncContext<'_, AppTools>,
input: String,
) -> XllResult<String> {
context.check_cancelled()?;
Ok(input)
}
}
The framework-owned future retains the current open-generation lease and per-call cancellation token. AsyncContext<'_, AppTools> borrows those capabilities for the invocation, so it is available only with the async feature and only to async fn; it cannot escape into a detached task. An async function may omit the context if it does not need state or cancellation.
Compatibility table
| Mode | How selected | Excel MTR | Can use raw references | Can return a new handle object |
|---|---|---|---|---|
| Main thread | default or main_thread context | no | no | yes |
| Thread-safe | thread_safe or thread_safe context | yes | no | no |
| Macro-sheet | macro_sheet or macro_sheet context | no | yes | no |
| Asynchronous | async fn | native async ABI | no | no |
A function marked volatile must still return a type valid for its mode. Handle objects and HandleAlias<'_, T> support volatile main-thread return semantics. Borrowed Handle<'_, T> values are synchronous call-scoped inputs and cannot be used in async functions. An async function that needs a formula-owned object must use the generation-scoped HandleLease<'_, T> input, which pins the registry payload before the task is committed.
Function metadata
#[excel_function] is the single source of truth for registration metadata:
#![allow(unused)]
fn main() {
/// Computes the area of a rectangle.
#[excel_function(
name = "MATH.AREA",
id = "math_area",
category = "Math",
help_topic = "https://example.invalid/math/area",
thread_safe
)]
fn calculate_area(
#[excel_arg(name = "Width", description = "Rectangle width.")] width: f64,
#[excel_arg(name = "Height", description = "Rectangle height.")] height: f64,
) -> f64 {
width * height
}
}
Function fields
| Field or flag | Meaning |
|---|---|
name = "..." | Excel-visible function name; defaults to the Rust function name |
id = "..." | stable generated export ID; defaults to the Rust function name |
category = "..." | Function Wizard category; empty means the add-in default category |
description = "..." | function description; defaults to joined Rust doc comments |
help_topic = "..." | optional help URL or topic |
thread_safe | registers the function for multi-threaded recalculation |
macro_sheet | registers macro-sheet capability |
volatile | asks Excel to recalculate the function as volatile |
hidden | hides the function from normal Function Wizard discovery |
The id must be a Rust identifier fragment: ASCII letters, digits, and underscores, not beginning with a digit. It becomes part of an exported symbol and should remain stable once released.
Excel-visible function names use one ASCII case-insensitive identity rule for duplicate validation and cleanup. Unicode case folding is not applied; if a project uses non-ASCII names, their exact non-ASCII code points remain distinct.
Argument metadata
#![allow(unused)]
fn main() {
#[excel_arg(
name = "Factor",
description = "Scaling factor for calculation."
)]
factor: f64
}
Argument names must be non-empty counted strings without comma, NUL, carriage return, or line feed. Each name and the joined comma-separated list must fit Excel’s 32,767 UTF-16-unit counted-string limit.
Excel function registration has additional practical limits for argument help. xlfn supplies actual argument help for the leading entries and uses a terminal empty sentinel to avoid Excel’s trailing-help truncation behavior. Treat concise argument names and descriptions as part of the stable worksheet API.
Add-in metadata
At crate root:
#![allow(unused)]
fn main() {
#[excel_addin(
name = "Math Analytics",
id = "math-analytics",
category = "Math"
)]
pub struct MathAnalytics;
}
nameandcategorycontain 1 to 255 UTF-16 code units;idis a non-reserved ASCII slug of at most 64 bytes;- the ID begins with a letter and contains only letters, digits,
-, or_; - Windows reserved device names are rejected.
The ID participates in runtime identity, diagnostics, and temporary RTD registration ownership. Changing it is not a cosmetic release change.
Visibility and naming policy
Prefer a project or organization prefix. Excel has a process-wide function namespace, and registration conflicts are rejected rather than overwritten. Avoid generic names such as EVAL, VERSION, or LOOKUP in distributed add-ins.
Values and arrays
xlfn converts Excel values strictly. Ordinary parameters do not ask Excel to coerce text to numbers, booleans to numbers, or arrays to scalars. This makes worksheet behavior predictable and keeps conversion failures visible.
Scalar inputs
| Rust type | Accepted Excel value | Notes |
|---|---|---|
f64 | number or integer | must be finite |
bool | Boolean | numbers and text are not coerced |
i32 | integer, or an integral number in range | fractional values are rejected |
i64 | integer, or an exactly representable integral number | numeric input is limited to the exact Excel-double integer range, -2^53..=2^53 |
String | string | decoded as valid UTF-16 |
&str | string | decoded into call-local scratch; synchronous functions only |
ExcelErrorValue | Excel error | preserves the worksheet error |
ExcelCellRef<'call> | number, Boolean, string, error, or blank | borrowed cell view for the active call |
ExcelSerialDate | finite number | initially marked with ExcelDateSystem::Workbook |
ExcelValue | supported scalar, error, blank, missing, or array | use for intentionally dynamic input |
An Excel error passed to a parameter that expects another type is propagated as that Excel error rather than disguised as a generic type error.
Scalar outputs
The same scalar families can be returned. Important restrictions are:
f64must be finite;i64must be exactly representable by an Excel number;- strings must fit Excel’s counted UTF-16 representation;
ExcelValue::Missingand blankExcelCellValueare input states, not valid worksheet results; useExcelErrorValue(ExcelError::NotAvailable)for an explicit#N/Aresult;()is not a normal worksheet scalar result, although it is useful as an RTD value and in internal APIs.
Use ExcelErrorValue(ExcelError::NotAvailable) when an Excel error is the intended successful result. Use Err(...) when the function itself failed. That distinction improves diagnostics and instrumentation.
Matrices
For synchronous functions that only inspect or transform an Excel array during the call, use XlArrayRef<'_>. It borrows the xltypeMulti cell buffer and converts each XlValueRef lazily, so input traversal itself allocates nothing:
#![allow(unused)]
fn main() {
#[excel_function(name = "ARRAY.SUM.BORROWED", thread_safe)]
fn sum_borrowed(values: XlArrayRef<'_>) -> XllResult<f64> {
values
.cells()
.try_fold(0.0, |sum, cell| Ok(sum + cell.as_f64()?))
}
}
For typed, call-local grids use MatrixRef<'_, T>. Its elements are copied into
the call scratch arena and therefore require T: Copy; string elements can be
&str without a per-element String allocation:
#![allow(unused)]
fn main() {
#[excel_function(name = "ARRAY.TEXT.COUNT", thread_safe)]
fn text_count(values: MatrixRef<'_, &str>) -> f64 {
values.iter().map(|value| value.len() as f64).sum()
}
}
For large numeric outputs, explicitly enable the unstable-output crate feature and import xlfn::unstable::output::{XlArrayBuilder, XlArrayOutput}. Write directly into an XlArrayBuilder; the finished cell allocation is adopted by ReturnBlock without copying:
#![allow(unused)]
fn main() {
fn doubled(values: XlArrayRef<'_>) -> XllResult<XlArrayOutput> {
let (rows, columns) = values.shape();
let mut output = XlArrayBuilder::new(rows, columns)?;
for cell in values.cells() {
output.push_f64(cell.as_f64()? * 2.0)?;
}
output.finish()
}
}
Use the owned Matrix<T> path when values must outlive the exported call or cross into async work.
Matrix<T> stores a rectangular grid in row-major order:
#![allow(unused)]
fn main() {
#[excel_function(name = "ARRAY.IDENTITY", thread_safe)]
fn identity(size: i32) -> XllResult<Matrix<f64>> {
let size = usize::try_from(size)
.map_err(|_| XllError::input("size", InputError::OutOfRange))?;
if size == 0 {
return Err(XllError::input(
"size",
InputError::Malformed("matrix size must be non-zero"),
));
}
if size > 1_000 {
return Err(XllError::input(
"size",
InputError::TooLarge {
limit: 1_000,
actual: size,
},
));
}
let count = size.checked_mul(size).ok_or(XllError::Domain {
code: DomainErrorCode::Overflow,
})?;
let mut values = vec![0.0; count];
for index in 0..size {
values[index * size + index] = 1.0;
}
Matrix::new(size, size, values)
}
}
Matrix::new(rows, columns, data) checks shape multiplication, Excel’s row and column limits, framework element limits, and data length. A scalar input converts to a 1 x 1 matrix; an Excel multi-value converts to its rectangular shape.
Useful accessors include:
#![allow(unused)]
fn main() {
matrix.rows();
matrix.columns();
matrix.as_slice();
matrix.row(0);
matrix.column(0);
matrix.iter();
matrix[(0, 0)];
}
Indexing panics on an invalid coordinate. Use row and column when invalid coordinates should be handled as ordinary control flow.
One-dimensional shapes
Use Row<T> and Column<T> to state orientation explicitly:
#![allow(unused)]
fn main() {
#[excel_function(name = "ARRAY.CUMSUM", thread_safe)]
fn cumulative(values: Row<f64>) -> XllResult<Row<f64>> {
let mut total = 0.0;
Row::new(
values
.into_vec()
.into_iter()
.map(|value| {
total += value;
total
})
.collect(),
)
}
}
A Row<T> accepts a scalar or 1 x N input. A Column<T> accepts a scalar or N x 1 input. They reject a genuinely two-dimensional array instead of silently flattening it.
Vec<T> and BoundedVarArgs<T, MAX> are input-only one-dimensional containers. Prefer BoundedVarArgs for worksheet surfaces where a hard maximum is part of the contract:
#![allow(unused)]
fn main() {
#[excel_function(name = "STAT.MEAN", thread_safe)]
fn mean(values: BoundedVarArgs<f64, 128>) -> XllResult<f64> {
let values = values.as_slice();
if values.is_empty() {
return Err(XllError::input("values", InputError::Malformed("empty input")));
}
Ok(values.iter().copied().sum::<f64>() / values.len() as f64)
}
}
MAX must be greater than zero.
Array safety limits
The framework validates Excel’s structural limits and imposes additional memory bounds before reading or allocating arrays.
| Limit | 32-bit target | 64-bit target |
|---|---|---|
| Excel rows | 1,048,576 | 1,048,576 |
| Excel columns | 16,384 | 16,384 |
| framework elements | 1,000,000 | 4,000,000 |
| referenced XLOPER12 bytes | 64 MiB | 256 MiB |
| returned allocation bytes | 64 MiB | 256 MiB |
The lower 32-bit limits are intentional. A 32-bit Excel process has a much smaller virtual address space, and one large array can destabilize the host even when the nominal worksheet dimensions are legal.
Date serials
ExcelSerialDate preserves a finite Excel serial plus a date-system marker:
#![allow(unused)]
fn main() {
#[excel_function(name = "DATE.SERIAL", thread_safe)]
fn serial(date: ExcelSerialDate) -> f64 {
date.serial()
}
}
An ordinary worksheet argument does not, by itself, reveal whether the workbook uses the Windows 1900 or Mac 1904 system, so converted inputs use ExcelDateSystem::Workbook. Resolve or inject the actual workbook convention in application policy before converting the serial to a civil date. ExcelSerialDate::is_fictitious_1900_leap_day() detects serial 60 only after the value has been marked Windows1900.
Dynamic values
ExcelValue is useful for pass-through, inspection, and adapters whose type is intentionally dynamic. Prefer concrete Rust types in normal functions: they produce better Function Wizard signatures, clearer errors, and less downstream branching. Its array form contains only ExcelCellValue, so nested arrays and missing cells cannot be represented.
Optional arguments and enums
Excel distinguishes a missing positional argument from a reference to a blank cell. xlfn exposes that distinction instead of collapsing every absence into one value.
Option<T>
For ordinary arguments, Option<T> maps both missing and blank to None:
#![allow(unused)]
fn main() {
#[excel_function(name = "DATA.TRANSFORM", thread_safe)]
fn scaled(value: f64, factor: Option<f64>) -> f64 {
value / (1.0 + factor.unwrap_or(0.0))
}
}
Use this only when missing and blank have the same domain meaning.
Preserve presence explicitly
OptionalExcelValue<T> has three variants:
#![allow(unused)]
fn main() {
pub enum OptionalExcelValue<T> {
Missing,
Blank,
Value(T),
}
}
Example:
#![allow(unused)]
fn main() {
#[excel_function(name = "INPUT.STATE", thread_safe)]
fn state(value: OptionalExcelValue<String>) -> &'static str {
match value {
OptionalExcelValue::Missing => "missing",
OptionalExcelValue::Blank => "blank",
OptionalExcelValue::Value(_) => "value",
}
}
}
This is the correct representation when omission means “use configuration” but a blank cell means “clear the setting”, for example.
Declarative blank and missing policies
#[excel_arg] can apply a policy before ordinary conversion:
#![allow(unused)]
fn main() {
#[excel_function(name = "MATH.GROWTH", thread_safe)]
fn growth(
base: f64,
#[excel_arg(
name = "Factor",
default = 0.0,
missing = "default",
blank = "error"
)]
factor: f64,
) -> f64 {
base * (1.0 + factor)
}
}
Allowed policy strings are "default" and "error".
missing = "default"evaluates the Rustdefaultexpression for a missing argument;blank = "default"evaluates it for a blank cell;missing = "error"rejects omission;blank = "error"rejects a blank cell;- no policy means normal conversion applies.
A default = ... expression is accepted only when at least one presence state uses "default". The expression is inserted into the generated Rust wrapper and must evaluate to the declared argument type.
Defaults should be deterministic and inexpensive. Do not hide I/O, locks, or mutable global state in a default expression.
Reference arguments cannot use blank, missing, or default policies because they preserve raw Excel reference semantics.
Worksheet enums
Derive ExcelEnum for a small, closed string vocabulary:
#![allow(unused)]
fn main() {
}
#[derive(Clone, Copy, ExcelEnum)]
#[excel_enum(ascii_case_insensitive)]
enum Direction {
#[excel_value(name = "Forward")]
Forward,
#[excel_value(name = "Reverse")]
Reverse,
}
#[excel_function(name = "DIRECTION.SIGN", thread_safe)]
fn sign(direction: Direction) -> f64 {
match direction {
Direction::Forward => 1.0,
Direction::Reverse => -1.0,
}
The derive implements input conversion, output conversion, and all normal return-mode marker traits. Requirements:
- the target is an enum;
- every variant is unit-like;
- each Excel text value is non-empty;
- text values are unique under the selected comparison policy.
Without ascii_case_insensitive, matching is exact. With it, comparison is ASCII case-insensitive; it is not locale-sensitive Unicode case folding.
Use #[excel_value(name = "...")] to make worksheet text independent of Rust naming. Once workbooks depend on those strings, treat them as versioned public API.
Evolving an enum safely
Adding a new input value is usually backward-compatible. Renaming or removing one is not. A practical migration is:
- retain the old variant text for at least one release;
- map it to the new internal semantics;
- emit a diagnostic or documentation deprecation notice;
- remove it only in a planned breaking release.
The derive intentionally rejects aliases with duplicate text. When aliases are required during migration, implement FromExcel manually.
Custom conversions
The framework’s conversion traits let domain types appear directly in worksheet signatures. Keep conversions strict, owned, bounded, and independent of Excel callbacks.
Custom input with FromExcel
A custom input receives a call-scoped XlValueRef and the static argument name:
#![allow(unused)]
fn main() {
use xlfn::{
value::{FromExcel, XlValueRef},
error::{InputError, XllError, XllResult},
};
#[derive(Clone, Copy)]
struct PositiveFactor(f64);
impl<'call> FromExcel<'call> for PositiveFactor {
fn from_excel(
value: XlValueRef<'call>,
argument: &'static str,
) -> XllResult<Self> {
let factor = <f64 as FromExcel>::from_excel(value, argument)?;
if factor < 0.0 {
return Err(XllError::input(argument, InputError::OutOfRange));
}
Ok(Self(factor))
}
}
}
The call lifetime is explicit. Owned conversions work for every 'call; borrowed framework types such as XlArrayRef<'call> preserve that exact lifetime. Generated wrappers create a fresh lifetime per call, so Excel-owned memory cannot escape the exported function.
Reuse built-in conversions where possible. They already validate malformed pointers, UTF-16, numeric exactness, errors, shape, and memory limits.
Owned types used as ordinary Excel-visible parameters implement one FromExcel
contract. The framework also provides explicit call-scoped views such as
&str, MatrixRef<'_, T>, and ExcelCellRef<'_>; those are synchronous-only
and are not custom FromExcel implementations. Runtime context and
formula-fingerprint construction stay inside the framework boundary. Do not
retain XlValueRef or any pointer derived from it in an owned result.
Semantic identity for handle producers
A function returning a formula-owned handle is memoized by the converted
semantic inputs. Such a parameter must therefore also implement
ExcelInputIdentity; ordinary functions that do not produce a handle still
require only FromExcel:
#![allow(unused)]
fn main() {
use xlfn::value::{ExcelInputIdentity, InputIdentityEncoder};
impl ExcelInputIdentity for PositiveFactor {
fn encode_input_identity(&self, encoder: &mut InputIdentityEncoder) {
encoder.f64(self.0);
}
}
}
The identity implementation describes the Rust value observed by the UDF,
not the original XLOPER12 representation. Omitting this implementation for
a custom input in a handle-producing function is a compile-time error. The
framework supplies semantic identities for built-in conversions and derives a
variant ordinal for ExcelEnum values.
Custom cell and output conversions
A value used as one cell in a returned matrix, or as a custom scalar return, implements IntoExcel:
#![allow(unused)]
fn main() {
use xlfn::{
value::{ExcelCellOutput, IntoExcel},
error::XllResult,
};
struct Percentage(f64);
impl IntoExcel for Percentage {
fn into_excel(self) -> XllResult<ExcelCellOutput> {
self.0.into_excel()
}
}
}
The same implementation is used for scalar returns and matrix cells. Execution-mode capability checks are supplied by the framework’s internal return dispatcher.
A simpler alternative is to convert inside the function and return a built-in value:
#![allow(unused)]
fn main() {
#[excel_function(name = "FACTOR.PERCENT", thread_safe)]
fn percent(factor: PositiveFactor) -> f64 {
factor.0 * 100.0
}
}
Custom result errors
Application errors can remain domain-specific:
#![allow(unused)]
fn main() {
use xlfn::error::{InputError, IntoXllError, XllError};
#[derive(Debug)]
enum DataError {
MissingEntry,
InvalidInput,
}
impl IntoXllError for DataError {
fn into_xll_error(self) -> XllError {
match self {
Self::MissingEntry => XllError::input(
"dataset",
InputError::Malformed("missing entry"),
),
Self::InvalidInput => XllError::Domain {
code: xlfn::error::DomainErrorCode::InvalidInput,
},
}
}
}
}
Use Result<T, DataError> in the worksheet function. The generated boundary performs the conversion and records diagnostic detail.
Do not encode expected user errors as panics. Panic containment protects Excel from unwinding across the ABI, but it reports an internal defect rather than a domain error.
Conversion design rules
A production conversion should satisfy all of these:
- Owned: no Excel pointer escapes the current call.
- Bounded: reject unreasonable strings, arrays, recursion, or allocation sizes.
- Strict: do not perform surprising text, locale, or Boolean coercions.
- Deterministic: the same cell value and configuration produce the same domain value.
- Context-light: conversion should not perform network calls or long-running external work.
- Diagnostic: preserve the argument name and use a meaningful
InputErroror domain code.
For a closed string vocabulary, prefer ExcelEnum. For a formula-owned object, use handles rather than serializing an internal pointer into a string yourself.
Errors and diagnostics
xlfn separates what Excel sees from what operators need to diagnose. A worksheet receives a conventional Excel error; the runtime can emit structured detail without exposing sensitive internals in the cell.
Error types
Most add-ins use XllResult<T>, an alias for Result<T, XllError>.
Important XllError families include:
Inputwith an argument name andInputError;ExcelValue, preserving an input Excel error;Domainwith a stableDomainErrorCode;- invalid or stale handles;
- lifecycle states such as closing or overloaded;
- external-adapter or Excel-callback failures represented by the relevant application mappings;
Internalwith a stable diagnostic ID.
Use XllError::input(argument, reason) for user-correctable worksheet input. Reserve internal diagnostic IDs for defects or environmental failures that are not useful to expose directly in a cell.
Worksheet mapping
The framework maps errors conservatively:
| Error family | Typical Excel result |
|---|---|
| domain errors and numeric overflow | #NUM! |
preserved ExcelErrorValue | the original Excel error |
| invalid/stale handle, closing, overloaded, or reentrant operation | #N/A |
| malformed input, wrong type, callback failure, or internal failure | #VALUE! |
This mapping is intentionally coarse. The diagnostic stream carries the specific variant, argument, function ID, and diagnostic identifier.
Install the file sink
A basic production setup installs the built-in bounded file sink during Addin::open:
#![allow(unused)]
fn main() {
impl Addin for AppTools {
type SharedState = State;
type LifecycleState = ();
type Error = XllError;
type Layers = ();
fn open(context: &OpenContext) -> XllResult<Opened<Self::SharedState, Self::LifecycleState, Self::Layers>> {
let path = context
.diagnostics()
.install_file_sink()
.map_err(|error| XllError::Native {
code: -1,
message: error.to_string(),
})?;
tracing::info!(path = %path.display(), "diagnostic log installed");
Ok(Opened::new(State::new(), (), ()))
}
}
}
The sink writes one JSON object per line. Control characters are escaped and large text fields are bounded before writing, so an error cannot create additional log records or bypass rotation.
The default path is:
%LOCALAPPDATA%/<addin-id>/logs/diagnostics.log
When LOCALAPPDATA is unavailable, the implementation uses a temporary-directory fallback. The file rotates at 4 MiB and retains three generations.
The sink is process-wide. Installing a new sink flushes and joins the previous sink worker before replacement, and the framework stops the active sink during XLL shutdown. Make ownership explicit when multiple add-ins or test harnesses share a process; one add-in must not silently take telemetry ownership from another.
Custom sinks
Implement DiagnosticSink when events must go to an existing telemetry system:
#![allow(unused)]
fn main() {
use xlfn::diagnostics::{DiagnosticEvent, DiagnosticSink};
struct Telemetry;
impl DiagnosticSink for Telemetry {
fn report(&self, event: &DiagnosticEvent<'_>) {
// Copy only the fields needed by the bounded downstream queue.
// Do not block indefinitely or call Excel here.
let _ = event;
}
}
}
Install the custom sink during Addin::open:
#![allow(unused)]
fn main() {
impl Addin for AppTools {
type SharedState = State;
type LifecycleState = ();
type Error = XllError;
type Layers = ();
fn open(context: &OpenContext) -> XllResult<Opened<Self::SharedState, Self::LifecycleState, Self::Layers>> {
context
.diagnostics()
.set_sink(Telemetry)
.map_err(|error| XllError::Native {
code: -1,
message: error.to_string(),
})?;
Ok(Opened::new(State::new(), (), ()))
}
}
}
The runtime places a bounded asynchronous queue of 1,024 events in front of the sink. When producers outrun delivery, events are dropped rather than blocking worksheet execution. Monitor xlfn::diagnostics::stats().dropped_events as an operational signal.
The sink itself must still be bounded and panic-free. A slow or reentrant sink can delay shutdown even though ordinary producers use a queue.
tracing integration
The runtime emits structured tracing events in addition to the configured diagnostic sink. The host application owns the global subscriber. A library should not call set_global_default unconditionally; use an application-level subscriber policy that composes with other instrumentation.
Do not assume a tracing subscriber is infallible. The runtime contains panics around its own diagnostic boundaries, but add-in logging code should remain simple and non-panicking.
Diagnostic IDs
The runtime attaches a DiagnosticId to internal failures and emitted diagnostic events. Sinks can inspect this identifier via .as_u64() or format it as hexadecimal:
#![allow(unused)]
fn main() {
impl DiagnosticSink for Telemetry {
fn report(&self, event: &DiagnosticEvent<'_>) {
let numeric_id = event.diagnostic_id().as_u64();
tracing::error!(diagnostic = %format_args!("{numeric_id:016x}"), "UDF failure event");
}
}
}
These IDs are framework-internal correlation codes rather than user-defined error types.
User-facing error design
A high-quality worksheet API should make common errors actionable through function and argument descriptions. Diagnostics are not a substitute for clear contracts. Prefer:
#NUM!for a mathematically invalid domain;#N/Afor an unavailable or expired object/service result;- a preserved upstream Excel error when it is semantically the input;
#VALUE!for type or structure mismatch.
Use a companion information function only when users genuinely need structured status. Do not leak internal exception text into arbitrary worksheet cells.
Excel references
Ordinary xlfn arguments receive coerced values. Use a raw reference only when the function needs coordinates, sheet identity, multiple areas, or an explicit Excel callback such as coercing a range at call time.
Declare a reference parameter
A raw reference must use #[excel_arg(reference)] and requires macro-sheet capability:
#![allow(unused)]
fn main() {
#[excel_function(name = "RANGE.AREA.COUNT")]
fn area_count(
#[excel_context(macro_sheet)] _context: MacroSheetContext<'_, AppTools>,
#[excel_arg(reference, description = "A cell or range reference.")]
reference: ExcelReference<'_>,
) -> XllResult<i32> {
i32::try_from(reference.areas().count())
.map_err(|_| XllError::Domain {
code: xlfn::error::DomainErrorCode::Overflow,
})
}
}
The function may instead use the macro_sheet flag when no context is needed, but a context is required to call coerce or sheet_name.
Reference arguments are incompatible with asynchronous functions and cannot use blank, missing, or default policies.
Lifetime and thread restrictions
ExcelReference<'call> is a borrowed view over Excel-owned memory. It is deliberately neither Send nor Sync, and it is valid only for the current exported call.
Do not:
- store it in add-in state;
- place it in a handle object;
- move it to a worker thread;
- capture it in an async future;
- retain its raw pointer after the function returns.
Extract coordinates or coerce the data into an owned value first.
Coordinates and areas
ReferenceArea coordinates are zero-based and inclusive:
#![allow(unused)]
fn main() {
for area in reference.areas() {
let first_row = area.first_row();
let last_row = area.last_row();
let first_column = area.first_column();
let last_column = area.last_column();
}
}
The framework validates Excel’s worksheet bounds. A reference can contain at most 1,024 areas.
reference.sheet_id() returns:
Nonefor a same-sheetSRef, where Excel does not carry an explicit sheet ID;Some(SheetId)for a sheet-qualifiedRef.
is_multi_area() reports whether a qualified reference contains more than one area.
Coerce to owned data
Use MacroSheetContext before leaving the call:
#![allow(unused)]
fn main() {
#[excel_function(name = "RANGE.SUM")]
fn range_sum(
#[excel_context(macro_sheet)] context: MacroSheetContext<'_, AppTools>,
#[excel_arg(reference)] reference: ExcelReference<'_>,
) -> XllResult<f64> {
let values: Matrix<f64> = context.coerce_matrix(&reference)?;
Ok(values.iter().copied().sum())
}
}
coerce returns ExcelValue; coerce_matrix<T> applies normal element conversion to an owned matrix. Once coercion succeeds, the resulting value can be retained or passed to internal workers according to its Rust Send/Sync properties.
Coercion is an Excel callback and must occur on the permitted thread. It can fail because the reference is invalid, Excel rejects the callback in the current state, or an element cannot convert to T.
Sheet names
MacroSheetContext::sheet_name(&reference) asks Excel for the referenced sheet name. Do not infer names from opaque SheetId values. Sheet IDs are process/workbook identifiers, not stable persisted keys.
Prefer values unless coordinates matter
A raw reference changes recalculation and threading capabilities. It also makes testing more dependent on Excel. Use an ordinary Matrix<T> when the function only needs values. Reserve references for operations whose semantics genuinely depend on location or Excel reference identity.
Formula-owned handles
Enable the handles feature in the application’s xlfn dependency. Add async
as well when a generated asynchronous UDF consumes a handle.
Handles let a worksheet formula own an ownership edge to a typed Rust object without exposing a pointer or serialized object graph. A producer returns the object itself; a consumer accepts a call-scoped Handle<'_, T>. xlfn owns the formula binding, the published object identity, and the Rust value lifetime; multiple formula bindings may refer to the same object through an explicit alias. Any resource managed inside T remains part of T’s application-level contract.
Define a handle object
#![allow(unused)]
fn main() {
use xlfn::{error::InputError, prelude::*};
#[derive(ExcelHandleObject)]
pub struct Dataset {
times: Vec<f64>,
values: Vec<f64>,
}
impl Dataset {
fn evaluate(&self, time: f64) -> XllResult<f64> {
let first = self.times[0];
let last = self.times[self.times.len() - 1];
if time < first || time > last {
return Err(XllError::input("time", InputError::OutOfRange));
}
let right = self.times.partition_point(|point| *point < time);
if right == 0 {
return Ok(self.values[0]);
}
if right == self.times.len() {
return Ok(self.values[right - 1]);
}
let left = right - 1;
let weight = (time - self.times[left])
/ (self.times[right] - self.times[left]);
Ok(self.values[left]
+ weight * (self.values[right] - self.values[left]))
}
}
}
The derived trait requires the value to be Any + Send + Sync + 'static. The object may contain immutable data, synchronized application clients, typed resource identifiers, or other owned Rust values. It must not contain call-scoped Excel references.
Produce and consume
#![allow(unused)]
fn main() {
#[excel_function(name = "DATASET.CREATE")]
fn create_dataset(times: Row<f64>, values: Row<f64>) -> XllResult<Dataset> {
let times = times.into_vec();
let values = values.into_vec();
if times.len() != values.len() || times.is_empty() {
return Err(XllError::input(
"times",
InputError::Malformed("times and values must have equal non-zero length"),
));
}
if times[0] < 0.0 || times.windows(2).any(|pair| pair[0] >= pair[1]) {
return Err(XllError::input(
"times",
InputError::Malformed("times must be non-negative and strictly increasing"),
));
}
if values.iter().any(|val| *val <= 0.0) {
return Err(XllError::input(
"values",
InputError::Malformed("values must be positive"),
));
}
Ok(Dataset { times, values })
}
#[excel_function(name = "DATASET.EVALUATE", thread_safe)]
fn dataset_evaluate(dataset: Handle<'_, Dataset>, time: f64) -> XllResult<f64> {
dataset.evaluate(time)
}
}
Handle<'call, T> is a borrowed, call-scoped capability. It dereferences to T,
and its lifetime cannot outlive the active Excel call. It is neither Clone nor
an owned return value; do not store it in Add-in state, another handle object, or
an async task.
No handle argument attribute is required. Ordinary Rust trait resolution identifies Handle<'_, T>.
Async-scoped handle leases
An async UDF that needs an existing object accepts a generation-scoped lease:
#![allow(unused)]
fn main() {
#[excel_function(name = "DATASET.ASYNC.EVALUATE")]
async fn async_evaluate(
dataset: HandleLease<'_, Dataset>,
time: f64,
) -> XllResult<f64> {
std::future::ready(()).await;
dataset.evaluate(time)
}
}
HandleLease<'generation, T> is created only by the generated async boundary.
The token is decoded and the object pin is acquired while the Excel call is
still admitted; the pending pin is then branded when the async task is
committed. The lease may be used before and after .await, but its generation
brand prevents it from being returned, stored in 'static state, or moved into
an independently spawned thread.
Handle::pin() is not part of the public API. A saved token can look up an
object only while its formula binding remains live; the token does not extend
the object’s lifetime. Synchronous code retains an object through an active
formula binding, including a new binding published with HandleAlias<'_, T>.
This keeps call-scoped lookup separate from the async task lifetime.
HandleLease is not an Excel return value.
Re-evaluation semantics
Handle-producing functions are memoized by formula revision.
The worksheet cell is the formula owner. A revision is identified by that caller, the stable producer UDF ID, and an input fingerprint. Recalculation with the same revision reuses the existing formula binding and handle object without invoking the producer again.
Changing the caller, producer ID, or input fingerprint creates a new formula binding, object, and token.
A live token never changes the object it identifies.
The input fingerprint is a runtime-local BLAKE3 fingerprint of the converted Rust arguments. It is an implementation detail for memoization, not a stable serialized or cross-version identifier. Framework-provided conversions contribute semantic identities: for example, a Handle<'_, T> contributes its ObjectId, an enum contributes its normalized variant, and a defaulted argument contributes the value after default conversion. A custom FromExcel conversion used by a handle producer must explicitly implement ExcelInputIdentity; it describes the semantic Rust value rather than falling back to raw Excel bytes. Raw Excel representation is otherwise retained only by explicitly raw-view parameters such as XlArrayRef. Conversion and array layers enforce their own workbook-controlled resource bounds. Different tokens that alias the same object therefore have the same semantic input identity. The fingerprint distinguishes input revisions for memoization; it is not itself the ownership identity.
The caller portion uses Excel’s stable sheet identifier. Workbook and worksheet display names are used only to resolve that identifier and are not part of the runtime key, so renaming a sheet, renaming a workbook, or using Save As does not by itself create a new formula revision.
Changing the caller, function ID, or arguments creates a different formula revision. A producer must be deterministic: its output must depend only on its Excel-visible inputs and stable application state explicitly represented by those inputs.
External state and dependency design
Because the producer runs at most once per formula revision, reading hidden mutable state inside the producer does not produce automatic updates when that state changes. Make varying state an explicit Excel-visible dependency:
#![allow(unused)]
fn main() {
// NG: hidden mutable state is read but never triggers re-evaluation.
fn dataset() -> Dataset {
database.load_latest()
}
// OK: changing snapshot_id changes the input fingerprint and revision, creating a new object.
fn dataset(snapshot_id: String) -> Dataset { .. }
// OK: changing the underlying upstream object changes the downstream input fingerprint;
// aliases of the same object retain the same semantic identity.
fn model(dataset: Handle<'_, DatasetSnapshot>) -> Model { .. }
}
Handle alias functions
A function may explicitly republish an existing handle through HandleAlias:
#![allow(unused)]
fn main() {
#[excel_function(name = "DATASET.ALIAS")]
fn alias(dataset: Handle<'_, Dataset>) -> HandleAlias<'_, Dataset> {
dataset.alias()
}
}
HandleAlias<'call, T> is the only handle return capability. It is an
identity-only, call-scoped capability whose binding snapshot keeps the shared
object alive until publication or disposal. Consume it while the originating
call scope is active. Publishing adds a counted binding to the same
arena-owned object and installs a fresh formula binding; it does not clone
the business value. Once the call scope ends, an unconsumed alias is not a way to
keep the object alive. A plain Handle cannot be returned, cloned, or retained
after the call.
Lifetime
Each worksheet formula owns one runtime binding edge. Successful removal means the binding has been withdrawn: subsequent lookups cannot resolve that binding. It does not guarantee that the object’s destructor has completed. A lookup that observed a live binding before withdrawal may still succeed, and its call scope keeps the object alive until that call ends.
After the last binding is withdrawn, destruction follows the read grace period and release of any async handle pins. Removing threads and departing readers advance reclamation while borrowing the registry. A generation is sealed before its existing readers drain, so newer calls cannot extend its grace period. Final registry drain waits for outstanding retirement work; async task drain must also release the remaining pins before object quiescence completes.
An object’s destructor may run on the removing thread, when a call scope
ends, when an async task releases its last pin, or during shutdown. There is
no dedicated handle reclamation worker. Do not use removal as a
synchronization barrier for application side effects in Drop.
Destructors must obey the same shutdown rules as any in-process code:
- do not call Excel;
- do not block indefinitely;
- do not panic;
- do not directly destroy a thread-affine application resource from an arbitrary handle destructor.
The runtime supports at most 16,384 live handles per open generation. This is a safety bound, not a capacity target.
Retired bindings also have a bounded debt policy: new publication may return
Overloaded while retirement is waiting for readers or destructors. Removal
continues to withdraw existing bindings. Release long-running call scopes
before retrying publication.
Resource-backed handle objects
A handle object may represent or refer to a resource that is owned elsewhere in the application. Prefer a safe Rust client plus a typed logical identifier over a raw pointer. If a raw pointer is unavoidable, the application must independently prove that movement, concurrent access, and destruction from every possible drop thread are valid; adding unsafe impl Send or Sync only to satisfy ExcelHandleObject does not establish those properties.
If explicit close and Drop can both release the same application resource, make release idempotent. Dependencies between application resources are also application state: do not rely on an incidental Rust drop order when explicit invalidation can make a still-referenced object unusable.
Shutdown interaction
The close order relevant to handle objects is:
- xlfn stops and drains framework-managed work;
Addin::quiesceestablishes application-level quiescence;- xlfn closes the formula-handle registry and drops remaining Rust handle objects;
Addin::cleanupperforms bounded best-effort disposal.
A handle object’s Drop therefore must remain safe after quiesce has stopped application workers or owner threads. If resource destruction requires such an owner, release or invalidate the resource during quiesce while the owner is still available, and make the later Rust wrapper drop a local or idempotent operation. Do not defer the only copy of an application shutdown protocol to handle Drop.
Valid producer contexts
A newly constructed handle object uses main-thread return semantics. Producers cannot be:
- thread-safe UDFs;
- macro-sheet UDFs;
- asynchronous UDFs;
- functions with raw reference arguments;
- volatile UDFs.
HandleAlias<'_, T> uses main-thread return semantics. A borrowed
Handle<'_, T> is an input capability only and is not a valid return type.
HandleLease<'_, T> is intentionally limited to generated async UDF inputs.
The framework owns the raw pin and drains all scoped async tasks before
tearing down the formula-handle service and object arena. Consequently, a
non-zero pin count during final quiescence is a framework ordering invariant,
not a recoverable user-held lease.
Caller restrictions
Formula ownership requires one worksheet-cell caller. Contexts without a stable single cell, such as direct VBA invocation, Function Wizard evaluation, or some multi-cell caller shapes, return a controlled error rather than creating an unowned object.
Document this behavior for users who expose handle producers in automation-heavy workbooks.
Token security model
A token contains runtime/session identity, slot/generation data, and a keyed
BLAKE3 MAC. Rust type identity is intentionally not part of the wire format:
after authentication and slot/generation validation, the registry checks the
requested T against the canonical BindingRecord. Tokens from another
process generation, tokens of the wrong type, stale slot generations, and
modified tokens are rejected.
The token is a bearer capability inside the Excel process. It is not an authorization system, workbook ACL, encryption scheme, or durable serialization format. Do not parse it, persist it as an application identifier, or accept it outside the add-in’s worksheet boundary.
Object design guidance
A good handle object is:
- immutable or internally synchronized;
- suitable for call-scoped borrowing and counted bindings to an arena-owned value;
- explicit about any application-level thread affinity;
- free of workbook-owned pointers;
- bounded in memory;
- safe to drop during orderly add-in close.
Use Arc inside a value only when it reduces immutable payload copying;
the registry’s bindings and pins determine the handle object’s lifetime.
Asynchronous functions
The optional async feature maps Rust futures to Excel’s native asynchronous UDF ABI. Argument conversion occurs synchronously at the generated boundary; the future then owns its Rust inputs and completes through Excel’s async-return callback.
Enable the feature
[dependencies]
xlfn = { version = "0.2", features = ["async"] }
The project uses Excel 2010 or later as the operational baseline for this capability. Qualify the exact Excel versions and channels that you distribute to.
Define an async function
#![allow(unused)]
fn main() {
#[excel_function(name = "SERVICE.FETCH")]
async fn fetch(
#[excel_context(asynchronous)] context: AsyncContext<'_, ServiceAddin>,
key: String,
) -> XllResult<f64> {
context.check_cancelled()?;
let value = context.state().client.fetch(&key).await?;
context.check_cancelled()?;
Ok(value)
}
}
An async function may omit the context when it needs neither state nor cancellation:
#![allow(unused)]
fn main() {
#[excel_function(name = "TEXT.NORMALIZE")]
async fn normalize(value: String) -> String {
value.trim().to_owned()
}
}
If a context parameter is present on an async fn, its role must be asynchronous. It is passed by value and must be the first parameter.
Async functions are registered as thread-safe by the generated boundary. They cannot accept raw Excel references or return newly constructed handle objects.
The asynchronous meaning comes from the Rust function being written as
async fn; there is no #[excel_function(async)] attribute. Borrowed input
types such as &str, MatrixRef<'_, T>, ExcelCellRef<'_>, XlStrRef<'_>,
and XlArrayRef<'_> are rejected at compile time because their call scope
ends before the future may run. Use owned String, Matrix<T>, or another
Send + 'static representation instead.
State and converted inputs
The framework-owned future retains the open ServiceAddin generation lease and cancellation token. AsyncContext<'_, ServiceAddin> borrows those capabilities for the invocation, so it cannot be moved into a detached task that outlives the future. Ordinary arguments are fully converted before the future is scheduled, so String, Matrix<T>, and other owned inputs may move safely into the future. Call-scoped Excel memory never enters the executor.
Async handle inputs
An async UDF that needs a formula-owned object must accept HandleLease<'_, T>, not
Handle<'_, T>:
#![allow(unused)]
fn main() {
#[excel_function(name = "DATASET.ASYNC_EVALUATE")]
async fn async_evaluate(dataset: HandleLease<'_, Dataset>, time: f64) -> XllResult<f64> {
std::future::ready(()).await;
dataset.evaluate(time)
}
}
HandleLease<'generation, T> is decoded into an internal pending pin before
the Excel call ends, then branded at the single scoped-task construction point.
Its registry pin remains active while the future owns the value and is released
when the task completes, is cancelled, panics, or is dropped during shutdown.
Handle<'_, T> remains call-scoped and is rejected by the async parameter
assertion. Handle::pin() is not available; synchronous code should use the
token, formula binding, or HandleAlias instead.
The add-in controls executor size:
#![allow(unused)]
fn main() {
impl Addin for ServiceAddin {
type SharedState = State;
type LifecycleState = ();
type Error = XllError;
type Layers = ();
fn open(_: &OpenContext) -> XllResult<Opened<Self::SharedState, Self::LifecycleState, Self::Layers>> {
Ok(Opened::new(State::new(), (), ()).with_runtime_config(
RuntimeConfig::new().with_async_worker_count(
AsyncWorkerCount::new(4).expect("4 is within the supported range"),
),
))
}
}
}
AsyncWorkerCount accepts only values in 1..=32; values outside that range
are rejected. Choose the count from measured workload characteristics.
CPU-heavy work should usually use a dedicated bounded pool rather than
occupying every async executor thread.
Cancellation
AsyncContext exposes:
#![allow(unused)]
fn main() {
context.cancellation().is_cancelled();
context.cancellation_guarantee();
context.check_cancelled()?;
context.cancellation().cancelled().await;
}
Excel async calls currently receive CancellationGuarantee::BestEffort. The token becomes cancelled when the runtime observes the relevant Excel cancellation/lifecycle event or closes the add-in. Programmatic recalculation paths do not always produce the same calculation-event sequence, so code must not assume calculation-scoped cancellation unless the reported guarantee says so.
Cancellation is cooperative. Dropping a future or setting a token cannot forcibly interrupt:
- a blocking or foreign call;
- synchronous filesystem or network I/O;
- a lock held by another thread;
- foreign code that does not expose cancellation.
Check the token before expensive phases and after awaited operations. Use cancellation-aware libraries where possible. Isolate truly uninterruptible work out of process when safe XLL unload is required.
The runtime linearizes cancellation against result delivery: after cancellation wins, a late completion is not delivered as a valid result to Excel.
Do not block the async executor
A Rust async fn is not automatically non-blocking. This is poor:
#[excel_function(name = "DATA.FETCH")]
async fn fetch_data(
#[excel_context(asynchronous)] context: AsyncContext<'_, ServiceAddin>,
query: String,
) -> XllResult<f64> {
// Blocks an executor worker for the whole external call.
context.state().adapter.fetch_blocking(&query)
}
Submit blocking or thread-affine work through an application-owned bounded execution mechanism, then await an owned reply without blocking the xlfn executor. xlfn does not define that mechanism’s queueing, affinity, overload, or cancellation semantics. If it is reachable from a thread_safe function, its concurrency contract must also satisfy the rules in Execution modes and contexts.
Error and panic behavior
The future may return any Result<T, E> where E: IntoXllError and T is a valid async return type. Panics in construction, polling, conversion, or completion are contained at framework boundaries and diagnosed as internal errors.
Containment is not recovery. A panic can leave an external transaction partially complete. Keep business operations explicit and idempotent where retries are possible.
Shutdown
On add-in close, the async manager stops accepting work, cancels tracked tasks, and waits for task guards to become idle before executor state is released. User futures and their captured values can run Drop during cancellation, so destructors must not block indefinitely or re-enter a resource while holding incompatible locks.
Addin::quiesce runs only after framework-managed async tasks have drained. Application-owned background tasks must be stopped and joined by quiesce; best-effort resource disposal belongs in Addin::cleanup.
Streaming RTD
Enable the rtd feature in the application’s xlfn dependency. The handles
feature alone does not expose the generic streaming API.
Real-Time Data (RTD) is the appropriate model for a formula that should update repeatedly from a push source. xlfn hides the COM server transport and exposes typed sources, topics, sinks, and subscriptions.
Data flow
worksheet formula
-> MainThreadContext::rtd().subscribe(&source_handle, &parts)
-> RtdChannelSource starts a producer and publisher
-> producer calls sender.try_send(value)
-> framework publisher forwards queued values through RtdSink
-> framework batches RefreshData
-> Excel recalculates the dependent formula
The initial subscription returns the current value, which can be empty while the asynchronous producer starts. Later publications update the RTD topic and notify Excel.
Define a topic
#![allow(unused)]
fn main() {
let parts = [
"events",
topic.as_str(),
metric.as_str(),
];
context.rtd().subscribe(&source_handle, &parts)?;
}
For one part:
#![allow(unused)]
fn main() {
context.rtd().subscribe(&source_handle, &["service-health"])?;
}
The subscription call borrows its parts and copies them only when creating a new subscription. Source callbacks receive the canonical owned RtdTopic.
Use topic.len() to inspect the part count, topic.part(index) for an optional
&str, and topic.parts() for an allocation-free RtdTopicParts iterator over
&str values. The iterator implements ExactSizeIterator. RtdTopic::new and
RtdTopic::single accept string inputs through AsRef<str>; their storage
representation is private.
A topic must contain at least one non-empty part. Each part must fit Excel’s 32,767 UTF-16-unit counted-string representation. Topic parts are identity, not display labels; use stable, canonical values.
The runtime also applies bounded admission limits. The standard limits are 253 topic parts, 1 MiB of UTF-8 text per topic, 64 MiB of pending-topic text in aggregate, 4,096 pending preparations, 4,096 active streams, 4,096 queued updates, and 4,096 distinct live source identities. A custom RuntimeConfig::with_rtd_limits can choose lower limits during Addin::open; use RtdCapacity::bounded or RtdCapacity::disabled for each resource class so a disabled limit is explicit rather than an untyped zero. Exceeding a limit returns XllError::Overloaded (or a topic input error for an invalid topic).
Implement a source
Use RtdChannelSource for ordinary producers. The source owns a factory that
runs synchronously during subscription setup and returns an owned producer
job for the topic. Each job runs once on a framework-owned thread and receives
only a typed, bounded RtdSender. Its captures may include resources that are
neither Clone nor Sync. A separate publisher thread owns the internal sink.
The capacity passed to RtdChannelSource::new limits queued values per subscription.
#![allow(unused)]
fn main() {
use std::{num::NonZeroUsize, time::Duration};
use xlfn::{error::InputError, prelude::*, rtd::RtdValue};
use super::Client;
pub(crate) type MetricSource = RtdChannelSource<RtdValue>;
pub(crate) fn metric_source() -> MetricSource {
RtdChannelSource::new(NonZeroUsize::new(64).unwrap(), |topic| {
let mut parts = topic.parts();
let (Some(kind), Some(symbol), None) = (parts.next(), parts.next(), parts.next()) else {
return Err(XllError::input(
"RTD topic",
InputError::Malformed("expected [kind, symbol]"),
));
};
if kind != "last" {
return Err(XllError::input(
"RTD topic",
InputError::Malformed("unsupported metric topic"),
));
}
let symbol = symbol.to_owned();
// Each job owns its client; real integrations can open a connection here.
let client = Client;
Ok(move |sender: RtdSender<RtdValue>| {
while !sender.is_closed() {
match client.try_next_metric(&symbol) {
Ok(Some(value)) => sender.try_send(RtdValue::Number(value))?,
Ok(None) => {
sender.wait_closed(Duration::from_millis(50));
}
Err(_) => {
sender
.try_send(RtdValue::Error(ExcelErrorValue(ExcelError::NotAvailable)))?;
break;
}
}
}
Ok(())
})
})
}
}
In this illustrative loop, try_next_metric is a non-blocking poll and
wait_closed makes the polling delay interruptible. Use bounded or
cancellation-aware I/O so the callback returns promptly after admission
closes. The factory validates the topic before any worker starts. Producer
errors and panics close the channel and are reported during disconnect.
Shutdown and advanced sources
The channel adapter closes admission, discards pending values, and joins both
workers during disconnect. Sender clones can remain alive afterward: their
try_send calls return XllError::Closing, even after the RTD runtime has
been reclaimed. Reference counting shares only the empty, closed queue storage;
the subscription owns admission, payload disposal, and worker joins. Source
configuration and each producer job have separate unique owners, so dropping
the source neither drops a running job’s resources nor waits for a final shared
callback reference. Jobs release their captures before their workers are joined.
Successful producer completion drains accepted values before the publisher
stops. The producer must stop any additional threads or callbacks before it
returns. A producer that ignores cancellation delays disconnect and unload.
Each active channel subscription uses two threads. Advanced integrations that
share an event loop can implement the existing unsafe RtdSource and
RtdSubscription traits. In that path, a sink must not escape an Err or
panic from subscribe; the returned subscription must stop every sink user
before disconnect_and_wait exits, whether it succeeds, returns an error,
or unwinds. Preserve this guarantee during unwinding, for example with a
cleanup guard that joins all sink users. The framework contains cleanup panics and
may reclaim the runtime afterward; an error or panic does not extend sink
lifetime. Cancellation must be bounded, idempotent, panic-free, and must not
call Excel or re-enter framework subscription APIs. Sinks are non-owning
capabilities, so this shutdown contract is a memory-safety requirement for
the unsafe extension point.
Do not implement a timeout that abandons an in-process callback and then permits the XLL to unload. Put uninterruptible producers in another process.
Use the source from a function
Keep the source handle in add-in state and subscribe from a main-thread function:
#![allow(unused)]
#![deny(unsafe_op_in_unsafe_fn)]
fn main() {
use xlfn::prelude::*;
use xlfn::rtd::RtdValue;
mod metric_source;
use metric_source::{MetricSource, metric_source};
#[derive(Default)]
pub(crate) struct Client;
impl Client {
fn try_next_metric(&self, _symbol: &str) -> Result<Option<f64>, std::io::Error> {
Ok(None)
}
}
pub struct State {
metrics: RtdSourceHandle<MetricSource>,
}
#[excel_addin(name = "RTD Source Example", id = "rtd-source", category = "Examples")]
pub struct RtdSourceExample;
impl Addin for RtdSourceExample {
type SharedState = State;
type LifecycleState = ();
type Error = XllError;
type Layers = ();
fn open(
context: &OpenContext,
) -> Result<Opened<Self::SharedState, Self::LifecycleState, Self::Layers>, Self::Error> {
Ok(Opened::new(
State {
metrics: context.rtd().register_source(metric_source())?,
},
(),
(),
))
}
}
#[excel_function(name = "METRIC.LAST")]
pub fn last_metric(
#[excel_context(main_thread)] context: MainThreadContext<'_, RtdSourceExample>,
symbol: String,
) -> XllResult<RtdValue> {
context
.rtd()
.subscribe(&context.state().metrics, &["last", symbol.as_str()])
}
}
The source handle is the opaque RTD identity. Clone a handle when multiple
functions should refer to the same source; registering a new source creates a
distinct identity even when its value is equivalent. The runtime owns the
source and subscription identity; handles do not extend its lifetime.
Multiple formulas that observe the same active subscription share it; a failed
new observation rolls back only the reservation created by that attempt, not
an unrelated established subscriber. The complete compile-tested fixture,
including the add-in state and Client placeholder, lives under
examples/rtd-source.
RTD value types
RtdValue supports scalar transport:
- finite number;
- Boolean;
- integer;
- string;
- Excel error;
- empty.
IntoRtdValue is implemented for common scalar types, including f64, bool, i32, exactly representable i64, ExcelSerialDate, strings, ExcelErrorValue, and ().
RTD does not transport arrays. Publish a handle or another scalar identity and expose a separate function when a stream logically updates a complex object.
Backpressure and errors
RtdSender::try_send validates values before enqueueing and never waits for
queue capacity. It returns XllError::Overloaded when the per-subscription
queue is full and XllError::Closing after admission closes. Handle an error
by stopping or retrying with a bounded policy. Enqueue success is not a
delivery acknowledgement: disconnect can discard pending values. If the
publisher encounters a runtime error, it closes admission, wakes the producer,
and reports that error during disconnect. Closing is treated as normal
shutdown.
For an unsafe custom source, RtdSink::publish directly reports a closing
runtime, an inactive subscription, invalid values, or exhaustion of the
runtime’s queued-update limit. Do not loop tightly on a permanent error.
Publishing validates and queues a value; notification and RefreshData happen through the framework. User code must never call the COM update event directly.
Temporary COM registration
The XLL registers its RTD COM server temporarily. Ownership markers include the add-in identity, schema, module path, and CLSID. On a later start after an abnormal Excel exit, xlfn removes only stale registrations whose full marker set belongs to the same XLL. It does not broadly delete similarly named registry keys.
Installation still needs appropriate user-profile registry access. Test start, normal close, forced Excel termination, and restart in the deployment environment.
Choosing RTD versus async
Use async for one eventual result. Use RTD for a value that may change repeatedly while a formula remains subscribed. Do not simulate streaming by starting an infinite async function; it prevents normal completion and complicates cancellation and unload.
Calculation caches
The experimental cache module provides concurrent, bounded memoization for application data. It is independent of formula-owned handles: a handle controls worksheet ownership, while a cache controls reuse of an internal computation. Enable the explicit unstable-cache crate feature to use it.
Import from:
#![allow(unused)]
fn main() {
use xlfn::unstable::cache::{
BoundCacheEndpoint, CacheEndpoint, CacheLease, CacheRegistry, CalculationCache, CanonicalF64,
};
}
One typed cache
CalculationCache<K, V> uses Quick Cache with one global resident weight budget and a caller-defined weight:
#![allow(unused)]
fn main() {
#[derive(Clone, Eq, Hash, PartialEq)]
struct DatasetKey {
namespace: String,
version: i32,
}
let cache = CalculationCache::<DatasetKey, Dataset>::new(64 * 1024 * 1024);
let dataset = cache.get_or_try_insert_with(
key.clone(),
|dataset| dataset.estimated_bytes(),
|| build_dataset(&key),
)?;
}
The returned value is CacheLease<'_, V>, which implements Deref<Target = V>. Concurrent initializations for the same key are coalesced. A failed initialization is returned to its caller and is not cached.
The weight budget is an abstract integer. It can represent approximate bytes, external-resource units, or another monotone cost, but every call site for a cache must use one consistent definition. Zero is normalized to a minimum positive cache weight. A value heavier than the entire budget is returned but not retained.
Metrics such as len() and used_weight() observe current residency. Concurrent changes mean these remain operational estimates rather than a transactional snapshot.
Eviction and memory reclamation are separate. A live lease intentionally keeps
its value alive after eviction or clear(). Once the final pin is released,
the value enters a retirement queue until readers that could have observed its
pointer have finished. The resident entry destructor only queues work; it never waits
for readers or runs a value destructor inside the index lock.
Ordinary reads attempt reclamation when work is queued, without waiting for readers. Initialization attempts also apply backpressure when queued retirement reaches 256 nodes or the endpoint’s weight budget: the operation waits for existing readers before returning. This bounds accumulating debt during ongoing mutation, subject to concurrent operations; it is not a strict bound on process memory. A final lease drop and explicit clear also wait for reclamation. There is no background reclamation thread; idle caches can retain their last small batch until another operation or destruction.
reclamation_stats() on a cache or bound endpoint returns approximate counters
without performing maintenance: pending nodes and weight, their peak values,
the number of nodes handed to reclamation, the largest batch, and cumulative
grace-period time. Pending counts exclude resident entries and values retained
by live leases. Use these counters with used_weight() to distinguish eviction
capacity from retirement debt; caller-defined weights do not measure allocator
overhead or the process’s actual memory usage.
Typed endpoint registry
CacheRegistry creates caches lazily for static endpoints:
#![allow(unused)]
fn main() {
enum LookupEndpoint {}
static LOOKUP_DATASETS: CacheEndpoint<
LookupEndpoint,
DatasetKey,
Dataset,
> = CacheEndpoint::new("lookup-datasets-v1");
struct State<'registry> {
datasets: BoundCacheEndpoint<'registry, LookupEndpoint, DatasetKey, Dataset>,
}
fn build_state<'registry>(caches: &'registry CacheRegistry) -> XllResult<State<'registry>> {
Ok(State {
datasets: caches.bind(&LOOKUP_DATASETS)?,
})
}
fn cached_dataset<'a>(state: &'a State<'_>, key: DatasetKey) -> XllResult<CacheLease<'a, Dataset>> {
state.datasets.get_or_try_insert(
key.clone(),
|dataset| dataset.estimated_bytes(),
|| build_dataset(&key),
)
}
}
An endpoint identity includes its marker type, key type, value type, and static ID. The marker gives semantically different caches separate identities even when key and value types are the same.
Use versioned IDs when a cached value’s meaning changes:
#![allow(unused)]
fn main() {
CacheEndpoint::new("lookup-datasets-v1")
}
Changing an algorithm without changing the endpoint or key can silently reuse a value produced under old semantics in a long-lived Excel process.
Float keys
Do not use raw f64 as an ordinary hash key. CanonicalF64 rejects NaN and infinity and normalizes signed zero:
#![allow(unused)]
fn main() {
#[derive(Clone, Eq, Hash, PartialEq)]
struct QueryKey {
x: CanonicalF64,
y: CanonicalF64,
}
let key = QueryKey {
x: CanonicalF64::new(x)?,
y: CanonicalF64::new(y)?,
};
}
This solves basic finite-value hashing; it does not define a tolerance. When approximate equality is a domain requirement, quantize explicitly and document the error bound.
Clearing and generations
clear() advances a generation and invalidates older entries. In-flight computations that began before the clear may finish and return to their caller, but they cannot repopulate the new generation with stale results.
#![allow(unused)]
fn main() {
state.caches.clear();
}
The framework does not know when application external data, configuration, or adapter state has changed. The add-in owns invalidation policy. Common triggers include:
- an explicit worksheet/admin refresh function;
- a new external-data snapshot ID;
- configuration reload;
- a calculation-generation boundary when the cache is truly calculation-scoped;
- add-in close.
Prefer putting immutable dependency versions in the key. Broad clears are useful as a safety mechanism, but versioned keys give more precise reproducibility.
Reentry and computation rules
Starting another cache initialization on the same thread from a compute or weight function is rejected, including a different key or endpoint. Reading already-cached values is supported. Compute lower layers directly or resolve their cache dependencies before entering the initializer.
The compute and weight functions execute application code. They must:
- avoid panics;
- avoid unbounded blocking while internal single-flight state is held;
- return owned
Send + Sync + 'staticvalues; - avoid callbacks into Excel;
- use a deterministic key-to-value contract.
Panic containment prevents a permanently stuck initializer, but a panic still indicates a defect.
Cache versus handle versus RTD
| Need | Facility |
|---|---|
| reuse an internal pure or versioned computation | cache |
| let one worksheet formula own a typed object | handle |
| update a formula repeatedly from a push source | RTD |
They may be composed. For example, a handle producer can obtain immutable calibrated data from a cache, then create a formula-owned lightweight view over it.
UDF execution layers
Execution layers provide bounded admission control and instrumentation around every exported UDF. They observe call metadata before argument conversion and receive a classified outcome after completion.
Import from:
#![allow(unused)]
fn main() {
use xlfn::execution::{
CallMetadata, CallOutcome, UdfCompletionOutcome, UdfDeliveryOutcome, UdfLayer,
UdfLayerGuard,
};
}
Implement a layer
#![allow(unused)]
fn main() {
use std::time::Instant;
struct MetricsLayer;
struct MetricsGuard {
udf_id: &'static str,
started: Instant,
}
impl UdfLayer for MetricsLayer {
type Guard = MetricsGuard;
fn enter(&self, metadata: &CallMetadata) -> XllResult<Self::Guard> {
Ok(MetricsGuard {
udf_id: metadata.udf_id,
started: Instant::now(),
})
}
}
impl UdfLayerGuard for MetricsGuard {
fn exit(self, outcome: &CallOutcome<'_>) {
tracing::info!(
udf = self.udf_id,
completion = ?outcome.completion,
delivery = ?outcome.delivery,
duration_ns = outcome.duration.as_nanos(),
local_duration_ns = self.started.elapsed().as_nanos(),
"instrumented UDF"
);
}
}
}
Register layers from the add-in using static tuple composition:
#![allow(unused)]
fn main() {
impl Addin for AppTools {
type SharedState = State;
type LifecycleState = ();
type Error = XllError;
type Layers = (MetricsLayer,);
fn open(_: &OpenContext) -> XllResult<Opened<Self::SharedState, Self::LifecycleState, Self::Layers>> {
Ok(Opened::new(State::new(), (), (MetricsLayer,)))
}
}
}
Add-ins without layers specify type Layers = ();:
#![allow(unused)]
fn main() {
impl Addin for SimpleAddin {
type SharedState = State;
type LifecycleState = ();
type Error = XllError;
type Layers = ();
fn open(_: &OpenContext) -> XllResult<Opened<Self::SharedState, Self::LifecycleState, Self::Layers>> {
Ok(Opened::new(State::new(), (), ()))
}
}
Multiple layers are composed as tuples (LayerA, LayerB, LayerC) (up to 16 layers). The tuple element order is enter order (left-to-right). Guards exit in reverse order (right-to-left), like nested middleware, with static dispatch and zero heap allocations.
Metadata
CallMetadata includes:
- stable UDF ID;
- Excel-visible name;
- process-generation call ID;
- calculation ID;
- start time;
- current concurrent-call count.
The calculation ID is a runtime correlation identifier, not a workbook persistence key. The concurrent count is suitable for telemetry and coarse admission decisions, not exact resource accounting.
Outcomes
CallOutcome contains:
completion: success, a classified error, or cancellation;delivery: not applicable, delivered, failed, or unobserved;- framework-measured duration.
Completion and delivery are independent. For example, an asynchronous UDF
whose computation succeeds but whose xlAsyncReturn call is rejected reports
UdfCompletionOutcome::Success together with
UdfDeliveryOutcome::Failed { .. }. A computation error and a delivery error
are both retained in the same outcome.
Errors inside either outcome are borrowed and valid only during exit. Copy a
bounded classification or stable code into an asynchronous telemetry queue; do
not retain the references.
Admission control
A layer can reject a call by returning an XllError from enter:
#![allow(unused)]
fn main() {
struct ConcurrencyLimit {
maximum: usize,
}
struct NoopGuard;
impl UdfLayerGuard for NoopGuard {
fn exit(self, _: &CallOutcome<'_>) {}
}
impl UdfLayer for ConcurrencyLimit {
type Guard = NoopGuard;
fn enter(&self, metadata: &CallMetadata) -> XllResult<Self::Guard> {
if metadata.concurrent_calls > self.maximum {
return Err(XllError::Overloaded);
}
Ok(NoopGuard)
}
}
}
Admission runs before argument conversion. It therefore cannot inspect typed arguments. This is intentional: layers remain generic runtime policy rather than an alternate business-function mechanism.
Use a function-local check when policy depends on a input, resource, model, or other converted value.
Failure behavior
- If a layer’s
enterreturns an error, already-entered guards exit in reverse order with that classified error. - A panic in
enteris converted to a panic error. - Panics in
exitare caught so that one observer does not unwind through the ABI or prevent later guards from exiting. - A dropped internal guard receives an internal-error outcome as a final safety path.
Containment does not justify fallible or complex instrumentation. Layer code is on every UDF path and must be small, bounded, and non-reentrant.
Appropriate uses
Good uses include:
- concurrency limits;
- per-function latency and result metrics;
- trace correlation;
- maintenance-mode rejection;
- bounded license/admission checks that do not call Excel;
- recording external-adapter error-code distributions.
Avoid:
- mutating arguments or results;
- business-rule transformations;
- unbounded network logging;
- workbook callbacks;
- long lock acquisition;
- creating one thread or task per invocation.
The runtime already emits a standard structured completion event. Add a layer only for policy or telemetry that the built-in event does not provide.
Deployment and distribution
A deployable add-in is a versioned directory, not an isolated .xll file. Build, validate, sign, and install the XLL together with every packaged sidecar required by the application and its audit manifest.
Produce target directories
cargo xlfn package --all --locked
This creates win-x86 and win-x64 directories under the selected output root. Distribute only the directory matching the Excel process bitness, or package both with an installer that selects correctly.
Use an explicit output root for release automation:
cargo xlfn package --all --out artifacts/xlfn-1.4.0 --locked
The --all operation stages and validates both targets before replacing the output root. Do not point it at the repository root, current directory, or a directory that contains unrelated artifacts.
Package contents
Each target directory contains:
<artifact-name>.xll;- configured sidecar files and their packaged PE dependencies;
build-manifest.json.
The manifest records schema version 6, package and artifact identity, target, profile, selected features, requested and observed CRT policy, configured/resolved bundle sources, import-policy version, file sizes, and SHA-256 values. Its integrity section explicitly states that hashes are audit metadata and are not verified before DLL execution.
Keep all files together. Renaming a sidecar DLL or moving it to another directory can break both explicit loading and transitive imports.
Packaging boundary
Bundle staging and PE dependency validation are build and distribution facilities. They do not load a sidecar at runtime, resolve application symbols, choose an ABI or protocol, construct application objects, or prove that downstream calls are thread-safe or cancellable. If application code uses a packaged component, that code owns the runtime contract and loading policy.
If the add-in has no sidecar files, no bundle metadata is required.
Versioning worksheet APIs
A released workbook depends on more than the crate’s semantic version. Treat these as public contracts:
- Excel-visible function names;
- UDF IDs and generated export identities;
- argument order, names, defaults, blank/missing policy, and accepted types;
- enum strings;
- handle object types and producer semantics;
- RTD topic identity;
- add-in ID and category.
Adding a new function is usually compatible. Renaming a function, changing argument order, changing a default, or changing an enum text can silently alter existing workbooks.
For breaking worksheet changes, prefer a new Excel name or an explicit versioned function while the old function remains as a documented compatibility layer for one migration window. Do not leave undocumented shims indefinitely.
Installation location
Install into a directory that ordinary workbook input cannot select and unprivileged users cannot replace after approval. Appropriate enterprise mechanisms include a managed per-user directory with restricted ACLs or an administrator-controlled application directory.
Avoid:
- Downloads and temporary directories;
- workbook-adjacent writable directories;
- network shares without a deliberate trust policy;
- search-path-dependent DLL placement;
- copying only the XLL while resolving application sidecars from an ambient global directory.
If application code loads a packaged DLL, it should use an explicit path derived from the installed package rather than ambient search paths. xlfn does not perform that load. Transitive dependencies still resolve according to Windows loader behavior and the validated package import closure.
Code signing
Authenticode signing is intentionally external to xlfn because keys, hardware security modules, timestamps, and enterprise trust policy are deployment concerns. Sign:
- the XLL;
- every first-party executable sidecar;
- third-party executable sidecars when redistribution terms and signing policy permit;
- installers or package containers.
Verify signatures after the final byte-producing step. Signing changes the file hash, so generate or update release audit metadata in the order required by your release system. Do not sign one set of bytes and distribute another.
External imports
The package verifier recognizes a versioned default set of Windows system DLLs and API-set names. A non-packaged import outside that set fails validation unless its basename appears in external-imports.
An external import is an explicit deployment exception, not a general bypass. Use it only for a component guaranteed by the target environment, document who installs it, and test on a clean machine.
[package.metadata.xlfn.bundle]
external-imports = ["approved-inbox-component.dll"]
Do not add a missing application dependency to external-imports merely to pass the verifier.
Upgrade and rollback
Do not overwrite a loaded XLL in place. Excel can retain module and DLL file handles until the process exits. A reliable upgrade procedure is:
- close every Excel process using the add-in;
- verify that no background Excel process remains;
- install the complete new target directory transactionally with best-effort rollback, or publish it under a versioned path;
- preserve the previous signed directory for rollback;
- load the new version and run smoke tests;
- remove old versions only after the rollback window.
When the add-in’s executable sidecars, application-owned data or protocol assumptions, token semantics, or RTD ownership schema change, restart Excel rather than attempting an in-process hot swap.
Distribution checklist
Before publishing:
- build with
--lockedfrom a clean checkout; - record source commit, toolchain, target, and feature set;
- run both artifact and real-Excel qualification gates;
- review
build-manifest.json, its effective bundle policy, and staged bundle paths; - verify x86/x64 architecture independently;
- sign and verify every executable binary;
- scan the final package with organizational security tooling;
- install from the exact final package on a clean test machine;
- archive release evidence and the rollback package.
Testing and release qualification
No single test layer is sufficient for an XLL. Test the add-in’s Rust code, the linked Windows artifact, and the exact Excel environments that matter to the deployment. Repository contributors should use the checks in CONTRIBUTING.md; this page focuses on add-in authors and release operators.
Rust and artifact checks
Run the add-in’s unit and integration tests with its normal Cargo profile. For the xlfn packaging contract, validate the exact target and feature selection:
cargo xlfn check --target x86_64-pc-windows-msvc --all-features --locked
cargo xlfn check --target i686-pc-windows-msvc --all-features --locked
cargo xlfn package --all --all-features --locked
Verify that:
- required lifecycle, COM, async, and UDF exports are present;
- x86 decorated exports are correct;
- PE machine type matches the target;
- every packaged import resolves;
- bundle files have unique case-insensitive basenames;
- final staged bytes match the manifest records;
- a consumer crate can use the published
xlfncrate under both targets.
Artifact tests do not start Excel. They complement, rather than replace, the add-in’s own unit tests and real-Excel qualification.
Real-Excel qualification
Run the exact final package in every supported environment. At minimum, qualify each supported Excel bitness. When support claims include multiple Windows builds, Excel channels, or locales, include those combinations explicitly.
Record:
source commit:
package digest:
Windows edition/build:
Excel version/build/channel:
Excel bitness:
locale:
installation path/policy:
operator/date:
result and evidence:
Lifecycle
- first load and registration;
- open failure containment;
- normal close;
- forced Excel termination followed by stale RTD registration recovery;
- unload/reload repeatedly in one process where supported;
- shutdown while work is queued or running.
Ordinary functions
- scalar, string, Boolean, integer, error, date, and array round trips;
- blank versus missing policies;
- Function Wizard descriptions and help topics;
- thread-safe, volatile, and hidden registration behavior;
- wrong input types and propagated Excel errors.
References
- same-sheet and sheet-qualified references;
- multi-area references;
- coordinate bounds and sheet names;
- owned coercion;
- 32-bit and 64-bit
IDSHEETbehavior.
Formula-owned handles
- create and consume a handle;
- alias an existing handle;
- recalculate with the same formula revision and confirm object reuse;
- change an explicit revision input and confirm a new object and token;
- retire the formula and confirm cleanup;
- reject wrong-type, stale, forged, and previous-session tokens;
- verify Formula Wizard, VBA/direct calls, and multi-cell caller behavior;
- verify workbook-close and add-in-unload cleanup;
- verify external object destruction on its required application-owned executor.
A stable token does not mean that the producer runs on every recalculation. The same formula revision reuses its memoized object; revision changes create a new object and token. Test observable object behavior or expose an explicit version dependency instead of using token text as an application identifier.
Async
- immediate and delayed completion;
- cancellation before worker claim and while awaiting;
- late completion after cancellation;
- calculation end/cancel events;
- Excel close with queued and running work;
- error and panic containment.
RTD
- one, two, three, and a larger batch such as 100 updates;
- number, Boolean, integer, string, error, and empty variants;
ConnectData,RefreshData,DisconnectData, andServerTerminate;- blocked subscribe and notification during close;
- repeated publish and retry after a transient notification failure;
- unload/reload without stale callbacks.
External adapters
Adapt this matrix to the selected integration mechanism:
- required methods, symbols, endpoints, or protocol fields;
- version mismatch and missing-capability failure;
- wrong-architecture binary rejection where applicable;
- malformed outputs and bounded error conversion;
- authentication and authorization where applicable;
- maximum intended concurrency and overload behavior;
- context, object, connection, and process leak counters where available;
- exception, panic, crash, timeout, and disconnect containment at the adapter boundary.
Release evidence
Distinguish these statuses:
- implemented — source exists;
- unit-tested — host tests passed;
- Windows artifact-tested — linked x86/x64 package inspection passed;
- Excel validated — named real-Excel environments passed;
- signed/deployed — final binaries passed organizational release controls.
Do not mark one status based on another. Publish the supported environment matrix and any known unqualified combinations with the release notes.
Experimental cache resident backends
just miri-cache-backends runs the full-cache common regressions for the
production Quick Cache policy (1 shard), additional Quick Cache configurations
(8/32 shards), and the benchmark sharded maps (8/16/32/64 shards), under Stacked
Borrows and Tree Borrows with leak and alias checks enabled. The Moka benchmark
comparator is excluded because its Crossbeam intrusive-pointer path is blocked
under Miri. Production uses Quick Cache without a runtime backend selector.
just bench-cache-backends runs three serial repetitions of the existing
lookup/reclamation benchmarks and the supplemental throughput/latency/debt
matrix. Results and console logs go under target/cache-backend-qualification.
Set XLFN_CACHE_BACKEND=quick1|quick8|quick32|moka|sharded8|sharded16|sharded32|sharded64 when running
an individual cache Criterion benchmark. This selector is available only
with bench-internals and never changes the production default.
Security model
xlfn reduces unsafe surface area; it does not turn Excel, a workbook, an external component, or a service into a security boundary. This chapter defines what must be trusted and what the framework validates.
Trust boundaries
Trusted code and package
The add-in binary and every in-process sidecar execute inside Excel with the user’s privileges and must be treated as trusted executable code. Loading an in-process component may run initialization before application-level protocol or ABI checks. Out-of-process and remote adapters have different isolation properties but still require authentication, authorization, and bounded resource use.
Protect the final installation directory with appropriate ACLs and code signing. The build-manifest.json hashes support audit and reproducibility; they are not a runtime pre-execution integrity mechanism. Runtime loading, authentication, and trust decisions belong to the application adapter and deployment policy; xlfn does not perform them.
Untrusted workbook input
Cell values, strings, arrays, references, handle-token text, and RTD topic arguments can be malformed or adversarial. The generated boundary validates types, lengths, finite numbers, shapes, pointer structure, and memory limits before ordinary Rust code receives them.
Application code must still validate domain limits. A structurally valid array can request an expensive model calculation, and a valid string can be an unsafe filename, URL, query, or command if passed on without policy.
Never derive a sidecar path, executable path, shell command, SQL statement, credential scope, or authorization decision directly from workbook text.
Excel callbacks
Raw references and Excel callback operations are capability-limited to macro-sheet/main-thread contexts. Their results are still host-provided data and must be converted before being retained.
Panic and memory safety
Framework ABI boundaries catch Rust panics so unwinding does not cross Excel or COM ABIs. Unsafe pointer handling and return ownership are centralized and validated.
This containment has limits:
- a panic can leave an external or application transaction partially complete;
- an incorrect in-process ABI declaration can corrupt memory before Rust can catch anything;
- a foreign exception crossing an ABI boundary is undefined behavior unless contained by the application adapter;
- process aborts, stack corruption, and access violations are not Rust panics;
- a destructor that blocks indefinitely can prevent safe unload.
Set panic = "unwind" for release profiles that depend on containment. Do not replace domain errors with panics.
Temporal ownership and unsafe reclamation rules
Cache, handle, RTD, and async publication use unique owners and counted read capabilities. The temporal ownership models describe the required lifetime protocol; their proofs do not establish Rust pointer provenance or atomic memory ordering. Those obligations also require implementation review, Loom models, and Miri tests.
Any code that retains a raw pointer or provides lease-based access across concurrent operations must explicitly document and implement the following five criteria:
- Owner: Identifies the single unique owner holding primary ownership of the allocation.
- Admission Mechanism: Defines the short-lived admission gate (e.g.
StripedDrainGatedomain permit) entered before observing the raw pointer, preventing use-after-reclaim during concurrent retirement. - Retirement Point: The explicit transition where the object is marked retired (e.g. cache eviction, binding removal, callback replacement). No new lookups can acquire pins after retirement.
- Reclamation Point: The deferred deallocation point executed only after retirement, complete quiescence of the lookup admission domain (
admissions == 0), and release of all active pins (pins == 0). - Formal Invariant ID: An explicit reference tag in code (such as
[TR-OBSERVE-POINTER],[TR-LEASE-1],[TR-RECLAIM-1]) linking the unsafe block to its corresponding Lean 4 theorem and Loom exhaustive model.
Published allocations whose owners can move use the kernel’s
PublishedOwner<T>. It retains unique allocation ownership as a raw pointer,
exposes shared access, and recovers a Box only after the relevant readers
have drained. A stable heap address alone is insufficient: moving a Box
while raw readers use its allocation can invalidate their aliasing permissions.
Drain gates register their notification requirement in the same atomic state as the active count. A notifying final release holds the wait mutex before publishing zero, and a drain waiter synchronizes with that release’s final access. Async generation snapshots acquire a lifetime pin before dereferencing the current generation; completion retains the pin even after cancellation removes the task’s control entry. Executor destruction cancels, drains, and joins workers before reclaiming the uniquely owned executor allocation.
Handle tokens
Handle tokens are authenticated, session-scoped capabilities. The runtime verifies a keyed MAC, session/generation data, and slot generation first, then checks the requested Rust type against the canonical record. Type identity is not duplicated in the wire token.
They prevent accidental or textual fabrication of a valid live handle. They do not provide:
- workbook-level authorization;
- confidentiality;
- cross-process persistence;
- user identity;
- protection after an attacker already executes code in the Excel process.
Do not expose a token as a durable object ID or accept it over an external service boundary.
External components and sidecar files
An application adapter may use OpenContext::module_directory() to locate installed sidecar files. xlfn does not load those files. cargo xlfn checks the independently declared bundle architecture and packaged DLL import closure. strict-paths defaults to true; configured bundle paths reject symlink, junction, and reparse-point traversal observed during validation unless a manifest explicitly opts into the relaxed policy. This path-based check is not a defense against concurrent replacement of a checked component, so protect the manifest tree from mutation during packaging when that threat is in scope.
Still apply these controls:
- install in a non-user-replaceable directory appropriate to your threat model;
- sign all executable components;
- avoid ambient PATH or current-directory dependency resolution;
- package non-system transitive dependencies explicitly;
- keep
external-importsnarrowly reviewed; - validate sidecar and adapter-dependency provenance and hashes before building;
- test on a clean environment without developer-only DLLs.
RTD COM registration
RTD uses temporary per-user COM registration. Ownership markers prevent broad cleanup: only entries matching the same owner, schema, module path, and CLSID are scavenged after an abnormal exit.
Do not grant the add-in broad registry write privileges. Validate the exact keys in deployment tests, and ensure that uninstall logic removes only its own entries.
RTD source data is application data. Authenticate and authorize external feeds in the source adapter; the RTD transport itself does not do so.
Diagnostics and privacy
Diagnostics can contain function names, argument labels, paths, external status codes, and exception context. They should not contain workbook cells, customer data, credentials, access tokens, connection strings, or full external buffers unless an explicit protected support mode permits it.
The file sink writes under the user’s local application-data area and rotates bounded files. Apply the organization’s retention, support-bundle, and access policy. Monitor dropped diagnostic events so an error storm is not mistaken for silence.
Denial of service
The framework bounds several resources, including array sizes, handle count, diagnostic delivery, async execution, and RTD structures. Application-owned adapter queues are not framework-managed and must be bounded separately. RTD has explicit limits for topic parts and bytes (per topic and in aggregate), pending preparations, active streams, queued updates, and live source identities; the standard values are documented in the RTD chapter. Application code must also bound:
- algorithmic complexity and model iterations;
- cache weight and key cardinality;
- external request concurrency;
- external context/object counts;
- RTD publication rate;
- retry and backoff loops;
- string-to-path or string-to-query expansion;
- shutdown duration.
A thread-safe attribute can let Excel invoke many calls concurrently. Pair it with measured capacity and admission control rather than relying on Excel to protect a backend.
Supply-chain and release controls
For a production release:
- use a locked dependency graph and review dependency changes;
- build from a controlled clean environment;
- verify downloaded SDKs, sidecars, and adapter dependencies by digest and publisher;
- retain source commit, toolchain, target, and feature evidence;
- run static analysis, tests, ABI probes, and package inspection;
- sign the final bytes and verify the signatures;
- scan and install the final package in a clean environment;
- archive the package and evidence needed for incident response.
Security claims should name the tested artifact and environment. “Written in Rust” is not a substitute for an audited adapter and deployment boundary.
Troubleshooting
Start with the earliest failing boundary. Do not debug a worksheet result before confirming that the correct XLL loaded and its dependencies resolved.
Collect basic evidence
Record:
add-in version and source commit:
Windows version:
Excel version/channel and bitness:
XLL path and architecture:
feature set:
exact formula:
cell result or Excel dialog text:
diagnostic ID and relevant log lines:
reproduction after a clean Excel restart:
The built-in diagnostic log is normally at:
%LOCALAPPDATA%/<addin-id>/logs/diagnostics.log
Do not publish logs without reviewing them for sensitive installation or business data.
Excel refuses to load the XLL
Check:
- XLL architecture matches the Excel process, not merely Windows;
- the file is the staged
.xll, not the original Cargo.dll; - every file from the target distribution directory is present;
- the package was not copied from an untrusted source and blocked by Windows policy;
- required signatures are valid;
- endpoint protection did not quarantine a dependency;
- the package passed
cargo xlfn checkon the same target.
Rebuild explicitly:
cargo xlfn check --target x86_64-pc-windows-msvc --locked
Use the x86 target for 32-bit Excel.
The add-in loads but functions are missing
- Confirm that exactly one
#[excel_addin]is at crate root. - Confirm the function is linked into the
cdyliband attributed with#[excel_function]. - Check whether it is
hidden. - Look for a registration-name conflict with another XLL.
- Keep the UDF
idunique within the crate. - Run
cargo xlfn check; it compares.xllexpentries with actual PE exports. - Restart Excel after replacing an XLL. Excel may still hold the old module.
A registration conflict is rejected; xlfn does not overwrite another add-in’s name.
A cell shows #VALUE!
Typical causes:
- strict type mismatch, such as text supplied to
f64; - a blank or missing policy rejected the argument;
- invalid UTF-16 or malformed array/reference structure;
- a failed Excel callback or coercion;
- an internal or application-adapter error.
Check the argument named in diagnostics. xlfn does not perform broad Excel coercion for ordinary parameters.
A cell shows #NUM!
Typical causes:
- non-finite input or result;
i64outside Excel’s exact-2^53..=2^53range;- numeric conversion overflow;
- a domain error such as an invalid model state.
Validate model outputs before returning them. NaN and infinity are rejected rather than written into an XLOPER12.
A cell shows #N/A
Typical causes:
- invalid, stale, wrong-type, or previous-session handle;
- add-in or worker is closing;
- overloaded/reentrant operation;
- an intentionally unavailable result;
- an input-only
ExcelValue::Missingor blankExcelCellValuebeing treated as a worksheet return. UseExcelErrorValue(ExcelError::NotAvailable)for an explicit#N/Aresult.
Recalculate the handle-producing formula first. Do not edit or persist token text as an application identifier.
A handle does not appear to refresh
The visible token remains stable for the same formula revision by design. A same-revision recalculation reuses the memoized object without invoking the producer again. Changing an explicit revision input creates a new object and token; a live token never changes the object it identifies.
Test the object’s behavior or expose a safe version field rather than using token-string changes as evidence of refresh. Verify that the producer is actually recalculated and is not blocked by Excel calculation settings.
An async formula never completes
- Confirm the crate enabled the
asyncfeature. - Verify the linked async exports with
cargo xlfn check. - Ensure blocking work is submitted to a dedicated worker rather than occupying all async executor threads.
- Inspect the
RuntimeConfigasync worker count and downstream queue capacity. - Check cancellation; a cancelled call deliberately suppresses late delivery.
- Ensure the future retains every needed owned input and does not wait on a resource that requires the Excel thread.
- Verify that an external client actually wakes the future.
A cancellation token cannot interrupt a blocking foreign call. Instrument queue wait and adapter execution separately.
RTD does not update
- The worksheet function must subscribe from
MainThreadContext. RtdSource::subscribemust return without unbounded blocking.- Keep the returned subscription alive and keep its producer active.
- Handle errors from
RtdSink::publish. - Publish only supported scalar, finite, bounded values.
- Confirm Excel calculation is enabled.
- Test one, two, and three-topic batches; do not rely on a single happy path.
- Check temporary COM registration access and stale-registration recovery.
- Verify
request_canceldoes not block anddisconnect_and_waitreaches quiescence on success, error, and unwinding.
A tight retry loop after a permanent publish failure can create an error storm and fill diagnostics.
External adapter fails to initialize
Typical diagnostics depend on the application adapter and may include configuration failure, path-resolution failure, missing sidecar, protocol mismatch, authentication failure, unavailable service, missing symbol, ABI mismatch, or wrong architecture.
Check:
- the declared DLL basename exactly matches the packaged file;
- x86 and x64 metadata point to the correct files;
- the DLL and all non-system dependencies are in the package;
- required symbols match spelling and decoration;
- any application-defined protocol or ABI negotiation returns the expected version;
- antivirus or policy did not block the DLL;
- the final installation directory has not been modified.
For packaged PE components, use an external PE inspection tool and cargo xlfn check. For other adapters, use the diagnostics and qualification tools appropriate to the chosen transport. Do not weaken a required contract merely to bypass initialization failure.
External calls serialize unexpectedly
Serialization is an application-adapter policy, not an xlfn runtime policy. Inspect the adapter’s locks, queue topology, per-session affinity, downstream rate limits, and external implementation contract. Multiple Excel MTR calls or multiple application workers do not imply downstream concurrency. Enable concurrent dispatch only when the complete application contract covers calls, contexts, object operations, callbacks, and destruction, then measure actual throughput.
Excel hangs during close
A safe XLL close waits for in-process work to become quiescent. A hang usually indicates:
- running external or application code cannot be cancelled or bounded;
- an RTD subscription did not honor
request_cancel; disconnect_and_waitwaits for a callback that needs a held lock;- application-owned background work was not joined;
- a destructor performs blocking or reentrant work;
- graceful worker shutdown is draining an unexpectedly large queue.
Do not add a timeout that lets Excel unload while code may still execute. Capture thread dumps, identify the owner and wait dependency, then fix cancellation or move the uninterruptible operation out of process.
cargo xlfn package refuses paths or imports
artifact-namemust be a valid non-reserved Windows basename.- Bundle metadata paths are relative to the package manifest directory.
- Bundled basenames must be unique case-insensitively and must not collide with the XLL or
build-manifest.json. - With
strict-paths = true, configured paths reject symlinks or reparse points observed during validation; protect the manifest tree from concurrent mutation when that threat is in scope. - Every non-system import must be packaged or explicitly approved as an external import.
package --allrequires a dedicated replaceable output directory.
When commit and rollback both fail, preserve and inspect the recovery path reported by the tool.
Escalating an issue
A useful OSS issue contains a minimal reproducer, exact command output, environment matrix, diagnostic IDs, and a statement of whether the failure occurs in Rust tests, package validation, or real Excel. Remove proprietary workbooks and external binaries unless redistribution is authorized; replace them with a minimal mock when possible.
Attribute reference
This chapter is the compact reference for xlfn’s procedural macros. The compiler validates incompatible combinations; prefer the smallest attribute set that accurately describes the Excel contract.
Generated code resolves the framework’s dependency name from Cargo.toml, including renamed dependencies. The crate = "path" option can override that resolution for each macro.
Default names derived from Rust identifiers omit a raw identifier’s r# prefix: fn r#type(r#match: f64) registers as type with an argument named match. The same rule applies to add-in and enum names. Explicit string options keep their specified spelling.
#[excel_addin(...)]
Place exactly one #[excel_addin] on a non-generic struct declared at the crate root:
#![allow(unused)]
fn main() {
#[excel_addin(
name = "App Tools",
id = "app-tools",
category = "AppTools"
)]
pub struct AppTools;
}
| Option | Meaning | Default |
|---|---|---|
name = "..." | Display name shown by Excel’s Add-in Manager | Rust struct name |
id = "..." | Stable add-in identity used by runtime ownership and registration | lowercase Rust struct name |
category = "..." | Default Function Wizard category | display name |
physical_unload | Opt into physical DLL unload after an unsafe quiescence contract | disabled |
name and category must contain 1 through 255 UTF-16 code units. id must be a non-reserved ASCII slug of at most 64 bytes, begin with a letter, and contain only letters, digits, -, or _.
The macro emits the standard XLL lifecycle and COM exports. Implement Addin for the attributed type.
The default keeps the module resident after terminal removal because a safe
Addin cannot account for executable sources created through arbitrary Rust
threads or native callbacks. physical_unload is an unsafe opt-in: use it
only together with unsafe impl PhysicallyUnloadableAddin for YourAddin {} and
stop every such source before the stronger quiescence hook returns.
#[excel_function(...)]
Apply the attribute to an ordinary or async free function:
#![allow(unused)]
fn main() {
/// Computes the area of a rectangle.
#[excel_function(
name = "MATH.SCALE",
id = "math_scale_v1",
category = "Math",
help_topic = "https://docs.example.invalid/math/scale",
thread_safe
)]
fn calculate_area(width: f64, height: f64) -> f64 {
width * height
}
}
| Option | Meaning | Default |
|---|---|---|
name = "..." | Excel-visible function name | Rust function identifier |
id = "..." | Stable UDF identity and generated export identity | Rust function identifier |
category = "..." | Function Wizard category | add-in default when omitted or empty |
description = "..." | Function Wizard description | joined Rust doc comments |
help_topic = "..." | Help URL or topic supplied to Excel | empty |
thread_safe | Register for Excel multi-threaded recalculation | disabled |
macro_sheet | Register with macro-sheet capability | disabled |
volatile | Recalculate whenever Excel recalculates | disabled |
hidden | Hide the function from the Function Wizard | visible |
Use a stable explicit id before publishing workbooks. Changing an ID changes framework identity even when the Excel-visible name remains unchanged.
The macro supports at most 255 Excel-visible parameters for synchronous functions and 254 for async functions; Excel’s async handle consumes the remaining ABI slot. The optional injected context is not Excel-visible.
Function flag constraints
thread_safeis incompatible with main-thread and macro-sheet contexts.macro_sheetis incompatible withthread_safeand async functions.- reference arguments require macro-sheet capability.
- async functions cannot accept reference arguments.
- a return type must implement the marker trait for the selected execution mode.
- a volatile function’s return type must also implement
VolatileReturn.
See Execution modes and contexts and Conversion reference.
#[excel_context(...)]
A function may have at most one injected context. It must be the first parameter, passed by value, and must not also carry #[excel_arg].
#![allow(unused)]
fn main() {
fn lookup(
#[excel_context(thread_safe)] context: ThreadSafeContext<'_, AppTools>,
key: String,
) -> XllResult<f64> {
context.state().lookup(&key)
}
}
| Role | Rust context | Capability |
|---|---|---|
main_thread | MainThreadContext<'_, AppTools> | main-thread Excel callbacks, handles, RTD |
thread_safe | ThreadSafeContext<'_, AppTools> | shared state during MTR; no unsafe Excel callbacks |
macro_sheet | MacroSheetContext<'_, AppTools> | Excel references and macro-sheet registration |
asynchronous | AsyncContext<'_, AppTools> | cancellation and shared state for an async UDF |
An async fn may omit a context. When it has one, the role must be asynchronous. A synchronous function cannot use the asynchronous role.
#[excel_arg(...)]
Annotate an Excel-visible parameter to improve Function Wizard metadata or define presence/reference policy:
#![allow(unused)]
fn main() {
fn interpolate(
#[excel_arg(
name = "Method",
description = "Interpolation method.",
default = Method::Linear,
missing = "default",
blank = "error"
)]
method: Method,
) -> f64 {
// ...
}
}
| Option | Meaning |
|---|---|
name = "..." | Excel-visible argument name |
description = "..." | Function Wizard argument description |
default = <expr> | Rust expression used by a selected default policy |
blank = "default" | use default for an empty cell |
blank = "error" | reject an empty cell explicitly |
missing = "default" | use default for an omitted trailing argument |
missing = "error" | reject an omitted argument explicitly |
reference | receive an unevaluated Excel reference |
Rules:
defaultrequires at least oneblank = "default"ormissing = "default"policy.- a
"default"policy requiresdefault = .... referencecannot be combined with blank, missing, or default policies.referencerequiresmacro_sheetorMacroSheetContext.- argument patterns must be simple identifiers; destructuring belongs inside the function body.
Without an explicit presence policy, the parameter’s conversion type controls blank and missing behavior. See Optional arguments and enums.
#[derive(ExcelEnum)]
Derive strict text conversion for a fieldless enum:
#![allow(unused)]
fn main() {
}
#[derive(Clone, Copy, ExcelEnum)]
#[excel_enum(ascii_case_insensitive)]
enum Direction {
#[excel_value(name = "Forward")]
Forward,
#[excel_value(name = "Reverse")]
}
- variants must be unit variants;
- names default to Rust variant identifiers;
#[excel_value(name = "...")]assigns the worksheet spelling;#[excel_enum(ascii_case_insensitive)]enables ASCII case-insensitive matching;- effective names must be non-empty and unique under the selected comparison policy.
The derive implements input conversion, normalized variant identity for formula-revision handle inputs, scalar output conversion, and all execution-mode return markers.
#[derive(ExcelHandleObject)]
Derive this marker for an object that Excel formulas may own through an opaque typed handle:
#![allow(unused)]
fn main() {
#[derive(ExcelHandleObject)]
struct Dataset {
// immutable or internally synchronized state
}
}
The type must satisfy Any + Send + Sync + 'static. Returning the object
publishes it for the producer formula’s revision; a changed formula revision
publishes a new object while a same-revision recalculation reuses the memoized
object. Accepting Handle<'_, Dataset> resolves and type-checks the token.
HandleAlias<'_, Dataset> is the explicit main-thread return capability for
republishing an existing object. Borrowed Handle values are not return values,
and cannot be used by thread-safe, macro-sheet, or async functions. Async
functions that need an existing object use HandleLease<'_, Dataset>, which is
a generation-scoped input created by pinning the authenticated registry object
at the async boundary.
Treat compile errors as contract failures
The macros deliberately reject ambiguous or unsound declarations. Do not work around a diagnostic by weakening flags or changing an argument to a dynamic type without understanding the Excel ABI consequence. Compile-fail tests are appropriate for your own macro policies and published examples.
Conversion reference
This chapter summarizes the built-in worksheet conversion surface. The behavioral chapters remain authoritative for design guidance; generated rustdoc remains authoritative for exact method signatures.
Input conversions
| Rust parameter type | Accepted Excel representation | Important behavior |
|---|---|---|
f64 | number or integer | rejects non-finite values |
bool | Boolean | no numeric or text coercion |
i32 | integer or integral number | rejects fractions and overflow |
i64 | integer or exactly representable integral number | numeric path is limited to the exact binary64 integer range |
String | string | validates UTF-16 |
&str | string | decodes UTF-16 into call scratch; synchronous UDFs only |
ExcelErrorValue | Excel error | preserves the exact error |
ExcelCellRef<'call> | number, Boolean, string, error, or blank | zero-allocation cell view; synchronous UDFs only |
ExcelSerialDate | finite number | starts with ExcelDateSystem::Workbook |
Handle<'_, T> | string handle token | authenticates, checks generation, and checks object type; valid only for the active call |
HandleLease<'_, T> | string handle token | async-only; pins the typed object before task commit and carries a generation-scoped lifetime |
Option<T> | value, blank, or missing | blank and missing become None |
OptionalExcelValue<T> | value, blank, or missing | preserves all three states |
XlArrayRef<'call> | rectangular multi-value | zero-allocation borrowed cells; synchronous UDFs only |
Matrix<T> | scalar or rectangular multi-value | scalar becomes 1 x 1; validates shape and limits |
MatrixRef<'call, T> | scalar or rectangular multi-value | call-scoped, call-scratch-materialized Copy element view; synchronous UDFs only |
Row<T> | scalar or 1 x N | rejects a true 2-D shape |
Column<T> | scalar or N x 1 | rejects a true 2-D shape |
Vec<T> | scalar, row, or column | input only; rejects a true 2-D shape |
BoundedVarArgs<T, MAX> | scalar, row, or column | input only; requires MAX > 0 and enforces the bound |
ExcelValue | supported scalar, error, blank, missing, or array | intentionally dynamic, owned input representation; array cells are ExcelCellValue |
a type deriving ExcelEnum | string | exact or optional ASCII case-insensitive match |
a custom T: FromExcel<'call> | defined by the implementation | may borrow only for the generated call lifetime |
An Excel error supplied where another type is expected is propagated as that Excel error. Ordinary conversions do not ask Excel to coerce strings, booleans, references, or arrays into unrelated types.
When the return type is a formula-owned handle, input conversion also selects
the formula-revision mode. Built-in values record semantic identities after
conversion. A custom T: FromExcel<'call> must additionally implement
ExcelInputIdentity; otherwise it is valid for ordinary UDFs but rejected for
the handle-producing path. This keeps memoization tied to the Rust value the
UDF can observe instead of to incidental Excel storage details.
Reference conversions
A parameter marked #[excel_arg(reference)] uses FromExcelReference<'call>, not FromExcel.
The built-in ExcelReference<'call> preserves:
- same-sheet or sheet-qualified identity;
- one or more rectangular areas;
- zero-based row and column bounds;
- a lifetime tied to the active Excel call.
Reference parameters require macro-sheet capability and are unavailable to async functions. They are raw call-scoped capabilities rather than ordinary formula-revision inputs. Copy only bounded metadata out of the borrowed value. Use the main-thread reference APIs to coerce or inspect cells when required.
Scalar output conversions
The following are direct scalar returns:
f64,bool,i32, and exactly representablei64;Stringand&str;ExcelErrorValue;ExcelSerialDate;ExcelCellOutputand customIntoExcelimplementations;- a type deriving
ExcelEnum; RtdValue.
Matrix<T>, Row<T>, and Column<T> are owned array returns when every element implements IntoExcel. With the explicit unstable-output crate feature, xlfn::unstable::output::XlArrayBuilder::new produces XlArrayOutput directly in the final XLOPER12 cell buffer, avoiding an intermediate cell vector and a cell-buffer copy.
Result<T, E> is supported whenever T is supported for the selected execution mode and E: IntoXllError. The wrapper converts the error exactly once at the Excel boundary.
Execution-mode return matrix
| Return family | Main thread | Thread-safe | Macro-sheet | Async | Volatile |
|---|---|---|---|---|---|
| built-in scalar | yes | yes | yes | yes | yes |
ExcelEnum | yes | yes | yes | yes | yes |
Matrix<T>, Row<T>, Column<T>, XlArrayOutput | yes | yes | yes | yes | yes |
RtdValue | yes | yes | yes | yes | yes |
HandleAlias<'_, T> | yes | no | no | no | yes |
object deriving ExcelHandleObject | yes | no | no | no | yes |
custom T | according to implemented marker traits | according to implemented marker traits | according to implemented marker traits | according to implemented marker traits | according to implemented marker traits |
“Volatile” is an additional marker, not an execution thread. A volatile thread-safe function, for example, needs both ThreadSafeReturn and VolatileReturn.
Presence behavior
Excel distinguishes:
- value: a normal scalar, array, error, or reference;
- blank: an empty cell (
xltypeNil); - missing: an omitted trailing argument (
xltypeMissing).
Choose among:
Option<T>when blank and missing are intentionally equivalent;OptionalExcelValue<T>when the distinction matters;#[excel_arg(blank = ..., missing = ..., default = ...)]when presence policy belongs in the worksheet signature;- a required
Twhen neither state is valid.
See Optional arguments and enums.
Arrays and allocation limits
XlArrayRef is the allocation-free mixed-value input path. It validates every cell’s type tag when the array is admitted, then exposes shape, indexed access, and lazy payload conversion through XlValueRef; XlStrRef borrows a string’s UTF-16 units until decoding is actually requested. Admission is linear in the cell count even if only a subset is read. Use XlArrayRef when lazy cell conversion is enough. Use &str, ExcelCellRef, or MatrixRef<T> when a synchronous function needs typed call-local values; MatrixRef materializes its Copy elements in call scratch. Use String, ExcelCellValue, Matrix<T>, or Vec<T> when the input must be owned, especially for async work.
Borrowed strings and grids use one CallScope scratch root. String decoding allocates UTF-8 bytes there, and borrowed matrix elements are stored there only when T: Copy; no destructor-bearing collection is placed in call scratch. The scope is dropped after the generated synchronous call returns.
Matrix::new and XlArrayBuilder::new require non-zero dimensions, checked multiplication, matching element count, and values within both Excel and framework limits.
| Limit | x86 | x64 |
|---|---|---|
| worksheet rows | 1,048,576 | 1,048,576 |
| worksheet columns | 16,384 | 16,384 |
| framework array elements | 1,000,000 | 4,000,000 |
| referenced Excel allocation bytes | 64 MiB | 256 MiB |
| returned allocation bytes | 64 MiB | 256 MiB |
Validate application-specific limits before allocating. Framework checks are host-protection ceilings, not a recommendation to return multi-million-cell arrays routinely.
Dynamic value variants
ExcelCellValue represents:
Number | Boolean | String | Error | Blank
ExcelValue represents:
Scalar(ExcelCellValue) | Missing | Array(Matrix<ExcelCellValue>)
Missing is an omitted argument and cannot occur inside an array. Blank is an empty cell and can occur as a scalar or array cell. The raw xltypeInt transport form is canonicalized to Number; it is not an input semantic variant. ExcelCellOutput intentionally has no blank or missing variant. Return ExcelErrorValue(ExcelError::NotAvailable) or an explicit ExcelCellOutput::Error when an unavailable result is intended.
Custom conversion checklist
For FromExcel<'call> and the built-in borrowed parameter views:
- inspect only the active
XlValueRef<'_>; - copy owned data before returning;
- use the supplied static argument name in
XllError::Input; - reject unsupported coercions and non-finite values explicitly;
- bound all allocation from workbook-controlled lengths.
- keep owned conversion independent of framework runtime state; do not retain temporary Excel pointers or call-scoped views in an owned result.
For IntoExcel:
- validate the application value before allocation;
- do not call Excel from thread-safe or async conversion paths;
- preserve ownership until Excel calls
xlAutoFree12through framework-managed return storage.
See Custom conversions.
cargo xlfn reference
cargo-xlfn is an optional developer tool that validates linked XLL artifacts and produces bitness-specific package directories. Invoke it through Cargo:
cargo xlfn <COMMAND> [OPTIONS]
Install cargo-xlfn from crates.io:
cargo install cargo-xlfn --locked
Or from a local checkout:
cargo install --path crates/cargo-xlfn --locked --force
cargo xlfn check
cargo xlfn check [PROJECT OPTIONS] [BUILD OPTIONS] [--target <TARGET>]
Without --target, the command validates both supported targets:
i686-pc-windows-msvc
x86_64-pc-windows-msvc
With --target, it validates only the selected target.
For each target, check:
- runs
cargo buildfor the selected package and build configuration; - requires exactly one
cdylibtarget; - stages configured bundle files in a temporary package;
- copies the linked DLL as
<artifact-name>.xll; - verifies required XLL exports and the generated
.xllexpmanifest; - verifies the embedded effective Rust CRT policy and direct dynamic CRT imports;
- verifies PE architecture;
- validates the complete packaged DLL import closure.
The default Cargo profile is dev unless --profile is supplied. check does not create a persistent package directory.
Examples:
cargo xlfn check
cargo xlfn check --target x86_64-pc-windows-msvc
cargo xlfn check --target x86_64-pc-windows-msvc --crt dynamic
cargo xlfn check --package data-xlfn --profile release --locked
cargo xlfn check --manifest-path xlfn/examples/basic-xll/Cargo.toml --all-features
cargo xlfn package
cargo xlfn package (--target <TARGET> | --all) [--out <PATH>]
[PROJECT OPTIONS] [BUILD OPTIONS]
Exactly one of --target and --all is required.
--target i686-pc-windows-msvcwrites an x86 package.--target x86_64-pc-windows-msvcwrites an x64 package.--allstages both architectures and transactionally replaces the output root with best-effort rollback.--out <PATH>selects the output root; the default ispackage.
package uses the release profile by default unless --profile is supplied.
Typical output:
package/
├── win-x86/
│ ├── MyAddin.xll
│ ├── build-manifest.json
│ └── packaged sidecar files
└── win-x64/
├── MyAddin.xll
├── build-manifest.json
└── packaged sidecar files
Each target is fully staged and validated before commit. With --all, either both target directories are committed or the previous package is restored when rollback is possible. This is a transactional replacement, not a reader-visible atomic directory swap: readers may observe the replacement window, and power loss is outside the guarantee. The transaction journal is checked on the next invocation. If commit and rollback both fail because of a filesystem fault, the command reports a preserved recovery path rather than deleting the previous package.
Examples:
cargo xlfn package --all
cargo xlfn package --target x86_64-pc-windows-msvc
cargo xlfn package --all --out artifacts/xll --locked
cargo xlfn package --target i686-pc-windows-msvc --features async
For --all, the output root must be a dedicated directory; . and filesystem roots are rejected because the whole root is replaced transactionally. A single-target package replaces only its win-x86 or win-x64 subdirectory under --out, so an existing parent directory may be used.
Project options
| Option | Meaning |
|---|---|
--manifest-path <PATH> | Cargo manifest used for workspace discovery |
--package <NAME> | workspace package to build |
When no package is supplied, the workspace root package is selected. A virtual workspace or an ambiguous workspace requires --package.
Build options
| Option | Forwarded behavior |
|---|---|
--crt <POLICY> | inherit, static, or dynamic; see below |
--target-dir <PATH> | base target directory, separated by CRT policy |
--profile <NAME> | Cargo profile |
--features <A,B> | comma-separated feature selection |
--no-default-features | disable default features |
--all-features | enable all package features |
--locked | require the existing lock file |
--frozen | require lock file and no network access |
--offline | disable network access |
Feature flags affect both the binary and its expected export manifest. Always qualify the same feature set that will be distributed.
The CRT policy defaults to static, can be set persistently with
package.metadata.xlfn.crt, and is overridden by an explicit --crt:
inheritleavesRUSTFLAGS,CARGO_ENCODED_RUSTFLAGS, Cargo configuration, and wrappers untouched;staticenforces+crt-staticfor Rust invocations targeting the selected MSVC triple;dynamicenforces-crt-staticfor those target invocations.
Build output is isolated under xlfn-crt-inherit, xlfn-crt-static, or
xlfn-crt-dynamic beneath the selected Cargo target directory. Existing
RUSTC_WRAPPER and RUSTC_WORKSPACE_WRAPPER chains are preserved.
Exit behavior and CI use
The command returns a non-zero exit status on build, staging, export, PE, import-closure, or commit failure. Treat any failure as a release-blocking artifact failure.
A representative CI sequence is:
cargo fmt --all --check
cargo clippy --workspace --all-targets --all-features -- -D warnings
cargo test --workspace --all-features
cargo xlfn check --package my-addin --all-features --locked
cargo xlfn package --package my-addin --all --all-features --locked --out package
cargo xlfn check complements rather than replaces Rust tests and real-Excel qualification.
Cargo metadata reference
cargo-xlfn reads package-specific settings from Cargo.toml. Paths are interpreted relative to the selected package’s manifest directory, not the workspace root or current shell directory.
Basic metadata
[package.metadata.xlfn]
artifact-name = "DataTools"
crt = "dynamic"
artifact-name controls the distributed XLL basename:
DataTools.xll
When omitted, the Cargo package name is used.
The value must be a valid Windows basename. It must:
- be non-empty;
- contain no control characters or
< > : " / \\ | ? *; - not end with a dot or space;
- not use a reserved device stem such as
CON,PRN,AUX,NUL,COM1throughCOM9, orLPT1throughLPT9.
Do not add the .xll extension to artifact-name; the tool supplies it.
crt accepts inherit, static, or dynamic. The resolution order is an
explicit CLI --crt, then this metadata value, then the static default.
inherit is a deliberate no-op and does not mean dynamic.
Bundle metadata
List optional sidecar files by target. Bundle metadata controls staging and PE dependency validation; it does not define or implement runtime loading:
[package.metadata.xlfn.bundle]
x86 = [
"native/x86/NativeEngine.dll",
"native/x86/NativeSupport.dll",
]
x64 = [
"native/x64/NativeEngine.dll",
"native/x64/NativeSupport.dll",
]
external-imports = ["OrganizationRuntime.dll"]
strict-paths = true
| Key | Type | Meaning |
|---|---|---|
x86 | array of strings | files packaged for i686-pc-windows-msvc |
x64 | array of strings | files packaged for x86_64-pc-windows-msvc |
external-imports | array of strings | approved non-system DLL basenames supplied outside the package |
strict-paths | Boolean | reject symbolic links/reparse points present in configured source paths; defaults to true |
Unknown fields are rejected.
Bundle path rules
Every configured bundle path must:
- be a non-empty relative path;
- contain only normal path components—no root, drive prefix,
.or..; - resolve to a regular file;
- canonicalize within the package manifest directory;
- have a case-insensitively unique output basename;
- not collide with
<artifact-name>.xllorbuild-manifest.json.
With strict-paths = true, each configured component is also rejected when it is a symbolic link or Windows reparse point at validation time. This is the default, including when the key is omitted. The check is path-based and does not protect against a concurrent adversary replacing a checked component between validation and open; use an immutable or otherwise trusted manifest tree when that threat is in scope. To relax the check, set strict-paths = false explicitly; that still enforces canonical containment but permits links, and should be limited to a controlled development workflow whose trust boundary is documented.
The output package is flat. Directory structure in configured paths is not preserved, so basename uniqueness is mandatory.
External imports
An entry in external-imports must be a DLL basename such as:
external-imports = ["OrganizationRuntime.dll"]
Paths and non-DLL names are rejected. Matching is case-insensitive.
This option is an explicit deployment exception: the dependency need not be packaged because the deployment environment promises to resolve it. Do not list a missing application dependency merely to make validation pass. Record who installs the dependency, where it is loaded from, how it is versioned, and how its bitness is controlled.
Windows system imports are accepted by the versioned built-in windows-system-v1 policy. Every other direct or transitive import must resolve to a packaged basename or an approved external import.
Full example
[package]
name = "data-xlfn"
version = "1.4.0"
edition = "2024"
rust-version = "1.98.1"
[lib]
crate-type = ["cdylib"]
[dependencies]
xlfn = { version = "0.2", features = ["async"] }
[package.metadata.xlfn]
artifact-name = "DataTools"
crt = "dynamic"
[package.metadata.xlfn.bundle]
x86 = [
"native/x86/NativeEngine.dll",
"native/x86/NativeMath.dll",
]
x64 = [
"native/x64/NativeEngine.dll",
"native/x64/NativeMath.dll",
]
external-imports = []
strict-paths = true
[profile.release]
panic = "unwind"
lto = "thin"
codegen-units = 1
The selected package must contain exactly one cdylib target.
build-manifest.json
Every package directory contains schema version 6 audit metadata. Its top-level fields are:
| Field | Meaning |
|---|---|
schema | manifest schema number |
package | Cargo package name |
package_version | Cargo package version |
artifact | configured artifact basename |
target | Rust target triple |
profile | Cargo profile |
feature_selection | requested and resolved package feature set |
cargo_constraints | lock/network constraints and lockfile hash |
crt | requested/source/effective CRT policy, enforcement, observed dynamic CRT imports, and consistency |
bundle_sources | configured relative paths and their staged relative basenames |
bundle_policy | effective strict-paths setting, versioned system-DLL policy, and approved external imports |
integrity | explicit trust-boundary statement |
files | relative path, byte size, and SHA-256 for every distributed file |
The integrity block deliberately states that hashes are audit metadata only and are not verified before executable sidecar code runs. Windows may load and initialize a DLL before application-level protocol or ABI checks can run. Use access-controlled installation directories and code signing for runtime trust; do not treat the JSON file as a secure loader.
Metadata review checklist
Before release:
- compare the two architecture lists rather than assuming they are symmetric;
- inspect every transitive import reported by
cargo xlfn check; - keep
external-importsempty unless deployment owns the exception; - review the recorded bundle policy and staged relative paths;
- verify output basenames and signatures after staging;
- archive the manifest with release evidence, but do not use it as the sole integrity control.
Feature and compatibility reference
This chapter distinguishes implemented targets from environments that have been independently qualified. “Builds” and “validated in Excel” are different claims.
Crate and language baseline
For the source version documented by this guide:
| Item | Value |
|---|---|
supported xlfn facade version | 0.2.0 |
| Rust edition | 2024 |
| minimum/pinned Rust toolchain | 1.98.1 |
| license | MIT OR Apache-2.0 |
| Excel C API generation | Excel 12 / XLOPER12 |
The supported application contract is the xlfn facade. The implementation
crates xlfn-common, xlfn-kernel, and xlfn-macros intentionally use an
independent 0.x versioning domain and are not direct application APIs.
xlfn-sys, xlfn-package, and cargo-xlfn are evaluated separately because
their ABI, packaging, and CLI contracts are distinct from the facade.
The repository pins the toolchain and both Windows MSVC targets in rust-toolchain.toml. Downstream applications should record their actual compiler and cargo-xlfn version in release evidence.
Runtime targets
The supported XLL target implementations are:
| Excel process | Rust target | Package directory |
|---|---|---|
| 32-bit Excel | i686-pc-windows-msvc | win-x86 |
| 64-bit Excel | x86_64-pc-windows-msvc | win-x64 |
Select by Excel process bitness, not Windows bitness. A 64-bit Windows installation may run 32-bit Excel and therefore require the x86 package.
The intended operating-system baseline is Windows 10 or Windows 11 with the MSVC toolchain. Non-Windows hosts may run portable unit tests and inspect source, but they do not produce a runnable Excel XLL without the Windows target toolchain and linker environment.
Excel versions
Synchronous XLOPER12 functions target Excel versions that support the Excel 12 C API. Native asynchronous UDFs rely on Excel’s async ABI; use Excel 2010 or later as the operational baseline for the async feature.
Exact support for a particular Microsoft 365 channel, perpetual Excel build, locale, and organizational security configuration must be established by the release qualification matrix. See Testing and release qualification.
Qualification status
The repository contains automated Windows artifact checks and a real-Excel release-gate procedure. The release readiness record tracks the evidence and remaining gates for the 1.0 candidate. It does not claim completed real-Excel validation for all Windows 10/11 and 32/64-bit combinations.
Accordingly:
- the two MSVC architectures are implemented build targets;
- PE/export/import validation can be automated;
- production support claims must be based on recorded execution of the real-Excel matrix for the release candidate;
- downstream distributors should publish their own tested Excel versions and channels.
Do not convert an intended target into a support claim without evidence.
xlfn features
The xlfn crate has no default features.
| Feature | Adds | Use when |
|---|---|---|
async | native async UDF executor, async context, calculation cancellation exports | a formula produces one eventual result without blocking Excel |
handles | formula-owned typed objects, aliases, and scoped handle inputs | a worksheet formula owns a Rust object |
rtd | typed streaming sources, subscriptions, and RTD configuration | a formula receives repeated updates from a push source |
unstable-cache | lower-level calculation-cache API | the add-in explicitly accepts experimental cache API evolution |
unstable-output | lower-level array-output API | the add-in explicitly accepts experimental output API evolution |
handles and rtd share a private Excel RTD transport, but neither enables the
other’s public API. Async handle inputs need both async and handles.
refinement and bench-internals are repository verification facilities,
outside the supported application API. To use all supported capabilities,
declare features = ["async", "handles", "rtd"] on the xlfn dependency.
Examples:
[dependencies]
xlfn = "0.2"
[dependencies]
xlfn = { version = "0.2", features = ["async"] }
Qualify every feature combination that you distribute. Async changes the expected export set; bundle contents and application-adapter dependencies have separate packaging and trust requirements.
Raw Excel ABI access
The xlfn crate does not expose the raw Excel ABI. Applications that
intentionally need raw ABI types or calls should declare xlfn-sys directly:
[dependencies]
xlfn-sys = "0.2"
#![allow(unused)]
fn main() {
use xlfn_sys::XLOPER12;
}
Generated code may use hidden items under xlfn::__private, but that module is
an implementation detail and is not a supported application API.
Build-profile requirements
The framework catches panics at XLL boundaries and relies on unwinding behavior. Release profiles must use:
[profile.release]
panic = "unwind"
Do not switch an add-in to panic = "abort"; a panic would terminate Excel rather than being converted to a worksheet error and diagnostic event.
Dependency names
Procedural macros resolve the framework’s dependency name from Cargo.toml. Both the canonical name and a dependency alias are supported:
[dependencies]
my_xlfn = { package = "xlfn", version = "0.2" }
Use my_xlfn::prelude::* with this declaration. When accessing the framework through a Rust re-export, override resolution with crate = "path" on the relevant macro; see the attribute reference.
Source and binary compatibility
The 0.x line is pre-1.0. Treat public Rust APIs, macro diagnostics, package metadata, and generated artifacts as subject to intentional breaking change between minor releases. Pin versions for production builds and review release notes before upgrading.
Contract intended for 1.0
The version in this checkout is still 0.2.0. The following defines the scope
to freeze when 1.0 is released; it does not announce that release:
- The documented
xlfnfacade, its prelude, macro inputs and generated behavior, and theasync,handles, andrtdfeature APIs form the stable application contract. Removing or incompatibly changing them requires a major release. - Custom conversion, lifecycle, execution-layer, and RTD extension traits are included. Adding a required trait method or changing a public type’s fields, exhaustive variants, lifetimes, or thread-safety bounds must be reviewed for downstream source compatibility.
unstable-cache,unstable-output, hidden macro support, benchmark helpers, and refinement trace formats are excluded. Code opting into these facilities must pin the exact framework version. Experimental features are not implied by the supported feature set.- Macro error wording, rustc diagnostic formatting, backtraces, log prose, timing, allocation strategy, and private handle-token text are not stable formats. Documented errors and capability/lifetime restrictions remain part of the contract. Handles are session-scoped and must not be persisted.
- Rust has no stable binary ABI here. Rebuild the complete XLL and its generated wrappers together when updating the framework; do not mix compiled Rust objects from different versions. Workbook-visible names and semantics remain the add-in author’s responsibility.
- Rust
1.98.1is the initial minimum toolchain. A minimum-version increase must be documented and made in a minor or major release, not a patch release. Qualified Windows/Excel environments are recorded separately below.
xlfn-sys, the programmatic xlfn-package API, and the cargo-xlfn CLI have
their own release contracts. The facade’s 1.0 commitment does not implicitly
stabilize every implementation crate. See the maintainer’s
release procedure for version alignment, artifact
formats, and the baseline update required before the first stable release.
Workbook compatibility is a separate concern. The following are workbook-visible public API:
- Excel function names;
- argument order and presence policy;
- accepted enum strings;
- error semantics;
- calculation behavior;
- handle-producing versus scalar-producing behavior;
- stable UDF IDs where identity affects runtime state.
Use additive changes where possible. Rename or remove a published worksheet function only through an explicit workbook migration plan. xlfn does not require retaining Rust compatibility shims inside a developing add-in, but deployed workbook contracts still need operational governance.
External component compatibility
When the application uses an external binary component, its adapter must account for:
- Excel process bitness;
- PE machine type for the XLL and every bundled DLL;
- exact calling convention and symbol spelling;
- any selected ABI’s layout, packing, scalar widths, ownership, and error protocol;
- any application-defined protocol or ABI version negotiation;
- the transitive import policy;
- thread-affinity and concurrency guarantees.
xlfn does not perform runtime adapter loading or ABI negotiation. Any application-defined probe occurs according to the chosen adapter and is not a pre-execution security boundary for in-process code that has already been loaded.
Support matrix template
Publish a matrix for each release candidate:
| Environment | Artifact check | Load/open | sync UDF | MTR | handles | async | RTD | external adapter | unload/reload |
|---|---|---|---|---|---|---|---|---|---|
| Windows 10, Excel 32-bit, exact build/channel | |||||||||
| Windows 10, Excel 64-bit, exact build/channel | |||||||||
| Windows 11, Excel 32-bit, exact build/channel | |||||||||
| Windows 11, Excel 64-bit, exact build/channel |
Record failures and skipped capabilities explicitly; a blank cell must not be interpreted as a pass.
Glossary
ABI (Application Binary Interface) — The binary contract between compiled components: calling convention, symbol names, parameter widths, structure layout, alignment, ownership, and error protocol.
Add-in generation — One successful xlAutoOpen through the matching explicit xlAutoRemove teardown. State, handle tokens, async calculations, and RTD ownership are scoped to a generation; an ordinary xlAutoClose hint does not end it.
Async UDF — An Excel native asynchronous worksheet function that returns control promptly and later supplies one final result through Excel’s async handle.
Bearer capability — A value whose possession grants access. An xlfn handle token is a bearer capability: keep it unguessable and validate its type, authentication tag, and generation.
Bitness — The process architecture, x86 or x64. The XLL and every in-process binary dependency must match the Excel process, not merely the operating system.
Calculation ID — A runtime correlation identifier for one Excel calculation generation. It is not a durable workbook key.
Calculation cache — A concurrent, generation-aware application cache. Calling clear advances its internal generation so stale in-flight computations cannot repopulate the new generation; an application may choose to clear it at an Excel calculation boundary.
Cache endpoint — A typed, named view of a calculation cache. Its endpoint identity prevents unrelated key/value domains from colliding.
Formula owner — The worksheet cell identified by the caller coordinates. It owns the formula binding, but is distinct from the input revision and the underlying handle object.
Formula revision — The computation revision identified by a formula owner, stable producer UDF ID, and runtime-local semantic input fingerprint of the converted Rust arguments. Re-evaluation with the same revision reuses the memoized binding and object; a changed revision creates a new one.
Cancellation guarantee — The documented strength of cancellation for an async or application operation, such as guaranteed cancellation before start versus best-effort observation after start.
COM — Microsoft’s Component Object Model. Excel RTD uses COM interfaces for connection, notification, refresh, and server lifetime management.
Conversion boundary — The generated wrapper point where a raw XLOPER12 becomes a typed Rust parameter and a Rust result becomes framework-owned Excel return storage.
cdylib — A Rust library target that produces a C-compatible dynamic library. An xlfn add-in package must contain exactly one cdylib target.
Diagnostic ID — A stable, searchable numeric identifier attached to an internal failure. The worksheet receives a safe Excel error while operators use the ID to find detail.
Excel reference — An unevaluated cell or area reference received through macro-sheet capability. It is borrowed for the active call and may describe multiple areas.
Excel-visible argument — A parameter supplied by a worksheet formula. Injected contexts are not Excel-visible and do not consume Function Wizard arguments.
Formula-owned handle — An authenticated opaque string representing one formula-to-object ownership edge. Re-evaluation can reuse the binding, while an explicit alias can create another edge to the same underlying object.
Function Wizard — Excel’s UI for discovering functions and displaying category, description, help topic, and argument metadata.
Import closure — The complete directed graph of DLL imports rooted at the XLL and every bundled PE sidecar, including delay imports.
Main-thread context — A capability available only during a main-thread UDF invocation, permitting selected Excel callbacks, handle publication, and RTD subscription.
Macro-sheet capability — An Excel registration mode required for receiving references and using APIs that are not allowed in ordinary thread-safe functions. It does not mean authoring an Excel 4 macro sheet.
MTR (Multi-Threaded Recalculation) — Excel’s concurrent calculation engine. A thread_safe UDF may run simultaneously on Excel calculation threads and must avoid main-thread-only APIs.
Adapter call gate — An application-defined admission, serialization, or reentry policy in front of an external implementation. xlfn does not provide or select this policy.
Owner/handle split — A design in which a non-cloneable owner controls worker shutdown and resource destruction while cloneable handles submit bounded operations. This prevents worker ownership from escaping into jobs.
Package — One bitness-specific deployment directory containing the XLL, optional bundled sidecar files, and build-manifest.json.
PE (Portable Executable) — The Windows executable format used by XLLs and DLLs. cargo xlfn inspects PE architecture, exports, imports, and delay imports.
Quiescence — The state in which no operation can still execute framework-managed code or callbacks belonging to a subsystem. xlAutoRemove establishes logical quiescence; the module residency lease remains held for a safe Addin and is released only after the explicit physical-unload contract is selected and satisfied.
RTD (Real-Time Data) — Excel’s streaming update mechanism. A source creates a subscription, publishes repeated scalar values through a sink, and synchronously disconnects during shutdown.
RTD topic — An ordered sequence of stable string parts identifying one stream within a source.
Rustdoc — Generated API documentation from Rust items and doc comments. The user guide explains workflows and design; rustdoc provides exhaustive signatures and per-item details.
Stale handle — A structurally valid handle token whose add-in generation or object generation is no longer live.
System import policy — The versioned list/rules that classify standard Windows DLLs as system-provided during package import validation.
Thread-affine state — A resource that must be created, called, and destroyed on one OS thread. Preserving this requirement is the responsibility of the application adapter; xlfn does not provide an external-engine owner/worker abstraction.
Thread-safe context — A capability for an MTR-safe UDF. It exposes shared add-in state but not main-thread-only Excel operations.
UDF (User-Defined Function) — A worksheet function implemented by the XLL and registered with Excel.
UDF layer — Bounded middleware around every UDF invocation for admission control and instrumentation. Layers see call metadata before conversion and a classified outcome afterward.
Volatile function — A function Excel recalculates whenever a relevant recalculation occurs, even when explicit arguments appear unchanged. Volatility should be used sparingly.
Worker health — Application-defined state for an adapter worker or pool, used to distinguish running, closing, failed, and stopped execution resources. xlfn does not define this state.
XLL — An Excel native add-in: a Windows DLL with Excel-defined lifecycle, registration, callback, and memory-management exports.
XLOPER12 — The Excel 12 C API value structure used to exchange numbers, strings, errors, references, arrays, async handles, and other worksheet values.