Merge pull request #4300 from code-yeongyu/fix/issue-3919-desktop-native-search
fix(tools/grep, tools/glob): Node-safe subprocess streaming for Desktop utility-process compatibility (#3919)
This commit is contained in:
@@ -4,6 +4,7 @@ import { spawn } from "./bun-spawn-shim";
|
|||||||
import { bunWrite } from "./bun-file-shim";
|
import { bunWrite } from "./bun-file-shim";
|
||||||
import { validateArchiveEntries, type ArchiveEntry } from "./archive-entry-validator";
|
import { validateArchiveEntries, type ArchiveEntry } from "./archive-entry-validator";
|
||||||
import { extractZip } from "./zip-extractor";
|
import { extractZip } from "./zip-extractor";
|
||||||
|
import { readProcessStream } from "./process-stream-reader";
|
||||||
|
|
||||||
function isTarTraversalErrorOutput(output: string): boolean {
|
function isTarTraversalErrorOutput(output: string): boolean {
|
||||||
return /path contains '\.\.'|member name contains '\.\.'|removing leading [`'\"]?\.\.\//i.test(output)
|
return /path contains '\.\.'|member name contains '\.\.'|removing leading [`'\"]?\.\.\//i.test(output)
|
||||||
@@ -47,7 +48,8 @@ export async function extractTarGz(
|
|||||||
|
|
||||||
const exitCode = await proc.exited;
|
const exitCode = await proc.exited;
|
||||||
if (exitCode !== 0) {
|
if (exitCode !== 0) {
|
||||||
const stderr = await new Response(proc.stderr).text();
|
// #3919: Avoid Response(stream).text() in Windows Desktop utility processes.
|
||||||
|
const stderr = await readProcessStream(proc.stderr);
|
||||||
|
|
||||||
if (isTarTraversalErrorOutput(stderr)) {
|
if (isTarTraversalErrorOutput(stderr)) {
|
||||||
throw new Error(`Unsafe archive entry: path contains path traversal (${archivePath})`)
|
throw new Error(`Unsafe archive entry: path contains path traversal (${archivePath})`)
|
||||||
@@ -107,8 +109,9 @@ async function listTarEntries(archivePath: string, cwd?: string): Promise<Archiv
|
|||||||
|
|
||||||
const [exitCode, stdout, stderr] = await Promise.all([
|
const [exitCode, stdout, stderr] = await Promise.all([
|
||||||
proc.exited,
|
proc.exited,
|
||||||
new Response(proc.stdout).text(),
|
// #3919: Use Buffer-concat stream reads for Node utility-process compatibility.
|
||||||
new Response(proc.stderr).text(),
|
readProcessStream(proc.stdout),
|
||||||
|
readProcessStream(proc.stderr),
|
||||||
])
|
])
|
||||||
|
|
||||||
if (isTarTraversalErrorOutput(stderr)) {
|
if (isTarTraversalErrorOutput(stderr)) {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { Readable } from "node:stream"
|
||||||
|
|
||||||
import { spawn, spawnSync } from "./bun-spawn-shim"
|
import { createNodeSpawnOptions, createNodeSpawnSyncOptions, spawn, spawnSync } from "./bun-spawn-shim"
|
||||||
|
import { readProcessStream } from "./process-stream-reader"
|
||||||
|
|
||||||
describe("bun-spawn-shim", () => {
|
describe("bun-spawn-shim", () => {
|
||||||
test("#given array command #when spawn exits successfully #then exited resolves to zero", async () => {
|
test("#given array command #when spawn exits successfully #then exited resolves to zero", async () => {
|
||||||
@@ -17,7 +19,7 @@ describe("bun-spawn-shim", () => {
|
|||||||
|
|
||||||
const [exitCode, stdout] = await Promise.all([
|
const [exitCode, stdout] = await Promise.all([
|
||||||
proc.exited,
|
proc.exited,
|
||||||
new Response(proc.stdout).text(),
|
readProcessStream(proc.stdout),
|
||||||
])
|
])
|
||||||
|
|
||||||
expect(exitCode).toBe(0)
|
expect(exitCode).toBe(0)
|
||||||
@@ -48,7 +50,7 @@ describe("bun-spawn-shim", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const exitCode = await proc.exited
|
const exitCode = await proc.exited
|
||||||
const stdout = await new Response(proc.stdout).text()
|
const stdout = await readProcessStream(proc.stdout)
|
||||||
|
|
||||||
expect(exitCode).toBe(0)
|
expect(exitCode).toBe(0)
|
||||||
expect(stdout).toBe("")
|
expect(stdout).toBe("")
|
||||||
@@ -60,7 +62,7 @@ describe("bun-spawn-shim", () => {
|
|||||||
expect(result.exitCode).toBe(0)
|
expect(result.exitCode).toBe(0)
|
||||||
expect(result.success).toBe(true)
|
expect(result.success).toBe(true)
|
||||||
expect(result.stdout).toBeDefined()
|
expect(result.stdout).toBeDefined()
|
||||||
expect(Buffer.from(result.stdout!).toString().trim()).toBe("sync-ok")
|
expect(result.stdout?.toString().trim()).toBe("sync-ok")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("#given spawnSync command #when it completes #then result.pid is a positive number", () => {
|
test("#given spawnSync command #when it completes #then result.pid is a positive number", () => {
|
||||||
@@ -88,4 +90,38 @@ describe("bun-spawn-shim", () => {
|
|||||||
|
|
||||||
expect(observedError).toBeDefined()
|
expect(observedError).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("#given Windows platform #when building Node spawn options #then windowsHide is enabled", () => {
|
||||||
|
const options = createNodeSpawnOptions({ stdout: "pipe", stderr: "pipe" }, "win32")
|
||||||
|
|
||||||
|
expect(options.windowsHide).toBe(true)
|
||||||
|
expect(options.shell).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("#given Windows platform #when building Node spawnSync options #then windowsHide is enabled", () => {
|
||||||
|
const options = createNodeSpawnSyncOptions({ stdout: "pipe", stderr: "pipe" }, "win32")
|
||||||
|
|
||||||
|
expect(options.windowsHide).toBe(true)
|
||||||
|
expect(options.shell).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("#given Node readable output #when reading in a non-Bun host shape #then Buffer-concat returns text", async () => {
|
||||||
|
const stream = Readable.from([Buffer.from("node-stream-ok\n")])
|
||||||
|
|
||||||
|
const output = await readProcessStream(stream)
|
||||||
|
|
||||||
|
expect(output).toBe("node-stream-ok\n")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("#given empty process stream #when reading process output #then returns an empty string", async () => {
|
||||||
|
const stream = new ReadableStream<Uint8Array>({
|
||||||
|
start(controller) {
|
||||||
|
controller.close()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const output = await readProcessStream(stream)
|
||||||
|
|
||||||
|
expect(output).toBe("")
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { spawn as nodeSpawn, spawnSync as nodeSpawnSync } from "node:child_process"
|
import {
|
||||||
|
spawn as nodeSpawn,
|
||||||
|
spawnSync as nodeSpawnSync,
|
||||||
|
type SpawnOptions as NodeSpawnOptions,
|
||||||
|
type SpawnSyncOptions as NodeSpawnSyncOptions,
|
||||||
|
} from "node:child_process"
|
||||||
import { Readable, Writable } from "node:stream"
|
import { Readable, Writable } from "node:stream"
|
||||||
|
|
||||||
type AnyRecord = Record<string, unknown>
|
type AnyRecord = Record<string, unknown>
|
||||||
@@ -45,7 +50,10 @@ type BunSpawnRuntime = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const runtime = globalThis as typeof globalThis & { Bun?: BunSpawnRuntime }
|
const runtime = globalThis as typeof globalThis & { Bun?: BunSpawnRuntime }
|
||||||
const IS_BUN = typeof runtime.Bun !== "undefined"
|
|
||||||
|
function getBunRuntime(): BunSpawnRuntime | undefined {
|
||||||
|
return typeof Bun === "undefined" ? undefined : runtime.Bun
|
||||||
|
}
|
||||||
|
|
||||||
function emptyReadableStream(): ReadableStream<Uint8Array> {
|
function emptyReadableStream(): ReadableStream<Uint8Array> {
|
||||||
return new ReadableStream<Uint8Array>({
|
return new ReadableStream<Uint8Array>({
|
||||||
@@ -85,6 +93,48 @@ function resolveStdio(options: SpawnOptions): StdioTuple {
|
|||||||
return [options.stdin ?? "ignore", options.stdout ?? "pipe", options.stderr ?? "inherit"]
|
return [options.stdin ?? "ignore", options.stdout ?? "pipe", options.stderr ?? "inherit"]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createNodeSpawnOptions(
|
||||||
|
options: SpawnOptions,
|
||||||
|
platform: NodeJS.Platform = process.platform
|
||||||
|
): NodeSpawnOptions {
|
||||||
|
const nodeOptions: NodeSpawnOptions = {
|
||||||
|
stdio: resolveStdio(options),
|
||||||
|
shell: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.cwd !== undefined) nodeOptions.cwd = options.cwd
|
||||||
|
if (options.env !== undefined) nodeOptions.env = options.env
|
||||||
|
if (options.detached !== undefined) nodeOptions.detached = options.detached
|
||||||
|
if (options.signal !== undefined) nodeOptions.signal = options.signal
|
||||||
|
|
||||||
|
if (platform === "win32") {
|
||||||
|
// #3919: Windows Desktop utility processes must hide child consoles when spawning tools.
|
||||||
|
nodeOptions.windowsHide = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return nodeOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createNodeSpawnSyncOptions(
|
||||||
|
options: SpawnOptions,
|
||||||
|
platform: NodeJS.Platform = process.platform
|
||||||
|
): NodeSpawnSyncOptions {
|
||||||
|
const nodeOptions: NodeSpawnSyncOptions = {
|
||||||
|
stdio: resolveStdio(options),
|
||||||
|
shell: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.cwd !== undefined) nodeOptions.cwd = options.cwd
|
||||||
|
if (options.env !== undefined) nodeOptions.env = options.env
|
||||||
|
|
||||||
|
if (platform === "win32") {
|
||||||
|
// #3919: Match async spawn so Windows sync probes do not surface a console window.
|
||||||
|
nodeOptions.windowsHide = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return nodeOptions
|
||||||
|
}
|
||||||
|
|
||||||
function wrapNodeProcess(proc: ReturnType<typeof nodeSpawn>): SpawnedProcess {
|
function wrapNodeProcess(proc: ReturnType<typeof nodeSpawn>): SpawnedProcess {
|
||||||
let exitCode: number | null = null
|
let exitCode: number | null = null
|
||||||
const exited = new Promise<number>((resolve, reject) => {
|
const exited = new Promise<number>((resolve, reject) => {
|
||||||
@@ -127,20 +177,27 @@ function wrapNodeProcess(proc: ReturnType<typeof nodeSpawn>): SpawnedProcess {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toSpawnSyncBuffer(output: Buffer | string | null): Buffer | undefined {
|
||||||
|
if (output === null) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return Buffer.isBuffer(output) ? output : Buffer.from(output, "utf8")
|
||||||
|
}
|
||||||
|
|
||||||
export function spawn(command: string[], options?: SpawnOptions): SpawnedProcess
|
export function spawn(command: string[], options?: SpawnOptions): SpawnedProcess
|
||||||
export function spawn(options: SpawnOptions & { cmd: string[] }): SpawnedProcess
|
export function spawn(options: SpawnOptions & { cmd: string[] }): SpawnedProcess
|
||||||
export function spawn(cmdOrOpts: unknown, opts?: unknown): SpawnedProcess {
|
export function spawn(cmdOrOpts: unknown, opts?: unknown): SpawnedProcess {
|
||||||
if (IS_BUN) return runtime.Bun!.spawn(cmdOrOpts as string[] & SpawnOptions & { cmd: string[] }, opts as SpawnOptions)
|
|
||||||
|
|
||||||
const { cmd, opts: options } = resolveCommand(cmdOrOpts, opts)
|
const { cmd, opts: options } = resolveCommand(cmdOrOpts, opts)
|
||||||
|
const bun = getBunRuntime()
|
||||||
|
if (bun) return bun.spawn(cmd, options)
|
||||||
|
|
||||||
const [bin, ...args] = cmd
|
const [bin, ...args] = cmd
|
||||||
const proc = nodeSpawn(bin, args, {
|
if (bin === undefined) {
|
||||||
cwd: options.cwd,
|
throw new Error("Cannot spawn an empty command")
|
||||||
env: options.env,
|
}
|
||||||
stdio: resolveStdio(options),
|
|
||||||
detached: options.detached,
|
const proc = nodeSpawn(bin, args, createNodeSpawnOptions(options))
|
||||||
signal: options.signal,
|
|
||||||
})
|
|
||||||
|
|
||||||
return wrapNodeProcess(proc)
|
return wrapNodeProcess(proc)
|
||||||
}
|
}
|
||||||
@@ -148,20 +205,21 @@ export function spawn(cmdOrOpts: unknown, opts?: unknown): SpawnedProcess {
|
|||||||
export function spawnSync(command: string[], options?: SpawnOptions): SpawnSyncResult
|
export function spawnSync(command: string[], options?: SpawnOptions): SpawnSyncResult
|
||||||
export function spawnSync(options: SpawnOptions & { cmd: string[] }): SpawnSyncResult
|
export function spawnSync(options: SpawnOptions & { cmd: string[] }): SpawnSyncResult
|
||||||
export function spawnSync(cmdOrOpts: unknown, opts?: unknown): SpawnSyncResult {
|
export function spawnSync(cmdOrOpts: unknown, opts?: unknown): SpawnSyncResult {
|
||||||
if (IS_BUN) return runtime.Bun!.spawnSync(cmdOrOpts as string[] & SpawnOptions & { cmd: string[] }, opts as SpawnOptions)
|
|
||||||
|
|
||||||
const { cmd, opts: options } = resolveCommand(cmdOrOpts, opts)
|
const { cmd, opts: options } = resolveCommand(cmdOrOpts, opts)
|
||||||
|
const bun = getBunRuntime()
|
||||||
|
if (bun) return bun.spawnSync(cmd, options)
|
||||||
|
|
||||||
const [bin, ...args] = cmd
|
const [bin, ...args] = cmd
|
||||||
const result = nodeSpawnSync(bin, args, {
|
if (bin === undefined) {
|
||||||
cwd: options.cwd,
|
throw new Error("Cannot spawnSync an empty command")
|
||||||
env: options.env,
|
}
|
||||||
stdio: resolveStdio(options),
|
|
||||||
})
|
const result = nodeSpawnSync(bin, args, createNodeSpawnSyncOptions(options))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
exitCode: result.status ?? 1,
|
exitCode: result.status ?? 1,
|
||||||
stdout: result.stdout ?? undefined,
|
stdout: toSpawnSyncBuffer(result.stdout),
|
||||||
stderr: result.stderr ?? undefined,
|
stderr: toSpawnSyncBuffer(result.stderr),
|
||||||
success: (result.status ?? 1) === 0,
|
success: (result.status ?? 1) === 0,
|
||||||
pid: result.pid ?? -1,
|
pid: result.pid ?? -1,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { Readable } from "node:stream"
|
||||||
|
|
||||||
|
export type ProcessReadableStream = ReadableStream<Uint8Array> | Readable | null | undefined
|
||||||
|
|
||||||
|
function bufferFromChunk(chunk: unknown): Buffer {
|
||||||
|
if (Buffer.isBuffer(chunk)) {
|
||||||
|
return chunk
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chunk instanceof Uint8Array) {
|
||||||
|
return Buffer.from(chunk)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof chunk === "string") {
|
||||||
|
return Buffer.from(chunk, "utf8")
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new TypeError(`Unsupported process stream chunk type: ${typeof chunk}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readWebStream(stream: ReadableStream<Uint8Array>): Promise<Buffer[]> {
|
||||||
|
const reader = stream.getReader()
|
||||||
|
const chunks: Buffer[] = []
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const result = await reader.read()
|
||||||
|
if (result.done) {
|
||||||
|
return chunks
|
||||||
|
}
|
||||||
|
chunks.push(Buffer.from(result.value))
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
reader.releaseLock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readNodeStream(stream: Readable): Promise<Buffer[]> {
|
||||||
|
const chunks: Buffer[] = []
|
||||||
|
|
||||||
|
for await (const chunk of stream) {
|
||||||
|
chunks.push(bufferFromChunk(chunk))
|
||||||
|
}
|
||||||
|
|
||||||
|
return chunks
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWebReadableStream(stream: ProcessReadableStream): stream is ReadableStream<Uint8Array> {
|
||||||
|
return typeof ReadableStream !== "undefined" && stream instanceof ReadableStream
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readProcessStream(stream: ProcessReadableStream): Promise<string> {
|
||||||
|
if (!stream) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// #3919: Buffer-concat avoids Response(stream).text() crashes in Windows utility processes.
|
||||||
|
const chunks = isWebReadableStream(stream)
|
||||||
|
? await readWebStream(stream)
|
||||||
|
: await readNodeStream(stream)
|
||||||
|
|
||||||
|
return Buffer.concat(chunks).toString("utf8")
|
||||||
|
}
|
||||||
@@ -20,12 +20,19 @@ let autoInstallAttempted = false
|
|||||||
|
|
||||||
function findExecutable(name: string): string | null {
|
function findExecutable(name: string): string | null {
|
||||||
const isWindows = process.platform === "win32"
|
const isWindows = process.platform === "win32"
|
||||||
const cmd = isWindows ? "where" : "which"
|
const cmd = isWindows ? "where.exe" : "which"
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = spawnSync(cmd, [name], { encoding: "utf-8", timeout: 5000 })
|
// #3919: Keep Windows executable probes hidden and shell-free in Desktop utility processes.
|
||||||
if (result.status === 0 && result.stdout.trim()) {
|
const result = spawnSync(cmd, [name], {
|
||||||
return result.stdout.trim().split("\n")[0]
|
encoding: "utf-8",
|
||||||
|
timeout: 5000,
|
||||||
|
windowsHide: isWindows,
|
||||||
|
shell: false,
|
||||||
|
})
|
||||||
|
const stdout = result.stdout
|
||||||
|
if (result.status === 0 && stdout.trim()) {
|
||||||
|
return stdout.trim().split("\n")[0]
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { spawn } from "../bun-spawn-shim"
|
import { spawn } from "../bun-spawn-shim"
|
||||||
|
|
||||||
import type { ArchiveEntry } from "../archive-entry-validator"
|
import type { ArchiveEntry } from "../archive-entry-validator"
|
||||||
|
import { readProcessStream } from "../process-stream-reader"
|
||||||
|
|
||||||
export type PowerShellZipExtractor = "pwsh" | "powershell"
|
export type PowerShellZipExtractor = "pwsh" | "powershell"
|
||||||
|
|
||||||
@@ -82,8 +83,9 @@ export async function listZipEntriesWithPowerShell(
|
|||||||
|
|
||||||
const [exitCode, stdout, stderr] = await Promise.all([
|
const [exitCode, stdout, stderr] = await Promise.all([
|
||||||
proc.exited,
|
proc.exited,
|
||||||
new Response(proc.stdout).text(),
|
// #3919: Use Buffer-concat stream reads for Node utility-process compatibility.
|
||||||
new Response(proc.stderr).text(),
|
readProcessStream(proc.stdout),
|
||||||
|
readProcessStream(proc.stderr),
|
||||||
])
|
])
|
||||||
|
|
||||||
if (exitCode !== 0) {
|
if (exitCode !== 0) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { spawn, spawnSync } from "../bun-spawn-shim"
|
import { spawn, spawnSync } from "../bun-spawn-shim"
|
||||||
|
|
||||||
import type { ArchiveEntry } from "../archive-entry-validator"
|
import type { ArchiveEntry } from "../archive-entry-validator"
|
||||||
|
import { readProcessStream } from "../process-stream-reader"
|
||||||
|
|
||||||
export function isPythonZipListingAvailable(): boolean {
|
export function isPythonZipListingAvailable(): boolean {
|
||||||
const proc = spawnSync(["python3", "--version"], {
|
const proc = spawnSync(["python3", "--version"], {
|
||||||
@@ -43,8 +44,9 @@ export async function listZipEntriesWithPython(
|
|||||||
|
|
||||||
const [exitCode, stdout, stderr] = await Promise.all([
|
const [exitCode, stdout, stderr] = await Promise.all([
|
||||||
proc.exited,
|
proc.exited,
|
||||||
new Response(proc.stdout).text(),
|
// #3919: Use Buffer-concat stream reads for Node utility-process compatibility.
|
||||||
new Response(proc.stderr).text(),
|
readProcessStream(proc.stdout),
|
||||||
|
readProcessStream(proc.stderr),
|
||||||
])
|
])
|
||||||
|
|
||||||
if (exitCode !== 0) {
|
if (exitCode !== 0) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { spawn } from "../bun-spawn-shim"
|
import { spawn } from "../bun-spawn-shim"
|
||||||
|
import { readProcessStream } from "../process-stream-reader"
|
||||||
|
|
||||||
export async function readZipSymlinkTarget(
|
export async function readZipSymlinkTarget(
|
||||||
archivePath: string,
|
archivePath: string,
|
||||||
@@ -11,8 +12,9 @@ export async function readZipSymlinkTarget(
|
|||||||
|
|
||||||
const [exitCode, stdout, stderr] = await Promise.all([
|
const [exitCode, stdout, stderr] = await Promise.all([
|
||||||
proc.exited,
|
proc.exited,
|
||||||
new Response(proc.stdout).text(),
|
// #3919: Use Buffer-concat stream reads for Node utility-process compatibility.
|
||||||
new Response(proc.stderr).text(),
|
readProcessStream(proc.stdout),
|
||||||
|
readProcessStream(proc.stderr),
|
||||||
])
|
])
|
||||||
|
|
||||||
if (exitCode !== 0) {
|
if (exitCode !== 0) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { spawn } from "../bun-spawn-shim"
|
|||||||
|
|
||||||
import type { ArchiveEntry } from "../archive-entry-validator"
|
import type { ArchiveEntry } from "../archive-entry-validator"
|
||||||
import { log } from "../logger"
|
import { log } from "../logger"
|
||||||
|
import { readProcessStream } from "../process-stream-reader"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -81,8 +82,9 @@ export async function listZipEntriesWithTar(
|
|||||||
|
|
||||||
const [exitCode, stdout, stderr] = await Promise.all([
|
const [exitCode, stdout, stderr] = await Promise.all([
|
||||||
proc.exited,
|
proc.exited,
|
||||||
new Response(proc.stdout).text(),
|
// #3919: Use Buffer-concat stream reads for Node utility-process compatibility.
|
||||||
new Response(proc.stderr).text(),
|
readProcessStream(proc.stdout),
|
||||||
|
readProcessStream(proc.stderr),
|
||||||
])
|
])
|
||||||
|
|
||||||
if (exitCode !== 0) {
|
if (exitCode !== 0) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { spawn, spawnSync } from "../bun-spawn-shim"
|
import { spawn, spawnSync } from "../bun-spawn-shim"
|
||||||
|
|
||||||
import type { ArchiveEntry } from "../archive-entry-validator"
|
import type { ArchiveEntry } from "../archive-entry-validator"
|
||||||
|
import { readProcessStream } from "../process-stream-reader"
|
||||||
import { readZipSymlinkTarget } from "./read-zip-symlink-target"
|
import { readZipSymlinkTarget } from "./read-zip-symlink-target"
|
||||||
|
|
||||||
export function parseZipInfoListedEntry(line: string): ArchiveEntry | null {
|
export function parseZipInfoListedEntry(line: string): ArchiveEntry | null {
|
||||||
@@ -45,8 +46,9 @@ export async function listZipEntriesWithZipInfo(
|
|||||||
|
|
||||||
const [exitCode, stdout, stderr] = await Promise.all([
|
const [exitCode, stdout, stderr] = await Promise.all([
|
||||||
proc.exited,
|
proc.exited,
|
||||||
new Response(proc.stdout).text(),
|
// #3919: Use Buffer-concat stream reads for Node utility-process compatibility.
|
||||||
new Response(proc.stderr).text(),
|
readProcessStream(proc.stdout),
|
||||||
|
readProcessStream(proc.stderr),
|
||||||
])
|
])
|
||||||
|
|
||||||
if (exitCode !== 0) {
|
if (exitCode !== 0) {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { spawn, spawnSync } from "./bun-spawn-shim"
|
import { spawn, spawnSync, type SpawnedProcess } from "./bun-spawn-shim"
|
||||||
import { release } from "os"
|
import { release } from "os"
|
||||||
|
|
||||||
import { validateArchiveEntries } from "./archive-entry-validator"
|
import { validateArchiveEntries } from "./archive-entry-validator"
|
||||||
|
import { readProcessStream } from "./process-stream-reader"
|
||||||
import {
|
import {
|
||||||
isPythonZipListingAvailable,
|
isPythonZipListingAvailable,
|
||||||
isZipInfoZipListingAvailable,
|
isZipInfoZipListingAvailable,
|
||||||
@@ -53,7 +54,7 @@ export async function extractZip(archivePath: string, destDir: string): Promise<
|
|||||||
const entries = await listZipEntries(archivePath)
|
const entries = await listZipEntries(archivePath)
|
||||||
validateArchiveEntries(entries, destDir)
|
validateArchiveEntries(entries, destDir)
|
||||||
|
|
||||||
let proc
|
let proc: SpawnedProcess
|
||||||
|
|
||||||
if (process.platform === "win32") {
|
if (process.platform === "win32") {
|
||||||
const extractor = getWindowsZipExtractor()
|
const extractor = getWindowsZipExtractor()
|
||||||
@@ -89,7 +90,8 @@ export async function extractZip(archivePath: string, destDir: string): Promise<
|
|||||||
const exitCode = await proc.exited
|
const exitCode = await proc.exited
|
||||||
|
|
||||||
if (exitCode !== 0) {
|
if (exitCode !== 0) {
|
||||||
const stderr = await new Response(proc.stderr).text()
|
// #3919: Avoid Response(stream).text() in Windows Desktop utility processes.
|
||||||
|
const stderr = await readProcessStream(proc.stderr)
|
||||||
throw new Error(`zip extraction failed (exit ${exitCode}): ${stderr}`)
|
throw new Error(`zip extraction failed (exit ${exitCode}): ${stderr}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,36 @@
|
|||||||
import { describe, it, expect } from "bun:test"
|
import { describe, it, expect, mock } from "bun:test"
|
||||||
import { buildRgArgs, buildFindArgs, buildPowerShellCommand } from "./cli"
|
import { Writable } from "node:stream"
|
||||||
|
import type { SpawnOptions, SpawnedProcess } from "../../shared/bun-spawn-shim"
|
||||||
|
import { buildRgArgs, buildFindArgs, buildPowerShellCommand, runRgFiles } from "./cli"
|
||||||
|
|
||||||
|
function createTextStream(text: string): ReadableStream<Uint8Array> {
|
||||||
|
return new ReadableStream<Uint8Array>({
|
||||||
|
start(controller) {
|
||||||
|
if (text.length > 0) {
|
||||||
|
controller.enqueue(new TextEncoder().encode(text))
|
||||||
|
}
|
||||||
|
controller.close()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSpawnedProcess(exitCode: number, stdout = "", stderr = ""): SpawnedProcess {
|
||||||
|
return {
|
||||||
|
exitCode,
|
||||||
|
exited: Promise.resolve(exitCode),
|
||||||
|
stdout: createTextStream(stdout),
|
||||||
|
stderr: createTextStream(stderr),
|
||||||
|
stdin: new Writable({
|
||||||
|
write(_chunk, _encoding, callback) {
|
||||||
|
callback()
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
pid: 3919,
|
||||||
|
kill() {},
|
||||||
|
ref() {},
|
||||||
|
unref() {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
describe("buildRgArgs", () => {
|
describe("buildRgArgs", () => {
|
||||||
// given default options (no hidden/follow specified)
|
// given default options (no hidden/follow specified)
|
||||||
@@ -166,4 +197,32 @@ describe("buildPowerShellCommand", () => {
|
|||||||
const command = args.join(" ")
|
const command = args.join(" ")
|
||||||
expect(command).toContain("test''s.ts")
|
expect(command).toContain("test''s.ts")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("uses LiteralPath so fallback paths are not wildcard-expanded (#3919)", () => {
|
||||||
|
const args = buildPowerShellCommand({ pattern: "*.ts", paths: ["C:\\repo[1]"] })
|
||||||
|
const command = args.join(" ")
|
||||||
|
expect(args[0]).toBe("powershell.exe")
|
||||||
|
expect(command).toContain("Get-ChildItem -LiteralPath 'C:\\repo[1]'")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("runRgFiles", () => {
|
||||||
|
it("#given empty stdout #when rg exits successfully #then returns an empty result", async () => {
|
||||||
|
const spawnMock = mock((_command: string[], _options?: SpawnOptions): SpawnedProcess =>
|
||||||
|
createSpawnedProcess(0)
|
||||||
|
)
|
||||||
|
|
||||||
|
const result = await runRgFiles(
|
||||||
|
{ pattern: "*.ts", paths: ["."], timeout: 1000 },
|
||||||
|
{ path: "rg", backend: "rg" },
|
||||||
|
spawnMock
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
files: [],
|
||||||
|
totalFiles: 0,
|
||||||
|
truncated: false,
|
||||||
|
})
|
||||||
|
expect(spawnMock).toHaveBeenCalled()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+24
-23
@@ -1,5 +1,5 @@
|
|||||||
import { resolve } from "node:path"
|
import { resolve } from "node:path"
|
||||||
import { spawn } from "../../shared/bun-spawn-shim"
|
import { spawn, type SpawnOptions, type SpawnedProcess } from "../../shared/bun-spawn-shim"
|
||||||
import {
|
import {
|
||||||
resolveGrepCli,
|
resolveGrepCli,
|
||||||
type GrepBackend,
|
type GrepBackend,
|
||||||
@@ -13,12 +13,15 @@ import {
|
|||||||
import type { GlobOptions, GlobResult, FileMatch } from "./types"
|
import type { GlobOptions, GlobResult, FileMatch } from "./types"
|
||||||
import { stat } from "node:fs/promises"
|
import { stat } from "node:fs/promises"
|
||||||
import { rgSemaphore } from "../shared/semaphore"
|
import { rgSemaphore } from "../shared/semaphore"
|
||||||
|
import { collectSearchProcessOutput } from "../shared/search-process-output"
|
||||||
|
|
||||||
export interface ResolvedCli {
|
export interface ResolvedCli {
|
||||||
path: string
|
path: string
|
||||||
backend: GrepBackend
|
backend: GrepBackend
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SearchProcessSpawner = (command: string[], options?: SpawnOptions) => SpawnedProcess
|
||||||
|
|
||||||
function buildRgArgs(options: GlobOptions): string[] {
|
function buildRgArgs(options: GlobOptions): string[] {
|
||||||
const args: string[] = [
|
const args: string[] = [
|
||||||
...RG_FILES_FLAGS,
|
...RG_FILES_FLAGS,
|
||||||
@@ -65,7 +68,8 @@ function buildPowerShellCommand(options: GlobOptions): string[] {
|
|||||||
const escapedPath = searchPath.replace(/'/g, "''")
|
const escapedPath = searchPath.replace(/'/g, "''")
|
||||||
const escapedPattern = options.pattern.replace(/'/g, "''")
|
const escapedPattern = options.pattern.replace(/'/g, "''")
|
||||||
|
|
||||||
let psCommand = `Get-ChildItem -Path '${escapedPath}' -File -Recurse -Depth ${maxDepth - 1} -Filter '${escapedPattern}'`
|
// #3919: Keep PowerShell fallback direct-spawned and single-quote escaped, not shell-interpolated.
|
||||||
|
let psCommand = `Get-ChildItem -LiteralPath '${escapedPath}' -File -Recurse -Depth ${maxDepth - 1} -Filter '${escapedPattern}'`
|
||||||
|
|
||||||
if (options.hidden !== false) {
|
if (options.hidden !== false) {
|
||||||
psCommand += " -Force"
|
psCommand += " -Force"
|
||||||
@@ -78,7 +82,7 @@ function buildPowerShellCommand(options: GlobOptions): string[] {
|
|||||||
|
|
||||||
psCommand += " -ErrorAction SilentlyContinue | Select-Object -ExpandProperty FullName"
|
psCommand += " -ErrorAction SilentlyContinue | Select-Object -ExpandProperty FullName"
|
||||||
|
|
||||||
return ["powershell", "-NoProfile", "-Command", psCommand]
|
return ["powershell.exe", "-NoProfile", "-Command", psCommand]
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getFileMtime(filePath: string): Promise<number> {
|
async function getFileMtime(filePath: string): Promise<number> {
|
||||||
@@ -94,11 +98,12 @@ export { buildRgArgs, buildFindArgs, buildPowerShellCommand }
|
|||||||
|
|
||||||
export async function runRgFiles(
|
export async function runRgFiles(
|
||||||
options: GlobOptions,
|
options: GlobOptions,
|
||||||
resolvedCli?: ResolvedCli
|
resolvedCli?: ResolvedCli,
|
||||||
|
processSpawner: SearchProcessSpawner = spawn
|
||||||
): Promise<GlobResult> {
|
): Promise<GlobResult> {
|
||||||
await rgSemaphore.acquire()
|
await rgSemaphore.acquire()
|
||||||
try {
|
try {
|
||||||
return await runRgFilesInternal(options, resolvedCli)
|
return await runRgFilesInternal(options, resolvedCli, processSpawner)
|
||||||
} finally {
|
} finally {
|
||||||
rgSemaphore.release()
|
rgSemaphore.release()
|
||||||
}
|
}
|
||||||
@@ -106,7 +111,8 @@ export async function runRgFiles(
|
|||||||
|
|
||||||
async function runRgFilesInternal(
|
async function runRgFilesInternal(
|
||||||
options: GlobOptions,
|
options: GlobOptions,
|
||||||
resolvedCli?: ResolvedCli
|
resolvedCli?: ResolvedCli,
|
||||||
|
processSpawner: SearchProcessSpawner = spawn
|
||||||
): Promise<GlobResult> {
|
): Promise<GlobResult> {
|
||||||
const cli = resolvedCli ?? resolveGrepCli()
|
const cli = resolvedCli ?? resolveGrepCli()
|
||||||
const timeout = Math.min(options.timeout ?? DEFAULT_TIMEOUT_MS, DEFAULT_TIMEOUT_MS)
|
const timeout = Math.min(options.timeout ?? DEFAULT_TIMEOUT_MS, DEFAULT_TIMEOUT_MS)
|
||||||
@@ -133,24 +139,19 @@ async function runRgFilesInternal(
|
|||||||
command = [cli.path, ...args]
|
command = [cli.path, ...args]
|
||||||
}
|
}
|
||||||
|
|
||||||
const proc = spawn(command, {
|
|
||||||
stdout: "pipe",
|
|
||||||
stderr: "pipe",
|
|
||||||
cwd,
|
|
||||||
})
|
|
||||||
|
|
||||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
||||||
const id = setTimeout(() => {
|
|
||||||
proc.kill()
|
|
||||||
reject(new Error(`Glob search timeout after ${timeout}ms`))
|
|
||||||
}, timeout)
|
|
||||||
proc.exited.then(() => clearTimeout(id))
|
|
||||||
})
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const stdout = await Promise.race([new Response(proc.stdout).text(), timeoutPromise])
|
const proc = processSpawner(command, {
|
||||||
const stderr = await new Response(proc.stderr).text()
|
stdout: "pipe",
|
||||||
const exitCode = await proc.exited
|
stderr: "pipe",
|
||||||
|
cwd,
|
||||||
|
})
|
||||||
|
|
||||||
|
// #3919: Read stdout/stderr with Buffer concat instead of Response(stream).text().
|
||||||
|
const { stdout, stderr, exitCode } = await collectSearchProcessOutput(
|
||||||
|
proc,
|
||||||
|
timeout,
|
||||||
|
`Glob search timeout after ${timeout}ms`
|
||||||
|
)
|
||||||
|
|
||||||
if (exitCode > 1 && stderr.trim()) {
|
if (exitCode > 1 && stderr.trim()) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { describe, expect, it, mock } from "bun:test"
|
||||||
|
import { Writable } from "node:stream"
|
||||||
|
import type { SpawnOptions, SpawnedProcess } from "../../shared/bun-spawn-shim"
|
||||||
|
import { runRg } from "./cli"
|
||||||
|
|
||||||
|
function createTextStream(text: string): ReadableStream<Uint8Array> {
|
||||||
|
return new ReadableStream<Uint8Array>({
|
||||||
|
start(controller) {
|
||||||
|
if (text.length > 0) {
|
||||||
|
controller.enqueue(new TextEncoder().encode(text))
|
||||||
|
}
|
||||||
|
controller.close()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSpawnedProcess(exited: Promise<number>, stdout = "", stderr = ""): SpawnedProcess {
|
||||||
|
return {
|
||||||
|
exitCode: null,
|
||||||
|
exited,
|
||||||
|
stdout: createTextStream(stdout),
|
||||||
|
stderr: createTextStream(stderr),
|
||||||
|
stdin: new Writable({
|
||||||
|
write(_chunk, _encoding, callback) {
|
||||||
|
callback()
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
pid: 3919,
|
||||||
|
kill() {},
|
||||||
|
ref() {},
|
||||||
|
unref() {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("runRg", () => {
|
||||||
|
it("#given mocked spawn rejection #when grep runs #then returns a structured error result", async () => {
|
||||||
|
const spawnMock = mock((_command: string[], _options?: SpawnOptions): SpawnedProcess =>
|
||||||
|
createSpawnedProcess(Promise.reject(new Error("spawn rejected")))
|
||||||
|
)
|
||||||
|
|
||||||
|
const result = await runRg(
|
||||||
|
{ pattern: "needle", paths: ["."], timeout: 1000 },
|
||||||
|
{ path: "rg", backend: "rg" },
|
||||||
|
spawnMock
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.matches).toEqual([])
|
||||||
|
expect(result.totalMatches).toBe(0)
|
||||||
|
expect(result.filesSearched).toBe(0)
|
||||||
|
expect(result.truncated).toBe(false)
|
||||||
|
expect(result.error).toContain("spawn rejected")
|
||||||
|
})
|
||||||
|
})
|
||||||
+45
-37
@@ -1,4 +1,4 @@
|
|||||||
import { spawn } from "../../shared/bun-spawn-shim"
|
import { spawn, type SpawnOptions, type SpawnedProcess } from "../../shared/bun-spawn-shim"
|
||||||
import {
|
import {
|
||||||
resolveGrepCli,
|
resolveGrepCli,
|
||||||
type ResolvedCli,
|
type ResolvedCli,
|
||||||
@@ -17,6 +17,9 @@ import {
|
|||||||
} from "./constants"
|
} from "./constants"
|
||||||
import type { GrepOptions, GrepMatch, GrepResult, CountResult } from "./types"
|
import type { GrepOptions, GrepMatch, GrepResult, CountResult } from "./types"
|
||||||
import { rgSemaphore } from "../shared/semaphore"
|
import { rgSemaphore } from "../shared/semaphore"
|
||||||
|
import { collectSearchProcessOutput } from "../shared/search-process-output"
|
||||||
|
|
||||||
|
export type SearchProcessSpawner = (command: string[], options?: SpawnOptions) => SpawnedProcess
|
||||||
|
|
||||||
function buildRgArgs(options: GrepOptions): string[] {
|
function buildRgArgs(options: GrepOptions): string[] {
|
||||||
const args: string[] = [
|
const args: string[] = [
|
||||||
@@ -154,16 +157,24 @@ function parseCountOutput(output: string): CountResult[] {
|
|||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runRg(options: GrepOptions, resolvedCli?: ResolvedCli): Promise<GrepResult> {
|
export async function runRg(
|
||||||
|
options: GrepOptions,
|
||||||
|
resolvedCli?: ResolvedCli,
|
||||||
|
processSpawner: SearchProcessSpawner = spawn
|
||||||
|
): Promise<GrepResult> {
|
||||||
await rgSemaphore.acquire()
|
await rgSemaphore.acquire()
|
||||||
try {
|
try {
|
||||||
return await runRgInternal(options, resolvedCli)
|
return await runRgInternal(options, resolvedCli, processSpawner)
|
||||||
} finally {
|
} finally {
|
||||||
rgSemaphore.release()
|
rgSemaphore.release()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runRgInternal(options: GrepOptions, resolvedCli?: ResolvedCli): Promise<GrepResult> {
|
async function runRgInternal(
|
||||||
|
options: GrepOptions,
|
||||||
|
resolvedCli?: ResolvedCli,
|
||||||
|
processSpawner: SearchProcessSpawner = spawn
|
||||||
|
): Promise<GrepResult> {
|
||||||
const cli = resolvedCli ?? resolveGrepCli()
|
const cli = resolvedCli ?? resolveGrepCli()
|
||||||
const args = buildArgs(options, cli.backend)
|
const args = buildArgs(options, cli.backend)
|
||||||
const timeout = Math.min(options.timeout ?? DEFAULT_TIMEOUT_MS, DEFAULT_TIMEOUT_MS)
|
const timeout = Math.min(options.timeout ?? DEFAULT_TIMEOUT_MS, DEFAULT_TIMEOUT_MS)
|
||||||
@@ -176,23 +187,18 @@ async function runRgInternal(options: GrepOptions, resolvedCli?: ResolvedCli): P
|
|||||||
|
|
||||||
const paths = options.paths?.length ? options.paths : ["."]
|
const paths = options.paths?.length ? options.paths : ["."]
|
||||||
args.push(...paths)
|
args.push(...paths)
|
||||||
const proc = spawn([cli.path, ...args], {
|
|
||||||
stdout: "pipe",
|
|
||||||
stderr: "pipe",
|
|
||||||
})
|
|
||||||
|
|
||||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
||||||
const id = setTimeout(() => {
|
|
||||||
proc.kill()
|
|
||||||
reject(new Error(`Search timeout after ${timeout}ms`))
|
|
||||||
}, timeout)
|
|
||||||
proc.exited.then(() => clearTimeout(id))
|
|
||||||
})
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const stdout = await Promise.race([new Response(proc.stdout).text(), timeoutPromise])
|
const proc = processSpawner([cli.path, ...args], {
|
||||||
const stderr = await new Response(proc.stderr).text()
|
stdout: "pipe",
|
||||||
const exitCode = await proc.exited
|
stderr: "pipe",
|
||||||
|
})
|
||||||
|
|
||||||
|
// #3919: Read stdout/stderr with Buffer concat instead of Response(stream).text().
|
||||||
|
const { stdout, stderr, exitCode } = await collectSearchProcessOutput(
|
||||||
|
proc,
|
||||||
|
timeout,
|
||||||
|
`Search timeout after ${timeout}ms`
|
||||||
|
)
|
||||||
|
|
||||||
const truncated = stdout.length >= DEFAULT_MAX_OUTPUT_BYTES
|
const truncated = stdout.length >= DEFAULT_MAX_OUTPUT_BYTES
|
||||||
const outputToProcess = truncated ? stdout.substring(0, DEFAULT_MAX_OUTPUT_BYTES) : stdout
|
const outputToProcess = truncated ? stdout.substring(0, DEFAULT_MAX_OUTPUT_BYTES) : stdout
|
||||||
@@ -232,11 +238,12 @@ async function runRgInternal(options: GrepOptions, resolvedCli?: ResolvedCli): P
|
|||||||
|
|
||||||
export async function runRgCount(
|
export async function runRgCount(
|
||||||
options: Omit<GrepOptions, "context">,
|
options: Omit<GrepOptions, "context">,
|
||||||
resolvedCli?: ResolvedCli
|
resolvedCli?: ResolvedCli,
|
||||||
|
processSpawner: SearchProcessSpawner = spawn
|
||||||
): Promise<CountResult[]> {
|
): Promise<CountResult[]> {
|
||||||
await rgSemaphore.acquire()
|
await rgSemaphore.acquire()
|
||||||
try {
|
try {
|
||||||
return await runRgCountInternal(options, resolvedCli)
|
return await runRgCountInternal(options, resolvedCli, processSpawner)
|
||||||
} finally {
|
} finally {
|
||||||
rgSemaphore.release()
|
rgSemaphore.release()
|
||||||
}
|
}
|
||||||
@@ -244,7 +251,8 @@ export async function runRgCount(
|
|||||||
|
|
||||||
async function runRgCountInternal(
|
async function runRgCountInternal(
|
||||||
options: Omit<GrepOptions, "context">,
|
options: Omit<GrepOptions, "context">,
|
||||||
resolvedCli?: ResolvedCli
|
resolvedCli?: ResolvedCli,
|
||||||
|
processSpawner: SearchProcessSpawner = spawn
|
||||||
): Promise<CountResult[]> {
|
): Promise<CountResult[]> {
|
||||||
const cli = resolvedCli ?? resolveGrepCli()
|
const cli = resolvedCli ?? resolveGrepCli()
|
||||||
const args = buildArgs({ ...options, context: 0 }, cli.backend)
|
const args = buildArgs({ ...options, context: 0 }, cli.backend)
|
||||||
@@ -259,21 +267,21 @@ async function runRgCountInternal(
|
|||||||
args.push(...paths)
|
args.push(...paths)
|
||||||
|
|
||||||
const timeout = Math.min(options.timeout ?? DEFAULT_TIMEOUT_MS, DEFAULT_TIMEOUT_MS)
|
const timeout = Math.min(options.timeout ?? DEFAULT_TIMEOUT_MS, DEFAULT_TIMEOUT_MS)
|
||||||
const proc = spawn([cli.path, ...args], {
|
|
||||||
stdout: "pipe",
|
|
||||||
stderr: "pipe",
|
|
||||||
})
|
|
||||||
|
|
||||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
||||||
const id = setTimeout(() => {
|
|
||||||
proc.kill()
|
|
||||||
reject(new Error(`Search timeout after ${timeout}ms`))
|
|
||||||
}, timeout)
|
|
||||||
proc.exited.then(() => clearTimeout(id))
|
|
||||||
})
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const stdout = await Promise.race([new Response(proc.stdout).text(), timeoutPromise])
|
const proc = processSpawner([cli.path, ...args], {
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
})
|
||||||
|
|
||||||
|
// #3919: Count mode uses the same Node-safe stream reader as normal grep.
|
||||||
|
const { stdout, stderr, exitCode } = await collectSearchProcessOutput(
|
||||||
|
proc,
|
||||||
|
timeout,
|
||||||
|
`Search timeout after ${timeout}ms`
|
||||||
|
)
|
||||||
|
if (exitCode > 1 && stderr.trim()) {
|
||||||
|
throw new Error(stderr.trim())
|
||||||
|
}
|
||||||
return parseCountOutput(stdout)
|
return parseCountOutput(stdout)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(`Count search failed: ${e instanceof Error ? e.message : String(e)}`)
|
throw new Error(`Count search failed: ${e instanceof Error ? e.message : String(e)}`)
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import type { SpawnedProcess } from "../../shared/bun-spawn-shim"
|
||||||
|
import { readProcessStream } from "../../shared/process-stream-reader"
|
||||||
|
|
||||||
|
export interface SearchProcessOutput {
|
||||||
|
readonly stdout: string
|
||||||
|
readonly stderr: string
|
||||||
|
readonly exitCode: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function getErrorMessage(error: unknown): string {
|
||||||
|
return error instanceof Error ? error.message : String(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
function createProcessTimeout(
|
||||||
|
proc: SpawnedProcess,
|
||||||
|
timeoutMs: number,
|
||||||
|
timeoutMessage: string
|
||||||
|
): Promise<never> {
|
||||||
|
return new Promise<never>((_, reject) => {
|
||||||
|
const id = setTimeout(() => {
|
||||||
|
proc.kill()
|
||||||
|
reject(new Error(timeoutMessage))
|
||||||
|
}, timeoutMs)
|
||||||
|
|
||||||
|
// #3919: Handle rejected exits here so timeout cleanup cannot leak unhandled rejections.
|
||||||
|
void proc.exited.then(
|
||||||
|
() => clearTimeout(id),
|
||||||
|
() => clearTimeout(id)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function collectSearchProcessOutput(
|
||||||
|
proc: SpawnedProcess,
|
||||||
|
timeoutMs: number,
|
||||||
|
timeoutMessage: string
|
||||||
|
): Promise<SearchProcessOutput> {
|
||||||
|
const stderrPromise = readProcessStream(proc.stderr).catch(getErrorMessage)
|
||||||
|
const stdout = await Promise.race([
|
||||||
|
readProcessStream(proc.stdout),
|
||||||
|
createProcessTimeout(proc, timeoutMs, timeoutMessage),
|
||||||
|
])
|
||||||
|
const [exitCode, stderr] = await Promise.all([proc.exited, stderrPromise])
|
||||||
|
|
||||||
|
return { stdout, stderr, exitCode }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user