Skip to content

Commit 4677199

Browse files
authored
fix(openscience): atomic + locked session writes, distinguish logout from read error (#131)
- saveSession uses atomic temp+rename so a crash or concurrent reader never sees a torn session file. - updateSession serializes its read-modify-write under a lock, so two concurrent patches (interactive last_check_ts vs background cached_v) can't lose each other's field. - getSession distinguishes a MISSING file (genuine logout → null) from a read/parse error (torn file, EMFILE, permissions): the latter is no longer silently mis-read as 'signed out' (which flipped the billing gate to BYOK and diverted usage to the unauthenticated queue for an authenticated user) — it still returns null so callers stay simple, but logs the failure. (#12)
1 parent 9f54034 commit 4677199

2 files changed

Lines changed: 48 additions & 3 deletions

File tree

backend/cli/src/openscience/index.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,11 @@ export namespace OpenScience {
336336
}
337337

338338
export async function getSession(): Promise<OpenScienceSession | null> {
339+
// A missing file is a genuine logout → null, silently. Distinguish it from a
340+
// read/parse error below so a torn file / EMFILE / permission blip isn't
341+
// silently mis-read as "signed out" (which flips the billing gate to BYOK
342+
// and diverts usage to the unauthenticated queue for an authed user).
343+
if (!existsSync(filepath)) return null
339344
try {
340345
const file = Bun.file(filepath)
341346
const data = (await file.json()) as Partial<OpenScienceSession> & { access_token?: string }
@@ -359,13 +364,21 @@ export namespace OpenScience {
359364
cached_v: data.cached_v,
360365
last_check_ts: data.last_check_ts,
361366
}
362-
} catch {
367+
} catch (e) {
368+
// The file exists but couldn't be read/parsed — NOT a logout. Return null
369+
// so callers stay simple, but surface it so the transient failure is
370+
// diagnosable rather than a silent, mis-billed "signed out".
371+
log.warn("could not read session file (treating as signed out)", {
372+
error: e instanceof Error ? e.message : String(e),
373+
})
363374
return null
364375
}
365376
}
366377

367378
export async function saveSession(session: OpenScienceSession) {
368-
await Bun.write(Bun.file(filepath), JSON.stringify(session, null, 2), { mode: 0o600 })
379+
// Atomic temp+rename so a crash or a concurrent reader never sees a torn
380+
// session file (which getSession would mis-read as a logout).
381+
await atomicWrite(filepath, JSON.stringify(session, null, 2), { mode: 0o600 })
369382
await ensureAtlasCliConfig(session)
370383
}
371384

@@ -402,8 +415,12 @@ export namespace OpenScience {
402415
}
403416

404417
/** Merge-update the persisted session. Fetches current session, spreads
405-
* the patch on top, and writes back. No-ops when unauthenticated. */
418+
* the patch on top, and writes back. No-ops when unauthenticated. Serialized
419+
* under a lock so two concurrent patches (e.g. an interactive last_check_ts
420+
* update racing a background cached_v update) can't lose each other's field
421+
* in the read-modify-write. */
406422
async function updateSession(patch: Partial<OpenScienceSession>): Promise<void> {
423+
using _ = await Lock.write(filepath)
407424
const session = await getSession()
408425
if (!session) return
409426
await saveSession({ ...session, ...patch })
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { describe, expect, test } from "bun:test"
2+
import { OpenScience } from "../../src/openscience"
3+
4+
// Test env is XDG-isolated (see test/preload.ts), so the session file lives in a
5+
// throwaway dir and starts absent.
6+
7+
describe("OpenScience session file", () => {
8+
test("getSession returns null when no session file exists (real logout)", async () => {
9+
await OpenScience.clearSession()
10+
expect(await OpenScience.getSession()).toBeNull()
11+
expect(await OpenScience.isAuthenticated()).toBe(false)
12+
})
13+
14+
test("saveSession then getSession round-trips atomically", async () => {
15+
await OpenScience.saveSession({ api_key: "thk_test.secret", user_id: "u1", device_name: "dev" })
16+
const s = await OpenScience.getSession()
17+
expect(s?.api_key).toBe("thk_test.secret")
18+
expect(s?.user_id).toBe("u1")
19+
await OpenScience.clearSession()
20+
expect(await OpenScience.getSession()).toBeNull()
21+
})
22+
23+
test("a session without an api_key is treated as no session", async () => {
24+
await OpenScience.saveSession({ api_key: "", user_id: "u1" } as any)
25+
expect(await OpenScience.getSession()).toBeNull()
26+
await OpenScience.clearSession()
27+
})
28+
})

0 commit comments

Comments
 (0)