Production-grade ExtendScript infrastructure for Illustrator-era JavaScript engines.
|
ESON ESB64 ESARR ESSTR ESCHARS ESHTTP ESTIMER |
ESPACK ESMIN ESOBF coming soon |
Also from the same team: ArcFit.dev, deterministic arc warp for Illustrator.
- Why ESTIMER?
- Features
- Get the Release
- Installation
- Quick Start
- API
- Validation
- Performance
- Security Model
- Compatibility
- Engine quirks that shaped the design
- Development
- Repository layout
- Research corrections
- Credits
- License
ExtendScript ships no usable high-resolution timer. There is no performance.now(), no Date.now(), and Date().getTime() only reaches millisecond resolution. The one µs source the engine does ship — $.hiresTimer — is a delta clock, not a wall clock: each read returns µs since the property was last accessed, so consecutive values must never be subtracted like timestamps. Probed live on Adobe Illustrator 30.6.0 (build 109R) / ExtendScript 4.5.6 (docs/research-probes.md, gaps G1-G8), the engine's timer has four documented traps and one missing feature:
- The first read is engine/thread startup µs, not a sample. The first ever read in the main engine measured 675,881,881 µs (≈ 11.26 min — the engine's own age, not the process's). A naive harness records that huge value as a measurement; it must be discarded.
- The counter is signed 32-bit and wraps. It turns negative at 2^31 µs (35.79 min of counter uptime) and fully aliases every 2^32 µs (71.58 min). The correction is exact for single-wrap intervals and unrecoverable past that — the honest long-span lane is the Date wall clock. (Measured + RE-confirmed on 30.6.0 and PS 2026: the timer is a 64-bit QPC-based counter, so reads spanning the documented wrap point came back positive and accurate — the +2^32 correction is the deterministic safety net, and the multi-wrap limit still stands.)
- Nested reads corrupt naive outer timings. A stopwatch read inside a stopwatch steals the parent's elapsed time — the profiler bug that once reported a parent phase at ~0 ms while its children summed to ~20 s (ArcFit history, HANDOFF_ARCFIT_RUNTIME_OPTIMIZATION).
$.sleeppolls coarsely (~100 ms granularity; can overrun or wake early) — fine for pauses, useless for timing.- No µs wall clock at all:
Dateis ms-only, and the read itself costs 1 µs median (p99 2 µs) on 30.6.0 — a correction floor for sub-10 µs measurements.
ESTIMER solves all of it with one accumulator: every raw read advances a monotonic, wrap-corrected absolute µs clock (abs += wrapCorrect(delta)), so now() is safe anywhere — including inside measured functions and nested stopwatches. The first read is discarded by prime(), samples are validated per the house rejection protocol (> 0, <= maxValidUs), and the raw source is injected so the identical bundle runs in the engine ($.hiresTimer), in Node (performance.now()), and on the degraded Date lane.
- Monotonic, wrap-corrected absolute clock — 0 negative deltas in 10,000 consecutive live reads (G5,
probe_hirestimer.jsx); the accumulator floors uncorrectable reads at 0 (never backwards) and single-wrap-corrects negative deltas by +2^32 (exact for true intervals in [2^31, 2^32) µs ≈ 35.8-71.6 min). - First-read discard —
prime()consumes and discards the startup read (measured 675,881,881 µs) idempotently; live-verify confirmsnow()starts near 0 afterprime()even in a ~40 min-old engine. - Nesting-safe stopwatch —
elapsed()is cumulative; an inner stopwatch ornow()betweenstart()andstop()cannot corrupt the outer run (live-verify: "stopwatch outer >= inner", "elapsed >= outer"). - House rejection protocol — samples validated
> 0 && <= 1e8 µs(100 s), warmup runs (default 2) discarded, rejected samples reduce the set with{index, value, reason}carried viacollectRejected;measureUs(fn, 1)returnsnulllive-verified. - Calibrated sleep — busy-wait below 25 ms lands within +0.2 % of the request; at/above 25 ms in the engine,
$.sleep(ms - 1)+ a final calibrated busy-wait never wakes early (measured overshoot 10-38 %, returned as the actual µs slept). - Stats, edge-exact —
stats()returns{count, min, max, mean, median, p95, p99, rejected}with house upper-median indexing; n=0 → zeros, n=1 → the single value; 3,000 differential stats checks + 2,000 medianOf checks against the Node reference model. - Differential-validated core — 5,165 unit checks (
npm test) + 2,000,028 seeded fuzz iterations vs the hand-rolled reference accumulator (npm run fuzz, seed 1337, 0 divergences), including scripted wrap-boundary, negative-delta, and startup-value sequences. - Live-verified in the real engine — 56/56 engine checks pass on Illustrator 30.6.0 / ExtendScript 4.5.6 (
npm run live-verify): prime/now/epoch, 100-read monotonicity, measureUs rejection, samples/median/best, stopwatch nesting, wrap-policy round-trip, sleep lanes, calibrate. - Three lanes, one code path —
engine($.hiresTimer, delta, wraps),node(performance.now(), absolute),date(ms wall clock, degraded) auto-detected at load;setSource()injects a fake source so wrap/negative-delta behavior is tested deterministically without 35-minute waits. - No runtime dependencies, ES3-clean — one 13.7 KB runtime file (
vendor-estimer.js); nolet/const/arrows/Promise/Map, no"use strict", functions-only exports (esbuild IIFE getter quirk — see Engine quirks).
All production bundles ship as GitHub release assets — this repo holds sources. Grab the runnable builds from the Releases page.
How it works, in three steps:
- Open the Releases page.
- Pick the latest stable tag.
- Download the asset that matches your use case:
| You are... | Take this release | And this asset |
|---|---|---|
A script that wants the drop-in facade on $.global |
Latest stable | vendor-estimer.js — assigns $.global.ESTIMER |
A bannerless IIFE for $.evalFile / COM eval |
Latest stable | ESTIMER.jsx — defines var ESTIMER |
| Node.js testing / tooling | Latest stable | estimer-core.esm.mjs — ESM core |
| A fix that isn't released yet | Pre-release / main |
Build from source: npm run build |
// @includepath "path/to/estimer/dist"
#include "vendor-estimer.js"
// ESTIMER is now on $.global — drop-in, no install call needed.Or load explicitly in any order / from COM:
$.evalFile(File("C:/path/to/estimer/dist/vendor-estimer.js"));
// or, for a bannerless IIFE that defines var ESTIMER without touching $.global:
$.evalFile(File("C:/path/to/estimer/dist/ESTIMER.jsx"));Prime once per engine/thread: the accumulator's base is set from the first read of whichever engine the library is loaded into (see Engine quirks).
ESTIMER.prime(); // discard the startup read; anchor the epoch
var us = ESTIMER.now(); // µs since the first raw read (monotonic, wrap-corrected)
var t = ESTIMER.measureUs(function () { // one validated sample; null when rejected
for (var i = 0; i < 1000; i++) { work(i); }
});
var med = ESTIMER.median(9, function () { // median-of-9, warmup 2, rejection protocol
work();
});
var sw = ESTIMER.stopwatch(); // nesting-safe
sw.start();
work();
var runUs = sw.stop(); // this run's µs
ESTIMER.calibrate(); // median read overhead µs — subtract for sub-10 µs ops
ESTIMER.sleep(100); // calibrated: $.sleep(99) + final busy-wait to land at 100 ms
var s = ESTIMER.stats(ESTIMER.samples(9, work)); // { count, min, max, mean, median, p95, p99, rejected }
var wall = ESTIMER.wallNow(); // Date ms — the honest lane for long spansThe facade is functions only — no exported var bindings (the ES3 engine evaluates esbuild's IIFE var-export getters at define time; constants ride on ESTIMER.constants()).
prime()— idempotent; consumes and discards one read so the first (startup/thread-init) value is never a sample; records the epoch base.now()— µs, monotonic, wrap-corrected; absolute accumulated µs since the first raw read (safe anywhere, including nested contexts).nowMs()—now() / 1000(ms convenience, µs precision kept).wallNow()—Date().getTime()ms; the honest lane for spans past ~35.8 min between reads.epoch()— µs sinceprime(); auto-primes on first call.measureUs(fn, maxValidUs?)→number | null— one validated sample (> 0,<= maxValidUs, default 1e8 µs);nullwhen rejected.measureMs(fn, maxValidMs?)→number | null— ms version, default cap 1e5 ms.samples(n, fn, opts?)→SampleSet— n measured runs with{warmup=2, maxValidUs=1e8, minValidUs=0, collectRejected}; rejected samples reduce the set;array.rejected = [{index, value, reason}]whencollectRejected.median(n, fn, opts?)→number | null— median-of-n primed samples (the house benchmark protocol).best(n, fn, opts?)→number | null— min-of-n primed samples.stopwatch()→{ start, stop, elapsed, reset, running }— nesting-safe;elapsed()cumulative;stop()returns the just-completed run's µs; double-start / stop-without-start are no-ops.calibrate(n?)→ number — median read-overhead µs (default n=100, samples validated<= 10 ms); result recorded indescribe().calibratedUs.sleep(ms)→ number — actual µs slept; busy-wait below 25 ms; engine lane at/above:$.sleep(ms - 1)+ calibrated busy-wait (never early; overshoot returned).stats(samples)→{count, min, max, mean, median, p95, p99, rejected}— house upper-median indexing; n=0 → zeros, n=1 → single value.medianOf(values)/minOf(values)— pure array lanes (no timer dependency).setSource(source?, {wrapPolicy}?)— inject aTimerSource { readUs(), delta?, wraps? }(test hook;undefinedre-detects the lane) and re-create the accumulator.setWrapPolicy('correct'|'reject')/wrapPolicy()—'correct'(default): negative delta += 2^32 (single-wrap);'reject': negatives never advance.lane()—'engine' | 'node' | 'date' | 'custom'.describe()→{lane, engine, wrapPolicy, primed, reads, calibratedUs}— the evidence snapshot (host/version recorded with every measurement).constants()→{MAX_VALID_US: 1e8, MAX_VALID_MS: 1e5, MIN_VALID_US: 0, WRAP_PERIOD_US: 4294967296, WRAP_POINT_US: 2147483648, SLEEP_COARSE_THRESHOLD_MS: 25}.
| Check | Command | Result |
|---|---|---|
| TypeScript strict | npx tsc --noEmit -p . |
clean (exit 0) |
| Node harness (clock vectors + stats/median differential) | npm test |
5,165 checks, 0 failures |
| Seeded differential fuzz vs reference model | npm run fuzz |
2,000,028 iterations, seed 1337, 0 divergences |
| Live engine parity | npm run live-verify |
56/56 checks, Illustrator 30.6.0 / ExtendScript 4.5.6 |
| Node reference benchmark | npm run benchmark |
exit 0 (tables in Performance) |
The differential oracle is a hand-rolled reference model of the delta-clock accumulator (tests/vectors.ts — the same model npm test validates bit-for-bit in Node); engine parity is verified by running the identical bundled code in the real engine via the COM tool.
Measured live in Adobe Illustrator 30.6.0 (build 109R) / ExtendScript 4.5.6 (engine build 80.1), Windows 10/64, x86-64. Protocol: prime before each lane, 5 warmups discarded, 9 measured runs per lane, samples validated in (0, 1e8] µs, one lane per eval, 3 rounds per lane, median of the round medians. Full tables + raw JSON: docs/benchmark-rounds-1.md, bench/raw/.
| lane | medianUs | minUs | p95Us | n | rej |
|---|---|---|---|---|---|
| measureUs-noop | 10 | 9 | 10 | 9 | 0 |
| now-read | 5 | 4 | 5 | 9 | 0 |
| epoch-read | 5 | 5 | 5 | 9 | 0 |
| stopwatch-cycle | 11 | 10 | 13 | 9 | 0 |
| median9-noop | 98 | 95 | 101 | 9 | 0 |
| medianOf-31 | 86 | 85 | 87 | 9 | 0 |
| empty-loop-1e6 | 56,474 | 54,320 | 61,609 | 9 | 0 |
Read overhead decomposition (engine): raw $.hiresTimer read 1 µs median (p99 2 µs, G6); calibrate() through the facade 2 µs; now() 5 µs (accumulator + wrap-correction per read); measureUs(noop) 9-10 µs; the full median-of-9 protocol 98 µs.
| reqMs | sleptUs (median) | wallMs (median) | error % vs req |
|---|---|---|---|
| 2 | 2,004 | 2 | +0.2 % |
| 25 | 34,463 | 35 | +37.9 % |
| 50 | 60,810 | 61 | +21.6 % |
| 250 | 275,888 | 276 | +10.4 % |
The busy-wait lane (< 25 ms) lands within ~0.2 %. The coarse lane (>= 25 ms) never sleeps early but overshoots 10-38 % — $.sleep polls at ~100 ms granularity, and the landing policy is deliberately "never early"; the actual µs slept is the return value (the contract).
| lane | medianUs (node) | medianUs (engine) | ratio engine/node |
|---|---|---|---|
| measureUs-noop | 0.40 | 10 | 25x |
| now-read | 0.70 | 5 | 7.1x |
| epoch-read | 0.70 | 5 | 7.1x |
| medianOf-31 | 4.10 | 86 | 21x |
| empty-loop-1e6 | 462.6 | 56,474 | 122x |
Node host: node v22.23.2 (lane=node, performance.now() x1000, calibrated read overhead 0.30 µs). The engine is 7-122x slower depending on the lane — raw read overhead ~1 µs engine vs ~0.3 µs Node; interpreter throughput 122x slower on the empty loop. The Node lane exists for harness parity, not production timing.
ESTIMER is a pure data-transform library. It executes no eval, loads no native code, writes nothing to disk, and makes no network access; the shipped bundle is plain ES3 function definitions plus one closure state machine. The only host interactions are reads of $.hiresTimer and Date for time values, and a call to $.sleep only inside ESTIMER.sleep() (engine lane, at/above the 25 ms coarse threshold) — both read-only or deliberately invoked by the caller. The one injection surface is setSource(), an explicit test/embedding hook that replaces the timer source with a caller-provided function; it is never called by the library itself.
| Target | Status |
|---|---|
ExtendScript ES3 (no let/const/arrows/Promise/Map in the bundle; "use strict" stripped; functions-only exports) |
Bundled |
| Adobe Illustrator 30.6.0 / ExtendScript 4.5.6 / Windows x86-64 | Verified live (56/56 engine checks + G1-G8 probes) |
| Any other ExtendScript host (InDesign, Photoshop, After Effects, InCopy, Bridge) | ES3-safe by construction; $.hiresTimer getter chain RE-confirmed 64-bit QPC on Illustrator 30.6.0 and Photoshop 2026 (evidence/re-ai-sccore.md, evidence/re-ps-extendscript.md, evidence/re-ps-sccore.md); re-probe other hosts before relying on timing values |
| Node.js v18+ (v22.23.2 used) | Build and test harnesses |
All measured live on Illustrator 30.6.0 (build 109R) / ExtendScript 4.5.6 (engine build 80.1); re-probe other hosts.
- The first read is engine/thread startup µs, not ~0. Main-engine first read: 675,881,881 µs (the engine's age). A naive harness records it as a sample; ESTIMER discards it via
prime(). - The counter base is engine creation, not process start. The main engine was created ~9.2 h after process start; its first read was 11.26 min of engine age. Each engine has its own clock (transient engine first read = its own 13.15 s age).
$.hiresTimeris a delta clock. Every read is µs since the property was last accessed — never a timestamp.abs += wrapCorrect(delta)turns it into an absolute clock.- Signed 32-bit wrap. 2^31 µs = 35.79 min = the wrap point (value turns negative); 2^32 µs = 71.58 min = the full alias period.
'correct'adds +2^32 to a single-wrap negative (exact for [2^31, 2^32) µs intervals); multi-wrap gaps are unrecoverable —wallNow()is the honest long-span lane. Measured on 30.6.0, the engine's own delta math is wrap-safe in practice — reads spanning the wrap point came back positive and accurate (0 negatives across every probe + 10k consecutive reads), so'correct'is the deterministic safety net for fake/adversarial sources, not a live-path correction on this engine; the multi-wrap long-span limit still holds. - Nested reads corrupt naive outer timings. The ArcFit profiler once reported a parent phase at ~0 ms while children summed to ~20 s. The single accumulator makes the outer interval exactly the sum of the inner intervals (live-verified: stopwatch outer >= inner).
$.sleeppolls coarsely (~100 ms granularity; can overrun or wake early). ESTIMER sleeps busy-wait-exact below 25 ms and lands a coarse sleep with a calibrated spin above it.- Zero-delta reads are legitimate. 27.6% of 10,000 tight reads measured 0 µs — sub-µs intervals round down (read overhead is ~1 µs median). ESTIMER's
samples()acceptsminValidUs = 0;measureUsrejects<= 0because a 0 µs operation is more likely a protocol error. - Read overhead is ~1 µs raw, 2 µs through
calibrate(), ~5 µs throughnow(), 9-10 µs throughmeasureUs(G6). ESTIMER never auto-subtracts; the caller decides. - ScriptUI callbacks share the main engine's clock. A button
onClickfirst read (15,586 µs) matched the wall gap since the main-thread prime (15 ms) — same thread, same delta clock, no fresh-thread startup value. - Delta spans stay accurate over minute-scale gaps. A 60 s
$.sleepspan measured 64,330,333 µs vs Date's 64,331 ms (ratio 0.99999); no drift. #targetengineis not honored via$.evalFile()/ COMDoJavaScriptFilein AI 30.6.0 — named engines were not created; evals landed inmain. Only the ESD transport'stransientengine exposes a second clock.- esbuild var-export getters evaluate at define time. The engine lacks
__defineGetter__, sodefineProperty-getter exports ofvarbindings evaluate immediately while hoisting still leaves themundefined— the export is permanentlyundefined. ESTIMER ships no exported var bindings: functions only, constants viaESTIMER.constants()(the esarr lesson). Timeris a closure factory, not a class — esbuild cannot downlevel a class to ES5 in this configuration (verified error); the factory pattern is the ES3-safe shape.
npm install # esbuild + typescript
npm run build # dist/ESTIMER.jsx, vendor-estimer.js, estimer-core.esm.mjs
npm test # Node harness: 5,165 checks (clock vectors + stats/median differential)
npm run fuzz # 2,000,028 seeded differential iterations (seed 1337)
npm run typecheck # npx tsc --noEmit -p .
npm run benchmark # Node reference lane (canonical protocol)
npm run live-verify # 56/56 engine checks via the COM tool (Illustrator 30.6.0)estimer/
src/ TypeScript core (index.ts facade, timer-core.ts accumulator, sleep.ts, stats.ts, types.ts)
tests/ Node harnesses (custom, no framework): unit + vectors + fuzz + benchmark + live-verify
scripts/ live-engine probe .jsx files (G1-G8 evidence)
bench/ engine-lane benchmark (bench-live.jsx, run-bench-live.mjs) + raw JSON
docs/ design.md + research-probes.md + benchmark-rounds-1.md
evidence/raw/ raw probe JSON outputs
dist/ generated bundles (gitignored; produced by npm run build)
- Counter base = ExtendScript engine creation, not process start. The repo previously phrased the wrap as "~35.8 min since the epoch/init" without defining the base. Probed: the main engine was created ~9.2 h after process start, yet its first read was +675.9e6 µs (11.26 min of engine age, positive, unwrapped). Wrap math and first-read interpretation must use engine-creation time, and per-engine clocks mean a fresh engine gets a fresh base (
docs/research-probes.mdG1/G2). #targetengineis not honored via$.evalFile()/ COMDoJavaScriptFilein AI 30.6.0. Named engines were not created (ESD engine list stayed["main", "transient"]); "fresh and reused engines" probes must use the ESD transport'stransientengine (G2).- Read overhead on 30.6.0 is 1 µs median (p99 2 µs) — the earlier round-2 figure of ~2 µs (30.5.1) remains compatible; 1 µs is now the measured median (G6).
- Zero-delta reads are not errors. 27.6% of 10,000 tight reads measured 0 µs (sub-µs intervals round down); validation must not treat them as failures (G5).
- G4 wrap sign: DISPROVED the "wrapped reads return negative" assumption. Live probes — main engine in wrapped territory at 52.7 min engine age (60 s delta 64,301,512 µs vs Date 64,301 ms, ratio 0.99999,
evidence/raw/probe_g4_longdelta_2026-08-11T120629.json), the 11:51:13 wrap-straddle read (+170.8e6 µs, positive), and the transient engine wrap-span read (12:12:33, +213,446,667 µs, positive,evidence/raw/transient_wrap_result.json) — all returned positive, sane deltas; 0 negatives across every probe + 10k consecutive reads (G5). Engine deltas are wrap-safe in practice on 30.6.0; the'correct'+2^32 policy remains the deterministic safety net for fake/adversarial sources, not a live-path correction on this engine (MEASURED-FACTS.md correction 5;docs/research-probes.mdG4). Root cause (static RE of ExtendScript.dll + host ScCore.dll, Illustrator 30.6.0 and Photoshop 2026): the$getter dispatches to the host importScCore::Thread::getHiResTimer()— a full 64-bit QPC-based delta (now - lastRead, per-thread TLS) converted to µs and stored as a double; no wrap arithmetic exists at either layer, so a negative read is structurally impossible on these builds (evidence/re-ai-sccore.md,evidence/re-ps-extendscript.md,evidence/re-ps-sccore.md; seedocs/design.md§3).
- docsforadobe and the docsforadobe.dev community — maintainers of the de-facto reference documentation for the ExtendScript runtime; their reverse-engineering of
$.hiresTimer's delta-clock and thread-local semantics made the measured findings in this README possible to write down at all. - The ESON/ESB64 family — the ES3 engineering patterns carry over directly:
$.hiresTimerdiscipline, the esbuild var-export quirk, and the (0, 1e8] µs rejection ceiling. - The es-family — ESARR's differential-validation harness shape and its
benchmark-rounds-1.mdprotocol, and ESSTR's release pipeline, are the templates this repository follows.
GPL-3.0-or-later. See LICENSE.
ESTIMER: ExtendScript TIMER. Measured on the engine, safe across the wrap.