janela 0.8.0 → 0.9.0

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/README.md CHANGED
@@ -279,9 +279,10 @@ in scriptc and can cost far more than the read did.
279
279
  **Use `app.sleep`, not `setTimeout`.** scriptc's own event loop is parked for
280
280
  as long as the program sits inside the `run()` FFI call, so `setTimeout`,
281
281
  `queueMicrotask` and `await` in host code never fire while the window is open
282
- (they all run after it closes). janela supplies its own loop instead: a native
283
- ticker posts work to the UI thread via `webview_dispatch`, and it only runs
284
- while something is queued, so an idle app costs nothing.
282
+ (they all run after it closes). janela schedules through the shell instead: the
283
+ runtime parks a continuation under an id, the shell keeps the clock and calls
284
+ it back on the UI thread when it comes due. Nothing polls, so an idle app
285
+ costs nothing at all.
285
286
 
286
287
  **Still single-threaded.** scriptc's runtime is not thread-safe (concurrent
287
288
  calls from several threads abort the process), so host code always runs on the
package/bin/janela.mjs CHANGED
@@ -324,21 +324,21 @@ function ffiManifest(shimLib) {
324
324
  returns: "i32",
325
325
  },
326
326
  {
327
- name: "wvOnTick", symbol: "wv_on_tick",
327
+ name: "wvOnTimer", symbol: "wv_on_timer",
328
328
  params: [
329
329
  "i32",
330
- { callback: { id: "tick", params: [{ context: "tick" }], returns: "void", lifetime: "retained" } },
331
- { context: "tick" },
330
+ { callback: { id: "timer", params: ["i32", { context: "timer" }], returns: "void", lifetime: "retained" } },
331
+ { context: "timer" },
332
332
  ],
333
333
  returns: "i32",
334
334
  },
335
335
  { name: "wvRun", symbol: "wv_run", params: ["i32"], returns: "i32" },
336
336
  { name: "wvTerminate", symbol: "wv_terminate", params: ["i32"], returns: "i32" },
337
- // async: deferred returns + the UI-thread pump behind app.defer/sleep
337
+ // async: the held-reply table (deferred returns) plus shell-owned
338
+ // scheduling — TS parks a continuation id, the shell calls it back due.
338
339
  { name: "wvDefer", symbol: "wv_defer", params: ["i32"], returns: "i32" },
339
340
  { name: "wvResolve", symbol: "wv_resolve", params: ["i32", "i32", "i32"], returns: "i32" },
340
- { name: "wvTickStart", symbol: "wv_tick_start", params: ["i32", "i32"], returns: "i32" },
341
- { name: "wvTickStop", symbol: "wv_tick_stop", params: ["i32"], returns: "i32" },
341
+ { name: "wvSchedule", symbol: "wv_schedule", params: ["i32", "i32", "i32"], returns: "i32" },
342
342
  // async file I/O: the blocking syscall runs on a shim worker thread
343
343
  { name: "wvFsRead", symbol: "wv_fs_read", params: ["i32", "string"], returns: "i32" },
344
344
  { name: "wvFsWrite", symbol: "wv_fs_write", params: ["i32", "string", "string"], returns: "i32" },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "janela",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Desktop apps in pure TypeScript, compiled to native. No Rust, no Node, no Electron.",
5
5
  "type": "module",
6
6
  "bin": {
package/runtime/janela.ts CHANGED
@@ -19,13 +19,12 @@ declare function wvEval(h: number, js: string): number;
19
19
  declare function wvBind(h: number, name: string): number;
20
20
  declare function wvReply(h: number, body: string): number;
21
21
  declare function wvOnInvoke(h: number, cb: (req: string) => number): number;
22
- declare function wvOnTick(h: number, cb: () => void): number;
22
+ declare function wvOnTimer(h: number, cb: (id: number) => void): number;
23
23
  declare function wvRun(h: number): number;
24
24
  declare function wvTerminate(h: number): number;
25
25
  declare function wvDefer(h: number): number;
26
26
  declare function wvResolve(h: number, id: number, status: number): number;
27
- declare function wvTickStart(h: number, intervalMs: number): number;
28
- declare function wvTickStop(h: number): number;
27
+ declare function wvSchedule(h: number, id: number, ms: number): number;
29
28
  declare function wvFsRead(h: number, path: string): number;
30
29
  declare function wvFsWrite(h: number, path: string, data: string): number;
31
30
  declare function wvJobStatus(h: number, id: number): number;
@@ -148,11 +147,10 @@ function encodeFilters(filters: DialogFilter[] | undefined): string {
148
147
  const DRAIN_BUDGET_MS = 4; // = a quarter of a 60fps frame
149
148
  const DRAIN_SLICE = 131072; // 128 KB - granularity within the budget
150
149
 
151
- // 8 ms is plenty for timers and task chains, but while a payload is draining
152
- // the loop does real work every turn, and waiting 8 ms between 4 ms slices
153
- // would halve throughput for no benefit.
154
- const TICK_IDLE_MS = 8;
155
- const TICK_DRAIN_MS = 4;
150
+ // The shell posts this id when a file read or a dialog reaches a terminal
151
+ // state. It is not a continuation - it means "service the jobs you are
152
+ // waiting on". Continuation ids start at 1, so the two can never collide.
153
+ const TIMER_JOBS = -1;
156
154
 
157
155
  /**
158
156
  * A running janela app.
@@ -171,21 +169,23 @@ export class JanelaAppImpl<
171
169
  names: string[] = [];
172
170
  handlers: CommandHandler[] = [];
173
171
 
174
- // ---- the host loop -------------------------------------------------------
172
+ // ---- scheduling ----------------------------------------------------------
175
173
  // scriptc's event loop is parked for as long as the program sits inside the
176
174
  // wvRun() FFI call, so setTimeout/await never fire while the window is open.
177
- // These queues are drained instead by the retained tick handler that the
178
- // shim's ticker posts to the UI thread, and the ticker only runs while there
179
- // is work - an idle app costs nothing.
175
+ // The shell schedules instead: a continuation is parked here under an id and
176
+ // handed to wvSchedule(), and the shim calls onTimer(id) back on the UI
177
+ // thread once it comes due. Nothing here polls and nothing wakes
178
+ // periodically - an idle app is genuinely idle.
179
+ //
180
+ // This is the same shape the iOS shell must use, where the compiled TS links
181
+ // no event loop at all and could not hold a timer even if it wanted to.
180
182
  asyncNames: string[] = [];
181
183
  asyncHandlers: AsyncCommandHandler[] = [];
182
- taskFns: (() => void)[] = [];
183
- timerFns: (() => void)[] = [];
184
- timerDue: number[] = [];
184
+ contIds: number[] = [];
185
+ contFns: (() => void)[] = [];
186
+ nextCont = 1; // ids start at 1; TIMER_JOBS (-1) is the shell's own
185
187
  jobIds: number[] = [];
186
188
  jobCbs: FsCallback[] = [];
187
- ticking = false;
188
- tickMs = TICK_IDLE_MS;
189
189
 
190
190
  // ---- the drain -----------------------------------------------------------
191
191
  // A finished job's bytes still have to be decoded into a TypeScript string,
@@ -194,6 +194,7 @@ export class JanelaAppImpl<
194
194
  // decoded a slice at a time, giving the run loop the thread back between
195
195
  // slices - total work is unchanged, but no single turn carries much of it.
196
196
  drainIds: number[] = [];
197
+ draining = false; // a drain continuation is already queued
197
198
  drainCbs: FsCallback[] = [];
198
199
  drainOk: boolean[] = [];
199
200
  drainParts: string[][] = [];
@@ -208,37 +209,49 @@ export class JanelaAppImpl<
208
209
  wvInit(h, BOOTSTRAP);
209
210
  }
210
211
 
211
- retick(): void {
212
- const want = this.drainIds.length > 0 ? TICK_DRAIN_MS : TICK_IDLE_MS;
213
- if (!this.ticking || want === this.tickMs) return;
214
- this.tickMs = want;
215
- wvTickStart(this.handle, want);
216
- }
217
-
218
- wake(): void {
219
- if (this.ticking) return;
220
- this.ticking = true;
221
- this.tickMs = this.drainIds.length > 0 ? TICK_DRAIN_MS : TICK_IDLE_MS;
222
- wvTickStart(this.handle, this.tickMs);
212
+ /**
213
+ * Park `fn` with the shell and ask to be called back in `ms`.
214
+ *
215
+ * The id is the whole protocol: TS keeps the closure, the shell keeps the
216
+ * clock, and neither needs to know anything else about the other.
217
+ */
218
+ schedule(ms: number, fn: () => void): void {
219
+ const id = this.nextCont;
220
+ this.nextCont = id + 1;
221
+ this.contIds.push(id);
222
+ this.contFns.push(fn);
223
+ const delay = ms > 0 ? ms : 0;
224
+ // `+ 0` per the note at the top of this file: a bare FFI call is not safe
225
+ // in every position, and this one is silently dropped without it.
226
+ const rc = wvSchedule(this.handle, id, delay) + 0;
227
+ if (rc < 0) console.log("[janela] could not schedule continuation", id);
223
228
  }
224
229
 
225
- idle(): void {
226
- if (!this.ticking) return;
227
- if (
228
- this.taskFns.length > 0 ||
229
- this.timerFns.length > 0 ||
230
- this.jobIds.length > 0 ||
231
- this.drainIds.length > 0
232
- ) {
233
- return;
230
+ /** Run the continuation parked under `id`, if it is still waiting. */
231
+ runCont(id: number): void {
232
+ for (let i = 0; i < this.contIds.length; i++) {
233
+ if (this.contIds[i] === id) {
234
+ const fn = this.contFns[i];
235
+ // Unregister BEFORE running: a continuation that schedules another one
236
+ // must not disturb the entry being removed, and a continuation that
237
+ // throws must not stay parked forever.
238
+ this.contIds.splice(i, 1);
239
+ this.contFns.splice(i, 1);
240
+ fn();
241
+ return;
242
+ }
234
243
  }
235
- this.ticking = false;
236
- wvTickStop(this.handle);
237
244
  }
238
245
 
239
246
  // Decode as much of the pending payloads as the budget allows, then yield.
240
247
  // Slices are taken from one job at a time so a big read finishes promptly
241
248
  // rather than every concurrent read finishing slowly.
249
+ /**
250
+ * Decode as much of the pending payloads as the budget allows, then hand the
251
+ * thread back. If work remains, a zero-delay continuation carries on at the
252
+ * top of the next turn, so a 100 MB read is spread across frames instead of
253
+ * freezing one.
254
+ */
242
255
  drainSome(): void {
243
256
  if (this.drainIds.length === 0) return;
244
257
  const started = Date.now() + 0;
@@ -281,38 +294,39 @@ export class JanelaAppImpl<
281
294
  // before starting another payload.
282
295
  }
283
296
 
284
- if (Date.now() - started >= DRAIN_BUDGET_MS) return;
297
+ if (Date.now() - started >= DRAIN_BUDGET_MS) {
298
+ this.drainMore();
299
+ return;
300
+ }
285
301
  }
286
302
  }
287
303
 
288
- // One turn of the loop: every task queued so far, plus every due timer.
289
- // Tasks queued *by* this turn wait for the next one, so a defer() chain
290
- // yields to the UI between slices instead of starving it.
291
- turn(): void {
292
- const tasks = this.taskFns;
293
- this.taskFns = [];
294
- for (let i = 0; i < tasks.length; i++) tasks[i]();
295
-
296
- if (this.timerFns.length > 0) {
297
- const now = Date.now() + 0;
298
- const keptFns: (() => void)[] = [];
299
- const keptDue: number[] = [];
300
- const fire: (() => void)[] = [];
301
- for (let i = 0; i < this.timerFns.length; i++) {
302
- if (this.timerDue[i] <= now) {
303
- fire.push(this.timerFns[i]);
304
- } else {
305
- keptFns.push(this.timerFns[i]);
306
- keptDue.push(this.timerDue[i]);
307
- }
308
- }
309
- this.timerFns = keptFns;
310
- this.timerDue = keptDue;
311
- for (let i = 0; i < fire.length; i++) fire[i]();
304
+ /** Continue draining on the next turn, without stacking duplicate work. */
305
+ drainMore(): void {
306
+ if (this.drainIds.length === 0 || this.draining) return;
307
+ this.draining = true;
308
+ this.schedule(0, () => {
309
+ this.draining = false;
310
+ this.drainSome();
311
+ });
312
+ }
313
+
314
+ /**
315
+ * The shell calls here on the UI thread when something it was holding comes
316
+ * due: a continuation TS parked (id >= 1), or a job that finished
317
+ * (TIMER_JOBS). This is the only entry into the loop, and it always lands at
318
+ * the top of a fresh turn — never underneath a TS frame.
319
+ */
320
+ onTimer(id: number): void {
321
+ if (id !== TIMER_JOBS) {
322
+ this.runCont(id);
323
+ return;
312
324
  }
325
+ this.serviceJobs();
326
+ }
313
327
 
314
- // Finished file jobs: the worker thread has already done the blocking
315
- // syscall, so all that happens on this (UI) thread is the drain.
328
+ /** A job reached a terminal state: move the finished ones to the drain. */
329
+ serviceJobs(): void {
316
330
  if (this.jobIds.length > 0) {
317
331
  const keptIds: number[] = [];
318
332
  const keptCbs: FsCallback[] = [];
@@ -346,8 +360,6 @@ export class JanelaAppImpl<
346
360
  }
347
361
 
348
362
  this.drainSome();
349
- this.retick();
350
- this.idle();
351
363
  }
352
364
 
353
365
  // Both dialog kinds share one path: start the job, then let the same drain
@@ -371,8 +383,7 @@ export class JanelaAppImpl<
371
383
  encodeFilters(filters),
372
384
  ) + 0;
373
385
  if (id < 0) {
374
- this.taskFns.push(() => cb(null, "EAGAIN: could not open a dialog"));
375
- this.wake();
386
+ this.defer(() => cb(null, "EAGAIN: could not open a dialog"));
376
387
  return;
377
388
  }
378
389
  this.jobIds.push(id);
@@ -384,7 +395,6 @@ export class JanelaAppImpl<
384
395
  // "null" is a cancel; anything else is a JSON array of paths.
385
396
  cb(JSON.parse(text) as string[] | null);
386
397
  });
387
- this.wake();
388
398
  }
389
399
 
390
400
  /**
@@ -427,16 +437,14 @@ export class JanelaAppImpl<
427
437
 
428
438
  /** Run fn on the next turn of the host loop - the way to slice long work. */
429
439
  defer(fn: () => void): void {
430
- this.taskFns.push(fn);
431
- this.wake();
440
+ // Zero delay: the shell posts straight to the next turn, no timer at all.
441
+ this.schedule(0, fn);
432
442
  }
433
443
 
434
- /** Run fn after at least ms. The host loop's timer; scriptc's setTimeout
444
+ /** Run fn after at least ms. The shell owns the clock; scriptc's setTimeout
435
445
  * cannot fire while the window is open (its loop is parked inside run()). */
436
446
  sleep(ms: number, fn: () => void): void {
437
- this.timerFns.push(fn);
438
- this.timerDue.push(Date.now() + (ms > 0 ? ms : 0));
439
- this.wake();
447
+ this.schedule(ms, fn);
440
448
  }
441
449
 
442
450
  /**
@@ -451,7 +459,6 @@ export class JanelaAppImpl<
451
459
  }
452
460
  this.jobIds.push(id);
453
461
  this.jobCbs.push(cb);
454
- this.wake();
455
462
  }
456
463
 
457
464
  /** Write a file without blocking the window; cb(null) on success. */
@@ -465,7 +472,6 @@ export class JanelaAppImpl<
465
472
  // The write payload is empty on success; the shared callback shape just
466
473
  // ignores the text argument.
467
474
  this.jobCbs.push((err, _text) => cb(err));
468
- this.wake();
469
475
  }
470
476
 
471
477
  /** Show the native "open" dialog; cb gets the paths, or null on cancel. */
@@ -531,8 +537,8 @@ export class JanelaAppImpl<
531
537
  const h = this.handle;
532
538
  // Both handlers are retained: registered once here, called by the shim
533
539
  // for as long as the window is open.
534
- wvOnTick(h, () => {
535
- this.turn();
540
+ wvOnTimer(h, (id) => {
541
+ this.onTimer(id);
536
542
  });
537
543
  wvOnInvoke(h, (req) => {
538
544
  const env = JSON.parse(req) as string[];
package/shim/wvshim.cc CHANGED
@@ -8,15 +8,22 @@
8
8
  // * format 3 — callback params may be `string`/`bytes`, so a payload crosses
9
9
  // into TS as one argument instead of one FFI call per byte. Payloads going
10
10
  // the other way ride `string` params on ordinary functions.
11
- // * format 4 — callbacks may be `retained`, so the invoke and tick handlers
11
+ // * format 4 — callbacks may be `retained`, so the invoke and timer handlers
12
12
  // are registered once and live for the app's lifetime. wv_run() is a plain
13
13
  // blocking call again; it no longer has to carry a callback whose "call
14
14
  // scope" was standing in for "app lifetime".
15
+ //
16
+ // The shell owns scheduling. TS never holds a timer: it registers a
17
+ // continuation under an id and calls wv_schedule(), and this shim calls back
18
+ // into TS with that id once the delay is up. That is the same shape a library
19
+ // -mode host (iOS) must use, where the compiled TS links no event loop at all.
15
20
 
16
21
  #include "webview.h"
17
22
 
23
+ #include <algorithm>
18
24
  #include <atomic>
19
25
  #include <chrono>
26
+ #include <condition_variable>
20
27
  #include <cstdint>
21
28
  #include <cstdio>
22
29
  #include <cstring>
@@ -50,6 +57,13 @@ struct Pending {
50
57
  std::string call_id;
51
58
  };
52
59
 
60
+ // A continuation TS has parked with the shell: run whatever TS registered
61
+ // under `id` once `due` has passed. The shell owns the clock; TS owns the id.
62
+ struct Timer {
63
+ int32_t id;
64
+ std::chrono::steady_clock::time_point due;
65
+ };
66
+
53
67
  struct App {
54
68
  webview_t w = nullptr;
55
69
  bool used = false;
@@ -59,8 +73,8 @@ struct App {
59
73
  // request rides in as a (ptr, len) string param.
60
74
  int32_t (*on_invoke)(const uint8_t *, size_t, void *) = nullptr;
61
75
  void *on_invoke_ctx = nullptr;
62
- void (*on_tick)(void *) = nullptr;
63
- void *on_tick_ctx = nullptr;
76
+ void (*on_timer)(int32_t, void *) = nullptr;
77
+ void *on_timer_ctx = nullptr;
64
78
 
65
79
  // Staging for the in-flight request.
66
80
  std::string req; // JSON args array from JS
@@ -69,13 +83,26 @@ struct App {
69
83
  uint32_t seq = 0;
70
84
 
71
85
  // ---- async support ----
72
- std::vector<Pending> pending; // deferred invokes, addressed by index
73
- bool deferred = false; // set by wv_defer() during the current call
74
- std::thread ticker; // pure-C++ thread; never touches TS itself
75
- std::atomic<bool> ticking{false};
76
- std::atomic<int32_t> tick_ms{16};
86
+ // The held-reply table: an invoke whose answer is not ready yet. The page's
87
+ // promise stays unsettled until wv_resolve() answers this call id.
88
+ std::vector<Pending> pending;
89
+ bool deferred = false; // set by wv_defer() during the current call
90
+
91
+ // The shell's timer queue, and the one thread that watches it. The thread
92
+ // only sleeps and posts — it never touches TS, because scriptc's runtime is
93
+ // not thread-safe. Everything reaches TS through webview_dispatch, on the UI
94
+ // thread, on a LATER turn of the shell's own loop (see timer_on_ui_thread).
95
+ std::vector<Timer> timers;
96
+ std::mutex timers_mu;
97
+ std::condition_variable timers_cv;
98
+ std::thread scheduler;
99
+ std::atomic<bool> scheduling{false};
77
100
  };
78
101
 
102
+ // Reserved timer id: not a TS continuation but "a job changed state, service
103
+ // them". TS allocates its own continuation ids from 1 upwards.
104
+ const int32_t TIMER_JOBS = -1;
105
+
79
106
  // Fixed-size table: handles are indices, never pointers.
80
107
  App g_apps[8];
81
108
 
@@ -92,8 +119,9 @@ std::string to_str(const uint8_t *p, size_t n) {
92
119
  // ---- jobs ------------------------------------------------------------------
93
120
  //
94
121
  // A job is any unit of work whose answer cannot be produced during the FFI
95
- // call that asks for it. TS starts one, gets an id back immediately, and polls
96
- // wv_job_status() from its tick loop until the job is terminal.
122
+ // call that asks for it. TS starts one and gets an id back immediately; when
123
+ // the job reaches a terminal state it posts TIMER_JOBS to the UI thread, and
124
+ // TS then reads wv_job_status() for the jobs it is waiting on.
97
125
  //
98
126
  // Two kinds use this pool, for opposite reasons:
99
127
  // * file I/O — the blocking syscall must happen off the UI thread, so a
@@ -102,9 +130,9 @@ std::string to_str(const uint8_t *p, size_t n) {
102
130
  // later, on the UI thread.
103
131
  // * native dialogs — the modal must run ON the UI thread, but not while TS
104
132
  // is on the stack (runModal/gtk_dialog_run spin a nested event loop, which
105
- // would re-enter the tick handler underneath the invoke handler that asked
106
- // for the dialog). So the job is posted with webview_dispatch and runs at
107
- // the top of a later turn, with no TS frame beneath it.
133
+ // would re-enter TS underneath the invoke handler that asked for the
134
+ // dialog). So the job is posted with webview_dispatch and runs at the top
135
+ // of a later turn, with no TS frame beneath it.
108
136
 
109
137
  const int32_t JOB_PENDING = 0;
110
138
  const int32_t JOB_OK = 1;
@@ -119,6 +147,9 @@ struct Job {
119
147
  std::string data; // payload on success, the error message on failure
120
148
  std::thread worker; // unused by dialog jobs, which run on the UI thread
121
149
  bool used = false;
150
+ // Which app to wake when this job finishes. Without the ticker there is
151
+ // nothing polling, so a finished job has to announce itself.
152
+ int32_t app = -1;
122
153
  };
123
154
 
124
155
  // Jobs are addressed by index and held behind unique_ptr so the vector may
@@ -135,7 +166,7 @@ Job *job_at(int32_t id) {
135
166
 
136
167
  // Reuses a finished slot when one is free, so a long-running app that reads
137
168
  // many files does not grow the table without bound.
138
- int32_t new_job() {
169
+ int32_t new_job(int32_t app) {
139
170
  std::lock_guard<std::mutex> lock(g_jobs_mu);
140
171
  for (size_t i = 0; i < g_jobs.size(); i++) {
141
172
  if (g_jobs[i]->used) continue;
@@ -143,10 +174,12 @@ int32_t new_job() {
143
174
  g_jobs[i]->status.store(JOB_PENDING);
144
175
  g_jobs[i]->data.clear();
145
176
  g_jobs[i]->used = true;
177
+ g_jobs[i]->app = app;
146
178
  return static_cast<int32_t>(i);
147
179
  }
148
180
  g_jobs.push_back(std::unique_ptr<Job>(new Job()));
149
181
  g_jobs.back()->used = true;
182
+ g_jobs.back()->app = app;
150
183
  return static_cast<int32_t>(g_jobs.size() - 1);
151
184
  }
152
185
 
@@ -166,9 +199,18 @@ std::string fs_error_message(const std::string &path, const char *op) {
166
199
  return "EIO: failed to " + std::string(op) + " '" + path + "'";
167
200
  }
168
201
 
202
+ // Defined below, once the app table is in scope. Posts `id` to the app's UI
203
+ // thread via webview_dispatch, so TS is entered on a later turn of the shell's
204
+ // own loop and never underneath a frame it is already inside.
205
+ void post_timer(int32_t app, int32_t id);
206
+
169
207
  void job_finish(Job *j, int32_t status, std::string payload) {
170
208
  j->data = std::move(payload);
171
209
  j->status.store(status, std::memory_order_release);
210
+ // Nothing polls any more, so a finished job announces itself. Safe from a
211
+ // worker thread: webview_dispatch is the documented cross-thread hand-off,
212
+ // and it only queues — TS runs later, on the UI thread.
213
+ if (j->app >= 0) post_timer(j->app, TIMER_JOBS);
172
214
  }
173
215
 
174
216
  void fs_read_worker(Job *j, std::string path) {
@@ -614,18 +656,67 @@ void trampoline(const char *id, const char *req, void *arg) {
614
656
  webview_return(a->w, a->cur_id.c_str(), status,
615
657
  a->reply.empty() ? "null" : a->reply.c_str());
616
658
  // wv_defer() treats a non-empty cur_id as "an invoke is in flight". Clearing
617
- // it here means a defer from anywhere else — a tick, say — fails with -1
659
+ // it here means a defer from anywhere else — a timer, say — fails with -1
618
660
  // instead of stealing this already-answered call's id.
619
661
  a->cur_id.clear();
620
662
  }
621
663
 
622
- // Runs on the UI thread (posted by the ticker via webview_dispatch), so the
623
- // TS it calls stays single-threaded — scriptc's runtime is NOT thread-safe.
624
- void tick_on_ui_thread(webview_t, void *arg) {
625
- App *a = &g_apps[reinterpret_cast<uintptr_t>(arg)];
626
- if (!a->used || !a->on_tick) return; // app quit between dispatch and delivery
664
+ // The app index and the timer id, packed into the single void* that
665
+ // webview_dispatch carries.
666
+ void *pack_timer(int32_t app, int32_t id) {
667
+ uintptr_t packed = (static_cast<uintptr_t>(static_cast<uint32_t>(app)) << 32) |
668
+ static_cast<uint32_t>(id);
669
+ return reinterpret_cast<void *>(packed);
670
+ }
671
+
672
+ // Runs on the UI thread, posted via webview_dispatch, so the TS it calls stays
673
+ // single-threaded — scriptc's runtime is NOT thread-safe.
674
+ //
675
+ // This is also the one place that guarantees the shell never re-enters TS from
676
+ // inside a frame TS is already in. Everything that wants to reach TS — a due
677
+ // timer, a finished file read, a dismissed dialog — goes through a dispatch
678
+ // and therefore lands at the top of a later turn, with no TS beneath it. That
679
+ // rule is invisible when broken: a violating host gets correct-looking results
680
+ // right up until it doesn't, so it is kept by construction, not by testing.
681
+ void timer_on_ui_thread(webview_t, void *arg) {
682
+ uintptr_t packed = reinterpret_cast<uintptr_t>(arg);
683
+ App *a = &g_apps[packed >> 32];
684
+ int32_t id = static_cast<int32_t>(static_cast<uint32_t>(packed & 0xffffffffu));
685
+ if (!a->used || !a->on_timer) return; // app quit between dispatch and delivery
627
686
  a->seq++;
628
- a->on_tick(a->on_tick_ctx);
687
+ a->on_timer(id, a->on_timer_ctx);
688
+ }
689
+
690
+ void post_timer(int32_t app, int32_t id) {
691
+ if (app < 0 || app >= 8) return;
692
+ App *a = &g_apps[app];
693
+ if (!a->used || !a->w) return;
694
+ webview_dispatch(a->w, timer_on_ui_thread, pack_timer(app, id));
695
+ }
696
+
697
+ // The scheduler thread: sleep until the earliest timer is due, hand its id to
698
+ // the UI thread, repeat. It never touches TS and holds no TS state.
699
+ void scheduler_loop(App *a, int32_t h) {
700
+ std::unique_lock<std::mutex> lk(a->timers_mu);
701
+ while (a->scheduling.load()) {
702
+ if (a->timers.empty()) {
703
+ a->timers_cv.wait(lk);
704
+ continue;
705
+ }
706
+ auto soonest = std::min_element(
707
+ a->timers.begin(), a->timers.end(),
708
+ [](const Timer &x, const Timer &y) { return x.due < y.due; });
709
+ auto due = soonest->due;
710
+ if (due > std::chrono::steady_clock::now()) {
711
+ a->timers_cv.wait_until(lk, due);
712
+ continue; // re-check: an earlier timer may have arrived meanwhile
713
+ }
714
+ int32_t id = soonest->id;
715
+ a->timers.erase(soonest);
716
+ lk.unlock();
717
+ post_timer(h, id);
718
+ lk.lock();
719
+ }
629
720
  }
630
721
 
631
722
  } // namespace
@@ -642,16 +733,16 @@ int32_t wv_create(int32_t debug) {
642
733
  g_apps[i].binds.clear();
643
734
  g_apps[i].on_invoke = nullptr;
644
735
  g_apps[i].on_invoke_ctx = nullptr;
645
- g_apps[i].on_tick = nullptr;
646
- g_apps[i].on_tick_ctx = nullptr;
736
+ g_apps[i].on_timer = nullptr;
737
+ g_apps[i].on_timer_ctx = nullptr;
647
738
  g_apps[i].req.clear();
648
739
  g_apps[i].cur_id.clear();
649
740
  g_apps[i].reply.clear();
650
741
  g_apps[i].seq = 0;
651
742
  g_apps[i].pending.clear();
652
743
  g_apps[i].deferred = false;
653
- g_apps[i].ticking.store(false);
654
- g_apps[i].tick_ms.store(16);
744
+ g_apps[i].scheduling.store(false);
745
+ g_apps[i].timers.clear();
655
746
  g_apps[i].w = w;
656
747
  g_apps[i].used = true;
657
748
  return i;
@@ -723,8 +814,8 @@ int32_t wv_reply(int32_t h, const uint8_t *p, size_t n) {
723
814
  // call, and wv_run() is one such call for the app's whole life — so setTimeout
724
815
  // and promise continuations in TS never fire while the window is open. These
725
816
  // four functions supply the missing loop: TS may postpone an invoke's answer
726
- // (wv_defer), answer it later (wv_resolve), and get called back periodically
727
- // on the UI thread to make progress (wv_tick_start / wv_tick_stop).
817
+ // (wv_defer), answer it later (wv_resolve), and park a continuation with the
818
+ // shell to be called back on the UI thread when it comes due (wv_schedule).
728
819
 
729
820
  // Postpone the answer to the invoke being handled right now. Returns a
730
821
  // pending id to hand back to wv_resolve(), or -1 outside a bind callback.
@@ -759,43 +850,60 @@ int32_t wv_resolve(int32_t h, int32_t id, int32_t status) {
759
850
  return 0;
760
851
  }
761
852
 
762
- // Start pumping the retained tick handler every interval_ms. The thread
763
- // itself only sleeps and posts; all TS execution happens on the UI thread.
764
- int32_t wv_tick_start(int32_t h, int32_t interval_ms) {
853
+ // Ask the shell to call the retained timer handler with `id` after `ms`.
854
+ //
855
+ // This is the whole of scheduling: TS keeps the continuation, the shell keeps
856
+ // the clock. A zero delay is not a special case — it posts on the next turn of
857
+ // the loop, which is exactly what app.defer() wants, with no timer involved.
858
+ //
859
+ // An idle app now costs nothing at all: with no timers queued the scheduler
860
+ // thread blocks on a condition variable rather than waking every few
861
+ // milliseconds to find nothing to do.
862
+ int32_t wv_schedule(int32_t h, int32_t id, int32_t ms) {
765
863
  App *a = app_at(h);
766
864
  if (!a) return -1;
767
- a->tick_ms = interval_ms > 0 ? interval_ms : 16;
768
- if (a->ticking.exchange(true)) return 0; // already running
769
- uintptr_t idx = static_cast<uintptr_t>(h);
770
- a->ticker = std::thread([a, idx]() {
771
- while (a->ticking.load()) {
772
- std::this_thread::sleep_for(
773
- std::chrono::milliseconds(a->tick_ms.load()));
774
- if (!a->ticking.load()) break;
775
- webview_dispatch(a->w, tick_on_ui_thread, reinterpret_cast<void *>(idx));
865
+
866
+ // Zero delay skips the queue: there is nothing to wait for, and posting
867
+ // straight to the UI thread keeps a defer() chain as short as possible.
868
+ if (ms <= 0) {
869
+ post_timer(h, id);
870
+ return 0;
871
+ }
872
+
873
+ {
874
+ std::lock_guard<std::mutex> lock(a->timers_mu);
875
+ a->timers.push_back(
876
+ Timer{id, std::chrono::steady_clock::now() +
877
+ std::chrono::milliseconds(ms)});
878
+ if (!a->scheduling.exchange(true)) {
879
+ a->scheduler = std::thread(scheduler_loop, a, h);
776
880
  }
777
- });
881
+ }
882
+ a->timers_cv.notify_one();
778
883
  return 0;
779
884
  }
780
885
 
781
- int32_t wv_tick_stop(int32_t h) {
782
- App *a = app_at(h);
783
- if (!a) return -1;
784
- if (!a->ticking.exchange(false)) return 0;
785
- if (a->ticker.joinable()) a->ticker.join();
786
- return 0;
886
+ // Stop the scheduler thread and drop any timers that never came due. Called on
887
+ // the way out of wv_run(), so nothing can reach TS after the window closes.
888
+ void stop_scheduler(App *a) {
889
+ if (!a->scheduling.exchange(false)) return;
890
+ a->timers_cv.notify_all();
891
+ if (a->scheduler.joinable()) a->scheduler.join();
892
+ std::lock_guard<std::mutex> lock(a->timers_mu);
893
+ a->timers.clear();
787
894
  }
788
895
 
789
896
  // ---- async file I/O ---------------------------------------------------------
790
897
  //
791
898
  // wv_fs_read/wv_fs_write start a worker thread and return immediately with a
792
- // job id. TS polls wv_job_status() from its tick loop and drains the payload
899
+ // job id. TS is woken with TIMER_JOBS when it finishes, reads wv_job_status()
900
+ // and drains the payload
793
901
  // with wv_fs_byte() once the job is terminal. On failure the payload is the
794
902
  // error message, so success and failure share one drain path.
795
903
 
796
904
  int32_t wv_fs_read(int32_t h, const uint8_t *p, size_t n) {
797
905
  if (!app_at(h)) return -1;
798
- int32_t id = new_job();
906
+ int32_t id = new_job(h);
799
907
  Job *j = job_at(id);
800
908
  if (!j) return -1;
801
909
  j->worker = std::thread(fs_read_worker, j, to_str(p, n));
@@ -805,7 +913,7 @@ int32_t wv_fs_read(int32_t h, const uint8_t *p, size_t n) {
805
913
  int32_t wv_fs_write(int32_t h, const uint8_t *p, size_t n, const uint8_t *dp,
806
914
  size_t dn) {
807
915
  if (!app_at(h)) return -1;
808
- int32_t id = new_job();
916
+ int32_t id = new_job(h);
809
917
  Job *j = job_at(id);
810
918
  if (!j) return -1;
811
919
  j->worker = std::thread(fs_write_worker, j, to_str(p, n), to_str(dp, dn));
@@ -906,7 +1014,7 @@ int32_t wv_dialog(int32_t h, int32_t kind, int32_t flags, const uint8_t *tp,
906
1014
  size_t nn, const uint8_t *fp, size_t fn) {
907
1015
  App *a = app_at(h);
908
1016
  if (!a) return -1;
909
- int32_t id = new_job();
1017
+ int32_t id = new_job(h);
910
1018
  Job *j = job_at(id);
911
1019
  if (!j) return -1;
912
1020
 
@@ -994,12 +1102,13 @@ int32_t wv_on_invoke(int32_t h,
994
1102
  return 0;
995
1103
  }
996
1104
 
997
- // Register the retained handler the ticker pumps on the UI thread.
998
- int32_t wv_on_tick(int32_t h, void (*cb)(void *), void *ctx) {
1105
+ // Register the retained handler the shell calls when a scheduled id comes due
1106
+ // (and with TIMER_JOBS when a file read or dialog finishes).
1107
+ int32_t wv_on_timer(int32_t h, void (*cb)(int32_t, void *), void *ctx) {
999
1108
  App *a = app_at(h);
1000
1109
  if (!a) return -1;
1001
- a->on_tick = cb;
1002
- a->on_tick_ctx = ctx;
1110
+ a->on_timer = cb;
1111
+ a->on_timer_ctx = ctx;
1003
1112
  return 0;
1004
1113
  }
1005
1114
 
@@ -1009,11 +1118,10 @@ int32_t wv_run(int32_t h) {
1009
1118
  if (!a) return -1;
1010
1119
  int rc = webview_run(a->w);
1011
1120
  // Nothing may call into TS once run() has returned.
1012
- a->ticking.store(false);
1013
- if (a->ticker.joinable()) a->ticker.join();
1121
+ stop_scheduler(a);
1014
1122
  jobs_join_all(); // nor may an in-flight read outlive the app
1015
1123
  a->on_invoke = nullptr;
1016
- a->on_tick = nullptr;
1124
+ a->on_timer = nullptr;
1017
1125
  return rc;
1018
1126
  }
1019
1127
 
@@ -1026,8 +1134,7 @@ int32_t wv_terminate(int32_t h) {
1026
1134
  int32_t wv_destroy(int32_t h) {
1027
1135
  App *a = app_at(h);
1028
1136
  if (!a) return -1;
1029
- a->ticking.store(false);
1030
- if (a->ticker.joinable()) a->ticker.join();
1137
+ stop_scheduler(a);
1031
1138
  webview_destroy(a->w);
1032
1139
  a->used = false;
1033
1140
  a->w = nullptr;