diff options
Diffstat (limited to 'v_windows/v/cmd/tools/vwatch.v')
-rw-r--r-- | v_windows/v/cmd/tools/vwatch.v | 381 |
1 files changed, 381 insertions, 0 deletions
diff --git a/v_windows/v/cmd/tools/vwatch.v b/v_windows/v/cmd/tools/vwatch.v new file mode 100644 index 0000000..0ac243d --- /dev/null +++ b/v_windows/v/cmd/tools/vwatch.v @@ -0,0 +1,381 @@ +module main + +import os +import time +import term +import flag + +const scan_timeout_s = get_scan_timeout_seconds() + +const max_v_cycles = 1000 + +const scan_frequency_hz = 4 + +const scan_period_ms = 1000 / scan_frequency_hz + +const max_scan_cycles = scan_timeout_s * scan_frequency_hz + +fn get_scan_timeout_seconds() int { + env_vw_timeout := os.getenv('VWATCH_TIMEOUT').int() + if env_vw_timeout == 0 { + $if gcboehm ? { + return 35000000 // over 1 year + } $else { + return 5 * 60 + } + } + return env_vw_timeout +} + +// +// Implements `v watch file.v` , `v watch run file.v` etc. +// With this command, V will collect all .v files that are needed for the +// compilation, then it will enter an infinite loop, monitoring them for +// changes. +// +// When a change is detected, it will stop the current process, if it is +// still running, then rerun/recompile/etc. +// +// In effect, this makes it easy to have an editor session and a separate +// terminal, running just `v watch run file.v`, and you will see your +// changes right after you save your .v file in your editor. +// +// +// Since -gc boehm is not available on all platforms yet, +// and this program leaks ~8MB/minute without it, the implementation here +// is done similarly to vfmt in 2 modes, in the same executable: +// +// a) A parent/manager process that only manages a single worker +// process. The parent process does mostly nothing except restarting +// workers, thus it does not leak much. +// +// b) A worker process, doing the actual monitoring/polling. +// NB: *workers are started with the --vwatchworker option* +// +// Worker processes will run for a limited number of iterations, then +// they will do exit(255), and then the parent will start a new worker. +// Exiting by any other code will cause the parent to also exit with the +// same error code. This limits the potential leak that a worker process +// can do, even without using the garbage collection mode. +// + +struct VFileStat { + path string + mtime int +} + +[unsafe] +fn (mut vfs VFileStat) free() { + unsafe { vfs.path.free() } +} + +enum RerunCommand { + restart + quit +} + +struct Context { +mut: + pid int // the pid of the current process; useful while debugging manager/worker interactions + is_worker bool // true in the workers, false in the manager process + check_period_ms int = scan_period_ms + vexe string + affected_paths []string + vfiles []VFileStat + opts []string + rerun_channel chan RerunCommand + child_process &os.Process + is_exiting bool // set by SIGINT/Ctrl-C + v_cycles int // how many times the worker has restarted the V compiler + scan_cycles int // how many times the worker has scanned for source file changes + clear_terminal bool // whether to clear the terminal before each re-run + silent bool // when true, watch will not print a timestamp line before each re-run + add_files []string // path to additional files that have to be watched for changes + ignore_exts []string // extensions of files that will be ignored, even if they change (useful for sqlite.db files for example) + cmd_before_run string // a command to run before each re-run + cmd_after_run string // a command to run after each re-run +} + +[if debug_vwatch ?] +fn (mut context Context) elog(msg string) { + eprintln('> vwatch $context.pid, $msg') +} + +fn (context &Context) str() string { + return 'Context{ pid: $context.pid, is_worker: $context.is_worker, check_period_ms: $context.check_period_ms, vexe: $context.vexe, opts: $context.opts, is_exiting: $context.is_exiting, vfiles: $context.vfiles' +} + +fn (mut context Context) get_stats_for_affected_vfiles() []VFileStat { + if context.affected_paths.len == 0 { + mut apaths := map[string]bool{} + // The next command will make V parse the program, and print all .v files, + // needed for its compilation, without actually compiling it. + copts := context.opts.join(' ') + cmd := '"$context.vexe" -silent -print-v-files $copts' + // context.elog('> cmd: $cmd') + mut paths := []string{} + if context.add_files.len > 0 && context.add_files[0] != '' { + paths << context.add_files + } + vfiles := os.execute(cmd) + if vfiles.exit_code == 0 { + paths_trimmed := vfiles.output.trim_space() + paths << paths_trimmed.split('\n') + } + for vf in paths { + apaths[os.real_path(os.dir(vf))] = true + } + context.affected_paths = apaths.keys() + // context.elog('vfiles paths to be scanned: $context.affected_paths') + } + // scan all files in the found folders + mut newstats := []VFileStat{} + for path in context.affected_paths { + mut files := os.ls(path) or { []string{} } + for pf in files { + pf_ext := os.file_ext(pf).to_lower() + if pf_ext in ['', '.bak', '.exe', '.dll', '.so', '.def'] { + continue + } + if pf_ext in context.ignore_exts { + continue + } + if pf.starts_with('.#') { + continue + } + if pf.ends_with('~') { + continue + } + f := os.join_path(path, pf) + fullpath := os.real_path(f) + mtime := os.file_last_mod_unix(fullpath) + newstats << VFileStat{fullpath, mtime} + } + } + // always add the v compiler itself, so that if it is recompiled with `v self` + // the watcher will rerun the compilation too + newstats << VFileStat{context.vexe, os.file_last_mod_unix(context.vexe)} + return newstats +} + +fn (mut context Context) get_changed_vfiles() int { + mut changed := 0 + newfiles := context.get_stats_for_affected_vfiles() + for vfs in newfiles { + mut found := false + for existing_vfs in context.vfiles { + if existing_vfs.path == vfs.path { + found = true + if existing_vfs.mtime != vfs.mtime { + context.elog('> new updates for file: $vfs') + changed++ + } + break + } + } + if !found { + changed++ + continue + } + } + context.vfiles = newfiles + if changed > 0 { + context.elog('> get_changed_vfiles: $changed') + } + return changed +} + +fn change_detection_loop(ocontext &Context) { + mut context := unsafe { ocontext } + for { + if context.v_cycles >= max_v_cycles || context.scan_cycles >= max_scan_cycles { + context.is_exiting = true + context.kill_pgroup() + time.sleep(50 * time.millisecond) + exit(255) + } + if context.is_exiting { + return + } + changes := context.get_changed_vfiles() + if changes > 0 { + context.rerun_channel <- RerunCommand.restart + } + time.sleep(context.check_period_ms * time.millisecond) + context.scan_cycles++ + } +} + +fn (mut context Context) kill_pgroup() { + if context.child_process == 0 { + return + } + if context.child_process.is_alive() { + context.child_process.signal_pgkill() + } + context.child_process.wait() +} + +fn (mut context Context) run_before_cmd() { + if context.cmd_before_run != '' { + context.elog('> run_before_cmd: "$context.cmd_before_run"') + os.system(context.cmd_before_run) + } +} + +fn (mut context Context) run_after_cmd() { + if context.cmd_after_run != '' { + context.elog('> run_after_cmd: "$context.cmd_after_run"') + os.system(context.cmd_after_run) + } +} + +fn (mut context Context) compilation_runner_loop() { + cmd := '"$context.vexe" ${context.opts.join(' ')}' + _ := <-context.rerun_channel + for { + context.elog('>> loop: v_cycles: $context.v_cycles') + if context.clear_terminal { + term.clear() + } + context.run_before_cmd() + timestamp := time.now().format_ss_milli() + context.child_process = os.new_process(context.vexe) + context.child_process.use_pgroup = true + context.child_process.set_args(context.opts) + context.child_process.run() + if !context.silent { + eprintln('$timestamp: $cmd | pid: ${context.child_process.pid:7d} | reload cycle: ${context.v_cycles:5d}') + } + for { + mut notalive_count := 0 + mut cmds := []RerunCommand{} + for { + if context.is_exiting { + return + } + if !context.child_process.is_alive() { + context.child_process.wait() + notalive_count++ + if notalive_count == 1 { + // a short lived process finished, do cleanup: + context.run_after_cmd() + } + } + select { + action := <-context.rerun_channel { + cmds << action + if action == .quit { + context.kill_pgroup() + return + } + } + 100 * time.millisecond { + should_restart := RerunCommand.restart in cmds + cmds = [] + if should_restart { + // context.elog('>>>>>>>> KILLING $context.child_process.pid') + context.kill_pgroup() + break + } + } + } + } + if !context.child_process.is_alive() { + context.child_process.wait() + context.child_process.close() + if notalive_count == 0 { + // a long running process was killed, do cleanup: + context.run_after_cmd() + } + break + } + } + context.v_cycles++ + } +} + +const ccontext = Context{ + child_process: 0 +} + +fn main() { + dump(scan_timeout_s) + mut context := unsafe { &Context(voidptr(&ccontext)) } + context.pid = os.getpid() + context.vexe = os.getenv('VEXE') + + mut fp := flag.new_flag_parser(os.args[1..]) + fp.application('v watch') + if os.args[1] == 'watch' { + fp.skip_executable() + } + fp.version('0.0.2') + fp.description('Collect all .v files needed for a compilation, then re-run the compilation when any of the source changes.') + fp.arguments_description('[--silent] [--clear] [--ignore .db] [--add /path/to/a/file.v] [run] program.v') + fp.allow_unknown_args() + fp.limit_free_args_to_at_least(1) + context.is_worker = fp.bool('vwatchworker', 0, false, 'Internal flag. Used to distinguish vwatch manager and worker processes.') + context.silent = fp.bool('silent', `s`, false, 'Be more silent; do not print the watch timestamp before each re-run.') + context.clear_terminal = fp.bool('clear', `c`, false, 'Clears the terminal before each re-run.') + context.add_files = fp.string('add', `a`, '', 'Add more files to be watched. Useful with `v watch -add=/tmp/feature.v run cmd/v /tmp/feature.v`, if you change *both* the compiler, and the feature.v file.').split(',') + context.ignore_exts = fp.string('ignore', `i`, '', 'Ignore files having these extensions. Useful with `v watch -ignore=.db run server.v`, if your server writes to an sqlite.db file in the same folder.').split(',') + show_help := fp.bool('help', `h`, false, 'Show this help screen.') + context.cmd_before_run = fp.string('before', 0, '', 'A command to execute *before* each re-run.') + context.cmd_after_run = fp.string('after', 0, '', 'A command to execute *after* each re-run.') + if show_help { + println(fp.usage()) + exit(0) + } + remaining_options := fp.finalize() or { + eprintln('Error: $err') + exit(1) + } + context.opts = remaining_options + context.elog('>>> context.pid: $context.pid') + context.elog('>>> context.vexe: $context.vexe') + context.elog('>>> context.opts: $context.opts') + context.elog('>>> context.is_worker: $context.is_worker') + context.elog('>>> context.clear_terminal: $context.clear_terminal') + context.elog('>>> context.add_files: $context.add_files') + context.elog('>>> context.ignore_exts: $context.ignore_exts') + if context.is_worker { + context.worker_main() + } else { + context.manager_main() + } +} + +fn (mut context Context) manager_main() { + myexecutable := os.executable() + mut worker_opts := ['--vwatchworker'] + worker_opts << os.args[2..] + for { + mut worker_process := os.new_process(myexecutable) + worker_process.set_args(worker_opts) + worker_process.run() + for { + if !worker_process.is_alive() { + worker_process.wait() + break + } + time.sleep(200 * time.millisecond) + } + if !(worker_process.code == 255 && worker_process.status == .exited) { + worker_process.close() + break + } + worker_process.close() + } +} + +fn (mut context Context) worker_main() { + context.rerun_channel = chan RerunCommand{cap: 10} + os.signal_opt(.int, fn (_ os.Signal) { + mut context := unsafe { &Context(voidptr(&ccontext)) } + context.is_exiting = true + context.kill_pgroup() + }) or { panic(err) } + go context.compilation_runner_loop() + change_detection_loop(context) +} |