@@ -5,6 +5,7 @@ import crypto from "node:crypto";
55import fs from "node:fs" ;
66import Module from "node:module" ;
77import path from "node:path" ;
8+ import { performance } from "node:perf_hooks" ;
89import ts from "typescript" ;
910
1011type CommonJsModule = NodeModule & {
@@ -62,6 +63,221 @@ const compilerOptions: ts.CompilerOptions = {
6263const cacheDir = path . join ( repoRoot , "node_modules" , ".cache" , "nemoclaw-source-require" ) ;
6364const compilerFingerprint = JSON . stringify ( { compilerOptions, typescript : ts . version } ) ;
6465fs . mkdirSync ( cacheDir , { recursive : true } ) ;
66+ const cacheWaitMs = envInt ( "NEMOCLAW_SOURCE_REQUIRE_CACHE_WAIT_MS" , 5_000 ) ;
67+ const cachePollMs = envInt ( "NEMOCLAW_SOURCE_REQUIRE_CACHE_POLL_MS" , 25 ) ;
68+ const cacheLockStaleMs = envInt ( "NEMOCLAW_SOURCE_REQUIRE_CACHE_LOCK_STALE_MS" , 30_000 ) ;
69+ const statsPath = process . env . NEMOCLAW_SOURCE_REQUIRE_STATS ;
70+
71+ const stats = {
72+ cacheHits : 0 ,
73+ cacheMisses : 0 ,
74+ compileMs : 0 ,
75+ duplicateFallbacks : 0 ,
76+ files : 0 ,
77+ lockWaits : 0 ,
78+ readCacheMs : 0 ,
79+ staleLocks : 0 ,
80+ transforms : 0 ,
81+ transformMs : 0 ,
82+ waitMs : 0 ,
83+ } ;
84+
85+ function envInt ( name : string , fallback : number ) : number {
86+ const raw = process . env [ name ] ;
87+ if ( raw === undefined ) return fallback ;
88+ const parsed = Number . parseInt ( raw , 10 ) ;
89+ return Number . isFinite ( parsed ) && parsed >= 0 ? parsed : fallback ;
90+ }
91+
92+ function nowMs ( ) : number {
93+ return performance . now ( ) ;
94+ }
95+
96+ const sleepBuffer = new SharedArrayBuffer ( 4 ) ;
97+ const sleepArray = new Int32Array ( sleepBuffer ) ;
98+
99+ function sleepSync ( ms : number ) : void {
100+ if ( ms <= 0 ) return ;
101+ Atomics . wait ( sleepArray , 0 , 0 , ms ) ;
102+ }
103+
104+ function readCachedOutput ( cachePath : string ) : string | null {
105+ const start = nowMs ( ) ;
106+ try {
107+ const output = fs . readFileSync ( cachePath , "utf8" ) ;
108+ stats . readCacheMs += nowMs ( ) - start ;
109+ return output ;
110+ } catch ( error ) {
111+ stats . readCacheMs += nowMs ( ) - start ;
112+ if ( ( error as NodeJS . ErrnoException ) . code !== "ENOENT" ) throw error ;
113+ return null ;
114+ }
115+ }
116+
117+ function writeAtomic ( cachePath : string , outputText : string ) : void {
118+ const temporaryPath = `${ cachePath } .${ process . pid } .${ crypto . randomUUID ( ) } ` ;
119+ fs . writeFileSync ( temporaryPath , outputText , { flag : "wx" , mode : 0o600 } ) ;
120+ try {
121+ fs . renameSync ( temporaryPath , cachePath ) ;
122+ } catch ( error ) {
123+ fs . rmSync ( temporaryPath , { force : true } ) ;
124+ if ( ( error as NodeJS . ErrnoException ) . code !== "EEXIST" ) throw error ;
125+ }
126+ }
127+
128+ function parseLockOwner ( lockPath : string ) : { pid ?: number } {
129+ try {
130+ const parsed = JSON . parse ( fs . readFileSync ( lockPath , "utf8" ) ) ;
131+ return typeof parsed ?. pid === "number" && Number . isInteger ( parsed . pid ) && parsed . pid > 0
132+ ? { pid : parsed . pid }
133+ : { } ;
134+ } catch {
135+ return { } ;
136+ }
137+ }
138+
139+ function processIsRunning ( pid : number ) : boolean {
140+ try {
141+ process . kill ( pid , 0 ) ;
142+ return true ;
143+ } catch ( error ) {
144+ return ( error as NodeJS . ErrnoException ) . code !== "ESRCH" ;
145+ }
146+ }
147+
148+ function tryAcquireLock ( lockPath : string , filename : string ) : boolean {
149+ try {
150+ fs . writeFileSync (
151+ lockPath ,
152+ `${ JSON . stringify ( {
153+ filename,
154+ pid : process . pid ,
155+ startedAt : new Date ( ) . toISOString ( ) ,
156+ startedAtMs : Date . now ( ) ,
157+ } ) } \n`,
158+ { flag : "wx" , mode : 0o600 } ,
159+ ) ;
160+ return true ;
161+ } catch ( error ) {
162+ if ( ( error as NodeJS . ErrnoException ) . code !== "EEXIST" ) throw error ;
163+ return false ;
164+ }
165+ }
166+
167+ function reclaimStaleLock ( lockPath : string ) : boolean {
168+ if ( cacheLockStaleMs <= 0 ) return false ;
169+ let ageMs : number ;
170+ try {
171+ ageMs = Date . now ( ) - fs . statSync ( lockPath ) . mtimeMs ;
172+ } catch ( error ) {
173+ if ( ( error as NodeJS . ErrnoException ) . code === "ENOENT" ) return false ;
174+ throw error ;
175+ }
176+ if ( ageMs < cacheLockStaleMs ) return false ;
177+ const { pid } = parseLockOwner ( lockPath ) ;
178+ if ( pid !== undefined && processIsRunning ( pid ) ) return false ;
179+ try {
180+ fs . rmSync ( lockPath , { force : true } ) ;
181+ stats . staleLocks += 1 ;
182+ return true ;
183+ } catch ( error ) {
184+ if ( ( error as NodeJS . ErrnoException ) . code === "ENOENT" ) return false ;
185+ throw error ;
186+ }
187+ }
188+
189+ function transpileSource ( source : string , filename : string ) : string {
190+ const start = nowMs ( ) ;
191+ const result = ts . transpileModule ( source , {
192+ compilerOptions,
193+ fileName : filename ,
194+ reportDiagnostics : true ,
195+ } ) ;
196+ stats . transformMs += nowMs ( ) - start ;
197+ stats . transforms += 1 ;
198+ const errors = result . diagnostics ?. filter (
199+ ( diagnostic ) => diagnostic . category === ts . DiagnosticCategory . Error ,
200+ ) ;
201+ if ( errors && errors . length > 0 ) {
202+ throw new Error (
203+ errors
204+ . map ( ( diagnostic ) => ts . flattenDiagnosticMessageText ( diagnostic . messageText , "\n" ) )
205+ . join ( "\n" ) ,
206+ ) ;
207+ }
208+ return result . outputText ;
209+ }
210+
211+ function waitForCache ( cachePath : string ) : string | null {
212+ if ( cacheWaitMs <= 0 ) return null ;
213+ const start = nowMs ( ) ;
214+ const deadline = start + cacheWaitMs ;
215+ stats . lockWaits += 1 ;
216+
217+ while ( nowMs ( ) < deadline ) {
218+ const output = readCachedOutput ( cachePath ) ;
219+ if ( output !== null ) {
220+ stats . waitMs += nowMs ( ) - start ;
221+ return output ;
222+ }
223+ sleepSync ( Math . min ( cachePollMs , Math . max ( 0 , deadline - nowMs ( ) ) ) ) ;
224+ }
225+ stats . waitMs += nowMs ( ) - start ;
226+ return null ;
227+ }
228+
229+ function compileWithCache ( filename : string , source : string , cachePath : string ) : string {
230+ const cached = readCachedOutput ( cachePath ) ;
231+ if ( cached !== null ) {
232+ stats . cacheHits += 1 ;
233+ return cached ;
234+ }
235+ stats . cacheMisses += 1 ;
236+
237+ const lockPath = `${ cachePath } .lock` ;
238+ let ownsLock = tryAcquireLock ( lockPath , filename ) ;
239+ if ( ! ownsLock && reclaimStaleLock ( lockPath ) ) {
240+ const cachedAfterReclaim = readCachedOutput ( cachePath ) ;
241+ if ( cachedAfterReclaim !== null ) {
242+ stats . cacheHits += 1 ;
243+ return cachedAfterReclaim ;
244+ }
245+ ownsLock = tryAcquireLock ( lockPath , filename ) ;
246+ }
247+
248+ if ( ! ownsLock ) {
249+ const waited = waitForCache ( cachePath ) ;
250+ if ( waited !== null ) {
251+ stats . cacheHits += 1 ;
252+ return waited ;
253+ }
254+ stats . duplicateFallbacks += 1 ;
255+ return transpileSource ( source , filename ) ;
256+ }
257+
258+ try {
259+ const outputText = transpileSource ( source , filename ) ;
260+ writeAtomic ( cachePath , outputText ) ;
261+ return outputText ;
262+ } finally {
263+ fs . rmSync ( lockPath , { force : true } ) ;
264+ }
265+ }
266+
267+ if ( statsPath ) {
268+ process . once ( "exit" , ( ) => {
269+ if ( stats . files === 0 ) return ;
270+ const row = {
271+ ...stats ,
272+ cacheDir,
273+ label : process . env . NEMOCLAW_SOURCE_REQUIRE_STATS_LABEL ?? null ,
274+ pid : process . pid ,
275+ rssMb : Math . round ( ( process . memoryUsage ( ) . rss / 1024 / 1024 ) * 10 ) / 10 ,
276+ } ;
277+ fs . mkdirSync ( path . dirname ( statsPath ) , { recursive : true } ) ;
278+ fs . appendFileSync ( statsPath , `${ JSON . stringify ( row ) } \n` , { mode : 0o600 } ) ;
279+ } ) ;
280+ }
65281
66282const resolveFilename = moduleRuntime . _resolveFilename ;
67283moduleRuntime . _resolveFilename = function resolveSourceFilename ( request , parent , isMain , options ) {
@@ -82,6 +298,8 @@ moduleRuntime._resolveFilename = function resolveSourceFilename(request, parent,
82298} ;
83299
84300moduleRuntime . _extensions [ ".ts" ] = ( module , filename ) => {
301+ const compileStart = nowMs ( ) ;
302+ stats . files += 1 ;
85303 const source = fs . readFileSync ( filename , "utf8" ) ;
86304 const cacheKey = crypto
87305 . createHash ( "sha256" )
@@ -92,36 +310,7 @@ moduleRuntime._extensions[".ts"] = (module, filename) => {
92310 . update ( compilerFingerprint )
93311 . digest ( "hex" ) ;
94312 const cachePath = path . join ( cacheDir , `${ cacheKey } .cjs` ) ;
95- let outputText : string ;
96-
97- try {
98- outputText = fs . readFileSync ( cachePath , "utf8" ) ;
99- } catch ( error ) {
100- if ( ( error as NodeJS . ErrnoException ) . code !== "ENOENT" ) throw error ;
101- const result = ts . transpileModule ( source , {
102- compilerOptions,
103- fileName : filename ,
104- reportDiagnostics : true ,
105- } ) ;
106- const errors = result . diagnostics ?. filter (
107- ( diagnostic ) => diagnostic . category === ts . DiagnosticCategory . Error ,
108- ) ;
109- if ( errors && errors . length > 0 ) {
110- throw new Error (
111- errors
112- . map ( ( diagnostic ) => ts . flattenDiagnosticMessageText ( diagnostic . messageText , "\n" ) )
113- . join ( "\n" ) ,
114- ) ;
115- }
116- outputText = result . outputText ;
117- const temporaryPath = `${ cachePath } .${ process . pid } .${ crypto . randomUUID ( ) } ` ;
118- fs . writeFileSync ( temporaryPath , outputText , { flag : "wx" , mode : 0o600 } ) ;
119- try {
120- fs . renameSync ( temporaryPath , cachePath ) ;
121- } catch ( error ) {
122- fs . rmSync ( temporaryPath , { force : true } ) ;
123- if ( ( error as NodeJS . ErrnoException ) . code !== "EEXIST" ) throw error ;
124- }
125- }
313+ const outputText = compileWithCache ( filename , source , cachePath ) ;
314+ stats . compileMs += nowMs ( ) - compileStart ;
126315 module . _compile ( outputText , filename ) ;
127316} ;
0 commit comments