janela 0.3.0 → 0.3.1

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
@@ -154,10 +154,14 @@ Errors arrive as values, never throws — `err` carries a Node-shaped message
154
154
  (`ENOENT: no such file or directory, open '/x'`). UTF-8 round-trips exactly,
155
155
  astral characters included.
156
156
 
157
- The payload crosses in a single call (format 3), and the drain that follows it
158
- costs about **10 ms per MB on the UI thread** fine for config files and
159
- documents, still worth chunking for very large media. See
160
- [docs/async.md](../../docs/async.md) for the measurements.
157
+ The payload crosses in a single call (format 3), and the decode that follows is
158
+ spread across turns under a 4 ms budget, so a large read no longer stalls the
159
+ window: a 100 MB file's worst UI pause is ~25 ms (p99 4 ms) rather than ~176 ms,
160
+ at the same throughput. The remaining pause is the one unavoidable copy that
161
+ materialises the string for your callback. See
162
+ [docs/async.md](../../docs/async.md) for the measurements — and note that
163
+ indexing a large string in your own callback (`text.length`, `slice`) is O(n)
164
+ in scriptc and can cost far more than the read did.
161
165
 
162
166
  **Use `app.sleep`, not `setTimeout`.** scriptc's own event loop is parked for
163
167
  as long as the program sits inside the `run()` FFI call, so `setTimeout`,
package/bin/janela.mjs CHANGED
@@ -345,14 +345,17 @@ function ffiManifest(shimLib) {
345
345
  // Job accessors, shared by file I/O and dialogs: both are work whose
346
346
  // answer cannot be produced during the FFI call that starts it.
347
347
  { name: "wvJobStatus", symbol: "wv_job_status", params: ["i32", "i32"], returns: "i32" },
348
+ { name: "wvJobSize", symbol: "wv_job_size", params: ["i32", "i32"], returns: "f64" },
348
349
  {
349
- name: "wvJobTake", symbol: "wv_job_take",
350
+ // One slice per call, so a large payload decodes across several UI turns
351
+ // instead of stalling on all of it at once. Returns the bytes covered.
352
+ name: "wvJobTakeAt", symbol: "wv_job_take_at",
350
353
  params: [
351
- "i32", "i32",
354
+ "i32", "i32", "f64", "f64",
352
355
  { callback: { id: "sink", params: ["string", { context: "sink" }], returns: "void", lifetime: "call" } },
353
356
  { context: "sink" },
354
357
  ],
355
- returns: "i32",
358
+ returns: "f64",
356
359
  },
357
360
  { name: "wvJobFree", symbol: "wv_job_free", params: ["i32", "i32"], returns: "i32" },
358
361
  // Native dialogs: the modal runs on a later UI-thread turn, so asking for
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "janela",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
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
@@ -29,7 +29,14 @@ declare function wvTickStop(h: number): number;
29
29
  declare function wvFsRead(h: number, path: string): number;
30
30
  declare function wvFsWrite(h: number, path: string, data: string): number;
31
31
  declare function wvJobStatus(h: number, id: number): number;
32
- declare function wvJobTake(h: number, id: number, sink: (text: string) => void): number;
32
+ declare function wvJobSize(h: number, id: number): number;
33
+ declare function wvJobTakeAt(
34
+ h: number,
35
+ id: number,
36
+ offset: number,
37
+ maxBytes: number,
38
+ sink: (text: string) => void,
39
+ ): number;
33
40
  declare function wvJobFree(h: number, id: number): number;
34
41
  declare function wvDialog(
35
42
  h: number,
@@ -214,19 +221,111 @@ export function createApp(cfg: WindowConfig): JanelaApp {
214
221
  let jobCbs: FsCallback[] = [];
215
222
  let ticking = false;
216
223
 
224
+ // ---- the drain -----------------------------------------------------------
225
+ // A finished job's bytes still have to be decoded into a TypeScript string,
226
+ // and that cost is proportional to the payload: taking a 100 MB file in one
227
+ // call froze the window for ~240 ms. So a finished job moves here and is
228
+ // decoded a slice at a time, giving the run loop the thread back between
229
+ // slices — total work is unchanged, but no single turn carries much of it.
230
+ //
231
+ // The budget is wall-clock rather than a byte count on purpose: a fixed
232
+ // chunk size fixes the WORST turn but also caps throughput (128 KB per 8 ms
233
+ // tick would cap reads at ~16 MB/s), whereas a time budget spends whatever
234
+ // the machine can do in the time available.
235
+ const DRAIN_BUDGET_MS = 4; // ≈ a quarter of a 60fps frame
236
+ const DRAIN_SLICE = 131072; // 128 KB — granularity within the budget
237
+ let drainIds: number[] = [];
238
+ let drainCbs: FsCallback[] = [];
239
+ let drainOk: boolean[] = [];
240
+ let drainParts: string[][] = [];
241
+ let drainOff: number[] = [];
242
+ let drainSize: number[] = [];
243
+
244
+ // Tick interval: 8 ms is plenty for timers and task chains, but while a
245
+ // payload is draining the loop is doing real work every turn, and waiting
246
+ // 8 ms between 4 ms slices would halve throughput for no benefit. So the
247
+ // ticker runs tighter for as long as there is a payload in flight.
248
+ const TICK_IDLE_MS = 8;
249
+ const TICK_DRAIN_MS = 4;
250
+ let tickMs = TICK_IDLE_MS;
251
+
252
+ const retick = (): void => {
253
+ const want = drainIds.length > 0 ? TICK_DRAIN_MS : TICK_IDLE_MS;
254
+ if (!ticking || want === tickMs) return;
255
+ tickMs = want;
256
+ wvTickStart(h, want);
257
+ };
258
+
217
259
  const wake = (): void => {
218
260
  if (ticking) return;
219
261
  ticking = true;
220
- wvTickStart(h, 8);
262
+ tickMs = drainIds.length > 0 ? TICK_DRAIN_MS : TICK_IDLE_MS;
263
+ wvTickStart(h, tickMs);
221
264
  };
222
265
 
223
266
  const idle = (): void => {
224
267
  if (!ticking) return;
225
- if (taskFns.length > 0 || timerFns.length > 0 || jobIds.length > 0) return;
268
+ if (
269
+ taskFns.length > 0 ||
270
+ timerFns.length > 0 ||
271
+ jobIds.length > 0 ||
272
+ drainIds.length > 0
273
+ ) {
274
+ return;
275
+ }
226
276
  ticking = false;
227
277
  wvTickStop(h);
228
278
  };
229
279
 
280
+ // Decode as much of the pending payloads as the budget allows, then yield.
281
+ // Slices are taken from one job at a time so a big read finishes promptly
282
+ // rather than every concurrent read finishing slowly.
283
+ const drainSome = (): void => {
284
+ if (drainIds.length === 0) return;
285
+ const started = Date.now() + 0;
286
+
287
+ while (drainIds.length > 0) {
288
+ let chunk = "";
289
+ const taken =
290
+ wvJobTakeAt(h, drainIds[0], drainOff[0], DRAIN_SLICE, (text) => {
291
+ chunk = text;
292
+ }) + 0;
293
+
294
+ // A negative count means the job vanished; treat the payload as final
295
+ // rather than spinning on it forever.
296
+ if (taken > 0) {
297
+ drainParts[0].push(chunk);
298
+ drainOff[0] = drainOff[0] + taken;
299
+ }
300
+
301
+ if (taken <= 0 || drainOff[0] >= drainSize[0]) {
302
+ // Joining is one unavoidable O(n) copy: the callback is handed a
303
+ // single string, so the whole payload must be materialised once.
304
+ const payload = drainParts[0].join("");
305
+ const cb = drainCbs[0];
306
+ const ok = drainOk[0];
307
+ wvJobFree(h, drainIds[0]);
308
+
309
+ drainIds = drainIds.slice(1);
310
+ drainCbs = drainCbs.slice(1);
311
+ drainOk = drainOk.slice(1);
312
+ drainParts = drainParts.slice(1);
313
+ drainOff = drainOff.slice(1);
314
+ drainSize = drainSize.slice(1);
315
+
316
+ if (ok) {
317
+ cb(null, payload);
318
+ } else {
319
+ cb(payload, "");
320
+ }
321
+ // User code just ran and may have taken a while; re-check the budget
322
+ // before starting another payload.
323
+ }
324
+
325
+ if (Date.now() - started >= DRAIN_BUDGET_MS) return;
326
+ }
327
+ };
328
+
230
329
  // One turn of the loop: every task queued so far, plus every due timer.
231
330
  // Tasks queued *by* this turn wait for the next one, so a defer() chain
232
331
  // yields to the UI between slices instead of starving it.
@@ -275,21 +374,20 @@ export function createApp(cfg: WindowConfig): JanelaApp {
275
374
  jobIds = keptIds;
276
375
  jobCbs = keptCbs;
277
376
  for (let i = 0; i < doneIds.length; i++) {
278
- // On failure the payload IS the error message, so one take serves both
279
- // outcomes. The sink runs synchronously inside wvFsTake (the callback
280
- // is lifetime:"call"), so `payload` is set by the time it returns.
281
- let payload = "";
282
- wvJobTake(h, doneIds[i], (text) => {
283
- payload = text;
284
- });
285
- wvJobFree(h, doneIds[i]);
286
- if (doneOk[i]) {
287
- doneCbs[i](null, payload);
288
- } else {
289
- doneCbs[i](payload, "");
290
- }
377
+ // On failure the payload IS the error message, so one path serves both
378
+ // outcomes. Nothing is decoded here: the job joins the drain queue and
379
+ // its bytes are taken a slice at a time, under a time budget.
380
+ drainIds.push(doneIds[i]);
381
+ drainCbs.push(doneCbs[i]);
382
+ drainOk.push(doneOk[i]);
383
+ drainParts.push([]);
384
+ drainOff.push(0);
385
+ drainSize.push(wvJobSize(h, doneIds[i]) + 0);
291
386
  }
292
387
  }
388
+
389
+ drainSome();
390
+ retick();
293
391
  idle();
294
392
  };
295
393
 
package/shim/wvshim.cc CHANGED
@@ -820,18 +820,65 @@ int32_t wv_job_status(int32_t h, int32_t id) {
820
820
  return j->status.load(std::memory_order_acquire);
821
821
  }
822
822
 
823
- // Hand a finished job's payload to TS in one call. The callback is
824
- // lifetime:"call", so it runs synchronously here on the UI thread, the only
825
- // thread allowed to touch the scriptc runtime. The worker is already done by
826
- // then (the caller has observed a terminal status), so `data` is stable.
827
- int32_t wv_job_take(int32_t h, int32_t id,
828
- void (*sink)(const uint8_t *, size_t, void *), void *ctx) {
823
+ // Payload size in bytes, so TS knows when it has drained the whole thing.
824
+ // f64 rather than i32: a payload may exceed 2 GB, and every scriptc number is
825
+ // a double anyway (exact for byte counts far beyond any plausible file).
826
+ double wv_job_size(int32_t h, int32_t id) {
827
+ if (!app_at(h)) return -1;
828
+ Job *j = job_at(id);
829
+ if (!j) return -1;
830
+ if (j->status.load(std::memory_order_acquire) == JOB_PENDING) return -1;
831
+ return static_cast<double>(j->data.size());
832
+ }
833
+
834
+ // Hand ONE SLICE of a finished job's payload to TS, and answer how many bytes
835
+ // it covered. The callback is lifetime:"call", so it runs synchronously here —
836
+ // on the UI thread, the only thread allowed to touch the scriptc runtime. The
837
+ // worker is already done by then (the caller has observed a terminal status),
838
+ // so `data` is stable for the whole drain.
839
+ //
840
+ // Slicing exists so the UI thread can decode a large payload across several
841
+ // turns instead of stalling on all of it at once; the caller advances `offset`
842
+ // by the returned count until it reaches wv_job_size().
843
+ //
844
+ // The slice end is pulled back to a UTF-8 sequence boundary, because scriptc
845
+ // decodes a `string` param as UTF-8: cutting mid-sequence would turn one
846
+ // character into replacement characters on both sides of the seam. Bytes that
847
+ // are not valid UTF-8 have no boundary to find, so after four steps the cut
848
+ // stands as asked and the payload is passed through unchanged.
849
+ double wv_job_take_at(int32_t h, int32_t id, double offset, double max_bytes,
850
+ void (*sink)(const uint8_t *, size_t, void *),
851
+ void *ctx) {
829
852
  if (!app_at(h)) return -1;
830
853
  Job *j = job_at(id);
831
854
  if (!j || !sink) return -1;
832
855
  if (j->status.load(std::memory_order_acquire) == JOB_PENDING) return -1;
833
- sink(reinterpret_cast<const uint8_t *>(j->data.data()), j->data.size(), ctx);
834
- return 0;
856
+ if (offset < 0 || max_bytes < 0) return -1;
857
+
858
+ const size_t size = j->data.size();
859
+ const size_t off = static_cast<size_t>(offset);
860
+ if (off > size) return -1;
861
+ if (off == size) return 0;
862
+
863
+ size_t want = static_cast<size_t>(max_bytes);
864
+ if (want == 0) return 0;
865
+ size_t end = off + want;
866
+ if (end >= size) {
867
+ end = size;
868
+ } else {
869
+ const unsigned char *d =
870
+ reinterpret_cast<const unsigned char *>(j->data.data());
871
+ // d[end] is the first byte of the NEXT slice; while it is a continuation
872
+ // byte (10xxxxxx) the cut sits inside a character, so step back.
873
+ size_t e = end;
874
+ for (int guard = 0; guard < 4 && e > off && (d[e] & 0xc0) == 0x80; guard++) {
875
+ e--;
876
+ }
877
+ if (e > off) end = e;
878
+ }
879
+
880
+ sink(reinterpret_cast<const uint8_t *>(j->data.data()) + off, end - off, ctx);
881
+ return static_cast<double>(end - off);
835
882
  }
836
883
 
837
884
  // Release the slot for reuse. Refuses while the worker is still running, so a