@webex/common-timers 3.12.0-next.1 → 3.12.0-next.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.repo-context.json +534 -0
- package/.sdd/manifest.json +268 -0
- package/AGENTS.md +240 -0
- package/docs/adr/index.md +34 -0
- package/docs/architecture.md +243 -0
- package/docs/getting-started.md +121 -0
- package/docs/index.md +50 -0
- package/docs/specs/README.md +86 -0
- package/package.json +5 -5
- package/schemas/repo-context.schema.json +796 -0
- package/src/docs/README.md +460 -0
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: Module Spec
|
|
3
|
+
title: 'common-timers package surface specification'
|
|
4
|
+
description: Responsibilities, boundaries, design, invariants, and verification for the @webex/common-timers package surface.
|
|
5
|
+
tags: [module, specification]
|
|
6
|
+
---
|
|
7
|
+
<!-- sdd-generated-metadata
|
|
8
|
+
doc_kind: module-spec
|
|
9
|
+
generated_from: module-spec@0.3.0
|
|
10
|
+
generated_by: claude-code
|
|
11
|
+
approved_by: pending
|
|
12
|
+
updated_at: 2026-09-28T05:34:36Z
|
|
13
|
+
validation_status: pass
|
|
14
|
+
-->
|
|
15
|
+
|
|
16
|
+
# common-timers package surface
|
|
17
|
+
|
|
18
|
+
This source-local document at `src/docs/README.md` owns the stable specification for the
|
|
19
|
+
**`@webex/common-timers` package surface**: the two scheduling wrappers that stop a pending timer
|
|
20
|
+
from holding a Node process open, and the `Timer` class that adds a guarded, restartable one-shot
|
|
21
|
+
lifecycle on top of them.
|
|
22
|
+
|
|
23
|
+
The whole package is one file. There is no sub-module, no network call, and no persisted state.
|
|
24
|
+
|
|
25
|
+
Related context: [repository architecture](../../docs/architecture.md) ·
|
|
26
|
+
[documentation index](../../docs/index.md) · [agent instructions](../../AGENTS.md) ·
|
|
27
|
+
[specification registry](../../docs/specs/README.md)
|
|
28
|
+
|
|
29
|
+
## Metadata
|
|
30
|
+
|
|
31
|
+
| Field | Value |
|
|
32
|
+
| ----------------- | --------------------------------------------------------------------------------------- |
|
|
33
|
+
| Owner | Cisco Webex for Developers |
|
|
34
|
+
| Source path | `src` |
|
|
35
|
+
| Resource kind | Package surface module |
|
|
36
|
+
| Status | Active |
|
|
37
|
+
| Last verified | 2026-09-28 at `bc61c78ba5` |
|
|
38
|
+
| Module id | `src` |
|
|
39
|
+
| Parent spec | — |
|
|
40
|
+
| Doc kind | Module spec |
|
|
41
|
+
| Coverage score | Field coverage 100% assessed 2026-09-24; 14 of 14 mandatory fields present, critical fields 9 of 9. Test coverage is **not** closed: the `unref` contract carries no automated assertion and this module has no characterization baseline |
|
|
42
|
+
| Validation status | stale; last assessed 2026-09-28 by validator `codex` against a test suite that has since been withdrawn from this change. Revalidation is required before promotion. The constructor timeout behaviour remains accepted-and-deferred as specified under [Pitfalls and constraints](#pitfalls-and-constraints) |
|
|
43
|
+
|
|
44
|
+
## Applicability
|
|
45
|
+
|
|
46
|
+
| Condition ID | Status | Evidence or reason | Owned section |
|
|
47
|
+
| ------------------------------------ | ---------- | ------------------------------------------------------------------------------------------------------------- | ----------------------------- |
|
|
48
|
+
| `module.has_tiers` | N/A | The package declares no tier, SLO, or tiered review rule in `package.json` | Tier |
|
|
49
|
+
| `module.has_ui` | N/A | No components, templates, or styles; the module renders nothing | UI use-case flow |
|
|
50
|
+
| `module.crosses_service_boundaries` | N/A | No HTTP, socket, RPC, or queue client in `src/index.ts`; every call stays inside the process | Cross-boundary use-case flow |
|
|
51
|
+
| `module.holds_client_state` | N/A | No store and no shared state model; the only state is one private field per Timer, owned by State machine | Client state model |
|
|
52
|
+
| `module.enforces_domain_rules` | N/A | No domain entities; callbacks and durations are opaque values the module never inspects | Business rules and invariants |
|
|
53
|
+
| `module.is_concurrent_async` | Applicable | Every export schedules a callback onto the host event loop; `test/unit/spec/index.ts` must fake time to observe it | Concurrency and reactive flow |
|
|
54
|
+
| `module.owns_persistence` | N/A | Nothing is written to any store, file, or schema | Data, schema, and migration |
|
|
55
|
+
| `module.stateful_transitions` | Applicable | `src/index.ts` implements the init to running to done lifecycle with a terminal state and rejected transitions | State machine |
|
|
56
|
+
| `module.exposes_wire_protocol` | N/A | No serialization, frame, or binary format | Protocol and wire format |
|
|
57
|
+
| `module.ui_multi_screen` | N/A | Gated by module.has_ui, which is N/A | UI flow |
|
|
58
|
+
| `module.large_data_model` | N/A | Gated by module.owns_persistence, which is N/A | Data model |
|
|
59
|
+
| `module.returns_caller_errors` | Applicable | The start, reset, and cancel methods each throw on an invalid transition in `src/index.ts` | Caller-visible failure modes |
|
|
60
|
+
| `module.module_specific_conventions` | N/A | `.eslintrc.js` only extends the shared workspace config; no rule is local to this module | Module-specific rules |
|
|
61
|
+
| `module.published_package` | Applicable | `package.json` declares the main, devMain, and deploy:npm fields | Export stability |
|
|
62
|
+
| `module.embedded_in_host` | N/A | Consumed as a dependency; no mount contract, plugin manifest, or widget registration | Host integration and theming |
|
|
63
|
+
| `module.has_design_tradeoff` | Applicable | Unconditional unref-ing, in `src/index.ts`, is the package's reason to exist and it changes process-exit semantics for every caller | Key design trade-off |
|
|
64
|
+
| `module.has_submodules` | N/A | No other module path is a direct child of the src module | Sub-modules |
|
|
65
|
+
|
|
66
|
+
## Evidence register
|
|
67
|
+
|
|
68
|
+
| Evidence | What it establishes |
|
|
69
|
+
| --------------------------- | -------------------------------------------------------------------------------------------------------------- |
|
|
70
|
+
| `src/index.ts` | The complete export surface, the `unref` capability check, the `Timer` lifecycle, and every guard clause |
|
|
71
|
+
| `package.json` | Published entry points, the absence of runtime dependencies, the Node floor, and every command |
|
|
72
|
+
| `README.md` | The stated purpose of the wrappers and the documented consumer-facing usage shape |
|
|
73
|
+
| `process` | The browser shim consumed by the browserify transform, showing the package is built for both platforms |
|
|
74
|
+
| `test/unit/spec/index.ts` | Callback delivery, interval repetition, and all nine `Timer` lifecycle branches including every rejected transition |
|
|
75
|
+
| `jest.config.js` | The resolved unit-test configuration, including that coverage collection is disabled for this package |
|
|
76
|
+
| `.eslintrc.js` | That lint behavior is inherited wholesale from the shared workspace config |
|
|
77
|
+
|
|
78
|
+
## Purpose and boundary
|
|
79
|
+
|
|
80
|
+
- **Responsibility:** own the package's entire timer vocabulary — how a scheduled callback is
|
|
81
|
+
created, what handle the caller gets back, whether that handle keeps the host process alive, and
|
|
82
|
+
what lifecycle rules a managed timer enforces.
|
|
83
|
+
- **In scope:** the exported surface of `src/index.ts`; the `unref` capability check applied to both
|
|
84
|
+
wrappers; the `Timer` lifecycle, its guard clauses, and the error messages they throw; the return
|
|
85
|
+
type union that every consumer sees.
|
|
86
|
+
- **Out of scope:** cancelling a timer. The module exports no `safeClearTimeout` or
|
|
87
|
+
`safeClearInterval`; callers pass the returned handle to the platform's own `clearTimeout` or
|
|
88
|
+
`clearInterval`. Also out of scope: retry policy, backoff schedules, deadline propagation, and
|
|
89
|
+
anything that decides *when* a timeout should fire — those belong to the consuming packages.
|
|
90
|
+
- **Consumers:** eleven workspace packages, and the declared set and the importing set are not the
|
|
91
|
+
same. Ten declare the dependency in their own `package.json`: `@webex/internal-plugin-mercury`,
|
|
92
|
+
`@webex/internal-plugin-metrics`, `@webex/internal-plugin-device`,
|
|
93
|
+
`@webex/internal-plugin-encryption`, `@webex/internal-plugin-avatar`,
|
|
94
|
+
`@webex/internal-plugin-dss`, `@webex/internal-plugin-ai-assistant`,
|
|
95
|
+
`@webex/internal-plugin-scheduler`, `@webex/webex-core`, and `@webex/calling` (which lives at
|
|
96
|
+
`packages/calling`, not under `packages/@webex`). Two of those pairings are worth knowing before
|
|
97
|
+
changing anything here:
|
|
98
|
+
- `@webex/internal-plugin-scheduler` declares the dependency but has no production import of it.
|
|
99
|
+
- `@webex/plugin-meetings` is the reverse: `src/meeting/index.ts` imports both wrappers, but its
|
|
100
|
+
`package.json` does not declare `@webex/common-timers`. It is a real consumer resolving through
|
|
101
|
+
workspace hoisting, so a breaking change reaches it even though no manifest records the edge.
|
|
102
|
+
|
|
103
|
+
The `Timer` class has two consumers — `@webex/internal-plugin-dss` and
|
|
104
|
+
`@webex/internal-plugin-ai-assistant` — both using it to bound a request that is answered by a
|
|
105
|
+
stream of events.
|
|
106
|
+
|
|
107
|
+
## Structure and key files
|
|
108
|
+
|
|
109
|
+
| Path | Responsibility |
|
|
110
|
+
| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
|
|
111
|
+
| `src/index.ts` | The entire module. Declares `safeSetTimeout`, `safeSetInterval`, and `Timer`; there is no other source file |
|
|
112
|
+
| `package.json` | Declares `main` (`dist/index.js`), `devMain` (`src/index.ts`), the Node `>=18` floor, and the build/test/lint/publish commands |
|
|
113
|
+
| `process` | A one-line browser shim (`{browser: true}`) resolved by the browserify transform when the package is bundled for a browser |
|
|
114
|
+
| `test/unit/spec/index.ts` | The unit suite; mirrors `src/` one-to-one, as every package in this workspace does |
|
|
115
|
+
|
|
116
|
+
## Public surface
|
|
117
|
+
|
|
118
|
+
| Surface | Consumer | Compatibility commitment | Source |
|
|
119
|
+
| ------------------ | ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | --------------- |
|
|
120
|
+
| `safeSetTimeout` | avatar, device, encryption, mercury, metrics, webex-core, plugin-meetings, calling | Accepts exactly the parameters of the platform `setTimeout` and returns its handle. The handle is unref-ed where the platform supports it | `src/index.ts` |
|
|
121
|
+
| `safeSetInterval` | metrics, plugin-meetings | Accepts exactly the parameters of the platform `setInterval` and returns its handle, unref-ed where supported | `src/index.ts` |
|
|
122
|
+
| `Timer` | dss, ai-assistant | Constructor takes `(callback: () => void, timeout: number)`. `start`, `reset`, and `cancel` are the only methods, and each throws rather than silently ignoring an invalid call | `src/index.ts` |
|
|
123
|
+
|
|
124
|
+
Exact declarations live in [`src/index.ts`](../index.ts), including the parameter tuple each wrapper
|
|
125
|
+
derives from the platform function it wraps and the `number | NodeJS.Timeout` return union. This
|
|
126
|
+
section routes to that declaration rather than restating it, so the source stays the single contract
|
|
127
|
+
surface.
|
|
128
|
+
|
|
129
|
+
## Dependencies
|
|
130
|
+
|
|
131
|
+
| Dependency | Why it is required | Failure behavior |
|
|
132
|
+
| --------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
133
|
+
| Host timer API (`host-timer-api`) | Provides `setTimeout`, `setInterval`, and `clearTimeout`. The module is a thin layer over these and has no fallback | Propagates. Anything the platform throws — an out-of-range delay, an exhausted timer table — surfaces unchanged to the caller |
|
|
134
|
+
| `Timeout.unref()` (Node-only extension) | Lets a pending timer stop holding the process open, which is the package's entire purpose | Silently skipped. Both wrappers test for the method before calling it, so a browser handle without `unref` takes the platform default behavior |
|
|
135
|
+
|
|
136
|
+
The package declares **no runtime dependencies**; `package.json` lists only `devDependencies`. Build,
|
|
137
|
+
lint, and test behavior are inherited from the workspace-internal `@webex/babel-config-legacy`,
|
|
138
|
+
`@webex/eslint-config-legacy`, `@webex/jest-config-legacy`, and `@webex/legacy-tools` packages, none
|
|
139
|
+
of which ship in the published artifact.
|
|
140
|
+
|
|
141
|
+
## Requirements
|
|
142
|
+
|
|
143
|
+
| ID | WHAT | WHY | Source evidence | Test or example evidence | Assumptions or gaps | Confidence |
|
|
144
|
+
| --------- | -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | --------------- | ------------------------- | ------------------------------------------------------------------------------------------------- | ---------- |
|
|
145
|
+
| `MOD-001` | `safeSetTimeout` forwards its arguments unchanged to the platform `setTimeout` and returns that call's handle | Consumers pass the handle straight to `clearTimeout`; returning anything else would break every cancellation site | `src/index.ts` | `test/unit/spec/index.ts` | Partial. Callback delivery and the returned handle are asserted; trailing-argument forwarding is not | Present |
|
|
146
|
+
| `MOD-002` | `safeSetTimeout` calls `unref()` on the returned handle whenever that method exists | A pending timeout must not keep a Node process alive after the SDK's work is done — this is the package's stated reason to exist | `src/index.ts` | none | Uncovered. No automated assertion that `unref()` is called, and none that a handle without `unref` is returned untouched. Supported by code reading only | Weak |
|
|
147
|
+
| `MOD-003` | `safeSetInterval` forwards its arguments unchanged to the platform `setInterval` and returns that call's handle | Same cancellation contract as `MOD-001`, using `clearInterval` | `src/index.ts` | `test/unit/spec/index.ts` | Partial. Repetition and the returned handle are asserted; trailing-argument forwarding is not | Present |
|
|
148
|
+
| `MOD-004` | `safeSetInterval` calls `unref()` on the returned handle whenever that method exists | A repeating interval is the worst case for a wedged process, because it never stops on its own | `src/index.ts` | none | Uncovered. Same gap as `MOD-002`, on the interval branch | Weak |
|
|
149
|
+
| `MOD-005` | Constructing a `Timer` schedules nothing; the timer is created in `init` and only `start()` arms it | Consumers build the timer inside a promise executor before wiring listeners, and must not race a callback that fires during setup | `src/index.ts` | `test/unit/spec/index.ts` | none | Present |
|
|
150
|
+
| `MOD-006` | `start()` arms the timer and moves it to `running`; calling it from any other state throws | A second `start()` would leak the first handle and fire the callback twice, which neither consumer can tolerate | `src/index.ts` | `test/unit/spec/index.ts` | none | Present |
|
|
151
|
+
| `MOD-007` | `reset()` clears the pending timer and re-arms it for the full original timeout, leaving the state `running` | This is how both consumers implement an idle deadline: every inbound event pushes the deadline out by a whole timeout, not by the remainder | `src/index.ts` | `test/unit/spec/index.ts` | none | Present |
|
|
152
|
+
| `MOD-008` | `cancel()` clears the pending timer and makes the state `done` | A cancelled timer must be indistinguishable from an expired one, so a later `start` or `reset` is rejected the same way | `src/index.ts` | `test/unit/spec/index.ts` | none | Present |
|
|
153
|
+
| `MOD-009` | On expiry the timer moves to `done` **before** the caller's callback runs | The callback frequently tears down the surrounding operation; if it re-entered `reset()` the timer must already be terminal | `src/index.ts` | none | Uncovered. No test calls `start()` or `reset()` from inside the expiry callback. Supported by code reading only | Weak |
|
|
154
|
+
| `MOD-010` | `done` is terminal: no method moves a timer out of it, and every method called on it throws | A `Timer` is single-use by contract; consumers create a new one rather than recycling | `src/index.ts` | `test/unit/spec/index.ts` | none | Present |
|
|
155
|
+
| `MOD-011` | Each rejection message names the operation and the state that rejected it, for example `Can't reset the timer when it's in done state` | Consumers surface these strings in logs; the unit suite matches them by regular expression, so the wording is part of the contract | `src/index.ts` | `test/unit/spec/index.ts` | none | Present |
|
|
156
|
+
| `MOD-012` | `Timer` arms itself through `safeSetTimeout`, so a managed timer inherits the same non-blocking process semantics | A consumer must not have to choose between lifecycle safety and process-exit safety | `src/index.ts` | none | Uncovered. No test captures the handle `Timer` schedules, so the inherited `unref` behaviour rests on code reading | Weak |
|
|
157
|
+
|
|
158
|
+
## Design overview
|
|
159
|
+
|
|
160
|
+
The module is deliberately two layers thin.
|
|
161
|
+
|
|
162
|
+
The **lower layer** is a pair of free functions that do one thing each: call the platform scheduler,
|
|
163
|
+
then ask the returned handle to stop counting toward process liveness. The capability check is
|
|
164
|
+
written as `if (timer.unref)` rather than as a platform test, so the same compiled code runs in Node,
|
|
165
|
+
where the handle is a `Timeout` object carrying `unref`, and in a browser, where it is a plain number
|
|
166
|
+
that does not. Nothing else about the platform is inspected, and no polyfill is installed.
|
|
167
|
+
|
|
168
|
+
The **upper layer** is `Timer`, which adds exactly one idea the free functions cannot express: a
|
|
169
|
+
timeout that a caller may push back repeatedly and then retire. It holds three private fields — the
|
|
170
|
+
lifecycle `state`, the readonly `timeout` and `callback`, and the current platform handle — and
|
|
171
|
+
routes every public method through the two private helpers `startTimer` and `clearTimer`. The
|
|
172
|
+
callback the class stores is not the caller's function but a wrapper that marks the timer `done`
|
|
173
|
+
first; that ordering is what makes `done` reliable from inside the callback itself.
|
|
174
|
+
|
|
175
|
+
Both layers own their state entirely within one `Timer` instance or one call. There is no module
|
|
176
|
+
level state, no registry of live timers, and no cleanup hook, which is why the module can be
|
|
177
|
+
imported by ten packages without coordination.
|
|
178
|
+
|
|
179
|
+
## Data flow and sequence coverage
|
|
180
|
+
|
|
181
|
+
The call style is **in-process synchronous function calls**. The only asynchrony is the host event
|
|
182
|
+
loop delivering a scheduled callback. Nothing is serialized, no boundary is crossed, and no call can
|
|
183
|
+
fail partway.
|
|
184
|
+
|
|
185
|
+
The module has two operation groups. They do not share a diagram: the first is stateless and cannot
|
|
186
|
+
reject a call, while the second is stateful, has a terminal state, and rejects invalid transitions.
|
|
187
|
+
|
|
188
|
+
| Operation group | Entry and outcome | Diagram or evidence | Failure and recovery coverage |
|
|
189
|
+
| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
|
|
190
|
+
| Fire-and-forget scheduling (`safeSetTimeout`, `safeSetInterval`) | Caller schedules a callback and receives a handle; the callback runs on the event loop until the caller clears the handle | Sequence diagram below; `src/index.ts` | No rejected path exists. The only branch is whether `unref` is present on the handle, shown as an `opt` |
|
|
191
|
+
| Managed timer lifecycle (`Timer`) | Caller constructs, arms, optionally re-arms, then either cancels or lets the timer expire | Sequence diagram below, the State machine section, and `src/index.ts` | Every invalid transition throws synchronously; shown as the `alt` branch. Expiry and cancellation both terminate at `done` |
|
|
192
|
+
|
|
193
|
+
**Operation group 1 — fire-and-forget scheduling.**
|
|
194
|
+
|
|
195
|
+
```mermaid
|
|
196
|
+
sequenceDiagram
|
|
197
|
+
participant Caller as Consuming package
|
|
198
|
+
participant Wrapper as safeSetTimeout / safeSetInterval
|
|
199
|
+
participant Host as Host timer API
|
|
200
|
+
Caller->>Wrapper: schedule(callback, delay)
|
|
201
|
+
Wrapper->>Host: setTimeout / setInterval(callback, delay)
|
|
202
|
+
Host-->>Wrapper: handle
|
|
203
|
+
opt handle exposes unref (Node)
|
|
204
|
+
Wrapper->>Host: handle.unref()
|
|
205
|
+
Note over Host: timer no longer holds the process open
|
|
206
|
+
end
|
|
207
|
+
Wrapper-->>Caller: handle
|
|
208
|
+
Host-->>Caller: callback() when the delay elapses
|
|
209
|
+
Caller->>Host: clearTimeout / clearInterval(handle)
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
**Operation group 2 — managed timer lifecycle.**
|
|
213
|
+
|
|
214
|
+
```mermaid
|
|
215
|
+
sequenceDiagram
|
|
216
|
+
participant Caller as dss / ai-assistant
|
|
217
|
+
participant Timer as Timer
|
|
218
|
+
participant Wrapper as safeSetTimeout
|
|
219
|
+
participant Host as Host timer API
|
|
220
|
+
Caller->>Timer: new Timer(callback, timeout)
|
|
221
|
+
Note over Timer: state = init; nothing scheduled
|
|
222
|
+
Caller->>Timer: start()
|
|
223
|
+
alt state is not init
|
|
224
|
+
Timer-->>Caller: throw "Can't start the timer when it's in <state> state"
|
|
225
|
+
else state is init
|
|
226
|
+
Timer->>Wrapper: safeSetTimeout(wrapped, timeout)
|
|
227
|
+
Wrapper->>Host: setTimeout + unref
|
|
228
|
+
Host-->>Timer: handle
|
|
229
|
+
Note over Timer: state = running
|
|
230
|
+
end
|
|
231
|
+
loop each inbound event before expiry
|
|
232
|
+
Caller->>Timer: reset()
|
|
233
|
+
Timer->>Host: clearTimeout(handle)
|
|
234
|
+
Timer->>Wrapper: safeSetTimeout(wrapped, timeout)
|
|
235
|
+
Note over Timer: state stays running; full timeout restarts
|
|
236
|
+
end
|
|
237
|
+
alt caller finishes first
|
|
238
|
+
Caller->>Timer: cancel()
|
|
239
|
+
Timer->>Host: clearTimeout(handle)
|
|
240
|
+
Note over Timer: state = done; callback never runs
|
|
241
|
+
else timeout elapses
|
|
242
|
+
Host-->>Timer: wrapped()
|
|
243
|
+
Note over Timer: state = done set before the caller callback runs
|
|
244
|
+
Timer-->>Caller: callback()
|
|
245
|
+
end
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
## Class and component relationships
|
|
249
|
+
|
|
250
|
+
`Timer` composes the free function rather than extending anything. There is no inheritance, no
|
|
251
|
+
interface, and no injection point in the module.
|
|
252
|
+
|
|
253
|
+
```mermaid
|
|
254
|
+
classDiagram
|
|
255
|
+
class Timer {
|
|
256
|
+
-state: "init" | "running" | "done"
|
|
257
|
+
-timeout: number
|
|
258
|
+
-callback: () => void
|
|
259
|
+
-currentTimer: number | NodeJS.Timeout
|
|
260
|
+
+start() void
|
|
261
|
+
+reset() void
|
|
262
|
+
+cancel() void
|
|
263
|
+
-startTimer() void
|
|
264
|
+
-clearTimer() void
|
|
265
|
+
}
|
|
266
|
+
class safeSetTimeout {
|
|
267
|
+
<<function>>
|
|
268
|
+
}
|
|
269
|
+
class safeSetInterval {
|
|
270
|
+
<<function>>
|
|
271
|
+
}
|
|
272
|
+
class HostTimerAPI {
|
|
273
|
+
<<external>>
|
|
274
|
+
setTimeout()
|
|
275
|
+
setInterval()
|
|
276
|
+
clearTimeout()
|
|
277
|
+
unref()
|
|
278
|
+
}
|
|
279
|
+
Timer ..> safeSetTimeout : arms through
|
|
280
|
+
Timer ..> HostTimerAPI : clearTimeout
|
|
281
|
+
safeSetTimeout ..> HostTimerAPI
|
|
282
|
+
safeSetInterval ..> HostTimerAPI
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
## Use cases and flows
|
|
286
|
+
|
|
287
|
+
| Use case | Actor or caller | Primary steps and outcome | Failure or boundary behavior | Evidence |
|
|
288
|
+
| -------- | ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------- |
|
|
289
|
+
| `UC-001` | Any SDK package scheduling deferred work | Call `safeSetTimeout(fn, ms)`, keep the handle, let the callback run. A Node process may exit before the delay elapses and the callback is simply never invoked | No rejected path. The caller cannot rely on the callback firing at shutdown | `src/index.ts`, `test/unit/spec/index.ts` |
|
|
290
|
+
| `UC-002` | A package running periodic work, such as metrics batching | Call `safeSetInterval(fn, ms)`, keep the handle, clear it when the owning component is torn down | The interval never stops on its own; failing to clear it leaks a repeating callback for the lifetime of the page or process | `src/index.ts`, `test/unit/spec/index.ts` |
|
|
291
|
+
| `UC-003` | `internal-plugin-dss` / `internal-plugin-ai-assistant` bounding a streamed response | Construct a `Timer` with the request timeout inside the promise executor, `start()` it, call `reset()` on every inbound chunk, and `cancel()` once the stream reports it is finished | If the timer expires first, its callback removes the event listener and rejects the promise, so no later `reset()` can reach the now-terminal timer | `src/index.ts`, `test/unit/spec/index.ts` |
|
|
292
|
+
| `UC-004` | A consumer retiring a timer early | Call `cancel()` while `running`; the pending callback is cleared and the timer becomes terminal | A second `cancel()`, or a `cancel()` before `start()`, throws synchronously | `src/index.ts`, `test/unit/spec/index.ts` |
|
|
293
|
+
|
|
294
|
+
## Concurrency and reactive flow
|
|
295
|
+
|
|
296
|
+
- **Execution model:** single-threaded host event loop. The module creates no worker, thread, or
|
|
297
|
+
subscription; it only hands callbacks to the platform scheduler and gets them back later.
|
|
298
|
+
- **Ordering guarantees:** exactly the platform's own. Callbacks scheduled with a shorter delay are
|
|
299
|
+
delivered first; the module adds no queue and reorders nothing. A `Timer` guarantees that at most
|
|
300
|
+
one of its callbacks is ever pending, because `reset` clears the previous handle before creating
|
|
301
|
+
the next one.
|
|
302
|
+
- **Idempotency and retry:** none, by design. `safeSetTimeout` and `safeSetInterval` create a new
|
|
303
|
+
timer on every call, and `Timer` rejects a repeated `start` or `cancel` rather than treating it as
|
|
304
|
+
a no-op. Retry belongs to the caller.
|
|
305
|
+
- **Shared-state protection:** not required. Each `Timer` owns its fields privately and the runtime
|
|
306
|
+
is single-threaded, so no lock, serialization, or immutability discipline applies.
|
|
307
|
+
- **Blocking restrictions:** the caller's callback runs on the event loop, so blocking work inside it
|
|
308
|
+
stalls every other timer in the process. For an expiring `Timer` the state has already moved to
|
|
309
|
+
`done` before the callback is entered, so a long-running callback cannot leave the timer in an
|
|
310
|
+
inconsistent state.
|
|
311
|
+
|
|
312
|
+
## State machine
|
|
313
|
+
|
|
314
|
+
A `Timer` has three states. `init` and `running` accept exactly one set of transitions each, and
|
|
315
|
+
`done` is terminal — every method called on a `done` timer throws.
|
|
316
|
+
|
|
317
|
+
```mermaid
|
|
318
|
+
stateDiagram-v2
|
|
319
|
+
[*] --> init: new Timer(callback, timeout)
|
|
320
|
+
init --> running: start()
|
|
321
|
+
running --> running: reset()
|
|
322
|
+
running --> done: cancel()
|
|
323
|
+
running --> done: timeout elapses, then callback() runs
|
|
324
|
+
init --> init: reset() or cancel() throws
|
|
325
|
+
running --> running: start() throws
|
|
326
|
+
done --> done: start(), reset(), or cancel() throws
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
| From | Trigger | Guard | To | Rejected when |
|
|
330
|
+
| --------- | ----------------- | -------------------- | --------- | -------------------------------------------------------------------------- |
|
|
331
|
+
| `init` | `start()` | state is `init` | `running` | Called on a `running` or `done` timer |
|
|
332
|
+
| `running` | `reset()` | state is `running` | `running` | Called on an `init` or `done` timer |
|
|
333
|
+
| `running` | `cancel()` | state is `running` | `done` | Called on an `init` or `done` timer |
|
|
334
|
+
| `running` | timeout elapses | none | `done` | Not rejectable; the transition happens before the caller's callback is run |
|
|
335
|
+
|
|
336
|
+
There is no accessor for the current state. A consumer that cannot prove which state a timer is in
|
|
337
|
+
must track the lifecycle itself; see [Pitfalls and constraints](#pitfalls-and-constraints).
|
|
338
|
+
|
|
339
|
+
## Caller-visible failure modes
|
|
340
|
+
|
|
341
|
+
| Condition | Signal or result | Caller behavior | Retry or recovery | Evidence |
|
|
342
|
+
| ----------------------------------------------- | -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | ------------------------------------------- |
|
|
343
|
+
| `start()` on a `running` timer | Throws `Error: Can't start the timer when it's in running state` | Synchronous throw at the call site; inside a promise executor this rejects the surrounding promise | Not recoverable on the same instance — the timer is already armed | `src/index.ts`, `test/unit/spec/index.ts` |
|
|
344
|
+
| `start()` on a `done` timer | Throws `Error: Can't start the timer when it's in done state` | Same | Construct a new `Timer`; `done` is terminal | `src/index.ts`, `test/unit/spec/index.ts` |
|
|
345
|
+
| `reset()` before `start()` | Throws `Error: Can't reset the timer when it's in init state` | Same | Call `start()` first | `src/index.ts`, `test/unit/spec/index.ts` |
|
|
346
|
+
| `reset()` after expiry or `cancel()` | Throws `Error: Can't reset the timer when it's in done state` | Same. This is the reachable one in production: an inbound event arriving after the deadline | The consumer must stop listening inside the expiry callback so this cannot be hit | `src/index.ts`, `test/unit/spec/index.ts` |
|
|
347
|
+
| `cancel()` before `start()` | Throws `Error: Can't cancel the timer when it's in init state` | Same | Only cancel a timer that was started | `src/index.ts`, `test/unit/spec/index.ts` |
|
|
348
|
+
| `cancel()` twice, or after expiry | Throws `Error: Can't cancel the timer when it's in done state` | Same | Cancel once; a `done` timer needs no cancellation | `src/index.ts`, `test/unit/spec/index.ts` |
|
|
349
|
+
| Platform rejects the schedule request | Whatever the host throws, propagated unchanged | The wrappers add no try/catch, so the platform error surfaces at the call site | Owned by the caller | `src/index.ts` |
|
|
350
|
+
|
|
351
|
+
The two free functions have **no** failure mode of their own. They never throw, never return
|
|
352
|
+
`undefined`, and never swallow a platform error.
|
|
353
|
+
|
|
354
|
+
## Pitfalls and constraints
|
|
355
|
+
|
|
356
|
+
- **The `Timer` constructor validates nothing, and an absent duration fails misleadingly.**
|
|
357
|
+
`undefined`, `null`, `0`, a negative number, and `NaN` all produce a timer that fires in about a
|
|
358
|
+
millisecond; only a numeric string such as `'5000'` is coerced correctly. This is an asymmetry —
|
|
359
|
+
the class rejects every invalid state transition with a precise message but accepts any duration
|
|
360
|
+
silently. It is reachable in practice: both `Timer` consumers read `config.requestTimeout`, and
|
|
361
|
+
plugin configuration is merged in a way that preserves a caller-supplied `null` rather than
|
|
362
|
+
falling back to the default, so a `null` from JSON or remote configuration makes every request
|
|
363
|
+
reject on its deadline immediately. The resulting error names a timeout while the real cause is a
|
|
364
|
+
missing configuration value, which points diagnosis away from the fault. Validate the duration at
|
|
365
|
+
the call site until the constructor does. Triaged 2026-09-24 as fix-separately. Not pinned by any
|
|
366
|
+
test; evidence is `src/index.ts`, which performs no duration validation.
|
|
367
|
+
- **A pending timer will not keep the process alive.** This is the point of the package, and it is
|
|
368
|
+
also the trap: in a short-lived Node script or a test harness, a `safeSetTimeout` callback may
|
|
369
|
+
simply never run because nothing else held the event loop open. Code that genuinely needs to block
|
|
370
|
+
exit must hold the process open some other way, not by scheduling a timer. Evidence: `src/index.ts`.
|
|
371
|
+
- **`reset()` on an expired timer throws, and there is no way to ask whether it is safe.** The class
|
|
372
|
+
exposes no state accessor, so a consumer cannot test before calling. Both production consumers
|
|
373
|
+
avoid this by removing their event listener inside the expiry callback, which is why the throw is
|
|
374
|
+
not observed in practice — but any new consumer that resets a timer from a handler it did not tear
|
|
375
|
+
down will crash rather than no-op. Evidence: `src/index.ts`, `test/unit/spec/index.ts`.
|
|
376
|
+
- **A `Timer` is single-use.** `done` is terminal for both cancellation and expiry, so a consumer
|
|
377
|
+
that wants to reuse a deadline must construct a new instance. Evidence: `src/index.ts`.
|
|
378
|
+
- **`reset()` restores the full timeout, not the remaining time.** It is an idle deadline, not a
|
|
379
|
+
pause. Evidence: `src/index.ts`, `test/unit/spec/index.ts`.
|
|
380
|
+
- **The return type is a union.** `safeSetTimeout` and `safeSetInterval` are typed
|
|
381
|
+
`number | NodeJS.Timeout`, so a TypeScript consumer cannot store the handle in a `number` field
|
|
382
|
+
without narrowing. Passing it straight to `clearTimeout` or `clearInterval` does typecheck.
|
|
383
|
+
Evidence: `src/index.ts`.
|
|
384
|
+
- **The package exports no clear function.** Cancelling is the caller's job using the platform's own
|
|
385
|
+
`clearTimeout` or `clearInterval`; there is no `safeClearTimeout`. Evidence: `src/index.ts`.
|
|
386
|
+
- **`yarn workspace @webex/common-timers test` cannot succeed.** The `test` script chains
|
|
387
|
+
`test:integration` and `test:browser`, and this package declares neither. Use the individual
|
|
388
|
+
`test:style` and `test:unit` commands. Evidence: `package.json`.
|
|
389
|
+
|
|
390
|
+
## Export stability
|
|
391
|
+
|
|
392
|
+
| Export or entry point | Consumer | Stability | Versioning and deprecation rule | Declaration or API report |
|
|
393
|
+
| --------------------- | ----------------------------------------------------------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------- |
|
|
394
|
+
| `safeSetTimeout` | Eight workspace packages, one of them undeclared | Stable | Published under the workspace's shared semantic version. Its signature is tied to the platform `setTimeout`, so it changes only if that does | `src/index.ts` |
|
|
395
|
+
| `safeSetInterval` | `internal-plugin-metrics`, `plugin-meetings` | Stable | Same | `src/index.ts` |
|
|
396
|
+
| `Timer` | `internal-plugin-dss`, `internal-plugin-ai-assistant` | Stable | The three method names, the constructor shape, and the rejected-transition behavior are the contract. The thrown message wording is asserted by the unit suite and should be treated as part of it | `src/index.ts` |
|
|
397
|
+
| `dist/index.js` | npm consumers | Stable | `main` for published builds; `devMain` points at `src/index.ts` for in-workspace development. Built by `build:src` | `package.json` |
|
|
398
|
+
|
|
399
|
+
The package ships no `browser` field and no separate browser bundle. The same module serves both
|
|
400
|
+
platforms because the only platform-specific behavior — `unref` — is feature-detected at runtime.
|
|
401
|
+
|
|
402
|
+
## Key design trade-off
|
|
403
|
+
|
|
404
|
+
| Chosen trade-off | Preserved invariant or benefit | Cost or limitation | Decision evidence |
|
|
405
|
+
| -------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------- |
|
|
406
|
+
| Unref every timer unconditionally, with no opt-out | An SDK consumer's Node process, CLI, or test run always exits when its own work is done, no matter how many SDK timers are still pending | A caller can never use these wrappers to *hold* the process open, and a pending callback may be dropped at exit. There is no flag to request the platform default | `src/index.ts`, `README.md` |
|
|
407
|
+
| Feature-detect `unref` on the handle rather than branching on the platform | One module, one build, and no bundler shim for browsers, where the handle is a bare number | The Node and browser paths cannot be distinguished by type, which is why the return type is the `number | NodeJS.Timeout` union every consumer has to accept | `src/index.ts`, `process` |
|
|
408
|
+
| Make `Timer` throw on an invalid transition instead of ignoring it | A double-`start` or post-expiry `reset` surfaces immediately instead of leaking a handle or firing a callback twice | Consumers must either prove the state or accept a throw, and the class gives them no accessor to check with | `src/index.ts`, `test/unit/spec/index.ts` |
|
|
409
|
+
| Mark the timer `done` before invoking the caller's callback | A callback that tears down its own operation always observes a terminal timer, so re-entrancy is impossible | The caller cannot distinguish "expired" from "cancelled" inside the callback, because both arrive as `done` | `src/index.ts` |
|
|
410
|
+
|
|
411
|
+
## Verification
|
|
412
|
+
|
|
413
|
+
| Requirement or invariant | Test level | Positive evidence | Negative or boundary evidence | Gap |
|
|
414
|
+
| ------------------------------------------------- | ---------- | ---------------------------------------------------- | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
|
|
415
|
+
| `MOD-001` timeout callback delivery and handle | Unit | `test/unit/spec/index.ts` — the callback fires once when the timer expires, and the handle is accepted by `clearTimeout` | none | Trailing-argument forwarding is not asserted |
|
|
416
|
+
| `MOD-002` timeout handle is unref-ed | Unit | none | none | Uncovered. No assertion that `unref()` is called, and none for the handle-without-unref branch |
|
|
417
|
+
| `MOD-003` interval repetition and handle | Unit | `test/unit/spec/index.ts` — the callback fires twice across two ticks, and the handle is accepted by `clearInterval` | none | Repetition is asserted at two ticks only; no long-run or clear-mid-flight case, and trailing-argument forwarding is not asserted |
|
|
418
|
+
| `MOD-004` interval handle is unref-ed | Unit | none | none | Uncovered. Same gap as `MOD-002`, on the interval branch |
|
|
419
|
+
| `MOD-005` construction schedules nothing | Unit | `test/unit/spec/index.ts` | `test/unit/spec/index.ts` — reset and cancel before start both throw | none |
|
|
420
|
+
| `MOD-006` `start()` arms once | Unit | `test/unit/spec/index.ts` | `test/unit/spec/index.ts` — throws from running, from done after cancel, and from done after expiry | none |
|
|
421
|
+
| `MOD-007` `reset()` restores the full timeout | Unit | `test/unit/spec/index.ts` — advances 500ms, resets, then proves the callback needs another full 1000ms | `test/unit/spec/index.ts` — throws from init and from done | none |
|
|
422
|
+
| `MOD-008` `cancel()` clears and terminates | Unit | `test/unit/spec/index.ts` | `test/unit/spec/index.ts` — throws from init and on a second cancel | none |
|
|
423
|
+
| `MOD-009` state is `done` before the callback runs | Unit | none | none | Uncovered. No test re-enters the timer from inside the expiry callback |
|
|
424
|
+
| `MOD-010` `done` is terminal | Unit | `test/unit/spec/index.ts` — all three methods rejected from done | same | none |
|
|
425
|
+
| `MOD-011` rejection messages name the state | Unit | `test/unit/spec/index.ts` — every assertion matches the message by regular expression | same | none |
|
|
426
|
+
| `MOD-012` `Timer` inherits the unref behavior | Unit | none | none | Uncovered. No case captures the handle `Timer` schedules, and none constructs a `Timer` against a handle with no `unref` method |
|
|
427
|
+
|
|
428
|
+
**Test routing.** `yarn workspace @webex/common-timers test:unit` runs one jest suite — 13 cases in
|
|
429
|
+
total, all passing as of 2026-10-05. The package declares no integration or browser tier, and
|
|
430
|
+
coverage collection is disabled for it by the resolved `jest.config.js`, so no coverage threshold
|
|
431
|
+
gates this module.
|
|
432
|
+
|
|
433
|
+
| Suite | Role |
|
|
434
|
+
| ----- | ---- |
|
|
435
|
+
| `test/unit/spec/index.ts` | Intended behaviour: timeout callback delivery, interval repetition, and every `Timer` lifecycle transition including all six rejected ones. Pre-existing suite; not authored by this specification change |
|
|
436
|
+
|
|
437
|
+
**The suite exercises the built artifact, not the source.** It imports the package by name, which
|
|
438
|
+
resolves through `main` to `dist/`. A green run after a source change proves nothing until
|
|
439
|
+
`yarn workspace @webex/common-timers build:src` has been run. Evidence: the `@webex/common-timers`
|
|
440
|
+
import in `test/unit/spec/index.ts` and the `main` field in `package.json`.
|
|
441
|
+
|
|
442
|
+
**Overall gap.** Coverage is materially incomplete, and the gaps are concentrated on the behaviour
|
|
443
|
+
this package exists for. `MOD-002`, `MOD-004`, `MOD-009` and `MOD-012` have no automated evidence at
|
|
444
|
+
all and are scored Weak; `MOD-001` and `MOD-003` are only partly covered. Nothing asserts that
|
|
445
|
+
`unref()` is ever called, so the package's entire reason to exist — that a pending timer will not
|
|
446
|
+
hold a Node process open — currently rests on reading `src/index.ts` rather than on a test. A
|
|
447
|
+
regression that dropped the `unref` call would not turn this suite red.
|
|
448
|
+
|
|
449
|
+
What *is* well covered is the `Timer` state machine: all six rejected transitions, the full-timeout
|
|
450
|
+
reset semantics, and the exact rejection wording are asserted by the pre-existing suite.
|
|
451
|
+
|
|
452
|
+
This module has no characterization baseline. The `Timer` constructor's missing duration validation
|
|
453
|
+
is therefore specified but unpinned: it was triaged on 2026-09-24 as fix-separately, and the
|
|
454
|
+
caller-facing hazard is recorded under [Pitfalls and constraints](#pitfalls-and-constraints), but no
|
|
455
|
+
executable case holds the behaviour in place. Adding validation is a breaking change and belongs in
|
|
456
|
+
its own change with its own requirement and review.
|
|
457
|
+
|
|
458
|
+
Closing these gaps — `unref` assertions on both platform branches, argument forwarding, re-entrancy
|
|
459
|
+
from the expiry callback, and a characterization baseline — is tracked as follow-up work and is a
|
|
460
|
+
precondition for promoting this module beyond its current coverage state.
|