Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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, and Addin::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 tracing events;
  • 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-msvc and/or x86_64-pc-windows-msvc Rust 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-xlfn from an audited Git revision or local checkout and replace generated version = "0.2" dependencies with the same Git revision or a local path. 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 processRust targetPackage directory
32-bit Exceli686-pc-windows-msvcpackage/win-x86/
64-bit Excelx86_64-pc-windows-msvcpackage/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"] }
FeatureAdds
asyncnative asynchronous UDF runtime, AsyncContext, cancellation tokens, and calculation-event exports
handlesformula-owned typed objects, aliases, and scoped handle inputs
rtdstreaming sources, subscriptions, and RTD configuration
unstable-cachelower-level calculation-cache API with an explicitly unstable contract
unstable-outputlower-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 .xllexp manifest 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:

  1. builds and links the selected cdylib;
  2. creates an isolated staging package;
  3. reads the generated .xllexp manifest;
  4. compares required lifecycle, COM, calculation-event, and UDF exports with the PE export table;
  5. verifies the PE machine type against the requested target;
  6. stages configured bundle files;
  7. 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:

  1. Excel process bitness versus XLL bitness;
  2. whether the complete package directory was copied;
  3. Windows file blocking and code-signing policy;
  4. missing or wrong-architecture binary dependencies;
  5. diagnostics emitted during xlAutoOpen;
  6. 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:

  1. reject new application submissions;
  2. signal cancellation or shutdown;
  3. resolve or reject queued requests according to the application’s contract;
  4. release thread-affine resources on the thread that owns them;
  5. join every application-owned worker or coordinator;
  6. release remaining application roots before quiesce returns.

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 quiesce returns;
  • 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:

  • coerce for an owned ExcelValue;
  • 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

ModeHow selectedExcel MTRCan use raw referencesCan return a new handle object
Main threaddefault or main_thread contextnonoyes
Thread-safethread_safe or thread_safe contextyesnono
Macro-sheetmacro_sheet or macro_sheet contextnoyesno
Asynchronousasync fnnative async ABInono

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 flagMeaning
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_saferegisters the function for multi-threaded recalculation
macro_sheetregisters macro-sheet capability
volatileasks Excel to recalculate the function as volatile
hiddenhides 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;
}
  • name and category contain 1 to 255 UTF-16 code units;
  • id is 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 typeAccepted Excel valueNotes
f64number or integermust be finite
boolBooleannumbers and text are not coerced
i32integer, or an integral number in rangefractional values are rejected
i64integer, or an exactly representable integral numbernumeric input is limited to the exact Excel-double integer range, -2^53..=2^53
Stringstringdecoded as valid UTF-16
&strstringdecoded into call-local scratch; synchronous functions only
ExcelErrorValueExcel errorpreserves the worksheet error
ExcelCellRef<'call>number, Boolean, string, error, or blankborrowed cell view for the active call
ExcelSerialDatefinite numberinitially marked with ExcelDateSystem::Workbook
ExcelValuesupported scalar, error, blank, missing, or arrayuse 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:

  • f64 must be finite;
  • i64 must be exactly representable by an Excel number;
  • strings must fit Excel’s counted UTF-16 representation;
  • ExcelValue::Missing and blank ExcelCellValue are input states, not valid worksheet results; use ExcelErrorValue(ExcelError::NotAvailable) for an explicit #N/A result;
  • () 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.

Limit32-bit target64-bit target
Excel rows1,048,5761,048,576
Excel columns16,38416,384
framework elements1,000,0004,000,000
referenced XLOPER12 bytes64 MiB256 MiB
returned allocation bytes64 MiB256 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 Rust default expression 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:

  1. retain the old variant text for at least one release;
  2. map it to the new internal semantics;
  3. emit a diagnostic or documentation deprecation notice;
  4. 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 InputError or 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:

  • Input with an argument name and InputError;
  • ExcelValue, preserving an input Excel error;
  • Domain with a stable DomainErrorCode;
  • invalid or stale handles;
  • lifecycle states such as closing or overloaded;
  • external-adapter or Excel-callback failures represented by the relevant application mappings;
  • Internal with 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 familyTypical Excel result
domain errors and numeric overflow#NUM!
preserved ExcelErrorValuethe 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/A for 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:

  • None for a same-sheet SRef, where Excel does not carry an explicit sheet ID;
  • Some(SheetId) for a sheet-qualified Ref.

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:

  1. xlfn stops and drains framework-managed work;
  2. Addin::quiesce establishes application-level quiescence;
  3. xlfn closes the formula-handle registry and drops remaining Rust handle objects;
  4. Addin::cleanup performs 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 + 'static values;
  • 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

NeedFacility
reuse an internal pure or versioned computationcache
let one worksheet formula own a typed objecthandle
update a formula repeatedly from a push sourceRTD

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 enter returns an error, already-entered guards exit in reverse order with that classified error.
  • A panic in enter is converted to a panic error.
  • Panics in exit are 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:

  1. close every Excel process using the add-in;
  2. verify that no background Excel process remains;
  3. install the complete new target directory transactionally with best-effort rollback, or publish it under a versioned path;
  4. preserve the previous signed directory for rollback;
  5. load the new version and run smoke tests;
  6. 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 --locked from 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 xlfn crate 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 IDSHEET behavior.

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, and ServerTerminate;
  • 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:

  1. Owner: Identifies the single unique owner holding primary ownership of the allocation.
  2. Admission Mechanism: Defines the short-lived admission gate (e.g. StripedDrainGate domain permit) entered before observing the raw pointer, preventing use-after-reclaim during concurrent retirement.
  3. 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.
  4. 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).
  5. 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-imports narrowly 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:

  1. use a locked dependency graph and review dependency changes;
  2. build from a controlled clean environment;
  3. verify downloaded SDKs, sidecars, and adapter dependencies by digest and publisher;
  4. retain source commit, toolchain, target, and feature evidence;
  5. run static analysis, tests, ABI probes, and package inspection;
  6. sign the final bytes and verify the signatures;
  7. scan and install the final package in a clean environment;
  8. 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:

  1. XLL architecture matches the Excel process, not merely Windows;
  2. the file is the staged .xll, not the original Cargo .dll;
  3. every file from the target distribution directory is present;
  4. the package was not copied from an untrusted source and blocked by Windows policy;
  5. required signatures are valid;
  6. endpoint protection did not quarantine a dependency;
  7. the package passed cargo xlfn check on 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 cdylib and attributed with #[excel_function].
  • Check whether it is hidden.
  • Look for a registration-name conflict with another XLL.
  • Keep the UDF id unique within the crate.
  • Run cargo xlfn check; it compares .xllexp entries 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;
  • i64 outside Excel’s exact -2^53..=2^53 range;
  • 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::Missing or blank ExcelCellValue being treated as a worksheet return. Use ExcelErrorValue(ExcelError::NotAvailable) for an explicit #N/A result.

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 async feature.
  • 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 RuntimeConfig async 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::subscribe must 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_cancel does not block and disconnect_and_wait reaches 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:

  1. the declared DLL basename exactly matches the packaged file;
  2. x86 and x64 metadata point to the correct files;
  3. the DLL and all non-system dependencies are in the package;
  4. required symbols match spelling and decoration;
  5. any application-defined protocol or ABI negotiation returns the expected version;
  6. antivirus or policy did not block the DLL;
  7. 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_wait waits 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-name must 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 --all requires 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;
}
OptionMeaningDefault
name = "..."Display name shown by Excel’s Add-in ManagerRust struct name
id = "..."Stable add-in identity used by runtime ownership and registrationlowercase Rust struct name
category = "..."Default Function Wizard categorydisplay name
physical_unloadOpt into physical DLL unload after an unsafe quiescence contractdisabled

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
}
}
OptionMeaningDefault
name = "..."Excel-visible function nameRust function identifier
id = "..."Stable UDF identity and generated export identityRust function identifier
category = "..."Function Wizard categoryadd-in default when omitted or empty
description = "..."Function Wizard descriptionjoined Rust doc comments
help_topic = "..."Help URL or topic supplied to Excelempty
thread_safeRegister for Excel multi-threaded recalculationdisabled
macro_sheetRegister with macro-sheet capabilitydisabled
volatileRecalculate whenever Excel recalculatesdisabled
hiddenHide the function from the Function Wizardvisible

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_safe is incompatible with main-thread and macro-sheet contexts.
  • macro_sheet is incompatible with thread_safe and 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)
}
}
RoleRust contextCapability
main_threadMainThreadContext<'_, AppTools>main-thread Excel callbacks, handles, RTD
thread_safeThreadSafeContext<'_, AppTools>shared state during MTR; no unsafe Excel callbacks
macro_sheetMacroSheetContext<'_, AppTools>Excel references and macro-sheet registration
asynchronousAsyncContext<'_, 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 {
    // ...
}
}
OptionMeaning
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
referencereceive an unevaluated Excel reference

Rules:

  • default requires at least one blank = "default" or missing = "default" policy.
  • a "default" policy requires default = ....
  • reference cannot be combined with blank, missing, or default policies.
  • reference requires macro_sheet or MacroSheetContext.
  • 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.

See Formula-owned handles.

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 typeAccepted Excel representationImportant behavior
f64number or integerrejects non-finite values
boolBooleanno numeric or text coercion
i32integer or integral numberrejects fractions and overflow
i64integer or exactly representable integral numbernumeric path is limited to the exact binary64 integer range
Stringstringvalidates UTF-16
&strstringdecodes UTF-16 into call scratch; synchronous UDFs only
ExcelErrorValueExcel errorpreserves the exact error
ExcelCellRef<'call>number, Boolean, string, error, or blankzero-allocation cell view; synchronous UDFs only
ExcelSerialDatefinite numberstarts with ExcelDateSystem::Workbook
Handle<'_, T>string handle tokenauthenticates, checks generation, and checks object type; valid only for the active call
HandleLease<'_, T>string handle tokenasync-only; pins the typed object before task commit and carries a generation-scoped lifetime
Option<T>value, blank, or missingblank and missing become None
OptionalExcelValue<T>value, blank, or missingpreserves all three states
XlArrayRef<'call>rectangular multi-valuezero-allocation borrowed cells; synchronous UDFs only
Matrix<T>scalar or rectangular multi-valuescalar becomes 1 x 1; validates shape and limits
MatrixRef<'call, T>scalar or rectangular multi-valuecall-scoped, call-scratch-materialized Copy element view; synchronous UDFs only
Row<T>scalar or 1 x Nrejects a true 2-D shape
Column<T>scalar or N x 1rejects a true 2-D shape
Vec<T>scalar, row, or columninput only; rejects a true 2-D shape
BoundedVarArgs<T, MAX>scalar, row, or columninput only; requires MAX > 0 and enforces the bound
ExcelValuesupported scalar, error, blank, missing, or arrayintentionally dynamic, owned input representation; array cells are ExcelCellValue
a type deriving ExcelEnumstringexact or optional ASCII case-insensitive match
a custom T: FromExcel<'call>defined by the implementationmay 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 representable i64;
  • String and &str;
  • ExcelErrorValue;
  • ExcelSerialDate;
  • ExcelCellOutput and custom IntoExcel implementations;
  • 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 familyMain threadThread-safeMacro-sheetAsyncVolatile
built-in scalaryesyesyesyesyes
ExcelEnumyesyesyesyesyes
Matrix<T>, Row<T>, Column<T>, XlArrayOutputyesyesyesyesyes
RtdValueyesyesyesyesyes
HandleAlias<'_, T>yesnononoyes
object deriving ExcelHandleObjectyesnononoyes
custom Taccording to implemented marker traitsaccording to implemented marker traitsaccording to implemented marker traitsaccording to implemented marker traitsaccording 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 T when 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.

Limitx86x64
worksheet rows1,048,5761,048,576
worksheet columns16,38416,384
framework array elements1,000,0004,000,000
referenced Excel allocation bytes64 MiB256 MiB
returned allocation bytes64 MiB256 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:

  1. inspect only the active XlValueRef<'_>;
  2. copy owned data before returning;
  3. use the supplied static argument name in XllError::Input;
  4. reject unsupported coercions and non-finite values explicitly;
  5. bound all allocation from workbook-controlled lengths.
  6. keep owned conversion independent of framework runtime state; do not retain temporary Excel pointers or call-scoped views in an owned result.

For IntoExcel:

  1. validate the application value before allocation;
  2. do not call Excel from thread-safe or async conversion paths;
  3. preserve ownership until Excel calls xlAutoFree12 through 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:

  1. runs cargo build for the selected package and build configuration;
  2. requires exactly one cdylib target;
  3. stages configured bundle files in a temporary package;
  4. copies the linked DLL as <artifact-name>.xll;
  5. verifies required XLL exports and the generated .xllexp manifest;
  6. verifies the embedded effective Rust CRT policy and direct dynamic CRT imports;
  7. verifies PE architecture;
  8. 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-msvc writes an x86 package.
  • --target x86_64-pc-windows-msvc writes an x64 package.
  • --all stages both architectures and transactionally replaces the output root with best-effort rollback.
  • --out <PATH> selects the output root; the default is package.

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

OptionMeaning
--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

OptionForwarded 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-featuresdisable default features
--all-featuresenable all package features
--lockedrequire the existing lock file
--frozenrequire lock file and no network access
--offlinedisable 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:

  • inherit leaves RUSTFLAGS, CARGO_ENCODED_RUSTFLAGS, Cargo configuration, and wrappers untouched;
  • static enforces +crt-static for Rust invocations targeting the selected MSVC triple;
  • dynamic enforces -crt-static for 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, COM1 through COM9, or LPT1 through LPT9.

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
KeyTypeMeaning
x86array of stringsfiles packaged for i686-pc-windows-msvc
x64array of stringsfiles packaged for x86_64-pc-windows-msvc
external-importsarray of stringsapproved non-system DLL basenames supplied outside the package
strict-pathsBooleanreject 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>.xll or build-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:

FieldMeaning
schemamanifest schema number
packageCargo package name
package_versionCargo package version
artifactconfigured artifact basename
targetRust target triple
profileCargo profile
feature_selectionrequested and resolved package feature set
cargo_constraintslock/network constraints and lockfile hash
crtrequested/source/effective CRT policy, enforcement, observed dynamic CRT imports, and consistency
bundle_sourcesconfigured relative paths and their staged relative basenames
bundle_policyeffective strict-paths setting, versioned system-DLL policy, and approved external imports
integrityexplicit trust-boundary statement
filesrelative 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:

  1. compare the two architecture lists rather than assuming they are symmetric;
  2. inspect every transitive import reported by cargo xlfn check;
  3. keep external-imports empty unless deployment owns the exception;
  4. review the recorded bundle policy and staged relative paths;
  5. verify output basenames and signatures after staging;
  6. 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:

ItemValue
supported xlfn facade version0.2.0
Rust edition2024
minimum/pinned Rust toolchain1.98.1
licenseMIT OR Apache-2.0
Excel C API generationExcel 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 processRust targetPackage directory
32-bit Exceli686-pc-windows-msvcwin-x86
64-bit Excelx86_64-pc-windows-msvcwin-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.

FeatureAddsUse when
asyncnative async UDF executor, async context, calculation cancellation exportsa formula produces one eventual result without blocking Excel
handlesformula-owned typed objects, aliases, and scoped handle inputsa worksheet formula owns a Rust object
rtdtyped streaming sources, subscriptions, and RTD configurationa formula receives repeated updates from a push source
unstable-cachelower-level calculation-cache APIthe add-in explicitly accepts experimental cache API evolution
unstable-outputlower-level array-output APIthe 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 xlfn facade, its prelude, macro inputs and generated behavior, and the async, handles, and rtd feature 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.1 is 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:

EnvironmentArtifact checkLoad/opensync UDFMTRhandlesasyncRTDexternal adapterunload/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.