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 +8 -4
- package/bin/janela.mjs +6 -3
- package/package.json +1 -1
- package/runtime/janela.ts +114 -16
- package/shim/wvshim.cc +55 -8
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
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
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: "
|
|
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
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
|
|
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
|
-
|
|
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 (
|
|
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
|
|
279
|
-
// outcomes.
|
|
280
|
-
//
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
//
|
|
824
|
-
//
|
|
825
|
-
//
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
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
|
-
|
|
834
|
-
|
|
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
|