prelude.odin (12865B)
1 /* 2 Package prelude is the first import of every script. 3 4 main :: proc() { 5 context = prelude.init() 6 cfg := must(os.read_entire_file_from_path("cfg.json", context.allocator)) 7 if len(cfg) == 0 { 8 die("empty config") 9 } 10 } 11 12 init picks the allocator and the logger, opens the audit log, and returns the 13 context the script should run under. finish runs at the end of the caller's 14 scope through @(deferred_none), writes the exit line, and reports allocator 15 issues when the debug allocator is in use. 16 17 Allocation policy: a growing virtual arena is context.allocator. Scripts never 18 free; the process exit reclaims everything. Set Options.debug (or ODIN_DEBUG, 19 or the environment variable ODIN_SCRIPT_DEBUG=1) to swap in the debug 20 allocator from jm:debug, which reports overflow, double free, and write after 21 free at exit. 22 23 Logging: one logfmt line per record in <log_dir>/<name>/<name>.log, plus a 24 human line on stderr for warning and above. die appends one line to 25 <log_dir>/deaths.log so every failed run across every script is auditable 26 from a single file. log_dir defaults to os.user_log_dir()/odin, which the 27 doc comments in core/os/user.odin place at: 28 29 Linux: ~/.local/state/odin 30 macOS: ~/Library/Logs/odin 31 Windows: %LOCALAPPDATA%\odin 32 33 Nothing in this package writes to stdout; stdout belongs to the script's data. 34 */ 35 package prelude 36 37 import "base:intrinsics" 38 import "base:runtime" 39 import "core:fmt" 40 import "core:log" 41 import "core:mem" 42 import "core:mem/virtual" 43 import "core:os" 44 import "core:path/filepath" 45 import "core:strings" 46 import "core:time" 47 48 import "jm:debug" 49 50 Options :: struct { 51 // Program name used in log paths and lines. Default: basename of os.args[0]. 52 name: string, 53 // Use the debug allocator. Default: ODIN_DEBUG or ODIN_SCRIPT_DEBUG=1. 54 debug: Maybe(bool), 55 // Print the debug allocator report even when it found no issues. 56 report_clean: bool, 57 // Root of the log tree. Default: os.user_log_dir()/odin. 58 log_dir: string, 59 // Skip the log file and deaths.log entirely; stderr only. 60 no_log_file: bool, 61 // Lowest level echoed to stderr. Default: Warning, or ODIN_LOG=debug|info|warning|error. 62 console_level: Maybe(log.Level), 63 // Lowest level written to the log file. Default: Debug. 64 file_level: Maybe(log.Level), 65 // Rotate the log file to .1 when it exceeds this many bytes. Default: 8 MiB. 66 max_log_bytes: i64, 67 } 68 69 DEFAULT_MAX_LOG_BYTES :: 8 * 1024 * 1024 70 71 State :: struct { 72 name: string, 73 arena: virtual.Arena, 74 dbg: debug.Allocator, 75 debugging: bool, 76 report_clean: bool, 77 allocator: mem.Allocator, // what the script runs under 78 internals: mem.Allocator, // heap; owns everything in this struct 79 log_file: ^os.File, 80 log_path: string, 81 deaths_path: string, 82 console_level: log.Level, 83 file_level: log.Level, 84 started: time.Tick, 85 initialised: bool, 86 finished: bool, 87 } 88 89 state: State 90 91 // init prepares the allocator and logger and returns the context to run under. 92 // Call it once at the top of main as `context = prelude.init()`. finish runs 93 // automatically when the calling scope ends. 94 @(deferred_none=finish) 95 init :: proc(opts := Options{}, loc := #caller_location) -> runtime.Context { 96 assert(!state.initialised, "prelude.init called twice", loc) 97 state.initialised = true 98 state.internals = runtime.heap_allocator() 99 state.started = time.tick_now() 100 state.report_clean = opts.report_clean 101 102 name := opts.name 103 if name == "" { 104 name = default_name(state.internals) 105 } 106 state.name = strings.clone(name, state.internals) 107 108 state.debugging = opts.debug.? or_else (ODIN_DEBUG || env_truthy("ODIN_SCRIPT_DEBUG")) 109 if state.debugging { 110 debug.init(&state.dbg, state.internals, state.internals) 111 state.allocator = debug.allocator(&state.dbg) 112 } else { 113 if err := virtual.arena_init_growing(&state.arena); err != nil { 114 // No arena: fall back to the heap rather than fail before main starts. 115 state.allocator = state.internals 116 } else { 117 state.allocator = virtual.arena_allocator(&state.arena) 118 } 119 } 120 121 state.console_level = opts.console_level.? or_else console_level_from_env() 122 state.file_level = opts.file_level.? or_else log.Level.Debug 123 124 if !opts.no_log_file { 125 open_log_file(opts) 126 } 127 platform_init() 128 129 ctx := context 130 ctx.allocator = state.allocator 131 ctx.logger = log.Logger { 132 procedure = logger_proc, 133 data = &state, 134 lowest_level = min(state.console_level, state.file_level), 135 options = {}, 136 } 137 138 context = ctx 139 log.debugf("event=start pid=%d cwd=%q args=%q", os.get_pid(), cwd(), args_string()) 140 return ctx 141 } 142 143 // finish writes the exit line and tears down. It runs on scope exit via the 144 // deferred attribute on init; exit and die call it explicitly. 145 finish :: proc() { 146 finish_with_code(0) 147 } 148 149 // exit ends the script with code, writing the exit line first. 150 exit :: proc(code: int) -> ! { 151 finish_with_code(code) 152 os.exit(code) 153 } 154 155 // die logs a fatal message to the script log, stderr, and deaths.log, then 156 // exits with code 1. 157 die :: proc(format: string, args: ..any, loc := #caller_location) -> ! { 158 msg := fmt.tprintf(format, ..args) 159 if state.initialised { 160 log.log(.Fatal, msg, location = loc) 161 record_death(msg, loc) 162 } else { 163 fmt.eprintf("%s: fatal: %s\n", default_name(context.temp_allocator), msg) 164 } 165 exit(1) 166 } 167 168 // must unwraps a (value, ok) or (value, err) pair, or checks a bare ok or 169 // err, and dies with the call site when the check fails. 170 // 171 // f := must(os.open(path)) 172 // n := must(strconv.parse_int(s)) 173 // must(os.make_directory_all(dir)) 174 must :: proc { 175 must_ok, 176 must_err, 177 must_bool, 178 must_error, 179 } 180 181 @(require_results) 182 must_ok :: proc(v: $T, ok: bool, loc := #caller_location) -> T { 183 if !ok { 184 die("must: check failed", loc = loc) 185 } 186 return v 187 } 188 189 @(require_results) 190 must_err :: proc(v: $T, err: $E, loc := #caller_location) -> T where !intrinsics.type_is_boolean(E) { 191 if failed(err) { 192 die("must: %v", err, loc = loc) 193 } 194 return v 195 } 196 197 must_bool :: proc(ok: bool, loc := #caller_location) { 198 if !ok { 199 die("must: check failed", loc = loc) 200 } 201 } 202 203 must_error :: proc(err: $E, loc := #caller_location) where !intrinsics.type_is_boolean(E) { 204 if failed(err) { 205 die("must: %v", err, loc = loc) 206 } 207 } 208 209 // failed reports whether an error value of any common shape is set: a union 210 // (nil when clear), an enum (zero when clear), or a string (empty when clear). 211 failed :: proc(err: $E) -> bool { 212 when intrinsics.type_is_union(E) { 213 return err != nil 214 } else when intrinsics.type_is_enum(E) { 215 return err != E(0) 216 } else when intrinsics.type_is_string(E) { 217 return len(err) > 0 218 } else when intrinsics.type_is_pointer(E) { 219 return err != nil 220 } else { 221 #panic("prelude.must: unsupported error type") 222 } 223 } 224 225 // env returns the environment variable or def when unset or empty. 226 env :: proc(key: string, def := "", allocator := context.allocator) -> string { 227 v, found := os.lookup_env(key, allocator) 228 if !found || v == "" { 229 return def 230 } 231 return v 232 } 233 234 // args returns the script arguments without the program name. 235 args :: proc() -> []string { 236 if len(os.args) == 0 { 237 return nil 238 } 239 return os.args[1:] 240 } 241 242 // name returns the program name init settled on. 243 name :: proc() -> string { 244 return state.name 245 } 246 247 // log_path returns the script's log file path, or "" when logging to a file is off. 248 log_path :: proc() -> string { 249 return state.log_path 250 } 251 252 // ---- internals ---------------------------------------------------------- 253 254 finish_with_code :: proc(code: int) { 255 if !state.initialised || state.finished { 256 return 257 } 258 state.finished = true 259 dur := time.tick_since(state.started) 260 log.debugf("event=exit code=%d dur=%v", code, dur) 261 if state.log_file != nil { 262 os.flush(state.log_file) 263 os.close(state.log_file) 264 state.log_file = nil 265 } 266 if state.debugging { 267 if debug.issue_count(&state.dbg) > 0 || state.report_clean { 268 debug.report(&state.dbg) 269 } 270 debug.destroy(&state.dbg) 271 } 272 } 273 274 default_name :: proc(allocator: mem.Allocator) -> string { 275 if len(os.args) == 0 { 276 return "odin-script" 277 } 278 stem := filepath.stem(os.args[0]) 279 return strings.clone(stem, allocator) 280 } 281 282 env_truthy :: proc(key: string) -> bool { 283 v, found := os.lookup_env(key, context.temp_allocator) 284 if !found { 285 return false 286 } 287 switch strings.to_lower(v, context.temp_allocator) { 288 case "1", "true", "yes", "on": 289 return true 290 } 291 return false 292 } 293 294 console_level_from_env :: proc() -> log.Level { 295 v, found := os.lookup_env("ODIN_LOG", context.temp_allocator) 296 if !found { 297 return .Warning 298 } 299 switch strings.to_lower(v, context.temp_allocator) { 300 case "debug": 301 return .Debug 302 case "info": 303 return .Info 304 case "warn", "warning": 305 return .Warning 306 case "error": 307 return .Error 308 case "fatal": 309 return .Fatal 310 } 311 return .Warning 312 } 313 314 open_log_file :: proc(opts: Options) { 315 root := opts.log_dir 316 if root == "" { 317 base, err := os.user_log_dir(context.temp_allocator) 318 if err != nil { 319 fmt.eprintf("%s: warning: no log directory: %v\n", state.name, err) 320 return 321 } 322 root = join(base, "odin") 323 } 324 dir := join(root, state.name) 325 if err := os.make_directory_all(dir); err != nil && !(err == os.General_Error.Exist && os.is_dir(dir)) { 326 fmt.eprintf("%s: warning: cannot create %s: %v\n", state.name, dir, err) 327 return 328 } 329 file_name := strings.concatenate({state.name, ".log"}, context.temp_allocator) 330 state.log_path = join(dir, file_name, state.internals) 331 state.deaths_path = join(root, "deaths.log", state.internals) 332 333 max_bytes := opts.max_log_bytes 334 if max_bytes <= 0 { 335 max_bytes = DEFAULT_MAX_LOG_BYTES 336 } 337 rotate(state.log_path, max_bytes) 338 339 f, err := os.open(state.log_path, {.Write, .Append, .Create}, LOG_PERMISSIONS) 340 if err != nil { 341 fmt.eprintf("%s: warning: cannot open %s: %v\n", state.name, state.log_path, err) 342 return 343 } 344 state.log_file = f 345 } 346 347 LOG_PERMISSIONS :: os.Permissions_Read_All + {.Write_User} 348 349 // rotate moves path to path.1 when it exceeds max_bytes. os.rename replaces an 350 // existing target on every platform (core:os uses MOVEFILE_REPLACE_EXISTING on 351 // Windows), so the previous .1 needs no separate removal. 352 rotate :: proc(path: string, max_bytes: i64) { 353 info, err := os.stat(path, context.temp_allocator) 354 if err != nil || info.size <= max_bytes { 355 return 356 } 357 old := strings.concatenate({path, ".1"}, context.temp_allocator) 358 os.rename(path, old) 359 } 360 361 record_death :: proc(msg: string, loc: runtime.Source_Code_Location) { 362 if state.deaths_path == "" { 363 return 364 } 365 f, err := os.open(state.deaths_path, {.Write, .Append, .Create}, LOG_PERMISSIONS) 366 if err != nil { 367 return 368 } 369 defer os.close(f) 370 b := strings.builder_make(context.temp_allocator) 371 write_logfmt_line(&b, .Fatal, msg, loc) 372 os.write_string(f, strings.to_string(b)) 373 } 374 375 logger_proc :: proc(data: rawptr, level: log.Level, text: string, options: log.Options, location := #caller_location) { 376 st := (^State)(data) 377 if st.log_file != nil && level >= st.file_level { 378 b := strings.builder_make(context.temp_allocator) 379 write_logfmt_line(&b, level, text, location) 380 os.write_string(st.log_file, strings.to_string(b)) 381 } 382 if level >= st.console_level { 383 if level >= .Error { 384 fmt.eprintf("%s: %s: %s:%d: %s\n", st.name, level_name(level), filepath.base(location.file_path), location.line, text) 385 } else { 386 fmt.eprintf("%s: %s: %s\n", st.name, level_name(level), text) 387 } 388 } 389 } 390 391 write_logfmt_line :: proc(b: ^strings.Builder, level: log.Level, text: string, loc: runtime.Source_Code_Location) { 392 stamp, _ := time.time_to_rfc3339(time.now(), 0, false, context.temp_allocator) 393 fmt.sbprintf(b, "t=%s lvl=%s prog=%s loc=%s:%d ", stamp, level_name(level), state.name, filepath.base(loc.file_path), loc.line) 394 // Text that is already logfmt (event=... from this package) goes through as is. 395 if strings.has_prefix(text, "event=") { 396 strings.write_string(b, text) 397 } else { 398 strings.write_string(b, "msg=") 399 write_quoted(b, text) 400 } 401 strings.write_byte(b, '\n') 402 } 403 404 write_quoted :: proc(b: ^strings.Builder, s: string) { 405 strings.write_byte(b, '"') 406 for c in s { 407 switch c { 408 case '"': 409 strings.write_string(b, `\"`) 410 case '\\': 411 strings.write_string(b, `\\`) 412 case '\n': 413 strings.write_string(b, `\n`) 414 case '\r': 415 strings.write_string(b, `\r`) 416 case '\t': 417 strings.write_string(b, `\t`) 418 case: 419 strings.write_rune(b, c) 420 } 421 } 422 strings.write_byte(b, '"') 423 } 424 425 level_name :: proc(level: log.Level) -> string { 426 switch level { 427 case .Debug: 428 return "debug" 429 case .Info: 430 return "info" 431 case .Warning: 432 return "warning" 433 case .Error: 434 return "error" 435 case .Fatal: 436 return "fatal" 437 } 438 return "info" 439 } 440 441 cwd :: proc() -> string { 442 dir, err := os.get_working_directory(context.temp_allocator) 443 if err != nil { 444 return "" 445 } 446 return dir 447 } 448 449 args_string :: proc() -> string { 450 return strings.join(os.args, " ", context.temp_allocator) 451 } 452 453 join :: proc(a, b: string, allocator := context.temp_allocator) -> string { 454 s, _ := filepath.join({a, b}, allocator) 455 return s 456 }