Runtime reuse and Daemon
Start with the Runtime lifecycle of a single CLI command, then learn how a daemon owns a Worker Runtime pool and reuses loaded Units across calls.
When a CLI command runs, the expensive part is usually not parsing arguments. It is turning a Unit into a live Univer that can be read and written: creating Univer, installing plugins, opening the collaboration backend, loading the checkpoint, applying changesets, and waiting for formulas to finish calculating.
This chapter focuses on two Runtime capabilities:
@univer-cli/univer-collaboration-runtime: loads and operates on a Unit in the current process;@univer-cli/univer-collaboration-runtime-pool: places Runtimes in Workers and leases, reuses, and retires them by Unit identity.
For a Worker Runtime to survive across multiple CLI invocations, it needs a long-lived owner. A local daemon fills that role here.
Start with three lifecycles
Before thinking about reuse, distinguish the lifetimes of these three objects:
| Object | Created | Ends | Owns |
|---|---|---|---|
| CLI process | Every time the user runs a command | After the command prints its result | Arguments, one request, and output formatting |
| Daemon process | On the first request, or when started directly | When stopped, restarted for an upgrade, or crashed | Runtime pool, business handlers, and local socket |
| Worker Runtime | When the pool first encounters a key | On TTL/LRU eviction, invalidation, daemon shutdown, or Worker crash | One Worker, one backend handle, one loaded Unit, and collaboration state |
The key mental model is: the CLI makes one call, the daemon owns the pool, the pool owns Workers, and each Worker owns a Runtime.
The daemon only forwards application-defined JSON requests. Snapshots, changesets, and blocks are read directly by the backend inside the Worker; they do not pass through the CLI or daemon.
Step 1: Load a Runtime directly for every CLI command
The simplest implementation creates @univer-cli/univer-collaboration-runtime inside a Commander action and closes it
after the operation finishes:
const runtime = await runtimeFactory.load(unitId, unitType);
try {
const pulled = await runtime.pull();
if (pulled.status === "conflict") throw new Error(pulled.conflict.message);
const execution = await runtime.execute({ mode: "write", code });
let commit = await runtime.commit();
if (commit.status === "pull-required") {
const repulled = await runtime.pull();
if (repulled.status === "conflict") throw new Error(repulled.conflict.message);
commit = await runtime.commit();
}
return { execution, commit };
} finally {
await runtime.close();
}Here, runtimeFactory is created by createUniverCollaborationRuntimeFactory(). From loading until closing, a Runtime
is always bound to exactly one Unit and retains that Unit's current revision, pending mutations, awaiting changesets,
and conflict state. The caller must also inspect the final commit status: only confirmed and nothing-to-commit mean
that the operation has finished.
A load() call does more than "read a file." It performs these steps in order:
- Open the collaboration backend supplied by the application;
- Create a headless Univer and install the collaboration capabilities;
- Load UnitData or a Snapshot checkpoint;
- Replay the changesets carried by the checkpoint;
- Fetch and replay the gap between the checkpoint and the latest remote revision;
- Wait until the latest results from the installed formula modules have been applied;
- Return a Runtime that can run
execute(),pull(), andcommit().
A complete one-off invocation looks like this:
This approach has direct ownership and leaves no state behind after a failure, making it suitable for low-frequency commands and one-off tasks. The cost is that the next CLI command starts a new Node.js process and repeats the entire loading sequence. It cannot reuse the Univer and Unit state built by the previous command.
Step 2: Host the Runtime in a Worker pool
@univer-cli/univer-collaboration-runtime-pool maintains a separate Worker process and Collaboration Runtime for every
resident key. The application first supplies a Worker entry; the backend, credentials, and Univer are all created
inside the Worker:
import { defineUniverCollaborationRuntimeWorker } from "@univer-cli/univer-collaboration-runtime-pool";
export default defineUniverCollaborationRuntimeWorker({
async createRuntime(init: { unitId: string; unitType: number }) {
return createRuntimeFromApplicationInit(init);
},
});The Worker entry must point to a built JavaScript ESM file. init is sent to the Worker only during a cold creation and
must be compatible with structured clone.
The caller does not need to design a separate Runtime RPC layer. The lease already proxies public APIs including
getState(), execute(), fetch(), pull(), commit(), and exportUnitData():
import { createUniverCollaborationRuntimePool } from "@univer-cli/univer-collaboration-runtime-pool";
const pool = createUniverCollaborationRuntimePool({
entry: new URL("./collaboration-worker.js", import.meta.url),
});
const lease = await pool.acquire({
key: targetKey,
init: { unitId, unitType },
});
try {
await lease.pull();
const result = await lease.execute({ mode: "read", code });
console.log(result.value);
} catch (error) {
await lease.invalidate();
throw error;
} finally {
await lease.release();
}The key identifies the Runtime to reuse; it is not merely a query argument. The same key must always represent the
same Unit, the same collaboration state chain, and compatible initialization semantics. A plain Unit and a Worktree
draft should use different keys even if they have the same unitId.
The pool has four important behaviors:
- The same key is granted to only one lease at a time. Concurrent requests wait in FIFO order so that two calls cannot modify the same live state simultaneously;
release()returns a trusted Runtime to the idle cache so the next request for the same key can reuse it;invalidate()indicates that the state can no longer be trusted. The Worker and Runtime are destroyed, and the next request performs a cold load;- Idle Runtimes can be evicted by TTL and LRU. Timeouts, Worker crashes, and protocol errors also invalidate the current instance.
However, if the pool itself is created inside a short-lived CLI process, reuse still cannot cross command boundaries:
Workers provide Runtime process isolation, exclusive leasing, and failure termination. To reuse them across CLI invocations, the pool still needs an owner that lives longer than any single command.
Step 3: Make the Daemon the long-lived pool owner
The daemon is an on-demand local Node.js process. The CLI calls it through a Unix socket or Windows named pipe. Exiting the CLI does not terminate the daemon, so the daemon's pool, Workers, and idle Runtimes can remain alive.
Create the pool once in the daemon entry, then acquire a lease inside each business handler:
import { createDaemonServer, DAEMON_SOCKET_ENV } from "@univer-cli/daemon";
import { createUniverCollaborationRuntimePool } from "@univer-cli/univer-collaboration-runtime-pool";
const socketPath = process.env[DAEMON_SOCKET_ENV];
if (!socketPath) throw new Error(`${DAEMON_SOCKET_ENV} is required`);
const pool = createUniverCollaborationRuntimePool({
entry: new URL("./collaboration-worker.js", import.meta.url),
cache: { idleTtlMs: 5 * 60_000, maxEntries: 20 },
});
const server = createDaemonServer({
identity: { id: "my-cli", version: "1.0.0" },
socketPath,
onShutdown: () => pool.close(),
});
server.handle("unit.execute", async (payload) => {
const input = parseExecuteRequest(payload);
const lease = await pool.acquire({
key: input.targetKey,
init: { unitId: input.unitId, unitType: input.unitType },
});
try {
const pulled = await lease.pull();
if (pulled.status === "conflict") throw new Error(pulled.conflict.message);
const execution =
input.mode === "read"
? await lease.execute({ mode: "read", code: input.code })
: await lease.execute({ mode: "write", code: input.code });
const commit = input.mode === "write" ? await commitWithPull(lease) : null;
return { commitStatus: commit?.status ?? null, value: execution.value };
} catch (error) {
await lease.invalidate();
throw error;
} finally {
await lease.release();
}
});
await server.listen();The application defines parseExecuteRequest() and the targetKey rules. The daemon only provides local transport and
lifecycle management; it does not understand Units, Runtimes, or the collaboration protocol. A daemon shutdown must
call pool.close() so that idle and active Workers have a clear final owner. In this example, commitWithPull() handles
the pull/commit sequence after pull-required and checks the final commit status. To keep the example conservative,
any unhandled error invalidates the current lease.
An ordinary CLI command now only sends one business request to the daemon:
import { createDaemonClient } from "@univer-cli/daemon";
const client = createDaemonClient({
entry: new URL("./daemon-entry.js", import.meta.url),
identity: { id: "my-cli", version: "1.0.0" },
socketPath,
});
const result = await client.request("unit.execute", {
targetKey,
unitId,
unitType,
mode,
code,
});If the socket does not exist, the client starts the configured daemon entry, waits for the identity and protocol handshake, and then sends the request. If a compatible daemon is already running, the client reuses it directly.
Step 4: Compare the first and subsequent requests
The first access to a key follows the cold path: the daemon may need to start, and the pool must create a Worker and Runtime.
Subsequent access to the same key follows the hot path. The CLI process still starts again, but the expensive Runtime load is not repeated:
The reused object is an already created and loaded live Runtime. Reuse does not bypass collaboration consistency. Code
should normally still call pull() after acquiring a lease, inspect the final status after commit(), and handle
pull-required, retry, unknown, or conflict.
Which costs disappear and which remain
| Cost | Load Runtime directly every time | Daemon + Worker pool |
|---|---|---|
| CLI startup, argument parsing, and result output | Every time | Every time |
| Local socket JSON request/response | None | Every time |
| Daemon startup and module loading | None | First request or after a restart |
| Worker startup and Worker entry loading | No Worker | On cold creation for each key |
| Creating Univer and installing plugins | Every time | On cold creation for each key |
| Loading checkpoint, replaying initial changesets, waiting for formulas | Every time | On cold creation for each key |
pull() for changesets created while idle | Every time | Usually still required every time |
execute() and commit() | Every time | Every time |
| Idle memory, backend handle, and Worker process | Released when the command exits | Retained until eviction, invalidation, or daemon shutdown |
Reuse trades resident memory and Worker count for lower startup latency on subsequent calls. Choose idleTtlMs,
maxEntries, the open timeout, and operation timeouts based on pool events and measured latency instead of retaining
every Unit indefinitely.
Concurrency, failures, and shutdown
Four rules summarize the system's behavior:
- Concurrent requests for the same key wait. A live Runtime belongs to only one lease at a time, preventing collaboration state from being modified by interleaved calls.
- Different keys can run in parallel in different Workers. The cost is a separate Worker and Runtime allocation for every resident key.
- Only trusted state can be reused. Call
release()after an operation succeeds and local mutations have been cleared. Callinvalidate()after a timeout, crash, protocol error, or whenever the application cannot confirm the local state. - Shutdown responsibility flows downward. The CLI completes one request; daemon shutdown calls
pool.close(); the pool then terminates Workers and closes their Runtimes and backends.
For low-frequency commands where one-off latency is acceptable, using
@univer-cli/univer-collaboration-runtime directly is simplest. Combine the daemon with
@univer-cli/univer-collaboration-runtime-pool only when measurements show that repeated loading is the dominant cost
and the application can accept a resident process and its memory usage.
See Package catalog for the complete package list.