From c1d07d3e5347bb9df9f107012a6df934b728e0c5 Mon Sep 17 00:00:00 2001 From: openscience Date: Fri, 14 Aug 2026 18:37:58 +0000 Subject: [PATCH 1/2] release: v2.0.25 --- backend/cli/package.json | 2 +- bun.lock | 16 ++++++------- frontend/docs/package.json | 2 +- frontend/landing/package.json | 2 +- frontend/ui/package.json | 2 +- frontend/workspace/package.json | 2 +- tooling/launcher/package.json | 2 +- tooling/plugin/package.json | 2 +- tooling/sdk/js/package.json | 2 +- tooling/sdk/js/src/v2/gen/types.gen.ts | 32 +++++++++++++------------- tooling/util/package.json | 2 +- 11 files changed, 33 insertions(+), 33 deletions(-) diff --git a/backend/cli/package.json b/backend/cli/package.json index c0f1dd7f..7c15077f 100644 --- a/backend/cli/package.json +++ b/backend/cli/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "2.0.24", + "version": "2.0.25", "name": "@synsci/openscience", "type": "module", "description": "AI-powered CLI for ML research and development workflows", diff --git a/bun.lock b/bun.lock index 2aed8838..f4c1a2ea 100644 --- a/bun.lock +++ b/bun.lock @@ -19,7 +19,7 @@ }, "backend/cli": { "name": "@synsci/openscience", - "version": "2.0.24", + "version": "2.0.25", "bin": { "openscience": "./bin/openscience", }, @@ -125,7 +125,7 @@ }, "frontend/docs": { "name": "@synsci/docs", - "version": "2.0.24", + "version": "2.0.25", "dependencies": { "lucide-react": "^0.453.0", "react": "^19.0.0", @@ -143,7 +143,7 @@ }, "frontend/ui": { "name": "@synsci/ui", - "version": "2.0.24", + "version": "2.0.25", "dependencies": { "@kobalte/core": "catalog:", "@pierre/diffs": "catalog:", @@ -189,7 +189,7 @@ }, "frontend/workspace": { "name": "@synsci/workspace", - "version": "2.0.24", + "version": "2.0.25", "dependencies": { "@fontsource-variable/inter": "5.3.0", "@kobalte/core": "catalog:", @@ -243,14 +243,14 @@ }, "tooling/launcher": { "name": "synsci", - "version": "2.0.24", + "version": "2.0.25", "bin": { "synsci": "bin/synsci.mjs", }, }, "tooling/plugin": { "name": "@synsci/plugin", - "version": "2.0.24", + "version": "2.0.25", "dependencies": { "@synsci/sdk": "workspace:*", "zod": "catalog:", @@ -270,7 +270,7 @@ }, "tooling/sdk/js": { "name": "@synsci/sdk", - "version": "2.0.24", + "version": "2.0.25", "devDependencies": { "@hey-api/openapi-ts": "0.90.10", "@tsconfig/node22": "catalog:", @@ -281,7 +281,7 @@ }, "tooling/util": { "name": "@synsci/util", - "version": "2.0.24", + "version": "2.0.25", "dependencies": { "zod": "catalog:", }, diff --git a/frontend/docs/package.json b/frontend/docs/package.json index e0086ed1..1d777278 100644 --- a/frontend/docs/package.json +++ b/frontend/docs/package.json @@ -2,7 +2,7 @@ "name": "@synsci/docs", "private": true, "type": "module", - "version": "2.0.24", + "version": "2.0.25", "license": "Apache-2.0", "scripts": { "dev": "vite", diff --git a/frontend/landing/package.json b/frontend/landing/package.json index c7d2441a..a5768d44 100644 --- a/frontend/landing/package.json +++ b/frontend/landing/package.json @@ -1,7 +1,7 @@ { "name": "openscience-landing", "private": true, - "version": "2.0.24", + "version": "2.0.25", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/ui/package.json b/frontend/ui/package.json index d23275b5..a6590df9 100644 --- a/frontend/ui/package.json +++ b/frontend/ui/package.json @@ -1,6 +1,6 @@ { "name": "@synsci/ui", - "version": "2.0.24", + "version": "2.0.25", "type": "module", "license": "Apache-2.0", "exports": { diff --git a/frontend/workspace/package.json b/frontend/workspace/package.json index e90c2ff0..13999eb3 100644 --- a/frontend/workspace/package.json +++ b/frontend/workspace/package.json @@ -1,6 +1,6 @@ { "name": "@synsci/workspace", - "version": "2.0.24", + "version": "2.0.25", "description": "", "type": "module", "exports": { diff --git a/tooling/launcher/package.json b/tooling/launcher/package.json index 63cfb1ab..1e20e8d4 100644 --- a/tooling/launcher/package.json +++ b/tooling/launcher/package.json @@ -1,6 +1,6 @@ { "name": "synsci", - "version": "2.0.24", + "version": "2.0.25", "description": "Install wizard for OpenScience, the open-source AI research workspace (optionally with Atlas)", "license": "Apache-2.0", "type": "module", diff --git a/tooling/plugin/package.json b/tooling/plugin/package.json index 796094d0..dd781062 100644 --- a/tooling/plugin/package.json +++ b/tooling/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@synsci/plugin", - "version": "2.0.24", + "version": "2.0.25", "type": "module", "license": "Apache-2.0", "scripts": { diff --git a/tooling/sdk/js/package.json b/tooling/sdk/js/package.json index 76a1a4a1..779c7c06 100644 --- a/tooling/sdk/js/package.json +++ b/tooling/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@synsci/sdk", - "version": "2.0.24", + "version": "2.0.25", "type": "module", "license": "Apache-2.0", "scripts": { diff --git a/tooling/sdk/js/src/v2/gen/types.gen.ts b/tooling/sdk/js/src/v2/gen/types.gen.ts index e6cc843c..070182ee 100644 --- a/tooling/sdk/js/src/v2/gen/types.gen.ts +++ b/tooling/sdk/js/src/v2/gen/types.gen.ts @@ -36,6 +36,20 @@ export type BadRequestError = { success: false } +export type EventServerConnected = { + type: "server.connected" + properties: { + [key: string]: unknown + } +} + +export type EventGlobalDisposed = { + type: "global.disposed" + properties: { + [key: string]: unknown + } +} + export type EventInstallationUpdated = { type: "installation.updated" properties: { @@ -91,20 +105,6 @@ export type EventProjectTrustChanged = { } } -export type EventServerConnected = { - type: "server.connected" - properties: { - [key: string]: unknown - } -} - -export type EventGlobalDisposed = { - type: "global.disposed" - properties: { - [key: string]: unknown - } -} - export type EventLspClientDiagnostics = { type: "lsp.client.diagnostics" properties: { @@ -990,13 +990,13 @@ export type EventWorktreeFailed = { } export type Event = + | EventServerConnected + | EventGlobalDisposed | EventInstallationUpdated | EventInstallationUpdateAvailable | EventProjectUpdated | EventServerInstanceDisposed | EventProjectTrustChanged - | EventServerConnected - | EventGlobalDisposed | EventLspClientDiagnostics | EventLspUpdated | EventFileWatcherUpdated diff --git a/tooling/util/package.json b/tooling/util/package.json index 9b27adf1..b4b5b6fb 100644 --- a/tooling/util/package.json +++ b/tooling/util/package.json @@ -1,6 +1,6 @@ { "name": "@synsci/util", - "version": "2.0.24", + "version": "2.0.25", "private": true, "type": "module", "license": "Apache-2.0", From 0bc4b9971ea609cf72854abe645c3bb1b628bd66 Mon Sep 17 00:00:00 2001 From: ishaan1124 Date: Sat, 15 Aug 2026 00:34:14 +0530 Subject: [PATCH 2/2] fix: retry Windows data-root marker replacement --- backend/cli/src/global/data-root-barrier.ts | 33 +++- backend/cli/test/global/data-root.test.ts | 200 +++++++++++++++++++- 2 files changed, 228 insertions(+), 5 deletions(-) diff --git a/backend/cli/src/global/data-root-barrier.ts b/backend/cli/src/global/data-root-barrier.ts index fe9bedf5..c4148521 100644 --- a/backend/cli/src/global/data-root-barrier.ts +++ b/backend/cli/src/global/data-root-barrier.ts @@ -24,7 +24,7 @@ export namespace DataRootBarrier { } interface PhysicalOperation extends AsyncDisposable { - reassign(owner: Owner): Promise + reassign(owner: Owner, windows: boolean): Promise } interface Record { @@ -56,6 +56,9 @@ export namespace DataRootBarrier { const pause = 20 const wait = 30_000 + const replaceWait = 2_000 + const replacePause = 10 + const replaceMaxPause = 100 export function configure(value: Configuration) { configuration = value @@ -172,6 +175,24 @@ export namespace DataRootBarrier { .catch(() => undefined) } + function replaceable(error: NodeJS.ErrnoException, windows: boolean) { + return windows && (error.code === "EPERM" || error.code === "EACCES" || error.code === "EBUSY") + } + + async function replace( + source: string, + destination: string, + windows: boolean, + deadline = Date.now() + replaceWait, + delay = replacePause, + ): Promise { + return fs.rename(source, destination).catch(async (error: NodeJS.ErrnoException) => { + if (!replaceable(error, windows) || Date.now() >= deadline) throw error + await Bun.sleep(Math.min(delay, Math.max(0, deadline - Date.now()))) + return replace(source, destination, windows, deadline, Math.min(delay * 2, replaceMaxPause)) + }) + } + async function exactOwner(value?: Owner): Promise { if (value) { if (!Number.isSafeInteger(value.pid) || value.pid <= 0 || !/^[a-f0-9]{64}$/.test(value.identity)) { @@ -264,7 +285,7 @@ export namespace DataRootBarrier { return result } return { - reassign(value: Owner) { + reassign(value: Owner, windows: boolean) { if (disposed) return Promise.reject(new Error("Cannot reassign a closed data-root operation")) return enqueue(async () => { const nextOwner = await exactOwner(value) @@ -274,7 +295,10 @@ export namespace DataRootBarrier { await replacement.writeFile(JSON.stringify({ ...nextOwner, token, created: Date.now() })) await replacement.sync() await replacement.close() - await fs.rename(temporary, marker) + // Windows can reject replacement while a scanner holds a + // conflicting destination handle. Retrying the same atomic + // update keeps the old complete marker authoritative. + await replace(temporary, marker, windows) } catch (error) { await replacement.close().catch(() => undefined) await fs.rm(temporary, { force: true }).catch(() => undefined) @@ -318,6 +342,7 @@ export namespace DataRootBarrier { if (inside(anchor)) { return Promise.reject(new Error("Cannot reassign a data-root operation from inside its structured scope")) } + const windows = process.platform === "win32" reassignments++ // Reject new structured uses immediately. Existing callbacks retain // admission until they settle, so they cannot strand this marker @@ -327,7 +352,7 @@ export namespace DataRootBarrier { try { await finishUses(anchor) await drain(anchor) - await operation.reassign(owner) + await operation.reassign(owner, windows) // A foreign owner can exit while this process remains alive, so // this marker can no longer admit same-process descendants. anchor.state = "detached" diff --git a/backend/cli/test/global/data-root.test.ts b/backend/cli/test/global/data-root.test.ts index 73b1a00a..ca898645 100644 --- a/backend/cli/test/global/data-root.test.ts +++ b/backend/cli/test/global/data-root.test.ts @@ -31,6 +31,21 @@ async function operationMarkers(config: string) { return fs.readdir(path.join(config, "data-root-operations")).catch(() => [] as string[]) } +function platform(value: NodeJS.Platform, action: () => T) { + const original = process.platform + if (original === value) return action() + Object.defineProperty(process, "platform", { value }) + try { + return action() + } finally { + Object.defineProperty(process, "platform", { value: original }) + } +} + +function windows(action: () => T) { + return platform("win32", action) +} + describe("managed data root", () => { test("Windows junction reparse buffer carries a mount-point tag and two UTF-16 paths", () => { const target = "C:\\OpenScience Data" @@ -513,6 +528,174 @@ describe("managed data root", () => { } }) + test("retries transient Windows marker locks without dropping physical coverage", async () => { + const base = await root() + const config = path.join(base, "config") + const data = path.join(base, "data") + const managed = await DataRoot.ensure(config, data, false) + DataRootBarrier.configure({ root: managed.path, config }) + const identity = await ProcessIdentity.capture(process.pid) + if (!identity) throw new Error("Current process identity is unavailable") + const operation = await DataRootBarrier.enter(managed.path, 2_000) + const [name] = await operationMarkers(config) + const marker = path.join(config, "data-root-operations", name!) + const original = JSON.parse(await fs.readFile(marker, "utf8")) as { token: string } + const renameOriginal = fs.rename.bind(fs) + const codes = ["EPERM", "EACCES", "EBUSY"] + const coverage: number[] = [] + let attempts = 0 + let exclusive = false + let switching: Promise | undefined + const rename = spyOn(fs, "rename").mockImplementation(async (source, destination) => { + if (!path.basename(String(source)).endsWith(".next")) return renameOriginal(source, destination) + attempts++ + coverage.push((await operationMarkers(config)).length) + expect(exclusive).toBe(false) + expect( + await fs.lstat(destination).then( + () => true, + () => false, + ), + ).toBe(true) + const code = codes[attempts - 1] + if (code) throw Object.assign(new Error(`mock ${code}`), { code }) + return renameOriginal(source, destination) + }) + + try { + switching = DataRootBarrier.exclusive(5_000).then((lease) => { + exclusive = true + return lease + }) + await waitForFile(path.join(config, "data-root-switch.intent")) + await windows(() => operation.reassign({ pid: process.pid, identity })) + const [currentName] = await operationMarkers(config) + const current = JSON.parse( + await fs.readFile(path.join(config, "data-root-operations", currentName!), "utf8"), + ) as { token: string } + expect(attempts).toBeGreaterThanOrEqual(4) + expect(coverage.every((count) => count === 1)).toBe(true) + expect(exclusive).toBe(false) + expect(currentName).toBe(name) + expect(current.token).toBe(original.token) + rename.mockRestore() + await operation[Symbol.asyncDispose]() + const lease = await switching + await lease[Symbol.asyncDispose]() + } finally { + rename.mockRestore() + await Promise.resolve(operation[Symbol.asyncDispose]()).catch(() => undefined) + const lease = await switching?.catch(() => undefined) + await lease?.[Symbol.asyncDispose]() + } + }) + + test("bounds persistent Windows marker locks and cleans the unpublished replacement", async () => { + const base = await root() + const config = path.join(base, "config") + const data = path.join(base, "data") + const managed = await DataRoot.ensure(config, data, false) + DataRootBarrier.configure({ root: managed.path, config }) + const identity = await ProcessIdentity.capture(process.pid) + if (!identity) throw new Error("Current process identity is unavailable") + const operation = await DataRootBarrier.enter(managed.path, 2_000) + const [name] = await operationMarkers(config) + const marker = path.join(config, "data-root-operations", name!) + const original = await fs.readFile(marker, "utf8") + const renameOriginal = fs.rename.bind(fs) + const coverage: number[] = [] + let attempts = 0 + let exclusive = false + let switching: Promise | undefined + const rename = spyOn(fs, "rename").mockImplementation(async (source, destination) => { + if (!path.basename(String(source)).endsWith(".next")) return renameOriginal(source, destination) + attempts++ + coverage.push((await operationMarkers(config)).length) + expect(exclusive).toBe(false) + expect( + await fs.lstat(destination).then( + () => true, + () => false, + ), + ).toBe(true) + throw Object.assign(new Error("mock persistent EPERM"), { code: "EPERM" }) + }) + + try { + switching = DataRootBarrier.exclusive(5_000).then((lease) => { + exclusive = true + return lease + }) + await waitForFile(path.join(config, "data-root-switch.intent")) + const started = performance.now() + const reassigning = windows(() => operation.reassign({ pid: process.pid, identity })) + await expect(reassigning).rejects.toMatchObject({ code: "EPERM" }) + const elapsed = performance.now() - started + expect(elapsed).toBeGreaterThanOrEqual(1_900) + expect(elapsed).toBeLessThan(6_000) + expect(attempts).toBeGreaterThan(3) + expect(coverage.every((count) => count === 1)).toBe(true) + expect(exclusive).toBe(false) + expect(await fs.readFile(marker, "utf8")).toBe(original) + expect((await fs.readdir(config)).filter((entry) => entry.endsWith(".next"))).toEqual([]) + rename.mockRestore() + await operation[Symbol.asyncDispose]() + const lease = await switching + await lease[Symbol.asyncDispose]() + } finally { + rename.mockRestore() + await Promise.resolve(operation[Symbol.asyncDispose]()).catch(() => undefined) + const lease = await switching?.catch(() => undefined) + await lease?.[Symbol.asyncDispose]() + } + }, 10_000) + + test("does not retry lock-shaped rename errors outside Windows", async () => { + const base = await root() + const config = path.join(base, "config") + const data = path.join(base, "data") + const managed = await DataRoot.ensure(config, data, false) + DataRootBarrier.configure({ root: managed.path, config }) + const identity = await ProcessIdentity.capture(process.pid) + if (!identity) throw new Error("Current process identity is unavailable") + const operation = await DataRootBarrier.enter(managed.path, 2_000) + const [name] = await operationMarkers(config) + const marker = path.join(config, "data-root-operations", name!) + const original = await fs.readFile(marker, "utf8") + const renameOriginal = fs.rename.bind(fs) + const codes = ["EPERM", "EACCES", "EBUSY"] + let code = codes[0]! + let attempts = 0 + const rename = spyOn(fs, "rename").mockImplementation(async (source, destination) => { + if (!path.basename(String(source)).endsWith(".next")) return renameOriginal(source, destination) + attempts++ + expect( + await fs.lstat(destination).then( + () => true, + () => false, + ), + ).toBe(true) + throw Object.assign(new Error(`mock ${code}`), { code }) + }) + + try { + for (const current of codes) { + code = current + const before = attempts + const started = performance.now() + const reassigning = platform("darwin", () => operation.reassign({ pid: process.pid, identity })) + await expect(reassigning).rejects.toMatchObject({ code: current }) + expect(performance.now() - started).toBeLessThan(500) + expect(attempts).toBe(before + 1) + expect(await fs.readFile(marker, "utf8")).toBe(original) + expect((await fs.readdir(config)).filter((entry) => entry.endsWith(".next"))).toEqual([]) + } + } finally { + rename.mockRestore() + await operation[Symbol.asyncDispose]() + } + }) + test("failed reassignment restores self-owned admission when no later transition is queued", async () => { const base = await root() const config = path.join(base, "config") @@ -524,10 +707,20 @@ describe("managed data root", () => { const startNested = Promise.withResolvers() const nestedDone = Promise.withResolvers() const renameOriginal = fs.rename.bind(fs) + const coverage: number[] = [] + let attempts = 0 let restoreRename = () => {} const outer = await DataRootBarrier.enter(managed.path, 2_000) const rename = spyOn(fs, "rename").mockImplementation(async (source, destination) => { - if (path.basename(String(source)).startsWith(".data-root-operation-")) { + if (path.basename(String(source)).endsWith(".next")) { + attempts++ + coverage.push((await operationMarkers(config)).length) + expect( + await fs.lstat(destination).then( + () => true, + () => false, + ), + ).toBe(true) throw Object.assign(new Error("mock reassign failure"), { code: "EIO" }) } return renameOriginal(source, destination) @@ -536,7 +729,12 @@ describe("managed data root", () => { let command: Promise | undefined let switching: Promise | undefined try { + const started = performance.now() await expect(outer.reassign({ pid: process.pid, identity })).rejects.toThrow("mock reassign failure") + expect(performance.now() - started).toBeLessThan(500) + expect(attempts).toBe(1) + expect(coverage).toEqual([1]) + expect((await fs.readdir(config)).filter((entry) => entry.endsWith(".next"))).toEqual([]) restoreRename() command = outer.during(async () => { await startNested.promise