From 220aed5b77b8646fdd3028e0bc7b58455030ffc7 Mon Sep 17 00:00:00 2001 From: Evan Hu Date: Sat, 19 Sep 2026 22:00:49 +0900 Subject: [PATCH 1/2] test(live): ask redis who is subscribed, not the relay's own map TestAChangeOnOneServerReachesTheOther fails on CI roughly one run in eight with "0 sync frame(s) arrived in ten seconds" -- the failure the test's own comment describes, from before the last attempt to close it. The readiness probe was the hole. Relay.Subscribe records a subscription in r.subscriptions and only then waits for Redis to confirm it, because Subscribe merely queues the command. waitForSubscription polled that map, so it returned during the window where the far server is registered locally but not yet with Redis. Pub/sub keeps no backlog, so the change published in that window is not delayed, it is dropped, and the test then waits out its full ten seconds for a frame that no longer exists. Redis is now asked directly with PUBSUB NUMSUB, which only counts a subscriber once the SUBSCRIBE has actually landed. The relay itself is unchanged: its own ordering is correct, it confirms before it publishes. Verified: the live suite passes, including under CPU contention and -race. Not verified: the original failure did not reproduce locally in 60 runs under load, so this closes a window that is certain from reading the code rather than one demonstrated by a local repro. --- apps/api/internal/live/relay_test.go | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/apps/api/internal/live/relay_test.go b/apps/api/internal/live/relay_test.go index be1e9901..0555e15e 100644 --- a/apps/api/internal/live/relay_test.go +++ b/apps/api/internal/live/relay_test.go @@ -54,25 +54,31 @@ func twoServers(t *testing.T) (*fakeAPI, *Server, string, *Server, string) { return api, first, firstAddress, second, secondAddress } -// waitForSubscription blocks until a server's relay is carrying a document, which is what makes a publish from the other one reach it. -func waitForSubscription(t *testing.T, server *Server, name string) { +// waitForSubscribers blocks until Redis itself reports that `want` relays are carrying a document, which is what makes a publish from one of them reach the others. +// +// Redis is asked rather than the relay because the relay registers a subscription in its own map before Redis has confirmed it: Subscribe only queues the command, and the confirmation it waits for comes several statements later. Polling that map therefore returns while a publish can still be dropped -- pub/sub keeps no backlog, so a message published before the far server is confirmed is not late, it is gone. That window is narrow enough to never open on an idle machine and wide enough to open on a loaded CI runner, which is what made this test flake with zero frames received in ten seconds. +func waitForSubscribers(t *testing.T, server *Server, name string, want int64) { t.Helper() + channel := documentChannel(name) deadline := time.Now().Add(5 * time.Second) + var seen int64 for time.Now().Before(deadline) { - server.relay.mu.Lock() - _, subscribed := server.relay.subscriptions[name] - server.relay.mu.Unlock() - if subscribed { + counts, err := server.relay.client.PubSubNumSub(context.Background(), channel).Result() + if err != nil { + t.Fatalf("ask redis who is subscribed to %s: %v", channel, err) + } + seen = counts[channel] + if seen >= want { return } time.Sleep(5 * time.Millisecond) } - t.Fatalf("the relay never subscribed to %s", name) + t.Fatalf("redis reports %d subscriber(s) on %s after five seconds, want %d", seen, channel, want) } // TestAChangeOnOneServerReachesTheOther is the whole reason the relay exists: two people editing one page do not necessarily reach the same server. func TestAChangeOnOneServerReachesTheOther(t *testing.T) { - _, first, firstAddress, second, secondAddress := twoServers(t) + _, first, firstAddress, _, secondAddress := twoServers(t) here := dial(t, firstAddress) here.authenticate(testPageID, "a-user") @@ -82,9 +88,8 @@ func TestAChangeOnOneServerReachesTheOther(t *testing.T) { there.authenticate(testPageID, "a-user") there.readUntil(hocuspocus.MessageAuth) - // Being authenticated is not the same as being subscribed. The document is taken up after the handshake, on the server's own schedule, and Redis pub/sub keeps no backlog -- so a change published before the far server has confirmed its subscription is not delayed, it is discarded. On a loaded machine that is the whole test: it saw zero frames in ten seconds while both servers were working correctly. - waitForSubscription(t, first, testPageID) - waitForSubscription(t, second, testPageID) + // Being authenticated is not the same as being subscribed. The document is taken up after the handshake, on the server's own schedule, and Redis pub/sub keeps no backlog -- so a change published before the far server has confirmed its subscription is not delayed, it is discarded. On a loaded machine that is the whole test: it saw zero frames in ten seconds while both servers were working correctly. Both servers share one Redis, so one question covers the pair. + waitForSubscribers(t, first, testPageID, 2) document, err := ydoc.ParseHTML("

across the cluster

") if err != nil { From 5cc95cc5219b4a7b5fad39831a07d624d448b22a Mon Sep 17 00:00:00 2001 From: Evan Hu Date: Sat, 19 Sep 2026 22:22:54 +0900 Subject: [PATCH 2/2] chore: bump the workspace version, which check-version requires of every PR --- apps/admin/package.json | 2 +- apps/space/package.json | 2 +- apps/web/package.json | 2 +- package.json | 2 +- packages/codemods/package.json | 2 +- packages/constants/package.json | 2 +- packages/editor/package.json | 2 +- packages/hooks/package.json | 2 +- packages/i18n/package.json | 2 +- packages/propel/package.json | 2 +- packages/services/package.json | 2 +- packages/shared-state/package.json | 2 +- packages/tailwind-config/package.json | 2 +- packages/types/package.json | 2 +- packages/typescript-config/package.json | 2 +- packages/ui/package.json | 2 +- packages/utils/package.json | 2 +- 17 files changed, 17 insertions(+), 17 deletions(-) diff --git a/apps/admin/package.json b/apps/admin/package.json index 37517bfa..d369d060 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -1,6 +1,6 @@ { "name": "admin", - "version": "1.4.44", + "version": "1.4.45", "private": true, "description": "Admin UI for Pace", "license": "AGPL-3.0", diff --git a/apps/space/package.json b/apps/space/package.json index 8ed7a530..2b0da047 100644 --- a/apps/space/package.json +++ b/apps/space/package.json @@ -1,6 +1,6 @@ { "name": "space", - "version": "1.4.44", + "version": "1.4.45", "private": true, "license": "AGPL-3.0", "type": "module", diff --git a/apps/web/package.json b/apps/web/package.json index bfe5be69..fd904fb4 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "web", - "version": "1.4.44", + "version": "1.4.45", "private": true, "license": "AGPL-3.0", "type": "module", diff --git a/package.json b/package.json index 4a398755..8096822e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pace", - "version": "1.4.44", + "version": "1.4.45", "private": true, "description": "Project management that keeps pace with the work", "license": "AGPL-3.0", diff --git a/packages/codemods/package.json b/packages/codemods/package.json index 9395c8ea..00108156 100644 --- a/packages/codemods/package.json +++ b/packages/codemods/package.json @@ -1,6 +1,6 @@ { "name": "@pace/codemods", - "version": "1.4.44", + "version": "1.4.45", "private": true, "scripts": { "check:format": "oxfmt --check .", diff --git a/packages/constants/package.json b/packages/constants/package.json index 9bf4c1e5..a4932270 100644 --- a/packages/constants/package.json +++ b/packages/constants/package.json @@ -1,6 +1,6 @@ { "name": "@pace/constants", - "version": "1.4.44", + "version": "1.4.45", "private": true, "license": "AGPL-3.0", "type": "module", diff --git a/packages/editor/package.json b/packages/editor/package.json index baf4e0ba..af9dfb71 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -1,6 +1,6 @@ { "name": "@pace/editor", - "version": "1.4.44", + "version": "1.4.45", "private": true, "description": "Core Editor that powers Pace", "keywords": [ diff --git a/packages/hooks/package.json b/packages/hooks/package.json index 2db8c109..b866842f 100644 --- a/packages/hooks/package.json +++ b/packages/hooks/package.json @@ -1,6 +1,6 @@ { "name": "@pace/hooks", - "version": "1.4.44", + "version": "1.4.45", "private": true, "description": "React hooks that are shared across multiple apps internally", "license": "AGPL-3.0", diff --git a/packages/i18n/package.json b/packages/i18n/package.json index 224127e9..63e887e7 100644 --- a/packages/i18n/package.json +++ b/packages/i18n/package.json @@ -1,6 +1,6 @@ { "name": "@pace/i18n", - "version": "1.4.44", + "version": "1.4.45", "private": true, "description": "I18n shared across multiple apps internally", "license": "AGPL-3.0", diff --git a/packages/propel/package.json b/packages/propel/package.json index 3abd01a9..d9e0a5c3 100644 --- a/packages/propel/package.json +++ b/packages/propel/package.json @@ -1,6 +1,6 @@ { "name": "@pace/propel", - "version": "1.4.44", + "version": "1.4.45", "private": true, "license": "AGPL-3.0", "type": "module", diff --git a/packages/services/package.json b/packages/services/package.json index 1fad9afc..037e1283 100644 --- a/packages/services/package.json +++ b/packages/services/package.json @@ -1,6 +1,6 @@ { "name": "@pace/services", - "version": "1.4.44", + "version": "1.4.45", "private": true, "license": "AGPL-3.0", "type": "module", diff --git a/packages/shared-state/package.json b/packages/shared-state/package.json index 00733efe..c85b3a00 100644 --- a/packages/shared-state/package.json +++ b/packages/shared-state/package.json @@ -1,6 +1,6 @@ { "name": "@pace/shared-state", - "version": "1.4.44", + "version": "1.4.45", "private": true, "description": "Shared state shared across multiple apps internally", "license": "AGPL-3.0", diff --git a/packages/tailwind-config/package.json b/packages/tailwind-config/package.json index 6bc9b245..eb830140 100644 --- a/packages/tailwind-config/package.json +++ b/packages/tailwind-config/package.json @@ -1,6 +1,6 @@ { "name": "@pace/tailwind-config", - "version": "1.4.44", + "version": "1.4.45", "private": true, "description": "common tailwind configuration across monorepo", "license": "AGPL-3.0", diff --git a/packages/types/package.json b/packages/types/package.json index 1f8d58f5..70ffe4e6 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -1,6 +1,6 @@ { "name": "@pace/types", - "version": "1.4.44", + "version": "1.4.45", "private": true, "license": "AGPL-3.0", "type": "module", diff --git a/packages/typescript-config/package.json b/packages/typescript-config/package.json index fc2d195f..04a92042 100644 --- a/packages/typescript-config/package.json +++ b/packages/typescript-config/package.json @@ -1,6 +1,6 @@ { "name": "@pace/typescript-config", - "version": "1.4.44", + "version": "1.4.45", "private": true, "license": "AGPL-3.0", "files": [ diff --git a/packages/ui/package.json b/packages/ui/package.json index fa4f9895..6c81f4ab 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@pace/ui", - "version": "1.4.44", + "version": "1.4.45", "private": true, "description": "UI components shared across multiple apps internally", "license": "AGPL-3.0", diff --git a/packages/utils/package.json b/packages/utils/package.json index 049b10dc..ec720be3 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -1,6 +1,6 @@ { "name": "@pace/utils", - "version": "1.4.44", + "version": "1.4.45", "private": true, "description": "Helper functions shared across multiple apps internally", "license": "AGPL-3.0",