gen_cache.ts (8481B)
1 import * as fs from 'fs'; 2 import * as path from 'path'; 3 import * as process from 'process'; 4 5 import { Cacheable, dataCache, setIsBuildingDataCache } from '../framework/data_cache.js'; 6 import { crc32, toHexString } from '../util/crc32.js'; 7 import { parseImports } from '../util/parse_imports.js'; 8 9 function usage(rc: number): void { 10 console.error(`Usage: tools/gen_cache [options] [SUITE_DIRS...] 11 12 For each suite in SUITE_DIRS, pre-compute data that is expensive to generate 13 at runtime and store it under 'src/resources/cache'. If the data file is found 14 then the DataCache will load this instead of building the expensive data at CTS 15 runtime. 16 Note: Due to differences in gzip compression, different versions of node can 17 produce radically different binary cache files. gen_cache uses the hashes of the 18 source files to determine whether a cache file is 'up to date'. This is faster 19 and does not depend on the compressed output. 20 21 Options: 22 --help Print this message and exit. 23 --list Print the list of output files without writing them. 24 --force Rebuild cache even if they're up to date 25 --validate Check the cache is up to date 26 --verbose Print each action taken. 27 `); 28 process.exit(rc); 29 } 30 31 // Where the cache is generated 32 const outDir = 'src/resources/cache'; 33 34 let forceRebuild = false; 35 let mode: 'emit' | 'list' | 'validate' = 'emit'; 36 let verbose = false; 37 38 const nonFlagsArgs: string[] = []; 39 40 for (const arg of process.argv) { 41 if (arg.startsWith('-')) { 42 switch (arg) { 43 case '--list': { 44 mode = 'list'; 45 break; 46 } 47 case '--help': { 48 usage(0); 49 break; 50 } 51 case '--force': { 52 forceRebuild = true; 53 break; 54 } 55 case '--verbose': { 56 verbose = true; 57 break; 58 } 59 case '--validate': { 60 mode = 'validate'; 61 break; 62 } 63 default: { 64 console.log('unrecognized flag: ', arg); 65 usage(1); 66 } 67 } 68 } else { 69 nonFlagsArgs.push(arg); 70 } 71 } 72 73 if (nonFlagsArgs.length < 3) { 74 usage(0); 75 } 76 77 dataCache.setStore({ 78 load: (path: string) => { 79 return new Promise<Uint8Array>((resolve, reject) => { 80 fs.readFile(`data/${path}`, (err, data) => { 81 if (err !== null) { 82 reject(err.message); 83 } else { 84 resolve(data); 85 } 86 }); 87 }); 88 }, 89 }); 90 setIsBuildingDataCache(); 91 92 const cacheFileSuffix = __filename.endsWith('.ts') ? '.cache.ts' : '.cache.js'; 93 94 /** 95 * @returns a list of all the files under 'dir' that has the given extension 96 * @param dir the directory to search 97 * @param ext the extension of the files to find 98 */ 99 function glob(dir: string, ext: string) { 100 const files: string[] = []; 101 for (const file of fs.readdirSync(dir)) { 102 const path = `${dir}/${file}`; 103 if (fs.statSync(path).isDirectory()) { 104 for (const child of glob(path, ext)) { 105 files.push(`${file}/${child}`); 106 } 107 } 108 109 if (path.endsWith(ext) && fs.statSync(path).isFile()) { 110 files.push(file); 111 } 112 } 113 return files; 114 } 115 116 /** 117 * Exception type thrown by SourceHasher.hashFile() when a file annotated with 118 * MUST_NOT_BE_IMPORTED_BY_DATA_CACHE is transitively imported by a .cache.ts file. 119 */ 120 class InvalidImportException { 121 constructor(path: string) { 122 this.stack = [path]; 123 } 124 toString(): string { 125 return `invalid transitive import for cache:\n ${this.stack.join('\n ')}`; 126 } 127 readonly stack: string[]; 128 } 129 /** 130 * SourceHasher is a utility for producing a hash of a source .ts file and its imported source files. 131 */ 132 class SourceHasher { 133 /** 134 * @param path the source file path 135 * @returns a hash of the source file and all of its imported dependencies. 136 */ 137 public hashOf(path: string) { 138 this.u32Array[0] = this.hashFile(path); 139 return this.u32Array[0].toString(16); 140 } 141 142 hashFile(path: string): number { 143 if (!fs.existsSync(path) && path.endsWith('.js')) { 144 path = path.substring(0, path.length - 2) + 'ts'; 145 } 146 147 const cached = this.hashes.get(path); 148 if (cached !== undefined) { 149 return cached; 150 } 151 152 this.hashes.set(path, 0); // Store a zero hash to handle cyclic imports 153 154 const content = fs.readFileSync(path, { encoding: 'utf-8' }); 155 const normalized = content.replace('\r\n', '\n'); 156 let hash = crc32(normalized); 157 for (const importPath of parseImports(path, normalized)) { 158 try { 159 const importHash = this.hashFile(importPath); 160 hash = this.hashCombine(hash, importHash); 161 } catch (ex) { 162 if (ex instanceof InvalidImportException) { 163 ex.stack.push(path); 164 throw ex; 165 } 166 } 167 } 168 169 if (content.includes('MUST_NOT_BE_IMPORTED_BY_DATA_CACHE')) { 170 throw new InvalidImportException(path); 171 } 172 173 this.hashes.set(path, hash); 174 return hash; 175 } 176 177 /** Simple non-cryptographic hash combiner */ 178 hashCombine(a: number, b: number): number { 179 return crc32(`${toHexString(a)} ${toHexString(b)}`); 180 } 181 182 private hashes = new Map<string, number>(); 183 private u32Array = new Uint32Array(1); 184 } 185 186 void (async () => { 187 const suiteDirs = nonFlagsArgs.slice(2); // skip <exe> <js> 188 for (const suiteDir of suiteDirs) { 189 await build(suiteDir); 190 } 191 })(); 192 193 async function build(suiteDir: string) { 194 if (!fs.existsSync(suiteDir)) { 195 console.error(`Could not find ${suiteDir}`); 196 process.exit(1); 197 } 198 199 // Load hashes.json 200 const fileHashJsonPath = `${outDir}/hashes.json`; 201 let fileHashes: Record<string, string> = {}; 202 if (fs.existsSync(fileHashJsonPath)) { 203 const json = fs.readFileSync(fileHashJsonPath, { encoding: 'utf8' }); 204 fileHashes = JSON.parse(json); 205 } 206 207 // Crawl files and convert paths to be POSIX-style, relative to suiteDir. 208 const filesToEnumerate = glob(suiteDir, cacheFileSuffix) 209 .map(p => `${suiteDir}/${p}`) 210 .sort(); 211 212 const fileHasher = new SourceHasher(); 213 const cacheablePathToTS = new Map<string, string>(); 214 const errors: Array<string> = []; 215 216 for (const file of filesToEnumerate) { 217 const pathWithoutExtension = file.substring(0, file.length - 3); 218 const mod = await import(`../../../${pathWithoutExtension}.js`); 219 if (mod.d?.serialize !== undefined) { 220 const cacheable = mod.d as Cacheable<unknown>; 221 222 { 223 // Check for collisions 224 const existing = cacheablePathToTS.get(cacheable.path); 225 if (existing !== undefined) { 226 errors.push( 227 `'${cacheable.path}' is emitted by both: 228 '${existing}' 229 and 230 '${file}'` 231 ); 232 } 233 cacheablePathToTS.set(cacheable.path, file); 234 } 235 236 const outPath = `${outDir}/${cacheable.path}`; 237 const fileHash = fileHasher.hashOf(file); 238 239 switch (mode) { 240 case 'emit': { 241 if (!forceRebuild && fileHashes[cacheable.path] === fileHash) { 242 if (verbose) { 243 console.log(`'${outPath}' is up to date`); 244 } 245 continue; 246 } 247 console.log(`building '${outPath}'`); 248 const data = await cacheable.build(); 249 const serialized = cacheable.serialize(data); 250 fs.mkdirSync(path.dirname(outPath), { recursive: true }); 251 fs.writeFileSync(outPath, serialized, 'binary'); 252 fileHashes[cacheable.path] = fileHash; 253 break; 254 } 255 case 'list': { 256 console.log(outPath); 257 break; 258 } 259 case 'validate': { 260 if (fileHashes[cacheable.path] !== fileHash) { 261 errors.push( 262 `'${outPath}' needs rebuilding. Generate with 'npx grunt run:generate-cache'` 263 ); 264 } else if (verbose) { 265 console.log(`'${outPath}' is up to date`); 266 } 267 } 268 } 269 } 270 } 271 272 // Check that there aren't stale files in the cache directory 273 for (const file of glob(outDir, '.bin')) { 274 if (cacheablePathToTS.get(file) === undefined) { 275 switch (mode) { 276 case 'emit': 277 fs.rmSync(file); 278 break; 279 case 'validate': 280 errors.push( 281 `cache file '${outDir}/${file}' is no longer generated. Remove with 'npx grunt run:generate-cache'` 282 ); 283 break; 284 } 285 } 286 } 287 288 // Update hashes.json 289 if (mode === 'emit') { 290 const json = JSON.stringify(fileHashes, undefined, ' '); 291 fs.writeFileSync(fileHashJsonPath, json, { encoding: 'utf8' }); 292 } 293 294 if (errors.length > 0) { 295 for (const error of errors) { 296 console.error(error); 297 } 298 process.exit(1); 299 } 300 }