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>
2 files changed,
+20
−2
changelog/2026-07-16-narrow-app-thread-create-race.md+4 −0web/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; |
| } |