11#!/usr/bin/env node
2- import { spawnSync } from "node:child_process" ;
2+ import { spawn } from "node:child_process" ;
33import { fileURLToPath } from "node:url" ;
44import {
55 KNIP_OPTIONAL_UNUSED_FILE_ALLOWLIST ,
88
99const KNIP_VERSION = "6.8.0" ;
1010export const KNIP_TIMEOUT_MS = 10 * 60 * 1000 ;
11+ export const KNIP_KILL_GRACE_MS = 5_000 ;
12+ export const KNIP_HEARTBEAT_MS = 60_000 ;
1113export const KNIP_MAX_BUFFER_BYTES = 16 * 1024 * 1024 ;
1214const KNIP_ARGS = [
1315 "--config" ,
@@ -114,35 +116,163 @@ function spawnErrorCode(error) {
114116 return error && typeof error === "object" && "code" in error ? String ( error . code ) : undefined ;
115117}
116118
117- export function runKnipUnusedFiles ( params = { } ) {
118- const run = params . spawnSyncCommand ?? spawnSync ;
119- const result = run (
120- "pnpm" ,
121- [
122- "--config.minimum-release-age=0" ,
123- "dlx" ,
124- "--package" ,
125- `knip@${ KNIP_VERSION } ` ,
126- "knip" ,
127- ...KNIP_ARGS ,
128- ] ,
129- {
130- encoding : "utf8" ,
131- killSignal : "SIGTERM" ,
132- maxBuffer : params . maxBufferBytes ?? KNIP_MAX_BUFFER_BYTES ,
133- stdio : [ "ignore" , "pipe" , "pipe" ] ,
134- timeout : params . timeoutMs ?? KNIP_TIMEOUT_MS ,
135- } ,
136- ) ;
137- return {
138- status : result . status ,
139- signal : result . signal ,
140- errorCode : spawnErrorCode ( result . error ) ,
141- errorMessage : result . error ?. message ,
142- output : `${ result . stdout ?? "" } ${ result . stderr ?? "" } ` ,
143- } ;
119+ function signalProcessTree ( child , signal ) {
120+ if ( ! child . pid ) {
121+ return ;
122+ }
123+ try {
124+ if ( process . platform === "win32" ) {
125+ process . kill ( child . pid , signal ) ;
126+ } else {
127+ process . kill ( - child . pid , signal ) ;
128+ }
129+ } catch {
130+ // The child may have exited between the timeout and signal delivery.
131+ }
144132}
145133
134+ export async function runKnipUnusedFiles ( params = { } ) {
135+ const run = params . spawnCommand ?? spawn ;
136+ const timeoutMs = params . timeoutMs ?? KNIP_TIMEOUT_MS ;
137+ const heartbeatMs = params . heartbeatMs ?? KNIP_HEARTBEAT_MS ;
138+ const maxBufferBytes = params . maxBufferBytes ?? KNIP_MAX_BUFFER_BYTES ;
139+ const killGraceMs = params . killGraceMs ?? KNIP_KILL_GRACE_MS ;
140+ const writeStatus = params . writeStatus ?? ( ( message ) => process . stderr . write ( `${ message } \n` ) ) ;
141+ const args = [
142+ "--config.minimum-release-age=0" ,
143+ "dlx" ,
144+ "--package" ,
145+ `knip@${ KNIP_VERSION } ` ,
146+ "knip" ,
147+ ...KNIP_ARGS ,
148+ ] ;
149+
150+ return await new Promise ( ( resolve ) => {
151+ const startedAt = Date . now ( ) ;
152+ let settled = false ;
153+ let timedOut = false ;
154+ let bufferExceeded = false ;
155+ let outputBytes = 0 ;
156+ const output = [ ] ;
157+ let killTimer ;
158+ let exitStatus = null ;
159+ let exitSignal = null ;
160+
161+ const child = run ( "pnpm" , args , {
162+ detached : process . platform !== "win32" ,
163+ stdio : [ "ignore" , "pipe" , "pipe" ] ,
164+ } ) ;
165+
166+ const heartbeatTimer = setInterval ( ( ) => {
167+ writeStatus (
168+ `[deadcode] Knip unused-file scan still running after ${ Math . round (
169+ ( Date . now ( ) - startedAt ) / 1000 ,
170+ ) } s.`,
171+ ) ;
172+ } , heartbeatMs ) ;
173+
174+ const timeoutTimer = setTimeout ( ( ) => {
175+ timedOut = true ;
176+ clearInterval ( heartbeatTimer ) ;
177+ writeStatus (
178+ `[deadcode] Knip unused-file scan timed out after ${ Math . round ( timeoutMs / 1000 ) } s; terminating.` ,
179+ ) ;
180+ signalProcessTree ( child , "SIGTERM" ) ;
181+ killTimer = setTimeout ( ( ) => signalProcessTree ( child , "SIGKILL" ) , killGraceMs ) ;
182+ } , timeoutMs ) ;
183+
184+ const finish = ( result ) => {
185+ if ( settled ) {
186+ return ;
187+ }
188+ settled = true ;
189+ clearTimeout ( timeoutTimer ) ;
190+ clearInterval ( heartbeatTimer ) ;
191+ clearTimeout ( killTimer ) ;
192+ resolve ( {
193+ ...result ,
194+ output : output . join ( "" ) ,
195+ } ) ;
196+ } ;
197+
198+ const appendOutput = ( chunk ) => {
199+ if ( settled ) {
200+ return ;
201+ }
202+ if ( bufferExceeded ) {
203+ return ;
204+ }
205+ const buffer = Buffer . isBuffer ( chunk ) ? chunk : Buffer . from ( String ( chunk ) ) ;
206+ const remainingBytes = maxBufferBytes - outputBytes ;
207+ if ( buffer . length <= remainingBytes ) {
208+ output . push ( buffer . toString ( "utf8" ) ) ;
209+ outputBytes += buffer . length ;
210+ return ;
211+ }
212+ if ( remainingBytes > 0 ) {
213+ output . push ( buffer . subarray ( 0 , remainingBytes ) . toString ( "utf8" ) ) ;
214+ outputBytes = maxBufferBytes ;
215+ }
216+ if ( ! bufferExceeded ) {
217+ bufferExceeded = true ;
218+ writeStatus (
219+ `[deadcode] Knip unused-file scan exceeded ${ maxBufferBytes } output bytes; terminating.` ,
220+ ) ;
221+ child . stdout ?. off ?. ( "data" , appendOutput ) ;
222+ child . stderr ?. off ?. ( "data" , appendOutput ) ;
223+ child . stdout ?. destroy ?. ( ) ;
224+ child . stderr ?. destroy ?. ( ) ;
225+ clearInterval ( heartbeatTimer ) ;
226+ signalProcessTree ( child , "SIGTERM" ) ;
227+ killTimer = setTimeout ( ( ) => signalProcessTree ( child , "SIGKILL" ) , killGraceMs ) ;
228+ }
229+ } ;
230+
231+ child . stdout ?. on ( "data" , appendOutput ) ;
232+ child . stderr ?. on ( "data" , appendOutput ) ;
233+ child . on ( "error" , ( error ) =>
234+ finish ( {
235+ errorCode : spawnErrorCode ( error ) ,
236+ errorMessage : error . message ,
237+ signal : null ,
238+ status : null ,
239+ } ) ,
240+ ) ;
241+ child . on ( "exit" , ( status , signal ) => {
242+ exitStatus = status ;
243+ exitSignal = signal ;
244+ } ) ;
245+ child . on ( "close" , ( status , signal ) => {
246+ exitStatus = exitStatus ?? status ;
247+ exitSignal = exitSignal ?? signal ;
248+ const elapsedSeconds = Math . round ( ( Date . now ( ) - startedAt ) / 1000 ) ;
249+ if ( timedOut ) {
250+ finish ( {
251+ errorCode : "ETIMEDOUT" ,
252+ errorMessage : `Knip unused-file scan timed out after ${ elapsedSeconds } s` ,
253+ signal : exitSignal ,
254+ status : exitStatus ,
255+ } ) ;
256+ return ;
257+ }
258+ if ( bufferExceeded ) {
259+ finish ( {
260+ errorCode : "ENOBUFS" ,
261+ errorMessage : `Knip unused-file scan exceeded ${ maxBufferBytes } output bytes` ,
262+ signal : exitSignal ,
263+ status : exitStatus ,
264+ } ) ;
265+ return ;
266+ }
267+ finish ( {
268+ errorCode : undefined ,
269+ errorMessage : undefined ,
270+ signal : exitSignal ,
271+ status : exitStatus ,
272+ } ) ;
273+ } ) ;
274+ } ) ;
275+ }
146276export function checkUnusedFiles (
147277 output ,
148278 allowlistFiles = KNIP_UNUSED_FILE_ALLOWLIST ,
@@ -161,8 +291,8 @@ export function checkUnusedFiles(
161291 } ;
162292}
163293
164- function main ( ) {
165- const result = runKnipUnusedFiles ( ) ;
294+ async function main ( ) {
295+ const result = await runKnipUnusedFiles ( ) ;
166296 if ( result . errorCode || result . status === null ) {
167297 console . error (
168298 `deadcode unused-file scan failed: ${ result . errorCode ?? result . signal ?? "unknown" } ${
@@ -190,5 +320,5 @@ function main() {
190320}
191321
192322if ( process . argv [ 1 ] === fileURLToPath ( import . meta. url ) ) {
193- main ( ) ;
323+ await main ( ) ;
194324}
0 commit comments