@@ -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,239 @@ 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 = Math . max ( 1 , 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 ( contents : string ) : { pid ?: number } {
129+ try {
130+ const parsed = JSON . parse ( contents ) ;
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 snapshot : { contents : string ; dev : number ; ino : number ; mtimeMs : number ; size : number } ;
170+ try {
171+ const stat = fs . statSync ( lockPath ) ;
172+ snapshot = {
173+ contents : fs . readFileSync ( lockPath , "utf8" ) ,
174+ dev : stat . dev ,
175+ ino : stat . ino ,
176+ mtimeMs : stat . mtimeMs ,
177+ size : stat . size ,
178+ } ;
179+ } catch ( error ) {
180+ if ( ( error as NodeJS . ErrnoException ) . code === "ENOENT" ) return false ;
181+ throw error ;
182+ }
183+ const ageMs = Date . now ( ) - snapshot . mtimeMs ;
184+ if ( ageMs < cacheLockStaleMs ) return false ;
185+ const { pid } = parseLockOwner ( snapshot . contents ) ;
186+ if ( pid !== undefined && processIsRunning ( pid ) ) return false ;
187+ try {
188+ const current = fs . statSync ( lockPath ) ;
189+ if (
190+ current . dev !== snapshot . dev ||
191+ current . ino !== snapshot . ino ||
192+ current . mtimeMs !== snapshot . mtimeMs ||
193+ current . size !== snapshot . size ||
194+ fs . readFileSync ( lockPath , "utf8" ) !== snapshot . contents
195+ ) {
196+ return false ;
197+ }
198+ fs . rmSync ( lockPath , { force : true } ) ;
199+ stats . staleLocks += 1 ;
200+ return true ;
201+ } catch ( error ) {
202+ if ( ( error as NodeJS . ErrnoException ) . code === "ENOENT" ) return false ;
203+ throw error ;
204+ }
205+ }
206+
207+ function transpileSource ( source : string , filename : string ) : string {
208+ const start = nowMs ( ) ;
209+ const result = ts . transpileModule ( source , {
210+ compilerOptions,
211+ fileName : filename ,
212+ reportDiagnostics : true ,
213+ } ) ;
214+ stats . transformMs += nowMs ( ) - start ;
215+ stats . transforms += 1 ;
216+ const errors = result . diagnostics ?. filter (
217+ ( diagnostic ) => diagnostic . category === ts . DiagnosticCategory . Error ,
218+ ) ;
219+ if ( errors && errors . length > 0 ) {
220+ throw new Error (
221+ errors
222+ . map ( ( diagnostic ) => ts . flattenDiagnosticMessageText ( diagnostic . messageText , "\n" ) )
223+ . join ( "\n" ) ,
224+ ) ;
225+ }
226+ return result . outputText ;
227+ }
228+
229+ function waitForCache ( cachePath : string ) : string | null {
230+ if ( cacheWaitMs <= 0 ) return null ;
231+ const start = nowMs ( ) ;
232+ const deadline = start + cacheWaitMs ;
233+ stats . lockWaits += 1 ;
234+
235+ while ( nowMs ( ) < deadline ) {
236+ const output = readCachedOutput ( cachePath ) ;
237+ if ( output !== null ) {
238+ stats . waitMs += nowMs ( ) - start ;
239+ return output ;
240+ }
241+ sleepSync ( Math . min ( cachePollMs , Math . max ( 0 , deadline - nowMs ( ) ) ) ) ;
242+ }
243+ stats . waitMs += nowMs ( ) - start ;
244+ return null ;
245+ }
246+
247+ function compileWithCache ( filename : string , source : string , cachePath : string ) : string {
248+ const cached = readCachedOutput ( cachePath ) ;
249+ if ( cached !== null ) {
250+ stats . cacheHits += 1 ;
251+ return cached ;
252+ }
253+ stats . cacheMisses += 1 ;
254+
255+ const lockPath = `${ cachePath } .lock` ;
256+ let ownsLock = tryAcquireLock ( lockPath , filename ) ;
257+ if ( ! ownsLock && reclaimStaleLock ( lockPath ) ) {
258+ const cachedAfterReclaim = readCachedOutput ( cachePath ) ;
259+ if ( cachedAfterReclaim !== null ) {
260+ stats . cacheHits += 1 ;
261+ return cachedAfterReclaim ;
262+ }
263+ ownsLock = tryAcquireLock ( lockPath , filename ) ;
264+ }
265+
266+ if ( ! ownsLock ) {
267+ const waited = waitForCache ( cachePath ) ;
268+ if ( waited !== null ) {
269+ stats . cacheHits += 1 ;
270+ return waited ;
271+ }
272+ stats . duplicateFallbacks += 1 ;
273+ return transpileSource ( source , filename ) ;
274+ }
275+
276+ try {
277+ const outputText = transpileSource ( source , filename ) ;
278+ writeAtomic ( cachePath , outputText ) ;
279+ return outputText ;
280+ } finally {
281+ fs . rmSync ( lockPath , { force : true } ) ;
282+ }
283+ }
284+
285+ if ( statsPath ) {
286+ process . once ( "exit" , ( ) => {
287+ if ( stats . files === 0 ) return ;
288+ const row = {
289+ ...stats ,
290+ cacheDir,
291+ label : process . env . NEMOCLAW_SOURCE_REQUIRE_STATS_LABEL ?? null ,
292+ pid : process . pid ,
293+ rssMb : Math . round ( ( process . memoryUsage ( ) . rss / 1024 / 1024 ) * 10 ) / 10 ,
294+ } ;
295+ fs . mkdirSync ( path . dirname ( statsPath ) , { recursive : true } ) ;
296+ fs . appendFileSync ( statsPath , `${ JSON . stringify ( row ) } \n` , { mode : 0o600 } ) ;
297+ } ) ;
298+ }
65299
66300const resolveFilename = moduleRuntime . _resolveFilename ;
67301moduleRuntime . _resolveFilename = function resolveSourceFilename ( request , parent , isMain , options ) {
@@ -82,6 +316,8 @@ moduleRuntime._resolveFilename = function resolveSourceFilename(request, parent,
82316} ;
83317
84318moduleRuntime . _extensions [ ".ts" ] = ( module , filename ) => {
319+ const compileStart = nowMs ( ) ;
320+ stats . files += 1 ;
85321 const source = fs . readFileSync ( filename , "utf8" ) ;
86322 const cacheKey = crypto
87323 . createHash ( "sha256" )
@@ -92,36 +328,7 @@ moduleRuntime._extensions[".ts"] = (module, filename) => {
92328 . update ( compilerFingerprint )
93329 . digest ( "hex" ) ;
94330 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- }
331+ const outputText = compileWithCache ( filename , source , cachePath ) ;
332+ stats . compileMs += nowMs ( ) - compileStart ;
126333 module . _compile ( outputText , filename ) ;
127334} ;
0 commit comments