Skip to content

Commit 36b87cf

Browse files
ntkmenex3
andauthored
Fix race condition (#30)
Co-authored-by: Natalie Weizenbaum <nweiz@google.com>
1 parent 7e897fd commit 36b87cf

File tree

9 files changed

+344
-183
lines changed

9 files changed

+344
-183
lines changed

.eslintignore

Lines changed: 0 additions & 3 deletions
This file was deleted.

.eslintrc

Lines changed: 0 additions & 14 deletions
This file was deleted.

.github/workflows/ci.yml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,6 @@ jobs:
4747

4848
deploy:
4949
name: Deploy
50-
runs-on: ubuntu-latest
5150
if: "startsWith(github.ref, 'refs/tags/') && github.repository == 'sass/sync-message-port'"
5251
needs: [static_analysis, tests]
5352
permissions:

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,15 @@
1+
## 1.2.0
2+
3+
* Calling `SyncMessagePort.receiveMessage()` or
4+
`SyncMessagePort.receiveMessageIfAvailable()` while the port has listeners is
5+
no longer an error. If these calls consume a message, it won't be propagated
6+
to the event listener, and vice versa. If a `receiveMessage()` call is active
7+
at the same time as a listener, an incoming message will be handled
8+
preferentially by `receiveMessage()`.
9+
10+
* Fix a race condition where sending many messages in a row could (rarely) cause
11+
a crash.
12+
113
## 1.1.3
214

315
* No user-visible changes.

eslint.config.js

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { defineConfig } from 'eslint/config';
2+
import gts from 'gts';
3+
4+
export default defineConfig([
5+
gts,
6+
{
7+
rules: {
8+
'@typescript-eslint/explicit-function-return-type': [
9+
'error',
10+
{'allowExpressions': true}
11+
],
12+
'func-style': ['error', 'declaration'],
13+
'prefer-const': ['error', {'destructuring': 'all'}],
14+
// It would be nice to sort import declaration order as well, but that's not
15+
// autofixable and it's not worth the effort of handling manually.
16+
'sort-imports': ['error', {'ignoreDeclarationSort': true}],
17+
},
18+
},
19+
{
20+
ignores: [
21+
'build/',
22+
'dist/',
23+
'**/*.js',
24+
],
25+
},
26+
]);

lib/atomic_counter.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/**
2+
* A counter that can be atomically incremented and decremented, and which
3+
* callers can synchronously listen for it becoming non-zero.
4+
*
5+
* This can be "open" or "closed"; once it's closed, {@link wait} will no longer
6+
* emit useful events.
7+
*/
8+
export class AtomicCounter {
9+
/**
10+
* The underlying BigInt64Array.
11+
*
12+
* The first BigInt64 represents the current value of the counter. The second
13+
* BigInt64 is used to track the closed state.
14+
*/
15+
private readonly buffer: BigInt64Array;
16+
17+
constructor(buffer: SharedArrayBuffer) {
18+
if (buffer.byteLength !== 16) {
19+
throw new Error('SharedArrayBuffer must have a byteLength of 16.');
20+
}
21+
this.buffer = new BigInt64Array(buffer);
22+
}
23+
24+
/** Atomically decrement the current value by one. */
25+
decrement(): void {
26+
Atomics.sub(this.buffer, 0, 1n);
27+
}
28+
29+
/** Atomically increment the current value by one. */
30+
increment(): void {
31+
if (Atomics.add(this.buffer, 0, 1n) === 0n) {
32+
Atomics.notify(this.buffer, 0, 1);
33+
}
34+
}
35+
36+
/**
37+
* Closes the counter.
38+
*
39+
* This will cause any outstanding calls to {@link wait} on any thread to
40+
* return `true` immediately.
41+
*/
42+
close(): void {
43+
// The current value is no longer relevant once closed, therefore set it to
44+
// non-zero to prevent a potential deadlock when `Atomics.notify` is called
45+
// immediately before `Atomics.wait`.
46+
if (
47+
Atomics.compareExchange(this.buffer, 1, 0n, 1n) === 0n &&
48+
Atomics.compareExchange(this.buffer, 0, 0n, 1n) === 0n
49+
) {
50+
Atomics.notify(this.buffer, 0);
51+
}
52+
}
53+
54+
/**
55+
* Waits until the current value is not zero or the counter is closed.
56+
*
57+
* Returns `true` when the counter is non-zero *or* when it's closed. Returns
58+
* `false` if the counter remains zero for `timeout` milliseconds.
59+
*/
60+
wait(timeout?: number): boolean {
61+
while (
62+
Atomics.load(this.buffer, 0) === 0n &&
63+
Atomics.load(this.buffer, 1) === 0n
64+
) {
65+
const result = Atomics.wait(this.buffer, 0, 0n, timeout);
66+
if (result !== 'ok') {
67+
return result !== 'timed-out';
68+
}
69+
}
70+
return true;
71+
}
72+
}

0 commit comments

Comments
 (0)