@@ -5,8 +5,15 @@ 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
11+ import {
12+ loadSourceRequireCompilerOptions ,
13+ sourceRequireCacheDir ,
14+ sourceRequireCachePath ,
15+ } from "./source-require-cache" ;
16+
1017type CommonJsModule = NodeModule & {
1118 _compile ( source : string , filename : string ) : void ;
1219} ;
@@ -23,45 +30,229 @@ const moduleRuntime = Module as unknown as {
2330 _resolveFilename : ResolveFilename ;
2431} ;
2532const repoRoot = path . resolve ( __dirname , "../.." ) ;
26- const configPath = path . join ( repoRoot , "tsconfig.src.json" ) ;
27- const configFile = ts . readConfigFile ( configPath , ts . sys . readFile ) ;
33+ const compilerOptions = loadSourceRequireCompilerOptions ( repoRoot ) ;
34+ // Keep the cross-process transpilation cache in this checkout's dependency
35+ // tree. A shared, predictable directory under the OS temp root could be
36+ // replaced by another local user before a test process reads from it.
37+ const cacheDir = sourceRequireCacheDir ( repoRoot ) ;
38+ fs . mkdirSync ( cacheDir , { recursive : true } ) ;
39+ const cacheWaitMs = envInt ( "NEMOCLAW_SOURCE_REQUIRE_CACHE_WAIT_MS" , 5_000 ) ;
40+ const cachePollMs = Math . max ( 1 , envInt ( "NEMOCLAW_SOURCE_REQUIRE_CACHE_POLL_MS" , 25 ) ) ;
41+ const cacheLockStaleMs = envInt ( "NEMOCLAW_SOURCE_REQUIRE_CACHE_LOCK_STALE_MS" , 30_000 ) ;
42+ const statsPath = process . env . NEMOCLAW_SOURCE_REQUIRE_STATS ;
43+
44+ const stats = {
45+ cacheHits : 0 ,
46+ cacheMisses : 0 ,
47+ compileMs : 0 ,
48+ duplicateFallbacks : 0 ,
49+ files : 0 ,
50+ lockWaits : 0 ,
51+ readCacheMs : 0 ,
52+ staleLocks : 0 ,
53+ transforms : 0 ,
54+ transformMs : 0 ,
55+ waitMs : 0 ,
56+ } ;
57+
58+ function envInt ( name : string , fallback : number ) : number {
59+ const raw = process . env [ name ] ;
60+ if ( raw === undefined ) return fallback ;
61+ const parsed = Number . parseInt ( raw , 10 ) ;
62+ return Number . isFinite ( parsed ) && parsed >= 0 ? parsed : fallback ;
63+ }
64+
65+ function nowMs ( ) : number {
66+ return performance . now ( ) ;
67+ }
68+
69+ const sleepBuffer = new SharedArrayBuffer ( 4 ) ;
70+ const sleepArray = new Int32Array ( sleepBuffer ) ;
71+
72+ function sleepSync ( ms : number ) : void {
73+ if ( ms <= 0 ) return ;
74+ Atomics . wait ( sleepArray , 0 , 0 , ms ) ;
75+ }
76+
77+ function readCachedOutput ( cachePath : string ) : string | null {
78+ const start = nowMs ( ) ;
79+ try {
80+ const output = fs . readFileSync ( cachePath , "utf8" ) ;
81+ stats . readCacheMs += nowMs ( ) - start ;
82+ return output ;
83+ } catch ( error ) {
84+ stats . readCacheMs += nowMs ( ) - start ;
85+ if ( ( error as NodeJS . ErrnoException ) . code !== "ENOENT" ) throw error ;
86+ return null ;
87+ }
88+ }
2889
29- if ( configFile . error ) {
30- throw new Error ( ts . flattenDiagnosticMessageText ( configFile . error . messageText , "\n" ) ) ;
90+ function writeAtomic ( cachePath : string , outputText : string ) : void {
91+ const temporaryPath = `${ cachePath } .${ process . pid } .${ crypto . randomUUID ( ) } ` ;
92+ fs . writeFileSync ( temporaryPath , outputText , { flag : "wx" , mode : 0o600 } ) ;
93+ try {
94+ fs . renameSync ( temporaryPath , cachePath ) ;
95+ } catch ( error ) {
96+ fs . rmSync ( temporaryPath , { force : true } ) ;
97+ if ( ( error as NodeJS . ErrnoException ) . code !== "EEXIST" ) throw error ;
98+ }
99+ }
100+
101+ function parseLockOwner ( contents : string ) : { pid ?: number } {
102+ try {
103+ const parsed = JSON . parse ( contents ) ;
104+ return typeof parsed ?. pid === "number" && Number . isInteger ( parsed . pid ) && parsed . pid > 0
105+ ? { pid : parsed . pid }
106+ : { } ;
107+ } catch {
108+ return { } ;
109+ }
110+ }
111+
112+ function processIsRunning ( pid : number ) : boolean {
113+ try {
114+ process . kill ( pid , 0 ) ;
115+ return true ;
116+ } catch ( error ) {
117+ return ( error as NodeJS . ErrnoException ) . code !== "ESRCH" ;
118+ }
119+ }
120+
121+ function lockOwnerContents ( filename : string ) : string {
122+ return `${ JSON . stringify ( {
123+ filename,
124+ pid : process . pid ,
125+ startedAt : new Date ( ) . toISOString ( ) ,
126+ startedAtMs : Date . now ( ) ,
127+ } ) } \n`;
128+ }
129+
130+ function tryAcquireLock ( lockPath : string , filename : string ) : boolean {
131+ try {
132+ fs . writeFileSync ( lockPath , lockOwnerContents ( filename ) , { flag : "wx" , mode : 0o600 } ) ;
133+ return true ;
134+ } catch ( error ) {
135+ if ( ( error as NodeJS . ErrnoException ) . code !== "EEXIST" ) throw error ;
136+ return false ;
137+ }
31138}
32139
33- const parsedConfig = ts . parseJsonConfigFileContent (
34- configFile . config ,
35- ts . sys ,
36- repoRoot ,
37- { } ,
38- configPath ,
39- ) ;
40- if ( parsedConfig . errors . length > 0 ) {
41- throw new Error (
42- parsedConfig . errors
43- . map ( ( error ) => ts . flattenDiagnosticMessageText ( error . messageText , "\n" ) )
44- . join ( "\n" ) ,
140+ function reclaimStaleLock ( lockPath : string , filename : string ) : boolean {
141+ if ( cacheLockStaleMs <= 0 ) return false ;
142+ let fd : number | undefined ;
143+ try {
144+ fd = fs . openSync ( lockPath , "r+" ) ;
145+ const stat = fs . fstatSync ( fd ) ;
146+ const contents = fs . readFileSync ( fd , "utf8" ) ;
147+ const ageMs = Date . now ( ) - stat . mtimeMs ;
148+ if ( ageMs < cacheLockStaleMs ) return false ;
149+ const { pid } = parseLockOwner ( contents ) ;
150+ if ( pid !== undefined && processIsRunning ( pid ) ) return false ;
151+
152+ fs . ftruncateSync ( fd , 0 ) ;
153+ fs . writeSync ( fd , lockOwnerContents ( filename ) , 0 , "utf8" ) ;
154+ stats . staleLocks += 1 ;
155+ return true ;
156+ } catch ( error ) {
157+ if ( ( error as NodeJS . ErrnoException ) . code === "ENOENT" ) return false ;
158+ throw error ;
159+ } finally {
160+ if ( fd !== undefined ) fs . closeSync ( fd ) ;
161+ }
162+ }
163+
164+ function transpileSource ( source : string , filename : string ) : string {
165+ const start = nowMs ( ) ;
166+ const result = ts . transpileModule ( source , {
167+ compilerOptions,
168+ fileName : filename ,
169+ reportDiagnostics : true ,
170+ } ) ;
171+ stats . transformMs += nowMs ( ) - start ;
172+ stats . transforms += 1 ;
173+ const errors = result . diagnostics ?. filter (
174+ ( diagnostic ) => diagnostic . category === ts . DiagnosticCategory . Error ,
45175 ) ;
176+ if ( errors && errors . length > 0 ) {
177+ throw new Error (
178+ errors
179+ . map ( ( diagnostic ) => ts . flattenDiagnosticMessageText ( diagnostic . messageText , "\n" ) )
180+ . join ( "\n" ) ,
181+ ) ;
182+ }
183+ return result . outputText ;
46184}
47185
48- const compilerOptions : ts . CompilerOptions = {
49- ...parsedConfig . options ,
50- declaration : false ,
51- declarationMap : false ,
52- inlineSourceMap : true ,
53- inlineSources : true ,
54- noEmit : false ,
55- outDir : undefined ,
56- rootDir : undefined ,
57- sourceMap : false ,
58- } ;
59- // Keep the cross-process transpilation cache in this checkout's dependency
60- // tree. A shared, predictable directory under the OS temp root could be
61- // replaced by another local user before a test process reads from it.
62- const cacheDir = path . join ( repoRoot , "node_modules" , ".cache" , "nemoclaw-source-require" ) ;
63- const compilerFingerprint = JSON . stringify ( { compilerOptions, typescript : ts . version } ) ;
64- fs . mkdirSync ( cacheDir , { recursive : true } ) ;
186+ function waitForCache ( cachePath : string ) : string | null {
187+ if ( cacheWaitMs <= 0 ) return null ;
188+ const start = nowMs ( ) ;
189+ const deadline = start + cacheWaitMs ;
190+ stats . lockWaits += 1 ;
191+
192+ while ( nowMs ( ) < deadline ) {
193+ const output = readCachedOutput ( cachePath ) ;
194+ if ( output !== null ) {
195+ stats . waitMs += nowMs ( ) - start ;
196+ return output ;
197+ }
198+ sleepSync ( Math . min ( cachePollMs , Math . max ( 0 , deadline - nowMs ( ) ) ) ) ;
199+ }
200+ stats . waitMs += nowMs ( ) - start ;
201+ return null ;
202+ }
203+
204+ function compileWithCache ( filename : string , source : string , cachePath : string ) : string {
205+ const cached = readCachedOutput ( cachePath ) ;
206+ if ( cached !== null ) {
207+ stats . cacheHits += 1 ;
208+ return cached ;
209+ }
210+ stats . cacheMisses += 1 ;
211+
212+ const lockPath = `${ cachePath } .lock` ;
213+ let ownsLock = tryAcquireLock ( lockPath , filename ) ;
214+ if ( ! ownsLock ) {
215+ const cachedAfterContention = readCachedOutput ( cachePath ) ;
216+ if ( cachedAfterContention !== null ) {
217+ stats . cacheHits += 1 ;
218+ return cachedAfterContention ;
219+ }
220+ ownsLock = reclaimStaleLock ( lockPath , filename ) ;
221+ }
222+
223+ if ( ! ownsLock ) {
224+ const waited = waitForCache ( cachePath ) ;
225+ if ( waited !== null ) {
226+ stats . cacheHits += 1 ;
227+ return waited ;
228+ }
229+ stats . duplicateFallbacks += 1 ;
230+ return transpileSource ( source , filename ) ;
231+ }
232+
233+ try {
234+ const outputText = transpileSource ( source , filename ) ;
235+ writeAtomic ( cachePath , outputText ) ;
236+ return outputText ;
237+ } finally {
238+ fs . rmSync ( lockPath , { force : true } ) ;
239+ }
240+ }
241+
242+ if ( statsPath ) {
243+ process . once ( "exit" , ( ) => {
244+ if ( stats . files === 0 ) return ;
245+ const row = {
246+ ...stats ,
247+ cacheDir,
248+ label : process . env . NEMOCLAW_SOURCE_REQUIRE_STATS_LABEL ?? null ,
249+ pid : process . pid ,
250+ rssMb : Math . round ( ( process . memoryUsage ( ) . rss / 1024 / 1024 ) * 10 ) / 10 ,
251+ } ;
252+ fs . mkdirSync ( path . dirname ( statsPath ) , { recursive : true } ) ;
253+ fs . appendFileSync ( statsPath , `${ JSON . stringify ( row ) } \n` , { mode : 0o600 } ) ;
254+ } ) ;
255+ }
65256
66257const resolveFilename = moduleRuntime . _resolveFilename ;
67258moduleRuntime . _resolveFilename = function resolveSourceFilename ( request , parent , isMain , options ) {
@@ -82,46 +273,11 @@ moduleRuntime._resolveFilename = function resolveSourceFilename(request, parent,
82273} ;
83274
84275moduleRuntime . _extensions [ ".ts" ] = ( module , filename ) => {
276+ const compileStart = nowMs ( ) ;
277+ stats . files += 1 ;
85278 const source = fs . readFileSync ( filename , "utf8" ) ;
86- const cacheKey = crypto
87- . createHash ( "sha256" )
88- . update ( filename )
89- . update ( "\0" )
90- . update ( source )
91- . update ( "\0" )
92- . update ( compilerFingerprint )
93- . digest ( "hex" ) ;
94- 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- }
279+ const cachePath = sourceRequireCachePath ( { compilerOptions, filename, repoRoot, source } ) ;
280+ const outputText = compileWithCache ( filename , source , cachePath ) ;
281+ stats . compileMs += nowMs ( ) - compileStart ;
126282 module . _compile ( outputText , filename ) ;
127283} ;
0 commit comments