@dxos/async 0.10.0 → 0.11.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/dist/lib/chunk-events.mjs +384 -0
- package/dist/lib/chunk-events.mjs.map +1 -0
- package/dist/lib/index.mjs +1327 -0
- package/dist/lib/index.mjs.map +1 -0
- package/dist/lib/testing.mjs +30 -0
- package/dist/lib/testing.mjs.map +1 -0
- package/dist/types/src/index.d.ts +0 -1
- package/dist/types/src/index.d.ts.map +1 -1
- package/dist/types/src/task-scheduling.d.ts.map +1 -1
- package/dist/types/src/update-scheduler.d.ts +40 -6
- package/dist/types/src/update-scheduler.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +14 -12
- package/src/index.ts +0 -1
- package/src/task-scheduling.ts +4 -0
- package/src/update-scheduler.test.ts +192 -2
- package/src/update-scheduler.ts +104 -30
- package/dist/lib/browser/index.mjs +0 -1677
- package/dist/lib/browser/index.mjs.map +0 -7
- package/dist/lib/browser/meta.json +0 -1
- package/dist/lib/node-esm/index.mjs +0 -1679
- package/dist/lib/node-esm/index.mjs.map +0 -7
- package/dist/lib/node-esm/meta.json +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dxos/async",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.1",
|
|
4
4
|
"description": "Async utilities.",
|
|
5
5
|
"homepage": "https://dxos.org",
|
|
6
6
|
"bugs": "https://github.com/dxos/dxos/issues",
|
|
@@ -14,12 +14,14 @@
|
|
|
14
14
|
"type": "module",
|
|
15
15
|
"exports": {
|
|
16
16
|
".": {
|
|
17
|
+
"source": "./src/index.ts",
|
|
17
18
|
"types": "./dist/types/src/index.d.ts",
|
|
18
|
-
"
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
19
|
+
"import": "./dist/lib/index.mjs"
|
|
20
|
+
},
|
|
21
|
+
"./testing": {
|
|
22
|
+
"source": "./src/test-stream.ts",
|
|
23
|
+
"types": "./dist/types/src/test-stream.d.ts",
|
|
24
|
+
"import": "./dist/lib/testing.mjs"
|
|
23
25
|
}
|
|
24
26
|
},
|
|
25
27
|
"types": "dist/types/src/index.d.ts",
|
|
@@ -30,12 +32,12 @@
|
|
|
30
32
|
"dependencies": {
|
|
31
33
|
"zen-observable": "^0.10.0",
|
|
32
34
|
"zen-push": "^0.3.1",
|
|
33
|
-
"@dxos/context": "0.
|
|
34
|
-
"@dxos/debug": "0.
|
|
35
|
-
"@dxos/invariant": "0.
|
|
36
|
-
"@dxos/log": "0.
|
|
37
|
-
"@dxos/node-std": "0.
|
|
38
|
-
"@dxos/util": "0.
|
|
35
|
+
"@dxos/context": "0.11.1",
|
|
36
|
+
"@dxos/debug": "0.11.1",
|
|
37
|
+
"@dxos/invariant": "0.11.1",
|
|
38
|
+
"@dxos/log": "0.11.1",
|
|
39
|
+
"@dxos/node-std": "0.11.1",
|
|
40
|
+
"@dxos/util": "0.11.1"
|
|
39
41
|
},
|
|
40
42
|
"devDependencies": {
|
|
41
43
|
"@types/zen-observable": "^0.8.3"
|
package/src/index.ts
CHANGED
|
@@ -16,7 +16,6 @@ export * from './persistent-lifecycle';
|
|
|
16
16
|
export * from './push-iterable';
|
|
17
17
|
export * from './stream-to-array';
|
|
18
18
|
export * from './task-scheduling';
|
|
19
|
-
export * from './test-stream';
|
|
20
19
|
export * from './testing';
|
|
21
20
|
export * from './timeout';
|
|
22
21
|
export * from './timer';
|
package/src/task-scheduling.ts
CHANGED
|
@@ -40,6 +40,10 @@ export class DeferredTask {
|
|
|
40
40
|
|
|
41
41
|
scheduleTask(this._ctx, async () => {
|
|
42
42
|
// The previous task might still be running, so we need to wait for it to finish.
|
|
43
|
+
//
|
|
44
|
+
// A single await suffices only because this is the one claim site and `_scheduled` collapses
|
|
45
|
+
// schedules into one waiter. A second path claiming `_currentTask` directly would let two
|
|
46
|
+
// callbacks run at once — see dxos/edge#758.
|
|
43
47
|
await this._currentTask; // Can't be rejected.
|
|
44
48
|
|
|
45
49
|
// Reset the flag. New tasks can now be scheduled. They would wait for the callback to finish.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Copyright 2023 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
-
import { describe,
|
|
5
|
+
import { describe, test } from 'vitest';
|
|
6
6
|
|
|
7
7
|
import { Context } from '@dxos/context';
|
|
8
8
|
|
|
@@ -10,7 +10,7 @@ import { sleep } from './timeout';
|
|
|
10
10
|
import { UpdateScheduler } from './update-scheduler';
|
|
11
11
|
|
|
12
12
|
describe('update-scheduler', () => {
|
|
13
|
-
test('schedules updates', async () => {
|
|
13
|
+
test('schedules updates', async ({ expect }) => {
|
|
14
14
|
let updates = 0;
|
|
15
15
|
|
|
16
16
|
const ctx = new Context();
|
|
@@ -24,4 +24,194 @@ describe('update-scheduler', () => {
|
|
|
24
24
|
await sleep(5);
|
|
25
25
|
expect(updates).to.eq(1);
|
|
26
26
|
});
|
|
27
|
+
|
|
28
|
+
// The flush-barrier contract: when `runBlocking` resolves, everything that was queued at call time
|
|
29
|
+
// has been fully processed — not claimed by a still-running pass the barrier failed to observe.
|
|
30
|
+
test('runBlocking does not resolve while a pass that claimed the queue is still in flight', async ({ expect }) => {
|
|
31
|
+
const ctx = new Context();
|
|
32
|
+
const queue: string[] = [];
|
|
33
|
+
const processed: string[] = [];
|
|
34
|
+
let inFlight = 0;
|
|
35
|
+
const gate = { resolve: () => {} };
|
|
36
|
+
const scheduler: UpdateScheduler = new UpdateScheduler(ctx, async () => {
|
|
37
|
+
const claimed = queue.splice(0, queue.length); // Claim synchronously, like _sendUpdates.
|
|
38
|
+
inFlight += claimed.length;
|
|
39
|
+
if (claimed.length > 0) {
|
|
40
|
+
await new Promise<void>((resolve) => {
|
|
41
|
+
gate.resolve = resolve;
|
|
42
|
+
setTimeout(resolve, 50); // The claimed batch takes real time to deliver.
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
processed.push(...claimed);
|
|
46
|
+
inFlight -= claimed.length;
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// A long pass is running (claimed nothing of interest)...
|
|
50
|
+
queue.push('warmup');
|
|
51
|
+
scheduler.trigger();
|
|
52
|
+
await sleep(5);
|
|
53
|
+
|
|
54
|
+
// ...now the real work arrives; a trigger parks behind the running pass...
|
|
55
|
+
queue.push('document-data');
|
|
56
|
+
scheduler.trigger();
|
|
57
|
+
await sleep(0); // Let the trigger's task start and park on the running pass, as in production.
|
|
58
|
+
|
|
59
|
+
// ...and a flush arrives after it.
|
|
60
|
+
await scheduler.runBlocking();
|
|
61
|
+
|
|
62
|
+
// Everything that was queued when runBlocking was called must be fully processed — not claimed
|
|
63
|
+
// by a still-running pass the barrier failed to observe.
|
|
64
|
+
expect(inFlight, 'runBlocking resolved with a claimed batch still in flight').toEqual(0);
|
|
65
|
+
expect(processed).toContain('document-data');
|
|
66
|
+
await ctx.dispose();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// The callback is not reentrant: it typically drains shared queued state, so two passes running at
|
|
70
|
+
// once each claim part of the queue. Historically a throttled `trigger` checked for a running pass
|
|
71
|
+
test('the callback does not overlap itself when runBlocking races a throttled trigger', async ({ expect }) => {
|
|
72
|
+
const ctx = new Context();
|
|
73
|
+
let calls = 0;
|
|
74
|
+
let active = 0;
|
|
75
|
+
let maxActive = 0;
|
|
76
|
+
const scheduler = new UpdateScheduler(
|
|
77
|
+
ctx,
|
|
78
|
+
async () => {
|
|
79
|
+
const call = ++calls;
|
|
80
|
+
active++;
|
|
81
|
+
maxActive = Math.max(maxActive, active);
|
|
82
|
+
// Make the `runBlocking` pass outlast the triggered pass's throttle delay.
|
|
83
|
+
await sleep(call === 2 ? 200 : 10);
|
|
84
|
+
active--;
|
|
85
|
+
},
|
|
86
|
+
{ maxFrequency: 10 }, // 100ms between passes.
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
// First pass consumes the throttle allowance, so the next `trigger` has to wait one out.
|
|
90
|
+
scheduler.trigger();
|
|
91
|
+
await sleep(40);
|
|
92
|
+
|
|
93
|
+
scheduler.trigger(); // Parks in its delay, having seen nothing running.
|
|
94
|
+
const blocking = scheduler.runBlocking(); // Starts a long pass while it sleeps.
|
|
95
|
+
await blocking;
|
|
96
|
+
await sleep(300); // Let the triggered pass run.
|
|
97
|
+
|
|
98
|
+
expect(calls, 'the coalesced flush run must have happened').toBeGreaterThanOrEqual(2);
|
|
99
|
+
expect(maxActive, 'two callbacks ran at once').toEqual(1);
|
|
100
|
+
await ctx.dispose();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// `runBlocking` is a flush barrier: a caller must observe that its batch was NOT handed off. The
|
|
104
|
+
// error belongs to the callers waiting on that run — and must not poison later barriers (a
|
|
105
|
+
// retained rejected pass would make `join`/dispose throw someone else's stale error).
|
|
106
|
+
test('runBlocking rejects with its run error and later barriers stay clean', async ({ expect }) => {
|
|
107
|
+
const ctx = new Context();
|
|
108
|
+
let fail = true;
|
|
109
|
+
const scheduler = new UpdateScheduler(ctx, async () => {
|
|
110
|
+
if (fail) {
|
|
111
|
+
throw new Error('send failed');
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
await expect(scheduler.runBlocking()).rejects.toThrow('send failed');
|
|
116
|
+
|
|
117
|
+
fail = false;
|
|
118
|
+
await expect(scheduler.join()).resolves.toBeUndefined();
|
|
119
|
+
await expect(scheduler.runBlocking()).resolves.toBeUndefined();
|
|
120
|
+
await ctx.dispose();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// The outcome handle must be read AFTER `trigger()`: when a run is already in flight the handle is
|
|
124
|
+
// still the finished-and-woken one from that run, so reading it first would return immediately and
|
|
125
|
+
// report a flush that never covered the caller's work.
|
|
126
|
+
test('runBlocking waits for a run that starts after it, not one already in flight', async ({ expect }) => {
|
|
127
|
+
const ctx = new Context();
|
|
128
|
+
const queue: string[] = [];
|
|
129
|
+
const drained: string[][] = [];
|
|
130
|
+
const scheduler = new UpdateScheduler(ctx, async () => {
|
|
131
|
+
drained.push(queue.splice(0, queue.length));
|
|
132
|
+
await sleep(30);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
queue.push('first');
|
|
136
|
+
scheduler.trigger();
|
|
137
|
+
await sleep(5); // The first run has claimed 'first' and is still in flight.
|
|
138
|
+
|
|
139
|
+
queue.push('second'); // Enqueued while that run is running, so it cannot be in its drain.
|
|
140
|
+
await scheduler.runBlocking();
|
|
141
|
+
|
|
142
|
+
expect(drained.flat(), 'runBlocking resolved without draining later work').toContain('second');
|
|
143
|
+
await ctx.dispose();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// Concurrent flushes coalesce onto one run that observes everything enqueued before either call —
|
|
147
|
+
// they are all asking the same question ("has what I queued been handed off?"), so one drain
|
|
148
|
+
// answers all of them.
|
|
149
|
+
test('concurrent runBlocking callers coalesce into a single run', async ({ expect }) => {
|
|
150
|
+
const ctx = new Context();
|
|
151
|
+
const queue: string[] = [];
|
|
152
|
+
const drained: string[][] = [];
|
|
153
|
+
const scheduler = new UpdateScheduler(ctx, async () => {
|
|
154
|
+
drained.push(queue.splice(0, queue.length));
|
|
155
|
+
await sleep(10);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
queue.push('a', 'b');
|
|
159
|
+
await Promise.all([scheduler.runBlocking(), scheduler.runBlocking()]);
|
|
160
|
+
|
|
161
|
+
expect(drained.length).toEqual(1);
|
|
162
|
+
expect(drained[0]).toEqual(['a', 'b']);
|
|
163
|
+
await ctx.dispose();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// Flush is urgent: it must not sit out the `maxFrequency` delay of a pass that was scheduled
|
|
167
|
+
// before it (the delay exists to pace background triggers, not barriers).
|
|
168
|
+
test('runBlocking skips the throttle delay', async ({ expect }) => {
|
|
169
|
+
const ctx = new Context();
|
|
170
|
+
let runs = 0;
|
|
171
|
+
const scheduler = new UpdateScheduler(
|
|
172
|
+
ctx,
|
|
173
|
+
async () => {
|
|
174
|
+
runs++;
|
|
175
|
+
},
|
|
176
|
+
{ maxFrequency: 2 }, // 500ms between passes.
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
// Consume the allowance, then queue a throttled pass.
|
|
180
|
+
await scheduler.runBlocking();
|
|
181
|
+
scheduler.trigger();
|
|
182
|
+
|
|
183
|
+
const started = performance.now();
|
|
184
|
+
await scheduler.runBlocking();
|
|
185
|
+
const elapsed = performance.now() - started;
|
|
186
|
+
|
|
187
|
+
expect(runs).toBeGreaterThanOrEqual(2);
|
|
188
|
+
expect(elapsed, `flush waited out the throttle (${Math.round(elapsed)}ms)`).toBeLessThan(400);
|
|
189
|
+
await ctx.dispose();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// A dispose must release a parked flush rather than strand it: the runner it was waiting on may
|
|
193
|
+
// never reach its claim once the context is gone.
|
|
194
|
+
test('dispose releases a parked runBlocking', async ({ expect }) => {
|
|
195
|
+
const ctx = new Context();
|
|
196
|
+
const scheduler = new UpdateScheduler(
|
|
197
|
+
ctx,
|
|
198
|
+
async () => {
|
|
199
|
+
await sleep(5);
|
|
200
|
+
},
|
|
201
|
+
{ maxFrequency: 1 }, // 1000ms delay — the flush parks behind it.
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
await scheduler.runBlocking(); // Consume the allowance.
|
|
205
|
+
scheduler.trigger();
|
|
206
|
+
// Park a flush on the throttled runner, then dispose before it can claim.
|
|
207
|
+
const parked = (async () => {
|
|
208
|
+
// Re-arm the delay skip is deliberately NOT done here: dispose must be what releases it.
|
|
209
|
+
const completionBefore = scheduler.runBlocking();
|
|
210
|
+
return completionBefore;
|
|
211
|
+
})();
|
|
212
|
+
await sleep(10);
|
|
213
|
+
await ctx.dispose();
|
|
214
|
+
|
|
215
|
+
await expect(parked).resolves.toBeUndefined();
|
|
216
|
+
});
|
|
27
217
|
});
|
package/src/update-scheduler.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { type Context } from '@dxos/context';
|
|
6
6
|
|
|
7
7
|
import { scheduleMicroTask } from './task-scheduling';
|
|
8
|
+
import { Trigger } from './trigger';
|
|
8
9
|
|
|
9
10
|
export type UpdateSchedulerOptions = {
|
|
10
11
|
/**
|
|
@@ -18,13 +19,41 @@ export type UpdateSchedulerOptions = {
|
|
|
18
19
|
*/
|
|
19
20
|
const TIME_PERIOD = 1_000;
|
|
20
21
|
|
|
22
|
+
/** A claimed run's error (or `undefined`). Resolves, never rejects — `runBlocking` re-throws it. */
|
|
23
|
+
type RunResult = Promise<Error | undefined>;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Runs a non-reentrant callback at most once at a time, coalescing triggers (optionally rate-limited
|
|
27
|
+
* via {@link UpdateSchedulerOptions.maxFrequency}).
|
|
28
|
+
*
|
|
29
|
+
* The runner inside {@link trigger} is the only place the callback starts; `runBlocking` and
|
|
30
|
+
* `forceTrigger` funnel into it. That, plus `_scheduled` collapsing triggers into one pending
|
|
31
|
+
* runner, is what makes mutual exclusion hold by construction (dxos/edge#758).
|
|
32
|
+
*/
|
|
21
33
|
export class UpdateScheduler {
|
|
34
|
+
/** The running pass; never rejects, non-null exactly while a run is executing. */
|
|
35
|
+
private _currentTask: Promise<void> | null = null;
|
|
36
|
+
private _scheduled = false;
|
|
37
|
+
|
|
22
38
|
/**
|
|
23
|
-
*
|
|
24
|
-
*
|
|
39
|
+
* Armed by the `trigger` that schedules a runner, woken by that runner when it claims — with the
|
|
40
|
+
* run's promise, which `Trigger` adopts, so waiters resume when the run *finishes*. Lets
|
|
41
|
+
* `runBlocking` await a run that does not exist in `_currentTask` yet.
|
|
42
|
+
*
|
|
43
|
+
* `reset()` abandons parked waiters, so it may only be armed in `trigger`'s non-coalesced branch:
|
|
44
|
+
* waiters park only while `_scheduled` is true, and that branch requires it false.
|
|
25
45
|
*/
|
|
26
|
-
private
|
|
27
|
-
|
|
46
|
+
private _runOutcome = new Trigger<RunResult>();
|
|
47
|
+
|
|
48
|
+
/** Woken to make the pending runner skip its throttle delay. Replaced at claim time. */
|
|
49
|
+
private _skipDelay = new Trigger();
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* `runBlocking` callers in flight, incremented synchronously before triggering. A failing run uses
|
|
53
|
+
* it to tell whether someone will throw the error; counting microtask hops to a waiter's resume
|
|
54
|
+
* would be a race.
|
|
55
|
+
*/
|
|
56
|
+
private _observers = 0;
|
|
28
57
|
|
|
29
58
|
private _lastUpdateTime = -TIME_PERIOD;
|
|
30
59
|
|
|
@@ -34,24 +63,37 @@ export class UpdateScheduler {
|
|
|
34
63
|
private readonly _params: UpdateSchedulerOptions = {},
|
|
35
64
|
) {
|
|
36
65
|
_ctx.onDispose(async () => {
|
|
37
|
-
await this.
|
|
66
|
+
await this._currentTask; // Context waits for callback to finish.
|
|
67
|
+
// Release `runBlocking` callers parked on a runner that never claimed. NOOP if already woken.
|
|
68
|
+
this._runOutcome.wake(Promise.resolve(undefined));
|
|
38
69
|
});
|
|
39
70
|
}
|
|
40
71
|
|
|
72
|
+
get scheduled() {
|
|
73
|
+
return this._scheduled;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Schedule the callback to run asynchronously. Triggers issued while a run is pending or in flight
|
|
78
|
+
* coalesce into the next run.
|
|
79
|
+
*/
|
|
41
80
|
trigger(): void {
|
|
42
81
|
if (this._scheduled) {
|
|
43
82
|
return;
|
|
44
83
|
}
|
|
84
|
+
this._scheduled = true;
|
|
85
|
+
this._runOutcome.reset(); // Only here — never on a coalesced trigger (see `_runOutcome`).
|
|
45
86
|
|
|
87
|
+
// The only claim site. A single await suffices: nothing else claims, so this is the only waiter.
|
|
46
88
|
scheduleMicroTask(this._ctx, async () => {
|
|
47
|
-
|
|
48
|
-
await this._promise; // Can't be rejected.
|
|
89
|
+
await this._currentTask; // Never rejects.
|
|
49
90
|
|
|
50
|
-
//
|
|
91
|
+
// Rate limiting. Suspending before the claim is safe only because no other claimant exists.
|
|
51
92
|
if (this._params.maxFrequency) {
|
|
52
93
|
const now = performance.now();
|
|
53
94
|
const delay = this._lastUpdateTime + TIME_PERIOD / this._params.maxFrequency - now;
|
|
54
95
|
if (delay > 0) {
|
|
96
|
+
const skipDelay = this._skipDelay;
|
|
55
97
|
await new Promise<void>((resolve) => {
|
|
56
98
|
const timeoutId = setTimeout(() => {
|
|
57
99
|
clearContext();
|
|
@@ -62,53 +104,85 @@ export class UpdateScheduler {
|
|
|
62
104
|
clearTimeout(timeoutId);
|
|
63
105
|
resolve();
|
|
64
106
|
});
|
|
107
|
+
|
|
108
|
+
// `runBlocking`/`forceTrigger` cut the delay short.
|
|
109
|
+
void skipDelay.wait().then(() => {
|
|
110
|
+
clearTimeout(timeoutId);
|
|
111
|
+
clearContext();
|
|
112
|
+
resolve();
|
|
113
|
+
});
|
|
65
114
|
});
|
|
66
115
|
}
|
|
67
116
|
}
|
|
68
117
|
|
|
69
118
|
if (this._ctx.disposed) {
|
|
119
|
+
this._runOutcome.wake(Promise.resolve(undefined));
|
|
70
120
|
return;
|
|
71
121
|
}
|
|
72
122
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
// Reset the flag. New tasks can now be scheduled. They would wait for the callback to finish.
|
|
123
|
+
// The claim: one synchronous slice from here to the `wake` below.
|
|
76
124
|
this._scheduled = false;
|
|
77
|
-
this.
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
(error) => {
|
|
82
|
-
|
|
83
|
-
|
|
125
|
+
this._skipDelay = new Trigger();
|
|
126
|
+
this._lastUpdateTime = performance.now();
|
|
127
|
+
const run: RunResult = this._callback().then(
|
|
128
|
+
() => undefined,
|
|
129
|
+
(error): Error => {
|
|
130
|
+
// A thrown non-Error is typed away here, matching `Context.raise`'s signature.
|
|
131
|
+
const failure = error as Error;
|
|
132
|
+
// Unobserved failures go to the context, as trigger-path failures always have.
|
|
133
|
+
if (this._observers === 0) {
|
|
134
|
+
this._ctx.raise(failure);
|
|
135
|
+
}
|
|
136
|
+
return failure;
|
|
84
137
|
},
|
|
85
138
|
);
|
|
139
|
+
const task: Promise<void> = run.then(() => {
|
|
140
|
+
if (this._currentTask === task) {
|
|
141
|
+
this._currentTask = null;
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
this._currentTask = task;
|
|
145
|
+
this._runOutcome.wake(run);
|
|
86
146
|
});
|
|
87
|
-
|
|
88
|
-
this._scheduled = true;
|
|
89
147
|
}
|
|
90
148
|
|
|
149
|
+
/**
|
|
150
|
+
* Run as soon as possible: schedules (coalescing with any pending run) and skips the throttle
|
|
151
|
+
* delay. Does not start a concurrent callback.
|
|
152
|
+
*/
|
|
91
153
|
forceTrigger(): void {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
});
|
|
154
|
+
this.trigger();
|
|
155
|
+
this._skipDelay.wake();
|
|
95
156
|
}
|
|
96
157
|
|
|
97
158
|
/**
|
|
98
159
|
* Waits for the current task to finish if it is running.
|
|
99
|
-
* Does not schedule a new task.
|
|
160
|
+
* Does not schedule a new task, and does not wait for one that is merely scheduled.
|
|
100
161
|
*/
|
|
101
162
|
async join(): Promise<void> {
|
|
102
|
-
await this.
|
|
163
|
+
await this._currentTask;
|
|
103
164
|
}
|
|
104
165
|
|
|
105
166
|
/**
|
|
106
|
-
*
|
|
167
|
+
* Ensure a run that observes everything enqueued so far, and wait for it to finish. Waits for the
|
|
168
|
+
* pending run to claim, then for its result; that run necessarily claims after this call, so its
|
|
169
|
+
* drain includes everything enqueued before it. Rejects with the run's error so a flush barrier can
|
|
170
|
+
* see its batch was not handed off. No-op once disposed.
|
|
107
171
|
*/
|
|
108
172
|
async runBlocking(): Promise<void> {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
173
|
+
if (this._ctx.disposed) {
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
this._observers++;
|
|
177
|
+
try {
|
|
178
|
+
this.trigger();
|
|
179
|
+
this._skipDelay.wake();
|
|
180
|
+
const error = await this._runOutcome.wait();
|
|
181
|
+
if (error !== undefined) {
|
|
182
|
+
throw error;
|
|
183
|
+
}
|
|
184
|
+
} finally {
|
|
185
|
+
this._observers--;
|
|
186
|
+
}
|
|
113
187
|
}
|
|
114
188
|
}
|