diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 207896cd..d33565c2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -103,6 +103,9 @@ jobs: - name: Run tests – Lookup-file (failed + restart) run: make test-lookup-file || echo "fail" > lookup-file.status + - name: Run tests – Rerun (editor commands + on-demand convergence) + run: make test-rerun || echo "fail" > rerun.status + - name: Report summary if: always() run: | @@ -116,6 +119,7 @@ jobs: [ ! -f stream.status ] && pass=$((pass+1)) || fail=$((fail+1)) [ ! -f register.status ] && pass=$((pass+1)) || fail=$((fail+1)) [ ! -f lookup-file.status ] && pass=$((pass+1)) || fail=$((fail+1)) + [ ! -f rerun.status ] && pass=$((pass+1)) || fail=$((fail+1)) echo "Passed: $pass" >> "$GITHUB_STEP_SUMMARY" echo "Failed: $fail" >> "$GITHUB_STEP_SUMMARY" [ $fail -eq 0 ] diff --git a/EDITOR-PROTOCOL.md b/EDITOR-PROTOCOL.md index 9bbfc3c1..77cc05c7 100644 --- a/EDITOR-PROTOCOL.md +++ b/EDITOR-PROTOCOL.md @@ -146,6 +146,20 @@ prime the VFS via `(register)` and `(open)`, then send `(resume)` to begin compilation — avoiding restarts from missing files. In `-stream` mode the engine starts paused, so `(resume)` is required to begin compilation. +```scheme +(rerun t) +(rerun nil) +(rerun-once) +``` + +Control convergence-on-idle. When enabled with `(rerun t)`, TeXpresso waits +for a short idle window after each change; if the `.aux` file changed since +the last pass, the engine is respawned with the prior-pass aux to converge +TOC/refs. `(rerun nil)` disables it. `(rerun-once)` triggers a single +immediate pass regardless of the enabled state — useful for committing the +current aux on demand before switching to reading mode. Defaults to disabled +at startup. + ```scheme (synctex-forward "path" line) ``` diff --git a/Makefile b/Makefile index 7afb491d..64b329aa 100644 --- a/Makefile +++ b/Makefile @@ -110,8 +110,11 @@ test-register: test-lookup-file: bash test/test-lookup-file.sh +test-rerun: + bash test/test-rerun.sh + macos-app: texpresso @[ "$$(uname)" = "Darwin" ] || { echo "macos-app requires macOS"; exit 1; } bash scripts/build-macos-app.sh -.PHONY: all dev clean config texpresso common texpresso-xetex re2c compile_commands.json fill-tectonic-cache test-texlive test-tectonic test-texpresso test-stream test-open-base64 test-register test-lookup-file macos-app +.PHONY: all dev clean config texpresso common texpresso-xetex re2c compile_commands.json fill-tectonic-cache test-texlive test-tectonic test-texpresso test-stream test-open-base64 test-register test-lookup-file test-rerun macos-app diff --git a/src/frontend/driver.c b/src/frontend/driver.c index 574697a1..9337b086 100644 --- a/src/frontend/driver.c +++ b/src/frontend/driver.c @@ -129,6 +129,9 @@ static void usage(void) int main(int argc, const char **argv) { + // Unbuffered stderr so debug/[fatal] lines hit disk before a crash. + setvbuf(stderr, NULL, _IONBF, 0); + char work_dir[PATH_MAX]; if (!getcwd(work_dir, PATH_MAX)) @@ -351,6 +354,8 @@ int main(int argc, const char **argv) .stream_mode = stream_mode, // In stream mode, start paused: editor primes the VFS before (resume). .paused = stream_mode, + .rerun_enabled = false, + .rerun_once_pending = false, }; int exit_code = 0; diff --git a/src/frontend/driver.h b/src/frontend/driver.h index 4f35d714..17d2ff04 100644 --- a/src/frontend/driver.h +++ b/src/frontend/driver.h @@ -71,6 +71,8 @@ struct persistent_state { bool line_output, use_tectonic, use_texlive, initialize_only, stream_mode; bool paused; + bool rerun_enabled; + bool rerun_once_pending; }; bool texpresso_main(struct persistent_state *ps); diff --git a/src/frontend/editor.c b/src/frontend/editor.c index 6ceddc9f..6face985 100644 --- a/src/frontend/editor.c +++ b/src/frontend/editor.c @@ -305,6 +305,21 @@ bool editor_parse(fz_context *ctx, goto arity; *out = (struct editor_command){.tag = EDIT_RESUME, .resume = {}}; } + else if (strcmp(verb, "rerun") == 0) + { + if (len != 2) + goto arity; + bool status = + truth_value(ctx, stack, val_array_get(ctx, stack, command, 1)); + *out = (struct editor_command){.tag = EDIT_RERUN, + .rerun = {.status = status}}; + } + else if (strcmp(verb, "rerun-once") == 0) + { + if (len != 1) + goto arity; + *out = (struct editor_command){.tag = EDIT_RERUN_ONCE, .rerun_once = {}}; + } else { fprintf(stderr, "[command] unknown verb: %s\n", verb); diff --git a/src/frontend/editor.h b/src/frontend/editor.h index d0cae343..05fdd1c4 100644 --- a/src/frontend/editor.h +++ b/src/frontend/editor.h @@ -35,6 +35,8 @@ enum EDITOR_COMMAND EDIT_REGISTER, EDIT_PAUSE, EDIT_RESUME, + EDIT_RERUN, + EDIT_RERUN_ONCE, }; struct editor_change @@ -127,6 +129,13 @@ struct editor_command struct { } resume; + + struct { + bool status; + } rerun; + + struct { + } rerun_once; }; }; diff --git a/src/frontend/engine.h b/src/frontend/engine.h index b077f69e..13e945de 100644 --- a/src/frontend/engine.h +++ b/src/frontend/engine.h @@ -74,6 +74,10 @@ struct txp_engine_class synctex_t *(*synctex)(txp_engine *self, fz_buffer **buf); fileentry_t *(*find_file)(txp_engine *self, fz_context *ctx, const char *path); void (*notify_file_changes)(txp_engine *self, fz_context *ctx, fileentry_t *entry, int offset); + bool (*aux_dirty)(txp_engine *self); + bool (*is_finishing)(txp_engine *self); + void (*start_finishing)(txp_engine *self); + void (*finish_convergence)(txp_engine *self, fz_context *ctx); }; #define TXP_ENGINE_DEF_CLASS \ @@ -93,6 +97,11 @@ struct txp_engine_class const char *path); \ static void engine_notify_file_changes(txp_engine *self, fz_context *ctx, \ fileentry_t *entry, int offset); \ + static bool engine_aux_dirty(txp_engine *_self); \ + static bool engine_is_finishing(txp_engine *_self); \ + static void engine_start_finishing(txp_engine *_self); \ + static void engine_finish_convergence(txp_engine *_self, \ + fz_context *ctx); \ \ static struct txp_engine_class _class = { \ .destroy = engine_destroy, \ @@ -107,6 +116,10 @@ struct txp_engine_class .detect_changes = engine_detect_changes, \ .end_changes = engine_end_changes, \ .notify_file_changes = engine_notify_file_changes, \ + .aux_dirty = engine_aux_dirty, \ + .is_finishing = engine_is_finishing, \ + .start_finishing = engine_start_finishing, \ + .finish_convergence = engine_finish_convergence, \ } #endif // GENERIC_ENGINE_H_ diff --git a/src/frontend/engine_dvi.c b/src/frontend/engine_dvi.c index 1d4cf25e..9b8dcacf 100644 --- a/src/frontend/engine_dvi.c +++ b/src/frontend/engine_dvi.c @@ -118,6 +118,24 @@ static void engine_notify_file_changes(txp_engine *_self, { } +static bool engine_aux_dirty(txp_engine *_self) +{ + return false; +} + +static bool engine_is_finishing(txp_engine *_self) +{ + return false; +} + +static void engine_start_finishing(txp_engine *_self) +{ +} + +static void engine_finish_convergence(txp_engine *_self, fz_context *ctx) +{ +} + txp_engine *txp_create_dvi_engine(fz_context *ctx, const char *dvi_path, dvi_reshooks hooks) { fz_buffer *buffer = fz_read_file(ctx, dvi_path); diff --git a/src/frontend/engine_pdf.c b/src/frontend/engine_pdf.c index 768999c6..939f8e7c 100644 --- a/src/frontend/engine_pdf.c +++ b/src/frontend/engine_pdf.c @@ -128,6 +128,24 @@ static void engine_notify_file_changes(txp_engine *_self, { } +static bool engine_aux_dirty(txp_engine *_self) +{ + return false; +} + +static bool engine_is_finishing(txp_engine *_self) +{ + return false; +} + +static void engine_start_finishing(txp_engine *_self) +{ +} + +static void engine_finish_convergence(txp_engine *_self, fz_context *ctx) +{ +} + txp_engine *txp_create_pdf_engine(fz_context *ctx, const char *pdf_path) { fz_document *doc = fz_open_document(ctx, pdf_path); diff --git a/src/frontend/engine_tex.c b/src/frontend/engine_tex.c index a36ecee1..5866b1b0 100644 --- a/src/frontend/engine_tex.c +++ b/src/frontend/engine_tex.c @@ -99,6 +99,9 @@ struct tex_engine query_t query; char path[1024]; } deferred; + + bool aux_dirty; + bool finishing; }; // Backtrackable process state & VFS representation @@ -236,6 +239,22 @@ static void pop_process(fz_context *ctx, struct tex_engine *self) log_rollback(ctx, self->log, mark); } +static void clear_convergence_stash(fz_context *ctx, struct tex_engine *self) +{ + fileentry_t *e; + for (int index = 0; (e = filesystem_scan(self->fs, &index));) + { + if (e->edit_data_from_convergence && e->edit_data) + { + fz_drop_buffer(ctx, e->edit_data); + e->edit_data = NULL; + e->edit_data_from_convergence = false; + } + } + self->aux_dirty = false; + self->finishing = false; +} + static bool read_query(struct tex_engine *self, channel_t *t, query_t *q) { process_t *p = get_process(self); @@ -799,6 +818,8 @@ static void answer_query(fz_context *ctx, struct tex_engine *self, query_t *q) editor_append(BUF_LOG, output_data(e), pos); else if (self->st.stdout.entry == e) editor_append(BUF_OUT, output_data(e), pos); + else + self->aux_dirty = true; a.tag = A_DONE; channel_write_answer(self->c, p->fd, &a); break; @@ -965,6 +986,8 @@ static void revert_trace(trace_entry_t *te) static void rollback_processes(fz_context *ctx, struct tex_engine *self, int reverted, int trace) { self->deferred.active = false; + clear_convergence_stash(ctx, self); + fprintf( stderr, "rolling back to position %d\nbefore rollback: %d bytes of output\n", @@ -1394,6 +1417,137 @@ static void engine_notify_file_changes(txp_engine *_self, rollback_add_change(ctx, self, entry, offset); } +static bool is_system_output(const char *path) +{ + if (strcmp(path, "stdout") == 0) + return true; + const char *dot = strrchr(path, '.'); + if (!dot) + return false; + return strcmp(dot, ".log") == 0 + || strcmp(dot, ".xdv") == 0 + || strcmp(dot, ".dvi") == 0 + || strcmp(dot, ".pdf") == 0 + || strcmp(dot, ".synctex") == 0; +} + +// Reset per-entry `seen` counters before respawning the engine. Without +// this, the new engine's Q_OPRD never enters the `e->seen < 0` branch +// in answer_query, so no first-open trace entry is generated. Later +// rollbacks would walk back through the trace without ever bringing +// entry->seen below the changed offset, walking past trace[0] into OOB +// memory and crashing in revert_trace. +static void reset_seen_for_respawn(struct tex_engine *self) +{ + fileentry_t *e; + for (int index = 0; (e = filesystem_scan(self->fs, &index));) + e->seen = -1; +} + +static bool engine_aux_dirty(txp_engine *_self) +{ + SELF; + return self->aux_dirty; +} + +static bool engine_is_finishing(txp_engine *_self) +{ + SELF; + return self->finishing; +} + +// Mark the engine as needing to run to end-of-document. The main loop +// keeps calling engine_step (because need_advance honours is_finishing), +// so the engine processes its remaining queries between SDL events — +// UI stays responsive. When the engine closes its socket, status flips +// to DOC_TERMINATED and finish_convergence does the post-pass work. +static void engine_start_finishing(txp_engine *_self) +{ + SELF; + self->finishing = true; +} + +static void engine_finish_convergence(txp_engine *_self, fz_context *ctx) +{ + SELF; + if (!self->finishing) + return; + if (engine_get_status(_self) != DOC_TERMINATED) + return; + + self->finishing = false; + + bool converged = true; + fileentry_t *e; + for (int index = 0; (e = filesystem_scan(self->fs, &index));) + { + if (e->saved.level != FILE_WRITE || !e->saved.data) + continue; + if (is_system_output(e->path)) + continue; + bool stashed_match = e->edit_data && e->edit_data_from_convergence + && e->saved.data->len == e->edit_data->len + && memcmp(e->saved.data->data, e->edit_data->data, + e->saved.data->len) == 0; + if (!stashed_match) + { + converged = false; + break; + } + } + + if (converged) + { + fprintf(stderr, "[rerun] aux byte-stable, convergence reached\n"); + // Finishing left the latest process dead (fd=-1) at the top of the + // snapshot stack. Strip dead processes so get_process() returns a + // live snapshot the next user edit can roll back into. + while (self->process_count > 0 && get_process(self)->fd == -1) + pop_process(ctx, self); + if (self->process_count == 0) + { + reset_seen_for_respawn(self); + incdvi_reset(self->dvi); + synctex_rollback(ctx, self->stex, 0); + editor_truncate(BUF_OUT, output_data(self->st.stdout.entry)); + editor_truncate(BUF_LOG, output_data(self->st.log.entry)); + prepare_process(ctx, self); + } + self->aux_dirty = false; + return; + } + + for (int index = 0; (e = filesystem_scan(self->fs, &index));) + { + if (e->saved.level != FILE_WRITE || !e->saved.data) + continue; + if (is_system_output(e->path)) + continue; + // Don't clobber edit_data the editor pushed (interpret_open sets + // edit_data without the convergence flag). + if (e->edit_data && !e->edit_data_from_convergence) + continue; + if (e->edit_data) + fz_drop_buffer(ctx, e->edit_data); + // Copy, don't share: log_rollback truncates saved.data->len in place. + e->edit_data = fz_new_buffer_from_copied_data(ctx, e->saved.data->data, + e->saved.data->len); + e->edit_data_from_convergence = true; + } + + while (self->process_count > 0) + pop_process(ctx, self); + + reset_seen_for_respawn(self); + incdvi_reset(self->dvi); + synctex_rollback(ctx, self->stex, 0); + editor_truncate(BUF_OUT, output_data(self->st.stdout.entry)); + editor_truncate(BUF_LOG, output_data(self->st.log.entry)); + + prepare_process(ctx, self); + self->aux_dirty = false; +} + static void engine_begin_changes(txp_engine *_self, fz_context *ctx) { SELF; diff --git a/src/frontend/main.c b/src/frontend/main.c index 1ffc318b..1b4db8b1 100644 --- a/src/frontend/main.c +++ b/src/frontend/main.c @@ -153,6 +153,12 @@ static int repaint_on_resize(void *data, SDL_Event *event) static bool need_advance(fz_context *ctx, ui_state *ui) { + if (send(get_status, ui->eng) != DOC_RUNNING) + return false; + + if (send(is_finishing, ui->eng)) + return true; + int need = send(page_count, ui->eng) <= ui->page; if (!need) @@ -164,7 +170,7 @@ static bool need_advance(fz_context *ctx, ui_state *ui) synctex_has_target(stx); } - return (need && send(get_status, ui->eng) == DOC_RUNNING); + return need; } static bool advance_engine(fz_context *ctx, ui_state *ui) @@ -725,6 +731,8 @@ static void realize_change(struct persistent_state *ps, #define BUFFERED_OPS 64 #define BUFFERED_CHARS 4096 +#define T_IDLE_MS 500 +#define MAX_RERUNS 5 struct { char buffer[BUFFERED_CHARS]; @@ -1098,6 +1106,18 @@ static void interpret_command(struct persistent_state *ps, send(step, ui->eng, ps->ctx, true); schedule_event(SCAN_EVENT); break; + + case EDIT_RERUN: + ps->rerun_enabled = cmd.rerun.status; + fprintf(stderr, "[command] rerun %s\n", + ps->rerun_enabled ? "enabled" : "disabled"); + break; + + case EDIT_RERUN_ONCE: + ps->rerun_once_pending = true; + fprintf(stderr, "[command] rerun-once: pending immediate pass\n"); + schedule_event(SCAN_EVENT); + break; } } @@ -1221,6 +1241,7 @@ bool texpresso_main(struct persistent_state *ps) SDL_Thread *poll_stdin_thread = SDL_CreateThread(poll_stdin_thread_main, "poll_stdin_thread", poll_stdin_pipe); bool stdin_eof = 0; + int rerun_count = 0; while (!quit) { @@ -1272,27 +1293,59 @@ bool texpresso_main(struct persistent_state *ps) if (!ps->paused) send(step, ui->eng, ps->ctx, true); schedule_event(RELOAD_EVENT); + rerun_count = 0; } // Process document { int before_page_count = send(page_count, ui->eng); bool advance = !ps->paused && advance_engine(ps->ctx, ui); + send(finish_convergence, ui->eng, ps->ctx); int after_page_count = send(page_count, ui->eng); fflush(stdout); if (ui->page >= before_page_count && ui->page < after_page_count) schedule_event(RELOAD_EVENT); + // Fire an on-demand rerun as soon as the engine is ready, regardless of + // idle state. Runs in the main body of the loop (not inside the idle + // wait) so it triggers even during active compilation cycles. + bool aux_ready = !send(is_finishing, ui->eng) + && send(aux_dirty, ui->eng); + if (ps->rerun_once_pending && aux_ready) + { + ps->rerun_once_pending = false; + fprintf(stderr, "[rerun] on-demand: finishing pass\n"); + send(start_finishing, ui->eng); + schedule_event(RELOAD_EVENT); + continue; + } + if (!has_event) { if (advance) continue; if (!stdin_eof) wakeup_poll_thread(poll_stdin_pipe, 'c'); - has_event = SDL_WaitEvent(&e); + + bool rerun_eligible = ps->rerun_enabled + && rerun_count < MAX_RERUNS + && aux_ready; + if (rerun_eligible) + has_event = SDL_WaitEventTimeout(&e, T_IDLE_MS); + else + has_event = SDL_WaitEvent(&e); if (!has_event) { + if (rerun_eligible) + { + rerun_count++; + fprintf(stderr, "[rerun] idle %dms: finishing pass %d/%d\n", + T_IDLE_MS, rerun_count, MAX_RERUNS); + send(start_finishing, ui->eng); + schedule_event(RELOAD_EVENT); + continue; + } fprintf(stderr, "SDL_WaitEvent error: %s\n", SDL_GetError()); break; } diff --git a/src/frontend/state.h b/src/frontend/state.h index afbebcdd..4854531d 100644 --- a/src/frontend/state.h +++ b/src/frontend/state.h @@ -52,6 +52,7 @@ typedef struct fileentry_s { // State of the file in the text editor (or NULL if unedited) fz_buffer *edit_data; bool promised; + bool edit_data_from_convergence; // State observed and/or produced by TeX process struct { diff --git a/test/refs.tex b/test/refs.tex new file mode 100644 index 00000000..f36320fa --- /dev/null +++ b/test/refs.tex @@ -0,0 +1,20 @@ +\documentclass[12pt]{article} + +\begin{document} + +\tableofcontents + +\section{Introduction}\label{sec:intro} + +See Section~\ref{sec:body} on page~\pageref{sec:body} for details. + +\section{Body}\label{sec:body} + +Refers back to Section~\ref{sec:intro} on page~\pageref{sec:intro}. + +\section{Conclusion}\label{sec:end} + +Cross-refs to Sections \ref{sec:intro}, \ref{sec:body}, and this one +(\ref{sec:end}) all require a second pass to resolve. + +\end{document} diff --git a/test/test-rerun.sh b/test/test-rerun.sh new file mode 100755 index 00000000..ae63c30a --- /dev/null +++ b/test/test-rerun.sh @@ -0,0 +1,77 @@ +#!/bin/bash +# Test rerun commands end-to-end: (rerun t), (rerun nil) parse and dispatch, +# and (rerun-once) actually causes a convergence pass. Uses -stream so +# commands are queued before compilation begins — no timing races. Uses +# refs.tex (which has TOC + cross-refs) so the first pass leaves aux dirty +# and (rerun-once) has real work to commit. +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +TEX_FILE="$SCRIPT_DIR/refs.tex" + +if [ ! -f "$TEX_FILE" ]; then + echo "FAIL: $TEX_FILE not found" >&2 + exit 1 +fi + +FIFO=$(mktemp -u /tmp/texpresso-fifo-XXXXXX) +STDERR_FILE=$(mktemp /tmp/texpresso-stderr-XXXXXX) +mkfifo "$FIFO" + +cleanup() { + rm -f "$FIFO" "$STDERR_FILE" + kill "$PID" 2>/dev/null || true +} +trap cleanup EXIT + +CONTENT=$(sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e 's/ /\\t/g' "$TEX_FILE" | \ + awk '{ if (NR > 1) printf "\\n"; printf "%s", $0 }') + +SDL_VIDEODRIVER=dummy build/texpresso -stream -test-initialize test/refs.tex \ + < "$FIFO" 2>"$STDERR_FILE" & +PID=$! + +exec 3>"$FIFO" +# Exercise all three commands while paused (stream mode starts paused). +printf '(rerun t)\n' >&3 +printf '(rerun nil)\n' >&3 +printf '(rerun-once)\n' >&3 +# Prime the VFS and start compilation. +printf '(register "%s")\n' "$TEX_FILE" >&3 +printf '(open "%s" "%s")\n' "$TEX_FILE" "$CONTENT" >&3 +printf '(resume)\n' >&3 +exec 3>&- + +if ! wait "$PID"; then + echo "FAIL: texpresso exited with error" + cat "$STDERR_FILE" + exit 1 +fi + +FAIL=0 +# Parse+dispatch assertions. +for msg in \ + '\[command\] rerun enabled' \ + '\[command\] rerun disabled' \ + '\[command\] rerun-once: pending immediate pass' +do + if ! grep -qE "$msg" "$STDERR_FILE"; then + echo "FAIL: missing stderr message matching: $msg" + FAIL=1 + fi +done + +# End-to-end assertion: (rerun-once) must actually fire a convergence pass. +# Emitted from the idle-block once aux_ready && rerun_once_pending are both set. +if ! grep -qE '\[rerun\] on-demand: finishing pass' "$STDERR_FILE"; then + echo "FAIL: (rerun-once) never triggered a finishing pass" + FAIL=1 +fi + +if [ $FAIL -eq 1 ]; then + echo "--- stderr ---" + cat "$STDERR_FILE" + exit 1 +fi + +echo "PASS: rerun test"