Repository: unjs/h3 (ts)

Issue:
Concurrent calls to `getSession` leads to only the first one getting the session
### Environment

stackblitz
h3: 1.10.0
node: 18.18

### Reproduction

Barebone reproduction using `H3Event` and `useSession` only.
[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/edit/stackblitz-starters-jyfqat?file=index.js)

Solid-Start repro by the issue author here https://github.com/solidjs/solid-start/issues/1192
[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/edit/metruzanca-solidstart-getsession-bug-repro-xd4fmg?file=README.md)

Steps (for Solid-Start Repro):

1. Press Login button
2. Refresh page - You are logged out
3. Press Index link - You are logged in

### Describe the bug

When calling `getSession` multiple times concurrently on a single page request the first call will initialize the `context.sessions[sessionName]` to an empty session. All other calls will see that there is a session on the context and return it.

### Additional context

I guess `unsealSession` gives the other calls a chance to advance to the context check and return early.
If appropriate the session assignment to the context could be done after `unsealSession`. While testing it helped but this would lead to the session being unseald multiple times.
https://github.com/unjs/h3/blob/ae91fc8658315dca75da22b665eda4175eef7ea8/src/utils/session.ts#L108

### Logs

_No response_

Pull request:
fix(getSession): use semaphore lock for unseal operation
<!---
☝️ PR title should follow conventional commits (https://conventionalcommits.org) 
-->

### 🔗 Linked issue

#610
<!-- Please ensure there is an open issue and mention its number as #123 -->

### ❓ Type of change

<!-- What types of changes does your code introduce? Put an `x` in all the boxes that apply. -->

- [ ] 📖 Documentation (updates to the documentation, readme, or JSdoc annotations)
- [x] 🐞 Bug fix (a non-breaking change that fixes an issue)
- [ ] 👌 Enhancement (improving an existing functionality like performance)
- [ ] ✨ New feature (a non-breaking change that adds functionality)
- [ ] 🧹 Chore (updates to the build process or auxiliary tools and libraries)
- [ ] ⚠️ Breaking change (fix or feature that would cause existing functionality to change)

### 📚 Description

<!-- Describe your changes in detail -->
I've added session locking that will asynchronously block consecutive `getSession` attempts when unsealing.
<!-- Why is this change required? What problem does it solve? -->
This should solve the problem where consecutive `getSession` calls break session data and prevent duplicate unseals (I tested the changes against the issue's [reproduction](https://stackblitz.com/edit/stackblitz-starters-jyfqat?file=index.js)).
<!-- If it resolves an open issue, please link to the issue here. For example "Resolves #1337" -->
Resolves #610

### 📝 Checklist

<!-- Put an `x` in all the boxes that apply. -->
<!-- If your change requires a documentation PR, please link it appropriately -->
<!-- If you're unsure about any of these, don't hesitate to ask. We're here to help! -->

- [x] I have linked an issue or discussion.
- [ ] I have updated the documentation accordingly.


Patch:
diff --git a/src/utils/session.ts b/src/utils/session.ts
index 6178ca9..e3b726e 100644
--- a/src/utils/session.ts
+++ b/src/utils/session.ts
@@ -8,10 +8,13 @@ import { getCookie, setCookie } from "./cookie";
 type SessionDataT = Record<string, any>;
 export type SessionData<T extends SessionDataT = SessionDataT> = T;
 
+const getSessionPromise = Symbol("getSession");
+
 export interface Session<T extends SessionDataT = SessionDataT> {
   id: string;
   createdAt: number;
   data: SessionData<T>;
+  [getSessionPromise]?: Promise<Session<T>>;
 }
 
 export interface SessionConfig {
@@ -74,8 +77,10 @@ export async function getSession<T extends SessionDataT = SessionDataT>(
   if (!event.context.sessions) {
     event.context.sessions = Object.create(null);
   }
-  if (event.context.sessions![sessionName]) {
-    return event.context.sessions![sessionName] as Session<T>;
+  // Wait for existing session to load
+  const existingSession = event.context.sessions![sessionName] as Session<T>;
+  if (existingSession) {
+    return existingSession[getSessionPromise] || existingSession;
   }
 
   // Prepare an empty session object and store in context
@@ -105,10 +110,15 @@ export async function getSession<T extends SessionDataT = SessionDataT>(
   }
   if (sealedSession) {
     // Unseal session data from cookie
-    const unsealed = await unsealSession(event, config, sealedSession).catch(
-      () => {},
-    );
-    Object.assign(session, unsealed);
+    const promise = unsealSession(event, config, sealedSession)
+      .catch(() => {})
+      .then((unsealed) => {
+        Object.assign(session, unsealed);
+        delete event.context.sessions![sessionName][getSessionPromise];
+        return session as Session<T>;
+      });
+    event.context.sessions![sessionName][getSessionPromise] = promise;
+    await promise;
   }
 
   // New session store in response cookies


Test patch:
diff --git a/test/session.test.ts b/test/session.test.ts
index e9832b8..b4c19fa 100644
--- a/test/session.test.ts
+++ b/test/session.test.ts
@@ -72,4 +72,27 @@ describe("session", () => {
       session: { id: "1", data: { foo: "bar" } },
     });
   });
+
+  it("gets same session back (concurrent)", async () => {
+    router.use(
+      "/concurrent",
+      eventHandler(async (event) => {
+        const sessions = await Promise.all(
+          [1, 2, 3].map(() =>
+            useSession(event, sessionConfig).then((s) => ({
+              id: s.id,
+              data: s.data,
+            })),
+          ),
+        );
+        return {
+          sessions,
+        };
+      }),
+    );
+    const result = await request.get("/concurrent").set("Cookie", cookie);
+    expect(result.body).toMatchObject({
+      sessions: [1, 2, 3].map(() => ({ id: "1", data: { foo: "bar" } })),
+    });
+  });
 });