Skip to content

Commit 15fa2be

Browse files
author
Taras Mankovski
committed
🐛 Fix signals.is() subscription race (#217)
is() checked the current value before subscribing, so a matching update that landed in the gap was sent to zero subscribers and lost, hanging until a second update. Establish the subscription first, then take the valueOf() snapshot, so a change during subscription setup is either reflected in the snapshot or buffered for consumption. is() becomes a stateless function returning an Operation whose [Symbol.iterator] subscribes before checking; the public signature and ValueSignal interface are unchanged and no replay is added. Add behavior and #217 regression coverage (bounded with @effectionx/timebox), align the README and no-sleep policy with the deterministic guarantee, and bump @effectionx/signals to 0.5.4.
1 parent 5c28704 commit 15fa2be

7 files changed

Lines changed: 102 additions & 21 deletions

File tree

.policies/no-sleep-test-sync.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ it("processes items concurrently", function* () {
6060
From `signals/helpers.test.ts`:
6161

6262
```typescript
63-
import { sleep, spawn, withResolvers } from "effection";
63+
import { spawn, withResolvers } from "effection";
6464
import { createBooleanSignal, is } from "@effectionx/signals";
6565

6666
it("waits until the value of the stream matches the predicate", function* () {
@@ -76,7 +76,7 @@ it("waits until the value of the stream matches the predicate", function* () {
7676
});
7777

7878
yield* spawn(function* () {
79-
yield* sleep(1);
79+
// is() observes the change deterministically; no sleep needed first.
8080
open.set(true);
8181
});
8282

@@ -115,10 +115,12 @@ it("resolves when the assertion passes within the timeout", function* () {
115115

116116
### Compliant: is() with signals for state changes
117117

118-
From `stream-helpers/test-helpers/faucet.test.ts`:
118+
Wait for a signal to reach a target state with `is()`. The producer publishes
119+
immediately — `is()` observes the matching change deterministically, so no
120+
`sleep()` is required to "give the consumer time to subscribe":
119121

120122
```typescript
121-
import { each, sleep, spawn } from "effection";
123+
import { each, spawn } from "effection";
122124
import { createArraySignal, is } from "@effectionx/signals";
123125
import { useFaucet } from "@effectionx/stream-helpers";
124126

@@ -134,7 +136,6 @@ it("creates a faucet that can pour items", function* () {
134136
});
135137

136138
yield* spawn(function* () {
137-
yield* sleep(1);
138139
yield* faucet.pour([1, 2, 3]);
139140
});
140141

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

signals/README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,12 @@ matches the predicate. It's useful when you want to wait for a signal to enter a
102102
specific state. Some of the common use cases are waiting for an array to reach a
103103
given length or for a boolean signal to become true or false.
104104

105+
`is` observes the signal's current state as well as any matching change, so it
106+
completes deterministically without the producer having to `yield` or `sleep`
107+
before publishing. It establishes its subscription before checking the current
108+
value, so a matching update that lands while it is starting to wait is never
109+
lost.
110+
105111
```ts
106112
import { run, spawn } from "effection";
107113
import { createBooleanSignal, is } from "@effectionx/signals";
@@ -114,6 +120,7 @@ await run(function* () {
114120
console.log("floodgates are open!");
115121
});
116122

123+
// No sleep or yield needed before publishing.
117124
open.set(true);
118125
});
119126
```

signals/helpers.test.ts

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { describe, it } from "@effectionx/vitest";
22
import { expect } from "expect";
3-
import { sleep, spawn, withResolvers } from "effection";
3+
import { spawn, withResolvers } from "effection";
4+
import { timebox } from "@effectionx/timebox";
45

56
import { createBooleanSignal } from "./boolean.ts";
7+
import { createArraySignal } from "./array.ts";
68
import { is } from "./helpers.ts";
79

810
describe("is", () => {
@@ -19,12 +21,66 @@ describe("is", () => {
1921
});
2022

2123
yield* spawn(function* () {
22-
yield* sleep(0);
2324
open.set(true);
2425
});
2526

2627
yield* operation;
2728

2829
expect(update).toEqual(["floodgates are open!"]);
2930
});
31+
32+
it("completes immediately when the current value already matches", function* () {
33+
const open = yield* createBooleanSignal(true);
34+
35+
const result = yield* timebox(1000, () =>
36+
is(open, (open) => open === true),
37+
);
38+
39+
expect(result.timeout).toEqual(false);
40+
expect(open.valueOf()).toEqual(true);
41+
});
42+
43+
it("completes on the first matching update after non-matching updates", function* () {
44+
const count = yield* createArraySignal<number>([]);
45+
46+
const { resolve, operation } = withResolvers<void>();
47+
const seen: number[] = [];
48+
49+
yield* spawn(function* () {
50+
yield* is(count, (xs) => xs.length >= 3);
51+
seen.push(count.length);
52+
resolve();
53+
});
54+
55+
yield* spawn(function* () {
56+
count.push(1);
57+
count.push(2);
58+
count.push(3);
59+
});
60+
61+
yield* operation;
62+
63+
expect(seen).toEqual([3]);
64+
expect(count.valueOf()).toEqual([1, 2, 3]);
65+
});
66+
67+
// Regression for #217: a matching change that lands between observing the
68+
// initial state and establishing the subscription must not be lost. The
69+
// producer publishes immediately with no sleep or preliminary yield. The
70+
// timebox deadline is only a diagnostic bound — it does not coordinate the
71+
// producer and consumer — so a lost update surfaces as a timeout failure
72+
// rather than a hang.
73+
it("completes on a match that lands during subscription setup", function* () {
74+
const open = yield* createBooleanSignal(false);
75+
76+
const result = yield* timebox(1000, function* () {
77+
yield* spawn(function* () {
78+
open.set(true);
79+
});
80+
yield* is(open, (open) => open === true);
81+
});
82+
83+
expect(result.timeout).toEqual(false);
84+
expect(open.valueOf()).toEqual(true);
85+
});
3086
});

signals/helpers.ts

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,35 @@
1-
import { each, type Operation } from "effection";
1+
import type { Operation } from "effection";
22
import type { ValueSignal } from "./types.ts";
33

44
/**
55
* Returns an operation that will wait until the value of the stream matches the predicate.
6+
*
7+
* The subscription is established before the current value is checked, so a
8+
* matching change that lands between observing the initial state and consuming
9+
* the stream is not lost. The producer does not need to yield or sleep before
10+
* publishing.
11+
*
612
* @param stream - The stream to check.
713
* @param predicate - The predicate to check the value against.
814
* @returns An operation that will wait until the value of the stream matches the predicate.
915
*/
10-
export function* is<T>(
16+
export function is<T>(
1117
stream: ValueSignal<T>,
1218
predicate: (item: T) => boolean,
1319
): Operation<void> {
14-
const result = predicate(stream.valueOf());
15-
if (result) {
16-
return;
17-
}
18-
for (const value of yield* each(stream)) {
19-
const result = predicate(value);
20-
if (result) {
21-
return;
22-
}
23-
yield* each.next();
24-
}
20+
return {
21+
*[Symbol.iterator]() {
22+
const subscription = yield* stream;
23+
if (predicate(stream.valueOf())) {
24+
return;
25+
}
26+
let next = yield* subscription.next();
27+
while (!next.done) {
28+
if (predicate(next.value)) {
29+
return;
30+
}
31+
next = yield* subscription.next();
32+
}
33+
},
34+
};
2535
}

signals/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@effectionx/signals",
33
"description": "Reactive signals and computed values for Effection operations",
4-
"version": "0.5.3",
4+
"version": "0.5.4",
55
"keywords": ["reactivity"],
66
"type": "module",
77
"main": "./dist/mod.js",
@@ -31,6 +31,7 @@
3131
},
3232
"sideEffects": false,
3333
"devDependencies": {
34+
"@effectionx/timebox": "workspace:*",
3435
"@effectionx/vitest": "workspace:*",
3536
"effection": "^4"
3637
}

signals/tsconfig.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77
"include": ["**/*.ts"],
88
"exclude": ["**/*.test.ts", "dist"],
99
"references": [
10+
{
11+
"path": "../timebox"
12+
},
1013
{
1114
"path": "../vitest"
1215
}

0 commit comments

Comments
 (0)