diff --git a/cmd/tools/vwatch.v b/cmd/tools/vwatch.v
index 0da360f7a0..f0b4cde48f 100644
--- a/cmd/tools/vwatch.v
+++ b/cmd/tools/vwatch.v
@@ -18,6 +18,14 @@ const max_scan_cycles = scan_timeout_s * scan_frequency_hz
const default_veb_suffixes = '*.v,*.html,*.css,*.js,*.md,*.tr'
+// build_debounce_ms is how long a build waits after the last change, so that
+// an editor writing a file several times starts one build, not several.
+const build_debounce_ms = 150
+
+// veb_status_env names the environment variable that tells a veb app, built
+// with `-d veb_livereload`, where `v watch` writes the build status.
+const veb_status_env = 'VEB_LIVERELOAD_STATUS'
+
fn get_scan_timeout_seconds() int {
env_vw_timeout := os.getenv('VWATCH_TIMEOUT').int()
if env_vw_timeout == 0 {
@@ -102,6 +110,11 @@ mut:
cmd_before_run string // a command to run before each re-run
cmd_after_run string // a command to run after each re-run
only_watch []string // If not empty, *all* files that trigger updates, should match *at least one* of these s.match_glob() patterns. This is also triggered for veb apps, to monitor for just *.v,*.js,*.css,*.html in veb projects.
+ has_run bool // whether the watched command is `run`
+ is_veb bool // whether the program uses veb
+ build_first bool // for veb apps: build while the old server runs, and swap on success
+ build_process &os.Process = unsafe { nil } // the build in progress, in build-first mode
+ status_path string // where build-first mode writes the build status, for veb's livereload
}
struct ParsedWatchOptions {
@@ -117,6 +130,7 @@ mut:
cmd_before_run string
cmd_after_run string
show_help bool
+ no_build_first bool
}
@[if debug_vwatch ?]
@@ -165,6 +179,18 @@ fn (mut context Context) is_ext_ignored(pf string, pf_ext string) bool {
return false
}
+// add_dir_tree adds `dir` and all of its subdirectories (not following
+// symlinks) to the set of watched folders.
+fn add_dir_tree(mut apaths map[string]bool, dir string) {
+ apaths[os.real_path(dir)] = true
+ for entry in os.ls(dir) or { []string{} } {
+ sub := os.join_path_single(dir, entry)
+ if os.is_dir(sub) && !os.is_link(sub) {
+ add_dir_tree(mut apaths, sub)
+ }
+ }
+}
+
fn (mut context Context) get_stats_for_affected_vfiles() []VFileStat {
if context.affected_paths.len == 0 {
mut apaths := map[string]bool{}
@@ -188,6 +214,7 @@ fn (mut context Context) get_stats_for_affected_vfiles() []VFileStat {
apaths[os.real_path(os.dir(vf))] = true
if vf.contains('veb.v') {
is_veb_found = true
+ context.is_veb = true
}
}
@@ -214,6 +241,13 @@ fn (mut context Context) get_stats_for_affected_vfiles() []VFileStat {
if os.is_dir(tr_dir) {
apaths[os.real_path(tr_dir)] = true
}
+ // Templates are compiled into the program, but the compiler does not
+ // report them either. Watch `templates/` and its subdirectories, so
+ // that a layout in `templates/layout/` triggers a rebuild too.
+ tpl_dir := os.join_path_single(path, 'templates')
+ if os.is_dir(tpl_dir) {
+ add_dir_tree(mut apaths, tpl_dir)
+ }
}
}
context.affected_paths = apaths.keys()
@@ -290,6 +324,7 @@ fn change_detection_loop(ocontext &Context) {
for {
if context.v_cycles >= max_v_cycles || context.scan_cycles >= max_scan_cycles {
context.is_exiting = true
+ context.kill_build()
context.kill_pgroup()
time.sleep(50 * time.millisecond)
exit(255)
@@ -318,6 +353,202 @@ fn (mut context Context) kill_pgroup() {
context.child_process.close()
}
+// kill_build stops the build in progress, if any. It returns true when there was one.
+fn (mut context Context) kill_build() bool {
+ if unsafe { context.build_process == 0 } {
+ return false
+ }
+ mut bp := context.build_process
+ context.build_process = unsafe { nil }
+ if bp.is_alive() {
+ bp.signal_pgkill()
+ }
+ bp.wait()
+ bp.close()
+ return true
+}
+
+// stop_app stops the running program, if any.
+fn (mut context Context) stop_app() {
+ context.kill_pgroup()
+ context.child_process = unsafe { nil }
+}
+
+// write_status records the build status for a veb app's livereload: the state
+// on the first line (`building`, `failed` or `ok`), then the compiler output.
+// It replaces the file in one step, so the app never reads a partial status.
+fn (mut context Context) write_status(state string, details string) {
+ tmp := context.status_path + '.tmp'
+ os.write_file(tmp, state + '\n' + details) or { return }
+ os.mv(tmp, context.status_path) or {}
+}
+
+// start_app runs the built program, telling it where to read the build status.
+fn (mut context Context) start_app(app_bin string, args []string) {
+ mut p := os.new_process(app_bin)
+ p.use_pgroup = true
+ p.set_args(args)
+ mut env := os.environ()
+ env[veb_status_env] = context.status_path
+ p.set_environment(env)
+ p.run()
+ context.child_process = p
+ context.child_has_tty = vwatchtty.set_foreground_process_group(p.pid, context.watcher_pgid)
+ if !context.silent {
+ eprintln('${time.now().format_ss_milli()}: ${app_bin} | pid: ${p.pid:7d} | reload cycle: ${context.v_cycles:5d}')
+ }
+ context.v_cycles++
+}
+
+// default_output_path returns where `v` puts the executable for `target` by
+// default: `dir/dir` for a folder, and `file` for `file.v`.
+fn default_output_path(target string) string {
+ abs := os.real_path(target)
+ if os.is_dir(abs) {
+ return os.join_path_single(abs, os.file_name(abs))
+ }
+ return abs.all_before_last('.v')
+}
+
+// drain_build_output appends what the build printed since the last call.
+fn (mut context Context) drain_build_output(mut output []string) {
+ if unsafe { context.build_process == 0 } {
+ return
+ }
+ mut bp := context.build_process
+ for {
+ chunk := bp.stdout_read()
+ if chunk == '' {
+ break
+ }
+ output << chunk
+ }
+}
+
+// build_first_loop is used for veb apps, unless `--no-build-first` is given.
+// It builds a new executable while the old server keeps serving, then swaps
+// them only if the build succeeds: a failed build leaves the old server
+// running. A change during a build cancels it and starts a new one, since its
+// result would already be out of date. The status of each build is written to
+// `context.status_path`, where a livereload enabled veb app picks it up.
+fn (mut context Context) build_first_loop() {
+ run_pos := context.opts.index('run')
+ mut build_opts := context.opts#[..run_pos].clone()
+ after_run := context.opts#[run_pos + 1..]
+ target := if after_run.len > 0 { after_run[0] } else { '.' }
+ program_args := after_run#[1..]
+ mut app_bin := default_output_path(target)
+ o_pos := build_opts.index('-o')
+ if o_pos >= 0 && o_pos + 1 < build_opts.len {
+ app_bin = os.abs_path(build_opts[o_pos + 1])
+ build_opts.delete(o_pos + 1)
+ build_opts.delete(o_pos)
+ }
+ $if windows {
+ if !app_bin.ends_with('.exe') {
+ app_bin += '.exe'
+ }
+ }
+ new_bin := os.join_path_single(os.dir(app_bin), '.vwatch-new-' + os.file_name(app_bin))
+ status_dir := os.join_path(os.vtmp_dir(), 'vwatch_${context.pid}')
+ os.mkdir_all(status_dir) or {}
+ context.status_path = os.join_path_single(status_dir, 'status')
+ mut pending := true
+ mut pending_since := time.now().add(-build_debounce_ms * time.millisecond)
+ mut output := []string{}
+ mut build_started := time.now()
+ for {
+ if context.is_exiting {
+ context.kill_build()
+ context.stop_app()
+ return
+ }
+ if pending && time.since(pending_since) >= build_debounce_ms * time.millisecond {
+ pending = false
+ if context.kill_build() && !context.silent {
+ eprintln('${time.now().format_ss_milli()}: build cancelled, a newer change arrived')
+ }
+ if context.clear_terminal {
+ term.clear()
+ }
+ context.write_status('building', '')
+ context.run_before_cmd()
+ output = []string{}
+ mut bp := os.new_process(context.vexe)
+ bp.use_pgroup = true
+ bp.set_args([...build_opts, '-o', new_bin, target])
+ bp.set_redirect_stdio_merged()
+ bp.run()
+ context.build_process = bp
+ build_started = time.now()
+ if !context.silent {
+ eprintln('${time.now().format_ss_milli()}: building ${target} | pid: ${bp.pid:7d}')
+ }
+ }
+ if unsafe { context.build_process != 0 } {
+ context.drain_build_output(mut output)
+ mut bp := context.build_process
+ if !bp.is_alive() {
+ bp.wait()
+ context.drain_build_output(mut output)
+ code := bp.code
+ bp.close()
+ context.build_process = unsafe { nil }
+ compiler_output := output.join('')
+ if compiler_output.len > 0 {
+ eprint(compiler_output)
+ }
+ if !context.silent {
+ took := f64(time.since(build_started).milliseconds()) / 1000.0
+ result := if code == 0 { 'succeeded' } else { 'failed' }
+ eprintln('${time.now().format_ss_milli()}: build ${result} in ${took:.2f}s')
+ }
+ if code == 0 {
+ context.stop_app()
+ $if windows {
+ os.rm(app_bin) or {}
+ }
+ os.mv(new_bin, app_bin) or {
+ eprintln('v watch: cannot replace ${app_bin}: ${err}')
+ context.write_status('failed', 'cannot replace ${app_bin}: ${err}')
+ continue
+ }
+ context.write_status('ok', '')
+ context.start_app(app_bin, program_args)
+ } else {
+ context.write_status('failed', compiler_output)
+ if !context.silent {
+ eprintln('${time.now().format_ss_milli()}: build failed; the previous version keeps running')
+ }
+ }
+ }
+ }
+ if unsafe { context.child_process != 0 } && !context.child_process.is_alive() {
+ context.restore_terminal()
+ context.child_process.wait()
+ context.child_process.close()
+ context.child_process = unsafe { nil }
+ context.run_after_cmd()
+ if context.keep_running && os.exists(app_bin) {
+ context.start_app(app_bin, program_args)
+ }
+ }
+ select {
+ action := <-context.rerun_channel {
+ if action == .quit {
+ context.kill_build()
+ context.stop_app()
+ return
+ }
+ pending = true
+ pending_since = time.now()
+ }
+ 100 * time.millisecond {
+ }
+ }
+ }
+}
+
fn (mut context Context) restore_terminal() {
if context.child_has_tty {
vwatchtty.restore_foreground_process_group(context.watcher_pgid)
@@ -350,6 +581,10 @@ fn (mut context Context) run_after_cmd() {
fn (mut context Context) compilation_runner_loop() {
cmd := '"${context.vexe}" ${context.opts.join(' ')}'
_ := <-context.rerun_channel
+ if context.build_first && context.has_run && context.is_veb {
+ context.build_first_loop()
+ return
+ }
for {
context.elog('>> loop: v_cycles: ${context.v_cycles}')
if context.clear_terminal {
@@ -460,6 +695,8 @@ fn main() {
context.only_watch = parsed_watch_options.only_watch
context.cmd_before_run = parsed_watch_options.cmd_before_run
context.cmd_after_run = parsed_watch_options.cmd_after_run
+ context.has_run = has_run
+ context.build_first = !parsed_watch_options.no_build_first
context.opts = []
context.opts << all_args_before_watch_cmd
context.opts << parsed_watch_options.remaining_options
@@ -510,6 +747,11 @@ fn parse_watch_options(args []string) !ParsedWatchOptions {
i++
continue
}
+ if is_watch_option_name(arg, 'no-build-first', 0) {
+ parsed.no_build_first = true
+ i++
+ continue
+ }
if is_watch_option_name(arg, 'help', `h`) {
parsed.show_help = true
i++
@@ -631,12 +873,23 @@ fn (mut context Context) manager_main(all_args_before_watch_cmd []string, all_ar
mut context := unsafe { &Context(voidptr(&ccontext)) }
context.resume_worker = true
}) or { panic(err) }
+ // Pass termination on to the worker, which stops the program it runs.
+ os.signal_opt(.term, manager_stop_signal_handler) or { panic(err) }
+ os.signal_opt(.hup, manager_stop_signal_handler) or { panic(err) }
}
for {
mut worker_process := os.new_process(myexecutable)
worker_process.set_args(worker_opts)
worker_process.run()
for {
+ if context.is_exiting {
+ if worker_process.is_alive() {
+ worker_process.signal_term()
+ }
+ worker_process.wait()
+ worker_process.close()
+ return
+ }
if context.resume_worker {
context.resume_worker = false
vwatchtty.continue_process_group_of(worker_process.pid)
@@ -655,6 +908,18 @@ fn (mut context Context) manager_main(all_args_before_watch_cmd []string, all_ar
}
}
+fn worker_stop_signal_handler(_ os.Signal) {
+ mut context := unsafe { &Context(voidptr(&ccontext)) }
+ context.is_exiting = true
+ context.kill_build()
+ context.kill_pgroup()
+}
+
+fn manager_stop_signal_handler(_ os.Signal) {
+ mut context := unsafe { &Context(voidptr(&ccontext)) }
+ context.is_exiting = true
+}
+
fn propagate_terminal_stop(_ os.Signal) {
context := unsafe { &Context(voidptr(&ccontext)) }
vwatchtty.suspend_manager_process_group(context.watcher_pgid)
@@ -663,11 +928,14 @@ fn propagate_terminal_stop(_ os.Signal) {
fn (mut context Context) worker_main() {
context.watcher_pgid = vwatchtty.process_group()
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) }
+ os.signal_opt(.int, worker_stop_signal_handler) or { panic(err) }
+ $if !windows {
+ // `kill`, a closed terminal, or a process supervisor: stop the build and
+ // the program too. They run in their own process groups, so they would
+ // otherwise keep running (and keep their port) after `v watch` is gone.
+ os.signal_opt(.term, worker_stop_signal_handler) or { panic(err) }
+ os.signal_opt(.hup, worker_stop_signal_handler) or { panic(err) }
+ }
$if !windows {
for signal in vwatchtty.terminal_stop_signals() {
os.signal_opt(signal, propagate_terminal_stop) or { panic(err) }
diff --git a/cmd/tools/vwatch_test.v b/cmd/tools/vwatch_test.v
index c01b651d2d..1bd21187ac 100644
--- a/cmd/tools/vwatch_test.v
+++ b/cmd/tools/vwatch_test.v
@@ -1,5 +1,6 @@
import os
import time
+import net.http
const vexe = @VEXE
const vroot = os.dir(vexe)
@@ -86,6 +87,146 @@ fn test_watch_run_reloads_on_source_change() {
assert reload_error == '', '${reload_error}\n${output}'
}
+// For veb apps, `v watch` builds the new version while the old one keeps serving,
+// keeps the old one when a build fails, writes the build status for veb's live
+// reload, and stops the app when it is itself terminated.
+fn test_watch_builds_veb_app_first_and_stops_it_on_sigterm() {
+ $if windows {
+ return
+ }
+ app_dir := os.join_path(tsource_dir, 'vebapp')
+ os.mkdir_all(app_dir)!
+ port := 28000 + os.getpid() % 1000
+ write_veb_app(app_dir, port, 'V1', true)!
+ base := 'http://127.0.0.1:${port}'
+
+ mut process := os.new_process(vexe)
+ process.set_work_folder(vroot)
+ process.set_redirect_stdio()
+ process.use_pgroup = true
+ process.set_args(['watch', '-d', 'veb_livereload', 'run', app_dir])
+ process.run()
+ mut log := []string{}
+
+ mut errors := []string{}
+ if !wait_for_http(base + '/', 'version V1', mut process, mut log) {
+ errors << 'the first build never served `version V1`'
+ } else {
+ // A broken build must leave the old version serving, and say so:
+ time.sleep(1500 * time.millisecond)
+ write_veb_app(app_dir, port, 'V2', false)!
+ if !wait_for_log('build failed; the previous version keeps running', mut process, mut
+ log)
+ {
+ errors << 'no `build failed` message after a broken edit'
+ }
+ if get_text(base + '/') != 'version V1' {
+ errors << 'the old version stopped serving after a failed build'
+ }
+ if !get_text(base + '/status').starts_with('failed\n') {
+ errors << 'the build status was not `failed`: `${get_text(base + '/status')}`'
+ }
+ // Fixing it swaps in the new version:
+ time.sleep(1500 * time.millisecond)
+ write_veb_app(app_dir, port, 'V3', true)!
+ if !wait_for_http(base + '/', 'version V3', mut process, mut log) {
+ errors << 'the fixed build never served `version V3`'
+ }
+ if get_text(base + '/status') != 'ok\n' {
+ errors << 'the build status was not `ok`: `${get_text(base + '/status')}`'
+ }
+ }
+ // SIGTERM to `v watch` itself (not its process group) must stop the app too:
+ process.signal_term()
+ stopwatch := time.new_stopwatch()
+ for get_text(base + '/') != '' && stopwatch.elapsed() < 10 * time.second {
+ time.sleep(watch_poll_interval)
+ }
+ if get_text(base + '/') != '' {
+ errors << 'the app kept serving after `v watch` got SIGTERM'
+ }
+ if process.is_alive() {
+ process.signal_pgkill()
+ }
+ process.wait()
+ drain_process_output(mut process, mut log)
+ process.close()
+ assert errors.len == 0, errors.join('\n') + '\n' + log.join('')
+}
+
+fn write_veb_app(app_dir string, port int, version string, valid bool) ! {
+ broken := if valid { '' } else { '\n\tthis does not compile' }
+ os.write_file(os.join_path(app_dir, 'main.v'), "module main
+
+import os
+import veb
+
+pub struct Context {
+ veb.Context
+}
+
+pub struct App {}
+
+pub fn (app &App) index(mut ctx Context) veb.Result {${broken}
+ return ctx.text('version ${version}')
+}
+
+pub fn (app &App) status(mut ctx Context) veb.Result {
+ return ctx.text(os.read_file(os.getenv('VEB_LIVERELOAD_STATUS')) or { 'no status' })
+}
+
+fn main() {
+ mut app := &App{}
+ veb.run_at[App, Context](mut app, family: .ip, host: '127.0.0.1', port: ${port}) or { panic(err) }
+}
+")!
+}
+
+fn get_text(url string) string {
+ res := http.get(url) or { return '' }
+ return res.body
+}
+
+fn drain_process_output(mut process os.Process, mut log []string) {
+ for {
+ chunk := process.stderr_read() + process.stdout_read()
+ if chunk == '' {
+ break
+ }
+ log << chunk
+ }
+}
+
+fn wait_for_http(url string, expected string, mut process os.Process, mut log []string) bool {
+ stopwatch := time.new_stopwatch()
+ for stopwatch.elapsed() < watch_ready_timeout {
+ drain_process_output(mut process, mut log)
+ if get_text(url) == expected {
+ return true
+ }
+ if !process.is_alive() {
+ return false
+ }
+ time.sleep(watch_poll_interval)
+ }
+ return false
+}
+
+fn wait_for_log(text string, mut process os.Process, mut log []string) bool {
+ stopwatch := time.new_stopwatch()
+ for stopwatch.elapsed() < watch_ready_timeout {
+ drain_process_output(mut process, mut log)
+ if log.join('').contains(text) {
+ return true
+ }
+ if !process.is_alive() {
+ return false
+ }
+ time.sleep(watch_poll_interval)
+ }
+ return false
+}
+
fn write_versioned_source(source_path string, version string) ! {
os.write_file(source_path,
"import os\nfn main() {\n\tos.write_file(os.args[1], '${version}') or { panic(err) }\n}\n")!
diff --git a/examples/vwatch/web_server/main.v b/examples/vwatch/web_server/main.v
index 27e439ee7f..ed75fb9802 100644
--- a/examples/vwatch/web_server/main.v
+++ b/examples/vwatch/web_server/main.v
@@ -5,11 +5,13 @@ module main
// Note 1: while developing services, it is useful to also add the `--keep` option of `v watch`,
// which will restart the app right away, even when it exits on its own.
//
-// Note 2: veb supports a special live reload mode, where it will make the browser to check for server
-// restarts, and it will trigger a refresh of the current page, right after that is detected.
+// Note 2: veb has a live reload mode. Compiled with `-d veb_livereload`, its pages update in place
+// whenever `v watch` restarts the app, keeping scroll position and form input; meanwhile
+// `v watch` builds the new version while the old one keeps serving.
//
// The above means, that to get the most optimal prototyping experience for veb apps, use:
-// `v -d veb_livereload watch --only-watch=*.v,*.html,*.css,*.js --keep run .`
+// `v watch -d veb_livereload --keep run .`
+// (put `-d veb_livereload` after `watch`, see https://github.com/vlang/v/issues/30199)
import os
import veb
import db.sqlite
@@ -57,7 +59,7 @@ fn (mut app App) update_db() !int {
fn main() {
println('App demonstrating the use of `veb` & `db.sqlite` together.')
println('For best prototyping experience, run with:')
- println('`v -d veb_livereload watch --keep run examples/vwatch/web_server/`')
+ println('`v watch -d veb_livereload --keep run examples/vwatch/web_server/`')
println('')
mut db := mydb()!
db.exec('CREATE TABLE visits (id integer primary key AUTOINCREMENT, created_at timestamp default current_timestamp);')!
diff --git a/tutorials/building_a_simple_web_blog_with_veb/README.md b/tutorials/building_a_simple_web_blog_with_veb/README.md
index afde2770b5..e70a69db6d 100644
--- a/tutorials/building_a_simple_web_blog_with_veb/README.md
+++ b/tutorials/building_a_simple_web_blog_with_veb/README.md
@@ -111,7 +111,8 @@ Custom routes can be defined using attributes like @['/index'].
-> TIP: run the following command to live-reload the server: `v watch run blog.v`
+> TIP: run `v watch -d veb_livereload run blog.v` to rebuild the server on every change,
+> and update the page open in your browser.
The `.text(string)` method returns a plain text document with the provided
text, which isn't frequently used in websites.
diff --git a/vlib/v/help/common/watch.txt b/vlib/v/help/common/watch.txt
index b86727a10b..56d8f8190f 100644
--- a/vlib/v/help/common/watch.txt
+++ b/vlib/v/help/common/watch.txt
@@ -32,6 +32,12 @@ Options:
--after A command to execute *after* each re-run.
Example: --after 'rm -rf /tmp/v/'
+ --no-build-first For veb apps, `v watch` builds the new version while
+ the old one keeps serving, and swaps them only when
+ the build succeeds; a change during a build cancels
+ it and starts a new one. This restores the old
+ behaviour: stop the app, then build and run it.
+
You can also customise the timeout, after which `v watch` will re-start a
monitored program automatically, even if it was not changed by setting the
environment variable VWATCH_TIMEOUT (in seconds). By default, it is 5 min
diff --git a/vlib/v/skills/v-veb/references/ROUTES.md b/vlib/v/skills/v-veb/references/ROUTES.md
index c19785b95c..7116eb053b 100644
--- a/vlib/v/skills/v-veb/references/ROUTES.md
+++ b/vlib/v/skills/v-veb/references/ROUTES.md
@@ -120,8 +120,9 @@ pub fn (app &App) index(mut ctx Context) veb.Result {
}
```
-`$veb.html()` also enables live reload in debug builds, so during development
-prefer it over naming a file.
+Live reload is enabled by compiling with `-d veb_livereload` (for example
+`v watch -d veb_livereload run .`). It applies to every HTML response that ends
+with `