eleven / messenger Public

web: narrow the cross-device race that can duplicate an app-thread (#1040)

Investigated #240 ("how did I get multiple ones open"). Root cause:
resolveAppThread's "does this app already have a thread" check only
consults the in-memory threadApp map, populated at the last
loadThreads(). Two devices (or tabs) calling it around the same time,
before either syncs the other's newly created self-thread, can both
decide "no thread yet" and each create one.

This can't get the same server-side treatment DM creation does
(postThread dedupes DMs to one per pair via a DB unique constraint)
because a self-thread's app identity lives only in its encrypted
name_blob — the server deliberately can't tell a todo thread from a
poll thread from plain "Notes" (see displayName's comment). There's
nothing for a server-side unique constraint to key on without adding
a new, even if opaque, per-app signal the server didn't have before —
a privacy tradeoff this change doesn't make.

So this only narrows the window rather than closing it: right before
committing to a create (not on the fast, already-exists path),
refresh from the server and re-check. A thread another device made
moments ago and this one hasn't synced yet gets picked up instead of
duplicated. A genuinely simultaneous cross-device create can still
race past this — full analysis and remaining options posted as a
comment on the issue.

Addresses #240
Co-authored-by: Claude <noreply@anthropic.com>
James Adam 3f44816d903e7c699741f9d78d764f2b01243296 parent 7898946
2 files changed, +20 −2
  • changelog/2026-07-16-narrow-app-thread-create-race.md +4 −0
  • web/static/shell.js +16 −2
diff --git a/changelog/2026-07-16-narrow-app-thread-create-race.md b/changelog/2026-07-16-narrow-app-thread-create-race.md
new file mode 100644
index 0000000..33089f9
--- /dev/null
+++ b/changelog/2026-07-16-narrow-app-thread-create-race.md
@@ -0,0 +1,4 @@
+🤖 Reduced a rare case where opening a personal app (like To-do) on two
+devices at nearly the same moment could create two copies of it — the app
+now re-checks with the server right before creating one, instead of only
+trusting what it already had loaded.
diff --git a/web/static/shell.js b/web/static/shell.js
index cc44ef8..840a79a 100644
--- a/web/static/shell.js
+++ b/web/static/shell.js
@@ -6085,12 +6085,26 @@ async function createAppThread(appName) {
}
// resolveAppThread returns the user's thread for a personal app, creating it on
-// first use. The in-flight guard stops a fast double "/todo" spawning two.
+// first use. The in-flight guard stops a fast double "/todo" spawning two on
+// THIS device — it can't see another device's in-flight create. A self-thread's
+// app identity lives only in its encrypted name_blob (the server deliberately
+// can't tell a todo from a poll from plain "Notes" — see displayName), so
+// there's no server-side unique constraint to lean on the way DM creation gets
+// one (postThread's "one thread per pair" dedupe). The best a client can do is
+// narrow the window: refresh from the server right before committing to a
+// create, so a thread another device made moments ago (and this one hasn't
+// synced yet) is picked up instead of duplicated. This doesn't close a
+// genuinely simultaneous cross-device race — see issue #240 for the fuller
+// picture and what a complete fix would need.
const appThreadInflight = {};
async function resolveAppThread(appName) {
for (const id in threadApp) if (threadApp[id] === appName) return id;
if (appThreadInflight[appName]) return appThreadInflight[appName];
- const p = createAppThread(appName).finally(() => delete appThreadInflight[appName]);
+ const p = (async () => {
+ await loadThreads();
+ for (const id in threadApp) if (threadApp[id] === appName) return id;
+ return createAppThread(appName);
+ })().finally(() => delete appThreadInflight[appName]);
appThreadInflight[appName] = p;
return p;
}