Skip to content

Commit cfac9d3

Browse files
committed
fix: address low-priority review items from Bun→Node migration (#990)
1. Remove dead SQLite polyfill code (NodeDatabasePolyfill + bunSqlitePlugin) — replaced by src/lib/db/sqlite.ts 2. Fix which polyfill: use 'command -v' (POSIX builtin) instead of 'which' binary, add 5s timeout to prevent indefinite blocking 3. Add stream cleanup to bspatch BufferedStreamReader: cancel() method + cleanup in applyPatch finally block 4. Fix scanner TOCTOU: use single fs.promises.open() file handle for atomic read + stat (content and mtime from same handle) 5. Add CI check (check:patches) to detect when pnpm patchedDependencies target versions diverge from installed versions Closes #990
1 parent 2e4435e commit cfac9d3

8 files changed

Lines changed: 170 additions & 145 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,7 @@ jobs:
193193
- run: bun run typecheck
194194
- run: bun run check:deps
195195
- run: bun run check:errors
196+
- run: bun run check:patches
196197

197198
test-unit:
198199
name: Unit Tests

.lore.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@
9393
* **ubuntu-latest defaults to Node 20 (EOL) — E2E jobs must use actions/setup-node**: Trap: \`ubuntu-latest\` GitHub Actions runner ships Node 20 (EOL). E2E jobs that run the npm bundle via \`node -e "..."\` silently use Node 20 unless \`actions/setup-node\` is explicitly added. This looks fine because the job succeeds, but it violates \`engines.node >=22.12\`. Fix: add \`actions/setup-node\` with \`node-version: 22\` to any job that invokes \`node\` directly. The \`build-npm\` job already does this correctly; E2E jobs need the same treatment. Never add feature-detection workarounds for Node 20 — it's EOL and unsupported.
9494

9595
<!-- lore:019e464f-8fcb-7d16-b7ee-f3b157e1565e -->
96-
* **whichSync must use 'command -v' not 'which' for PATH-restricted lookups**: (pattern) Bun→Node.js API replacements: \`Bun.which(cmd, {PATH})\`\`whichSync()\` from \`src/lib/which.ts\` (uses \`command -v\` shell builtin, NOT \`which\` binary — \`which\` fails when PATH is restricted to a test dir). \`Bun.spawn\`\`spawn\` from \`node:child\_process\`; wrap \`.exited\` as \`new Promise(r => proc.on('close', code => r(code ?? 1)))\`. \*\*CRITICAL: always attach \`proc.on('error', () => {})\` — Node crashes on unhandled spawn errors; Bun did not.\*\* \`Bun.spawnSync\`\`spawnSync\`. \`Bun.sleep(ms)\`\`import { setTimeout as sleepMs } from 'node:timers/promises'\`. \`new Bun.Glob(p).match(i)\`\`picomatch(p, { dot: true })(i)\` — pre-compile matchers outside hot loops. \`Bun.randomUUIDv7()\`\`uuidv7()\`. \`Bun.semver.order()\`\`compare()\` from \`semver\`. Only change actual API call sites — never comments. Update type annotations.
96+
* **Bun.which polyfill uses 'command -v' with 5s timeout for PATH-restricted lookups**: (pattern) Bun→Node.js API replacements live in \`script/node-polyfills.ts\` (injected at bundle time via esbuild). \`Bun.which(cmd, {PATH})\` polyfill uses \`command -v\` shell builtin (NOT \`which\` binary — \`which\` fails when PATH is restricted to a test dir) with \`timeout: 5000\` to prevent indefinite blocking. Windows uses \`where\`. \`Bun.spawn\` → \`spawn\` from \`node:child\_process\`; wrap \`.exited\` as \`new Promise(r => proc.on('close', code => r(code ?? 1)))\`. \*\*CRITICAL: always attach \`proc.on('error', () => {})\` — Node crashes on unhandled spawn errors; Bun did not.\*\* \`Bun.spawnSync\` → \`spawnSync\`. \`Bun.sleep(ms)\` → \`setTimeout\` promise. \`new Bun.Glob(p).match(i)\` → \`picomatch(p, { dot: true })(i)\` — pre-compile matchers outside hot loops. \`Bun.randomUUIDv7()\` → \`uuidv7()\`. \`Bun.semver.order()\` → \`compare()\` from \`semver\`. SQLite handled by \`src/lib/db/sqlite.ts\` (not the polyfill). Only change actual API call sites — never comments. Update type annotations.
9797

9898
<!-- lore:019db0c9-9cc7-7352-b1f2-61b34b87b252 -->
9999
* **Whole-buffer matchAll slower than split+test when aggregated over many files**: Grep/scan traps in \`src/lib/scan/\`: (1) Whole-buffer \`regex.exec\` 12× faster per-file but ~1.6× SLOWER over 10k files — early-exit at \`maxResults\` via \`mapFilesConcurrent.onResult\` wins. (2) Literal prefilter is FILE-LEVEL gate (\`indexOf\`→skip); per-line verify breaks cross-newline patterns and Unicode length-changing \`toLowerCase\` (Turkish \`İ\`\`\`). (3) Extractor \`hasTopLevelAlternation\`+\`skipGroup\` must call \`skipCharacterClass\` (PCRE \`\[]abc]\` ≠ JS empty class). (4) Wake-latch race: naive \`let notify=null; await new Promise(r=>notify=r)\` loses signals — use latched \`pendingWake\` flag. (5) \`mapFilesConcurrent\` filters \`null\` but NOT \`\[]\` — return \`null\` for no-op files. (6) \`collectGlob\`/\`collectGrep\` must NOT forward \`maxResults\` to iterator; drain uncapped, set \`truncated=true\`.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@
112112
"check:fragments": "bun run script/check-fragments.ts",
113113
"check:deps": "bun run script/check-no-deps.ts",
114114
"check:errors": "bun run script/check-error-patterns.ts",
115+
"check:patches": "bun run script/check-patches.ts",
115116
"check:docs-sections": "bun run script/generate-docs-sections.ts --check"
116117
},
117118
"type": "module",

script/bundle.ts

Lines changed: 1 addition & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -19,34 +19,6 @@ if (!SENTRY_CLIENT_ID) {
1919
process.exit(1);
2020
}
2121

22-
// Regex patterns for esbuild plugin (must be top-level for performance)
23-
const BUN_SQLITE_FILTER = /^bun:sqlite$/;
24-
const ANY_FILTER = /.*/;
25-
26-
/** Plugin to replace bun:sqlite with our node:sqlite polyfill. */
27-
const bunSqlitePlugin: Plugin = {
28-
name: "bun-sqlite-polyfill",
29-
setup(pluginBuild) {
30-
pluginBuild.onResolve({ filter: BUN_SQLITE_FILTER }, () => ({
31-
path: "bun:sqlite",
32-
namespace: "bun-sqlite-polyfill",
33-
}));
34-
35-
pluginBuild.onLoad(
36-
{ filter: ANY_FILTER, namespace: "bun-sqlite-polyfill" },
37-
() => ({
38-
contents: `
39-
// Use the polyfill injected by node-polyfills.ts
40-
const polyfill = globalThis.__bun_sqlite_polyfill;
41-
export const Database = polyfill.Database;
42-
export default polyfill;
43-
`,
44-
loader: "js",
45-
})
46-
);
47-
},
48-
};
49-
5022
type InjectedFile = { jsPath: string; mapPath: string; debugId: string };
5123

5224
/** Delete .map files after a successful upload — they shouldn't ship to users. */
@@ -180,11 +152,7 @@ const sentrySourcemapPlugin: Plugin = {
180152
};
181153

182154
// Always inject debug IDs (even without auth token); upload is gated inside the plugin
183-
const plugins: Plugin[] = [
184-
bunSqlitePlugin,
185-
sentrySourcemapPlugin,
186-
textImportPlugin,
187-
];
155+
const plugins: Plugin[] = [sentrySourcemapPlugin, textImportPlugin];
188156

189157
if (process.env.SENTRY_AUTH_TOKEN) {
190158
console.log(" Sentry auth token found, source maps will be uploaded");

script/check-patches.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
#!/usr/bin/env bun
2+
/**
3+
* Check Patched Dependency Versions
4+
*
5+
* Verifies that pnpm patchedDependencies target versions match installed versions.
6+
* Name-only keys in patchedDependencies (pnpm 10+ catalog style) apply patches
7+
* to whatever version resolves. If the installed version doesn't match the
8+
* patch file's target version, the patch may silently fail to apply.
9+
*
10+
* Mismatches are surfaced as warnings (not hard failures) because pnpm 10
11+
* name-only keys intentionally support version-agnostic patching — patches
12+
* often apply cleanly across minor/patch bumps. The warning ensures engineers
13+
* notice and can regenerate the patch if needed.
14+
*
15+
* Usage:
16+
* bun run script/check-patches.ts
17+
*
18+
* Exit codes:
19+
* 0 - All patch versions match, or mismatches are non-critical (warnings)
20+
* 1 - A patched package is missing entirely
21+
*/
22+
23+
const pkg: {
24+
pnpm?: { patchedDependencies?: Record<string, string> };
25+
} = await Bun.file("package.json").json();
26+
27+
const patches = pkg.pnpm?.patchedDependencies ?? {};
28+
const warnings: string[] = [];
29+
const errors: string[] = [];
30+
export {};
31+
32+
for (const [name, patchPath] of Object.entries(patches)) {
33+
// Extract version from patch path: "patches/@stricli%2Fcore@1.2.5.patch" → "1.2.5"
34+
const versionMatch = patchPath.match(/@(\d+\.\d+\.\d+[^.]*)\.patch$/);
35+
if (!versionMatch) {
36+
warnings.push(
37+
` ? ${name}: could not extract version from patch path "${patchPath}"`
38+
);
39+
continue;
40+
}
41+
const patchVersion = versionMatch[1];
42+
43+
// Resolve installed version
44+
const pkgJsonPath = `node_modules/${name}/package.json`;
45+
try {
46+
const installed: { version: string } = await Bun.file(pkgJsonPath).json();
47+
if (installed.version !== patchVersion) {
48+
warnings.push(
49+
` ${name}: patch targets ${patchVersion}, installed ${installed.version} — regenerate with: pnpm patch ${name}`
50+
);
51+
}
52+
} catch {
53+
errors.push(` ${name}: not installed (expected ${patchVersion})`);
54+
}
55+
}
56+
57+
// Emit GitHub Actions annotations for CI visibility
58+
const isCI = !!process.env.CI;
59+
for (const w of warnings) {
60+
if (isCI) {
61+
console.log(`::warning::Patch version mismatch:${w.trim()}`);
62+
} else {
63+
console.warn(`⚠ ${w}`);
64+
}
65+
}
66+
67+
if (errors.length > 0) {
68+
console.error("✗ Missing patched dependencies:");
69+
console.error("");
70+
for (const e of errors) {
71+
console.error(e);
72+
}
73+
console.error("");
74+
console.error("Run pnpm install to install missing dependencies.");
75+
process.exit(1);
76+
}
77+
78+
if (warnings.length === 0) {
79+
console.log("✓ All patched dependency versions match installed versions");
80+
} else {
81+
console.log(
82+
`✓ Patches applied (${warnings.length} version mismatch warning(s) — consider regenerating)`
83+
);
84+
}

script/node-polyfills.ts

Lines changed: 4 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,6 @@ import { promisify } from "node:util";
1212
// biome-ignore lint/performance/noNamespaceImport: runtime access to optional `zstdCompress`/`zstdDecompress` exports
1313
import * as zlibModule from "node:zlib";
1414
import { constants as zlibConstants } from "node:zlib";
15-
// node:sqlite is imported lazily inside NodeDatabasePolyfill to avoid
16-
// crashing on Node.js versions without node:sqlite support when the
17-
// bundle is loaded as a library (the consumer may never use SQLite).
1815

1916
import picomatch from "picomatch";
2017
import { compare as semverCompare } from "semver";
@@ -25,85 +22,6 @@ declare global {
2522
var Bun: typeof BunPolyfill;
2623
}
2724

28-
type SqliteValue = string | number | bigint | null | Uint8Array;
29-
30-
/** Lazy-loaded node:sqlite DatabaseSync constructor. */
31-
function getNodeSqlite(): typeof import("node:sqlite").DatabaseSync {
32-
// eslint-disable-next-line @typescript-eslint/no-require-imports
33-
return require("node:sqlite").DatabaseSync;
34-
}
35-
36-
/** Wraps node:sqlite StatementSync to match bun:sqlite query() API. */
37-
class NodeStatementPolyfill {
38-
// biome-ignore lint/suspicious/noExplicitAny: node:sqlite types loaded lazily
39-
private readonly stmt: any;
40-
41-
// biome-ignore lint/suspicious/noExplicitAny: node:sqlite types loaded lazily
42-
constructor(stmt: any) {
43-
this.stmt = stmt;
44-
}
45-
46-
get(...params: SqliteValue[]): Record<string, SqliteValue> | undefined {
47-
return this.stmt.get(...params) as Record<string, SqliteValue> | undefined;
48-
}
49-
50-
all(...params: SqliteValue[]): Record<string, SqliteValue>[] {
51-
return this.stmt.all(...params) as Record<string, SqliteValue>[];
52-
}
53-
54-
run(...params: SqliteValue[]): void {
55-
this.stmt.run(...params);
56-
}
57-
}
58-
59-
/** Wraps node:sqlite DatabaseSync to match bun:sqlite Database API. */
60-
class NodeDatabasePolyfill {
61-
// biome-ignore lint/suspicious/noExplicitAny: node:sqlite types loaded lazily
62-
private readonly db: any;
63-
64-
constructor(path: string) {
65-
// SQLite configuration (busy_timeout, foreign_keys, WAL mode) is applied
66-
// via PRAGMA statements in src/lib/db/index.ts after construction
67-
const DatabaseSync = getNodeSqlite();
68-
this.db = new DatabaseSync(path);
69-
}
70-
71-
exec(sql: string): void {
72-
this.db.exec(sql);
73-
}
74-
75-
query(sql: string): NodeStatementPolyfill {
76-
return new NodeStatementPolyfill(this.db.prepare(sql));
77-
}
78-
79-
close(): void {
80-
this.db.close();
81-
}
82-
83-
/**
84-
* Wraps a function in a transaction. Returns a callable that executes
85-
* the function within BEGIN/COMMIT, with ROLLBACK on error.
86-
* Matches Bun's db.transaction() API.
87-
*/
88-
transaction<T>(fn: () => T): () => T {
89-
return () => {
90-
this.db.exec("BEGIN");
91-
try {
92-
const result = fn();
93-
this.db.exec("COMMIT");
94-
return result;
95-
} catch (error) {
96-
this.db.exec("ROLLBACK");
97-
throw error;
98-
}
99-
};
100-
}
101-
}
102-
103-
const bunSqlitePolyfill = { Database: NodeDatabasePolyfill };
104-
(globalThis as Record<string, unknown>).__bun_sqlite_polyfill =
105-
bunSqlitePolyfill;
106-
10725
const BunPolyfill = {
10826
file(path: string) {
10927
return {
@@ -162,7 +80,9 @@ const BunPolyfill = {
16280
which(command: string, opts?: { PATH?: string }): string | null {
16381
try {
16482
const isWindows = process.platform === "win32";
165-
const cmd = isWindows ? `where ${command}` : `which ${command}`;
83+
// `command -v` is a POSIX shell builtin that works even with restricted
84+
// PATH (unlike the `which` binary). Windows has no equivalent — use `where`.
85+
const cmd = isWindows ? `where ${command}` : `command -v ${command}`;
16686
// If a custom PATH is provided, override it in the subprocess env.
16787
// Use !== undefined (not truthy) so empty-string PATH is respected.
16888
const env =
@@ -174,6 +94,7 @@ const BunPolyfill = {
17494
encoding: "utf-8",
17595
stdio: ["pipe", "pipe", "ignore"],
17696
env,
97+
timeout: 5000,
17798
})
17899
.trim()
179100
.split("\n")[0] || null

src/lib/bspatch.ts

Lines changed: 52 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,20 @@ class BufferedStreamReader {
183183

184184
return output;
185185
}
186+
187+
/** Release the underlying stream reader, cancelling any pending reads. */
188+
async cancel(): Promise<void> {
189+
try {
190+
await this.reader.cancel();
191+
} catch {
192+
// Stream may already be closed or errored — safe to ignore
193+
}
194+
try {
195+
this.reader.releaseLock();
196+
} catch {
197+
// Lock may already be released
198+
}
199+
}
186200
}
187201

188202
/**
@@ -270,6 +284,37 @@ async function loadOldBinary(oldPath: string): Promise<OldFileHandle> {
270284
}
271285
}
272286

287+
/** Resources to clean up after patch application. */
288+
type PatchResources = {
289+
writer: import("node:fs").WriteStream;
290+
writeError: Error | undefined;
291+
diffReader: BufferedStreamReader;
292+
extraReader: BufferedStreamReader;
293+
cleanupOldFile: () => void | Promise<void>;
294+
};
295+
296+
/**
297+
* Clean up all patch resources: flush the output stream, cancel decompression
298+
* readers, and remove temp files. Each step runs regardless of prior failures.
299+
*/
300+
async function cleanupPatchResources(r: PatchResources): Promise<void> {
301+
try {
302+
await new Promise<void>((resolve, reject) => {
303+
r.writer.end((err?: Error | null) => {
304+
const finalErr = err ?? r.writeError;
305+
if (finalErr) {
306+
reject(finalErr);
307+
} else {
308+
resolve();
309+
}
310+
});
311+
});
312+
} finally {
313+
await Promise.all([r.diffReader.cancel(), r.extraReader.cancel()]);
314+
await r.cleanupOldFile();
315+
}
316+
}
317+
273318
/**
274319
* Apply a TRDIFF10 binary patch with streaming I/O for minimal memory usage.
275320
*
@@ -367,20 +412,13 @@ export async function applyPatch(
367412
oldpos += seekBy;
368413
}
369414
} finally {
370-
try {
371-
await new Promise<void>((resolve, reject) => {
372-
writer.end((err?: Error | null) => {
373-
const finalErr = err ?? writeError;
374-
if (finalErr) {
375-
reject(finalErr);
376-
} else {
377-
resolve();
378-
}
379-
});
380-
});
381-
} finally {
382-
await cleanupOldFile();
383-
}
415+
await cleanupPatchResources({
416+
writer,
417+
writeError,
418+
diffReader,
419+
extraReader,
420+
cleanupOldFile,
421+
});
384422
}
385423

386424
// Validate output size matches header

0 commit comments

Comments
 (0)