@oh-my-pi/pi-utils 18.4.11 → 18.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,23 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.5.0] - 2026-10-03
6
+
7
+ ### Added
8
+
9
+ - Added the public `getSessionOwnersDir()` utility, which returns the profile-independent `~/.omp/run/session-owners` directory that names session ownership leases ([#14095](https://github.com/can1357/oh-my-pi/pull/14095) by [@andrebrait](https://github.com/andrebrait))
10
+
11
+ ### Fixed
12
+
13
+ - Fixed SQLite error messages doubling every backslash in Windows database paths
14
+ - Fixed corrupt-database recovery failing with `EBUSY` on Windows when several in-process openers of the same store failed at once
15
+
16
+ ## [18.4.12] - 2026-10-02
17
+
18
+ ### Fixed
19
+
20
+ - Fixed multi-second temp directory removal stalls on Windows by forcing a major GC before the first deletion retry ([#13044](https://github.com/can1357/oh-my-pi/pull/13044) by [@jchanghong023](https://github.com/jchanghong023)).
21
+
5
22
  ## [18.4.11] - 2026-10-02
6
23
 
7
24
  ### Added
@@ -322,6 +322,12 @@ export declare function getDaemonRuntimeDir(projectDir: string): string;
322
322
  export declare function getGlobalDaemonRuntimeRoot(): string;
323
323
  /** Get a profile-independent runtime directory for a machine-global daemon service. */
324
324
  export declare function getGlobalDaemonRuntimeDir(service: string): string;
325
+ /**
326
+ * Directory naming session ownership leases (~/.omp/run/session-owners; XDG
327
+ * default: $XDG_STATE_HOME/omp/run/session-owners). Shared across profiles:
328
+ * every omp process that opens a session must meet the same lease.
329
+ */
330
+ export declare function getSessionOwnersDir(): string;
325
331
  /** Get the provider in-flight root directory (~/.omp/run/provider-inflight; XDG default: $XDG_STATE_HOME/omp/run/provider-inflight). */
326
332
  export declare function getProviderInFlightRoot(): string;
327
333
  /** Get the marketplaces registry path (~/.omp/marketplaces.json; XDG default: $XDG_DATA_HOME/omp/marketplaces.json). Adopts a legacy registry on first XDG resolution. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-utils",
3
- "version": "18.4.11",
3
+ "version": "18.5.0",
4
4
  "description": "Shared utilities for pi packages",
5
5
  "keywords": [
6
6
  "cli",
@@ -57,7 +57,7 @@
57
57
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
58
58
  },
59
59
  "dependencies": {
60
- "@oh-my-pi/pi-natives": "18.4.11"
60
+ "@oh-my-pi/pi-natives": "18.5.0"
61
61
  },
62
62
  "devDependencies": {
63
63
  "@types/bun": "^1.3.14"
package/src/dirs.ts CHANGED
@@ -1095,6 +1095,15 @@ export function getGlobalDaemonRuntimeDir(service: string): string {
1095
1095
  return path.join(getGlobalDaemonRuntimeRoot(), service);
1096
1096
  }
1097
1097
 
1098
+ /**
1099
+ * Directory naming session ownership leases (~/.omp/run/session-owners; XDG
1100
+ * default: $XDG_STATE_HOME/omp/run/session-owners). Shared across profiles:
1101
+ * every omp process that opens a session must meet the same lease.
1102
+ */
1103
+ export function getSessionOwnersDir(): string {
1104
+ return dirs.baseRootSubdir(path.join("run", "session-owners"), "state");
1105
+ }
1106
+
1098
1107
  /** Get the provider in-flight root directory (~/.omp/run/provider-inflight; XDG default: $XDG_STATE_HOME/omp/run/provider-inflight). */
1099
1108
  export function getProviderInFlightRoot(): string {
1100
1109
  return dirs.rootSubdir(path.join("run", "provider-inflight"), "state");
package/src/sqlite.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  /** Shared SQLite opening, error attribution, and result-code classification for persistent stores. */
2
2
  import { Database } from "bun:sqlite";
3
3
  import * as fs from "node:fs";
4
+ import * as path from "node:path";
4
5
  import { getDbBusyTimeoutMs } from "./env";
5
6
  import { withFileLockSync } from "./file-lock";
6
7
  import { isEnoent } from "./fs-error";
@@ -12,6 +13,55 @@ const SQLITE_STORE_SUFFIXES = ["-wal", "-shm", "-journal", ""];
12
13
 
13
14
  type SqliteFileIdentity = string | null | undefined;
14
15
 
16
+ /**
17
+ * Corrupt handles held open by in-process openers awaiting recovery, keyed by
18
+ * resolved store path. Windows refuses to unlink a file any handle still holds,
19
+ * so the opener that quarantines the store closes its peers' dead handles too.
20
+ */
21
+ const pendingCorruptHandles = new Map<string, Set<Database>>();
22
+
23
+ function corruptHandleKey(dbPath: string): string {
24
+ const resolved = path.resolve(dbPath);
25
+ return process.platform === "win32" ? resolved.toLowerCase() : resolved;
26
+ }
27
+
28
+ function registerCorruptHandle(dbPath: string, db: Database | undefined): void {
29
+ if (!db) return;
30
+ const key = corruptHandleKey(dbPath);
31
+ let handles = pendingCorruptHandles.get(key);
32
+ if (!handles) {
33
+ handles = new Set();
34
+ pendingCorruptHandles.set(key, handles);
35
+ }
36
+ handles.add(db);
37
+ }
38
+
39
+ function unregisterCorruptHandle(dbPath: string, db: Database | undefined): void {
40
+ if (!db) return;
41
+ const key = corruptHandleKey(dbPath);
42
+ const handles = pendingCorruptHandles.get(key);
43
+ if (!handles) return;
44
+ handles.delete(db);
45
+ if (handles.size === 0) pendingCorruptHandles.delete(key);
46
+ }
47
+
48
+ /** Closes peers' failed handles on the store; each peer still closes (idempotently) and adopts on its own path. */
49
+ function closePeerCorruptHandles(dbPath: string, own: Database | undefined): void {
50
+ const handles = pendingCorruptHandles.get(corruptHandleKey(dbPath));
51
+ if (!handles) return;
52
+ for (const peer of handles) {
53
+ if (peer === own) continue;
54
+ try {
55
+ peer.close();
56
+ } catch (error) {
57
+ logger.warn("Failed to close a peer's corrupt SQLite handle before quarantine", {
58
+ path: dbPath,
59
+ error: String(error),
60
+ });
61
+ }
62
+ }
63
+ }
64
+
15
65
  class SqliteAttemptFailure extends Error {
16
66
  readonly original: unknown;
17
67
  readonly identity: SqliteFileIdentity;
@@ -99,6 +149,7 @@ async function openWithBusyRetries<T>(
99
149
  } catch (caught) {
100
150
  const error = options.recoverCorruption ? revealHiddenCorruption(db, caught) : caught;
101
151
  if (options.recoverCorruption && isSqliteCorruptionError(error)) {
152
+ registerCorruptHandle(dbPath, db);
102
153
  throw new SqliteAttemptFailure(error, identity, { db });
103
154
  }
104
155
  closeFailedDatabase(db, error, identity);
@@ -120,6 +171,7 @@ function openOnce<T>(dbPath: string, initialize: (db: Database) => T, options: S
120
171
  } catch (caught) {
121
172
  const error = options.recoverCorruption ? revealHiddenCorruption(db, caught) : caught;
122
173
  if (options.recoverCorruption && isSqliteCorruptionError(error)) {
174
+ registerCorruptHandle(dbPath, db);
123
175
  throw new SqliteAttemptFailure(error, identity, { db });
124
176
  }
125
177
  closeFailedDatabase(db, error, identity);
@@ -143,6 +195,10 @@ function quarantineCorruptSqliteStore(dbPath: string, db: Database | undefined):
143
195
  }
144
196
  }
145
197
  db?.close();
198
+ // Only Windows refuses to unlink a file another handle still holds. Elsewhere
199
+ // each peer must keep its own handle until it closes it after adopting the
200
+ // replacement; closing it here races the peer's recovery on POSIX.
201
+ if (process.platform === "win32") closePeerCorruptHandles(dbPath, db);
146
202
 
147
203
  const removed: string[] = [];
148
204
  try {
@@ -199,6 +255,7 @@ function recoverCorruptDatabase(dbPath: string, error: unknown, options: SqliteO
199
255
  return quarantineCorruptSqliteStore(dbPath, failure.db);
200
256
  });
201
257
  } finally {
258
+ unregisterCorruptHandle(dbPath, failure.db);
202
259
  closeFailedDatabase(failure.db, failure.original, failure.identity);
203
260
  }
204
261
  } catch (preservationError) {
@@ -267,7 +324,7 @@ export function openSqliteDatabaseSync<T>(
267
324
  /** Adds the failing store's path to an error without losing SQLite result codes or its original stack. */
268
325
  export function annotateSqliteError(error: unknown, dbPath: string): Error {
269
326
  const annotated = error instanceof Error ? error : new Error(String(error));
270
- annotated.message = `Database ${JSON.stringify(dbPath)}: ${annotated.message}`;
327
+ annotated.message = `Database "${dbPath}": ${annotated.message}`;
271
328
  return annotated;
272
329
  }
273
330
 
package/src/temp.ts CHANGED
@@ -94,6 +94,11 @@ export async function removeWithRetries(target: string): Promise<void> {
94
94
  return;
95
95
  } catch (err) {
96
96
  if (!shouldRetryRemove(err, attempt)) throw err;
97
+ // bun on Windows finalizes SQLite's db/-wal/-shm file and directory
98
+ // handles on GC, so a closed database can still block deletion for
99
+ // seconds. One forced major collection on the first retry releases
100
+ // them immediately instead of burning the retry window.
101
+ if (attempt === 0) Bun.gc(true);
97
102
  await Bun.sleep(kRemoveRetryDelayMs);
98
103
  }
99
104
  }
@@ -106,6 +111,7 @@ export function removeSyncWithRetries(target: string): void {
106
111
  return;
107
112
  } catch (err) {
108
113
  if (!shouldRetryRemove(err, attempt)) throw err;
114
+ if (attempt === 0) Bun.gc(true);
109
115
  sleepSync(kRemoveRetryDelayMs);
110
116
  }
111
117
  }