diff --git a/.github/workflows/extended_checks.yml b/.github/workflows/extended_checks.yml index 690fe1bff7..5eaf313ca3 100644 --- a/.github/workflows/extended_checks.yml +++ b/.github/workflows/extended_checks.yml @@ -564,13 +564,6 @@ jobs: python3 ci/test_fix_md_ascii.py python3 ci/fix_md_ascii.py --check - - name: "Test watchdog tray wording (python)" - if: matrix.role != 'modules' - run: | - set -eux - python3 utils/watchdog/test_tray_state.py - python3 utils/watchdog/test_consent.py - - name: "Test pip wheel repack (python)" if: matrix.role != 'modules' run: | diff --git a/CHANGELIST.md b/CHANGELIST.md index cbf142e379..1d5ea63867 100644 --- a/CHANGELIST.md +++ b/CHANGELIST.md @@ -232,6 +232,9 @@ Z3 SMT solver bindings as a dynamic module, dasLLVM-style. - **JIT debug tooling** (#3511); **`llvm_tune` per-box UX** (#3403) - scope / policy / `--tune`, self-tuning servers, `-exe` fix - **AOT batch-composition hash fix** (`g_isInAot` leak) + `aot_cpp` made AOT-linkable (#3409); **AOT fuzzer-failure hardening** (#3303) - **Standalone contexts link C++ modules** (#3947) - a `-ctx` context that reaches dasHV, fio or any handled type registers the modules it links (the builtin set in the C++ registrar's order, then dependencies-first) on first construction through a process-wide list (`include/daScript/simulate/standalone_modules.h`), so several generated contexts in one binary share one registry lifetime that ends with `Module::ShutdownStandalone` when the last context is destroyed; a builtin module the program calls at run time keeps its AOT header, the function table is dense (a slot per function the context still reaches), and `examples/standalone/06_full_runtime` (dasHV + fio, static, compiles nothing at run time, loads no shared module) is the worked example and a small-lane test +- **Standalone contexts emit every used function of every module** (#3950) - a `-ctx` context is one translation unit, so a required das module's functions, its class methods (through their Func slots) and the modules their externs reach now land in it; a generic instance several modules instantiated is emitted once per name. The entry module alone was emitted before, which the example survived only because its one cross-module das call was inlined. +- **`fio`: a long-lived child process** (#3950) - `spawn_process` / `process_drain` / `process_poll` / `process_wait` / `process_terminate` / `process_kill` / `process_pid` / `process_alive` / `close_process`, the `process_running` sentinel, and `with_process` in `daslib/fio`: an opaque `SubProcess?` a supervisor drives on its own clock, with a non-blocking line drain over merged stdout+stderr, `KEY=VALUE` environment overrides, a working directory, and tree-wide signals (a Windows job object, a POSIX process group). `popen_argv` stays the block-scoped, run-to-completion form. +- **The watchdog is daslang, and a static executable** (#3950) - `utils/watchdog/watchdog.py` (and its Python dependency in every release bundle) is replaced by `utils/watchdog/watchdog.das` plus `bin/watchdog`, a standalone context on the full runtime with dasHV static: no compiler, no shared module, no lock on the files a deploy replaces. Config, `watchdog.json` layering, layout discovery, the exit 0 / 3 / 4 contract, startup stages, the JSON-lines log with rotation, crash bundles, WER and notifications carry over; the exchange consent dialog, the sidecar tray rails and the tray icon do not. Manifests ship it with the new `release_include_tool("watchdog")`, which resolves `bin/watchdog` or `bin/Release/watchdog.exe` per platform. - **LLVM-AOT in a large embedding host** (#3715) - scalar call ABI matched at bool/reference seams, target triple + data layout pinned on emitted objects, `-dll-path`/`DAS_DLL_PATH` dasbind search, per-object glob-init deferred to first link #### Runtime, Tooling, and Hosting diff --git a/CLAUDE.md b/CLAUDE.md index 6f42336857..73be925bd5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -238,6 +238,9 @@ diagnostic in any tier. fresh clone per registration, not the same variable passed twice. - **`new WithCtor(field = v)` skips the user constructor** - it is plain field-init, so inherited fields stay zero. Write `new WithCtor(args)` when the constructor must run. +- **`exit(N)` does not set the process exit code under the daslang CLI.** It unwinds as an + abnormal termination and the process reports 1, whatever `N` was - a supervisor or shell sees a + crash. A code the parent must read comes from `def main() : int { return N }`. ### Code style - prefer idiomatic forms diff --git a/CMakeLists.txt b/CMakeLists.txt index 4b17962023..7b9a53c8d9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -2136,11 +2136,11 @@ install(FILES ${PROJECT_SOURCE_DIR}/utils/dasllama-convert/main.das DESTINATION utils/dasllama-convert ) -# Install watchdog (supervisor for long-running daslang programs; the worked -# example in the daspkg skill's release_include_from docs) +# Install watchdog (supervisor for long-running daslang programs; the static exe installs +# beside daslang from utils/CMakeLists.txt, these are the library and the interpreter entry) install(FILES - ${PROJECT_SOURCE_DIR}/utils/watchdog/watchdog.py - ${PROJECT_SOURCE_DIR}/utils/watchdog/dummy_server.py + ${PROJECT_SOURCE_DIR}/utils/watchdog/watchdog.das + ${PROJECT_SOURCE_DIR}/utils/watchdog/main.das ${PROJECT_SOURCE_DIR}/utils/watchdog/README.md DESTINATION utils/watchdog ) diff --git a/ci/test_check_shipped_skills.py b/ci/test_check_shipped_skills.py index 35cae3556b..8878059ad2 100644 --- a/ci/test_check_shipped_skills.py +++ b/ci/test_check_shipped_skills.py @@ -109,7 +109,7 @@ def test_utils_daslang_path_fires_watchdog_passes(self): "# Good\n\nsee utils/daslang/main.cpp for the gc hook\n") self.assert_fires("not in bundle", "utils/daslang/main.cpp") write(self.bundle, "skills/good.md", - "# Good\n\nrun `python utils/watchdog/watchdog.py`\n") + "# Good\n\nrun `daslang utils/watchdog/main.das -- --cwd .`\n") rc, out = self.run_gate() self.assertEqual(rc, 0, out) diff --git a/daslib/ARCHITECTURE_EMIT.md b/daslib/ARCHITECTURE_EMIT.md index df9cd8849c..fa3988c333 100644 --- a/daslib/ARCHITECTURE_EMIT.md +++ b/daslib/ARCHITECTURE_EMIT.md @@ -83,10 +83,20 @@ Companion to `ARCHITECTURE.md` in this folder; section numbers are unique across index. `[init]` function order is not re-derived at all: the emitter reads the simulated context's list through rtti `for_each_init_function`, so the C++ late-init sort stays the single source of truth. -- **Cross-module limits fail loud at emit time**: only main-module, AOT-emitted `[init]` - functions can be called from the ctor (required-module and `[no_aot]` ones are collected - emit errors with the reason), because the standalone TU only emits the entry module's - function bodies. +- **The TU holds every used function of every module.** A standalone context is one + translation unit with no other to link, so `prepareProgramForEmission`'s markers, the + block-variable collector, `registerAotCpp`'s `ArgsConverter` thunks and + `StandaloneContextGen` each walk the entry module through `visit_module` and then every + foreign used function through `visit(fn, adapter)` (`foreignUsedFunctions`, aot_cpp): a + builtin is C++ already, a template never emits, a used `[no_aot]` function was refused by + name before the walk. A generic instance several modules instantiated has one AOT name, + so declarations, bodies, thunks and table rows are emitted once per name. A foreign + global's initializer temporaries collect under the collector's null key, the one + `__init_script` declares. `UseTypeMarker` walks the foreign functions too, so an extern + or handled type reached only from one still links its module. +- **Cross-module `[init]` is refused at emit time**: only entry-module, AOT-emitted `[init]` + functions are called from the ctor; a required module's and `[no_aot]` ones are collected + emit errors with the reason. - **Every used function must have an AOT body** - a standalone context has no interpreter, so a used `noAot` function (the `[no_aot]` annotation, or `NoAotMarker` finding a type AOT cannot express) is a collected emit error, never a diff --git a/daslib/REVIEW.md b/daslib/REVIEW.md index 0dc0e20b8a..0837b873ef 100644 --- a/daslib/REVIEW.md +++ b/daslib/REVIEW.md @@ -137,10 +137,11 @@ spell it are `aotStructName` and the `VarInfo` emitter's inline `aotSuffixNameEx(info.name, "_S", ...)`. One site changed alone writes `offsetof`s that name a struct declared under a different name. -**A diff that adds or changes a function that runs `CppAot` or any subclass of it -keeps `buildStructEnumCollisions` running before that visitor runs - directly or in a -helper it calls.** The table decides when a name gets its collision suffix, and a run -that skips the seeding spells structs differently from the run that seeded it. +**A diff that adds or changes a function that emits a struct or enum C++ name - anything +reaching `aotStructName` / `aotEnumName`, a `CppAot` subclass and `ArgsConverter` alike - +keeps `buildStructEnumCollisions` running before it, directly or in a helper it calls.** +The table decides when a name gets its collision suffix, and a run that skips the seeding +spells structs differently from the run that seeded it. **Never pass a synthesized access expression's location to `match_error` - pass a pattern node's location.** `match_error` stores the `LineInfo` pointer BORROWED, and access nodes diff --git a/daslib/aot_cpp.das b/daslib/aot_cpp.das index 33223965d8..9ce915f1f9 100644 --- a/daslib/aot_cpp.das +++ b/daslib/aot_cpp.das @@ -4214,6 +4214,12 @@ def public dumpDependencies(program : ProgramPtr; var aotVisitor : CppAot?; prun var utm = new UseTypeMarker(); make_visitor(*utm) $(adapter) { visit(program, adapter); + if (prune_to_used) { // a standalone context emits the foreign functions too + var foreign <- foreignUsedFunctions(program, false) + for (fn in foreign) { + visit(fn, adapter) + } + } } let remUS = program._options |> find_arg("remove_unused_symbols") ?as tBool ?? true; let kept <- prune_to_used ? collectUsedModules(program) : table() @@ -4337,6 +4343,23 @@ def collectUsedFunctions(modules : array; totalFunctions : int; this_mo return <- fnn; } +//! The used functions living outside the entry module. A standalone context is one translation +//! unit, so each is emitted there or nowhere - builtins are C++ already, templates never emit. +//! `before_no_aot_marking` keeps the ones NoAotMarker has yet to judge; after it, `[no_aot]` is out. +def public foreignUsedFunctions(program : ProgramPtr; before_no_aot_marking : bool) : array { + let thisModule = program.getThisModule + var foreign : array + program.get_ptr() |> for_each_module_no_order($(pm) { + if (pm == thisModule) return + pm |> for_each_module_function($(pfun) { + if (pfun.index < 0 || !pfun.flags.used || pfun.flags.builtIn || pfun.moreFlags.isTemplate + || (!before_no_aot_marking && pfun.flags.noAot)) return + foreign |> push(pfun) + }) + }) + return <- foreign +} + def public collectProgramUsedFunctions(program : ProgramPtr; all_modules : bool; is_all : bool) : array { //! Collects all used functions from a program's modules for AOT code generation. var modules : array; @@ -4351,6 +4374,21 @@ def registerAotCpp(var logs : StringBuilderWriter?; program : ProgramPtr; var co var visitor = new ArgsConverter(logs, cross_platform); make_visitor(*visitor) $(adapter_marker) { visit(program, adapter_marker); + if (all_modules) { + // a whole-program table (a standalone context) needs a thunk per foreign row as well; + // a generic instance several modules share has one name and gets one thunk + var thunked : table + for (pfun in collectProgramUsedFunctions(program, false, false)) { + thunked |> insert(aotFuncName(pfun)) + } + var foreign <- foreignUsedFunctions(program, false) + for (fn in foreign) { + let name = aotFuncName(fn) + if (thunked |> key_exists(name)) continue + thunked |> insert(name) + visit(fn, adapter_marker) + } + } } unsafe { delete visitor @@ -4360,9 +4398,12 @@ def registerAotCpp(var logs : StringBuilderWriter?; program : ProgramPtr; var co write(*logs, "\n#ifdef _MSC_VER\n#pragma optimize(\"\", off)\n#endif\n"); write(*logs, "struct AotFunction \{ uint64_t hash; bool is_cmres; void * fn; vec4f (*wrappedFn)(Context*); \};\n"); write(*logs, "static AotFunction functions[] = \{\n"); + var listed : table for (fn in fnn) { let is_cmres = fn.flags.copyOnReturn || fn.flags.moveOnReturn ? "true" : "false"; let fn_name = aotFuncName(fn); + if (listed |> key_exists(fn_name)) continue + listed |> insert(fn_name) write(*logs, " // {get_aot_hash_comment(fn)}\n"); write(*logs, " \{ {fn.aotHash}, {is_cmres}, (void*)&{fn_name}, &__wrap_{fn_name} \},\n"); } @@ -4448,6 +4489,10 @@ def private collectUsedModules(program : ProgramPtr) : table { var utm = new UseTypeMarker() make_visitor(*utm) $(adapter) { visit(program, adapter) + var foreign <- foreignUsedFunctions(program, false) + for (fn in foreign) { + visit(fn, adapter) + } } for (st in keys(utm.useStructs)) { if (st._module != null) { diff --git a/daslib/aot_standalone.das b/daslib/aot_standalone.das index 00cbd4fe97..ccc1c7bc4a 100644 --- a/daslib/aot_standalone.das +++ b/daslib/aot_standalone.das @@ -129,7 +129,7 @@ def writeStandaloneCtor(cfg : StandaloneContextCfg; initFunctions : string; var let requested_stack = stack_arg ?as tInt ?? int(program.policies.stack) let stack_base = requested_stack > 0 && stack_arg is tInt ? requested_stack : max(requested_stack, min_init_stack) let stack_size = stack_base + int(program.globalInitStackSize) - let usedFunctionCount = length(collectProgramUsedFunctions(program, false, false)) + let usedFunctionCount = length(collectProgramUsedFunctions(program, true, false)) write(tw, "{cfg.class_name}::{cfg.class_name}() : Context({stack_size}/*stack*/) \{\n"); write(tw, " auto & context = *this;\n"); write(tw, " CodeOfPolicies policies;"); @@ -257,14 +257,15 @@ class StandaloneContextGen : CppAot { declarations = ss |> string_builder_str(); ss |> string_builder_clear(); write(*ss, "\n"); - let fnn = collectProgramUsedFunctions(prog, false, false); + // every used function of every module, declared once (a shared generic instance has one name) + let fnn = collectProgramUsedFunctions(prog, true, false); var inline_fns : array + inline_fns |> reserve(length(fnn)) for (pfun in fnn) { - let needInline = that == pfun._module; - if (needInline) { - inline_fns.push(describeCppFunc(pfun, collector, cross_platform, true, needInline)); - used_functions.insert(aotFuncName(pfun)); - } + let name = aotFuncName(pfun) + if (used_functions |> key_exists(name)) continue + inline_fns.push(describeCppFunc(pfun, collector, cross_platform, true, true)); + used_functions.insert(name); } let sep = ";\n"; let maybe_sem = inline_fns |> empty() ? "" : sep; @@ -308,7 +309,7 @@ def writeRegistration(var header : StringBuilderWriter; write(source, "using namespace {program.thisNamespace};\n"); write(header, "namespace {cfg.context_name} \{\n"); write(source, "namespace {cfg.context_name} \{\n"); - dumpRegisterAot(unsafe(addr(source)), program, context, false, cfg.cross_platform); + dumpRegisterAot(unsafe(addr(source)), program, context, true, cfg.cross_platform); writeModuleRegistration(source, registrations); writeStandaloneContext(program, initFunctions, header, source, cfg, context); write(header, "\} // namespace {cfg.context_name}\n"); @@ -374,9 +375,21 @@ def genStandaloneSrc(var program : ProgramPtr; make_visitor(*gen) $(adapter) { gen.adapter := adapter program |> visit_module(adapter, program.getThisModule); + // a generic instance several modules instantiated is one C++ function: emit the first copy only + var emitted : table + for (pfun in collectProgramUsedFunctions(program, false, false)) { + emitted |> insert(aotFuncName(pfun)) + } + var foreign <- foreignUsedFunctions(program, false) + for (fn in foreign) { + let name = aotFuncName(fn) + if (emitted |> key_exists(name)) continue + emitted |> insert(name) + visit(fn, adapter) + } } - initFunctions = addFunctionInfo(collectProgramUsedFunctions(program, false, false), gen.helper); + initFunctions = addFunctionInfo(collectProgramUsedFunctions(program, true, false), gen.helper); type_defs += gen.declarations; gen.declarations = ""; write(tw, gen.str()); @@ -421,14 +434,22 @@ def private checkAllUsedFunctionsCanAot(program : ProgramPtr) { def private prepareProgramForEmission(var program : ProgramPtr; context : Context) : BlockVariableCollector? { var noAotMarker = new NoAotMarker(); + var unmarked <- foreignUsedFunctions(program, true) make_visitor(*noAotMarker) $(adapter_no_aot) { visit(program, adapter_no_aot) + for (fn in unmarked) { + visit(fn, adapter_no_aot) + } } checkAllUsedFunctionsCanAot(program) + var foreign <- foreignUsedFunctions(program, false) var pmarker = new PrologueMarker(); make_visitor(*pmarker) $(adapter_p) { visit(program, adapter_p) + for (fn in foreign) { + visit(fn, adapter_p) + } } setAotHashes(program, context); @@ -436,6 +457,9 @@ def private prepareProgramForEmission(var program : ProgramPtr; context : Contex var flags = new SetPrinterFlags(); make_visitor(*flags) $(flags_adapter) { visit(program, flags_adapter) + for (fn in foreign) { + visit(fn, flags_adapter) + } } unsafe { delete flags @@ -446,6 +470,18 @@ def private prepareProgramForEmission(var program : ProgramPtr; context : Contex var coll = new BlockVariableCollector(); make_visitor(*coll) $(adapter_coll) { visit(program, adapter_coll) + for (fn in foreign) { + visit(fn, adapter_coll) + } + // foreign globals initialize in __init_script too: their temporaries collect under the null key + let thisModule = program.getThisModule + program.get_ptr() |> for_each_module_no_order($(pm) { + if (pm == thisModule) return + pm |> for_each_global($(pvar) { + if (pvar.index < 0 || !pvar.flags.used || pvar.init == null) return + visit_expression(pvar.init, adapter_coll) + }) + }) } return coll } diff --git a/daslib/daspkg.das b/daslib/daspkg.das index a380551d25..e33a3c44ac 100644 --- a/daslib/daspkg.das +++ b/daslib/daspkg.das @@ -137,6 +137,7 @@ struct ReleaseSpec { requires_jit : bool //!< app is JIT-only (per-box [tune]/[llvm_code] kernels); a baked -exe would run broken, so `daspkg release` refuses it include_symbols : bool //!< ship debug symbols for every shipped binary into `/symbols/` external_files : array //!< "src@dst" pairs shipped from outside the package; src is relative to , dst to the bundle root + tools : array //!< built tools shipped by bare name from /bin/ (bin/Release/ in an MSVC tree) to the bundle root; the platform adds .exe } var _release_spec : ReleaseSpec @@ -177,7 +178,7 @@ def release_include_if_missing(pattern : string) { _release_spec.include_if_missing_globs |> push(pattern) } -//! Ship a file that lives OUTSIDE this package — `release_include` only globs downward from the package root, so shared tooling (e.g. `utils/watchdog/watchdog.py`) is unreachable to it. `source` is relative to ``; `dest` is relative to the bundle root and defaults to `source`'s file name. Errors loudly if the source is missing, so a moved file fails the release instead of silently shipping nothing. +//! Ship a file that lives OUTSIDE this package — `release_include` only globs downward from the package root, so shared tooling (e.g. `dastest/dastest.das`) is unreachable to it. `source` is relative to ``; `dest` is relative to the bundle root and defaults to `source`'s file name. Errors loudly if the source is missing, so a moved file fails the release instead of silently shipping nothing. def release_include_from(source, dest : string) { _release_spec.external_files |> push("{source}@{dest}") } @@ -186,6 +187,11 @@ def release_include_from(source : string) { _release_spec.external_files |> push("{source}@") } +//! Ship a tool the daslang build produced - `bin/` (`bin/.exe` on Windows, also looked for under `bin/Release/` in an MSVC tree) - to the bundle root, so a manifest names it once for every platform. A build without it fails the release rather than shipping a bundle short a tool. The supervisor is the worked example: `release_include_tool("watchdog")`. +def release_include_tool(name : string) { + _release_spec.tools |> push(name) +} + def release_exclude(pattern : string) { _release_spec.exclude_globs |> push(pattern) } diff --git a/daslib/fio.das b/daslib/fio.das index cde51d9df6..b8a1222c2c 100644 --- a/daslib/fio.das +++ b/daslib/fio.das @@ -690,6 +690,23 @@ def rmdir_rec_result(path : string) : fs_result_bool { return fs_result_bool(value = res) } +typedef process = SubProcess? + +def with_process(argv : array; cwd : string; env : array; blk : block<(var p : process) : void>) { + //! Spawn `argv` as a long-lived child (no shell; a forward-slash ``argv[0]`` spawns everywhere), run + //! `blk` with the live handle, then close it on scope exit - closing kills a child still running (the + //! Windows job tree, the POSIX group). `cwd` empty inherits the parent's; `env` is ``KEY=VALUE`` overrides. + var p = unsafe(spawn_process(argv, cwd, env)) + invoke(blk, p) + unsafe(close_process(p)) +} + +def with_process(argv : array; blk : block<(var p : process) : void>) { + //! `with_process` inheriting the parent's directory and environment. + let noenv : array + with_process(argv, "", noenv, blk) +} + def run_and_capture(args : array; var output : string&; timeout_sec : float = 0.0) : int { //! Run an external command and capture its stdout+stderr (merged into one pipe by the underlying ``popen_argv``). Returns the process exit code; //! -1 means the spawn itself failed. No shell is involved, and a forward-slash ``args[0]`` spawns on every platform (``popen_argv`` hands Windows the backslash spelling). diff --git a/doc/reflections/das2rst.das b/doc/reflections/das2rst.das index 551f26c5ef..520851e628 100644 --- a/doc/reflections/das2rst.das +++ b/doc/reflections/das2rst.das @@ -228,7 +228,7 @@ def document_module_fio(_root : string) { group_by_regex("Glob and pattern matching", mod, %regex~(match_glob|glob|glob_filtered|is_glob_pattern|expand_glob|parse_file_list)$%%), group_by_regex("Filesystem queries", mod, %regex~(temp_directory|temp_directory_result|create_temp_file|create_temp_file_result|create_temp_directory|create_temp_directory_result|disk_space)$%%), group_by_regex("Terminal queries", mod, %regex~(is_terminal|terminal_width)$%%), - group_by_regex("OS specific routines", mod, %regex~(sleep|exit|system|popen|popen_binary|popen_timeout|spawn_argv|popen_argv|popen_argv_pipe|popen_timed_out|run_and_capture|get_env_variable|set_env_variable|sanitize_command_line|has_env_variable)$%%), + group_by_regex("OS specific routines", mod, %regex~(sleep|exit|system|popen|popen_binary|popen_timeout|spawn_argv|popen_argv|popen_argv_pipe|popen_timed_out|run_and_capture|spawn_process|process_drain|process_poll|process_wait|process_terminate|process_kill|process_pid|process_alive|close_process|process_running|with_process|get_env_variable|set_env_variable|sanitize_command_line|has_env_variable)$%%), group_by_regex("Dynamic modules", mod, %regex~(register_dynamic_module|register_native_path|describe_pending_dynamic_modules)$%%) ) documents("File input output library", mod, "fio.rst", groups) diff --git a/doc/source/reference/utils/watchdog.rst b/doc/source/reference/utils/watchdog.rst index 872be9fca9..59cf59e689 100644 --- a/doc/source/reference/utils/watchdog.rst +++ b/doc/source/reference/utils/watchdog.rst @@ -8,31 +8,40 @@ watchdog --- Program Supervisor ====================================== -One Python supervisor for any daslang program that needs to stay up. It +One supervisor for any daslang program that needs to stay up. It restarts the child with bounded backoff, captures crashes into bundles, -reports startup progress, and optionally exposes a per-program control -page. In-tree it supervises ``utils/dasllama-server`` (JIT) and the -telegram dictation example (a baked exe). +reports startup progress, and polls health. In-tree it supervises +``utils/dasllama-server`` (JIT) and the telegram dictation example (a +baked exe). + +It ships as a static executable, ``bin/watchdog`` (``bin/Release/watchdog.exe`` +in an MSVC tree): a standalone context on the full runtime that compiles +nothing at run time, loads no shared module, and holds no lock on any file a +deploy replaces. The same code runs under the interpreter for development. Quick start =========== In a deployed bundle, beside the program:: - python watchdog.py + ./watchdog From the source tree, point it at the program's directory:: - python utils/watchdog/watchdog.py --cwd utils/dasllama-server + bin/watchdog --cwd utils/dasllama-server + +or, under the interpreter:: + + daslang utils/watchdog/main.das -- --cwd utils/dasllama-server What to supervise resolves in order, first match winning: command-line flags (``--program``, ``--script``, ``--name``, …); ``watchdog.json`` -beside ``watchdog.py`` (each key sets the default for the same-named -flag; unknown keys are a hard error); layout discovery (``main.das`` -beside ``bin/Release/daslang`` → ``daslang -jit main.das``; exactly one -``*.exe`` → that program — anything ambiguous is an error, never a -guess). +beside the executable (each key is a flag name with underscores and sets +the default for that flag; unknown keys are a hard error); layout +discovery (``main.das`` beside ``bin/Release/daslang`` → ``daslang -jit +main.das``; exactly one ``*.exe`` → that program — anything ambiguous is +an error, never a guess). Everything after ``--`` goes to the child. .. seealso:: - :ref:`utils_daspkg` -- ``release_include_from`` ships the watchdog inside a package release + :ref:`utils_daspkg` -- ``release_include_tool("watchdog")`` ships the executable inside a package release diff --git a/doc/source/stdlib/handmade/Variable-fio-process_running.rst b/doc/source/stdlib/handmade/Variable-fio-process_running.rst new file mode 100644 index 0000000000..2dddd8e343 --- /dev/null +++ b/doc/source/stdlib/handmade/Variable-fio-process_running.rst @@ -0,0 +1 @@ +The value ``process_poll`` and ``process_wait`` return while the child is still running. ``INT32_MIN``: no signal number and no ordinary exit code takes that value; the one collision is a Windows process that returns ``0x80000000`` as its own status on purpose. diff --git a/doc/source/stdlib/handmade/annotation-fio-SubProcess.rst b/doc/source/stdlib/handmade/annotation-fio-SubProcess.rst new file mode 100644 index 0000000000..561d492295 --- /dev/null +++ b/doc/source/stdlib/handmade/annotation-fio-SubProcess.rst @@ -0,0 +1 @@ +A long-lived child process spawned by ``spawn_process``: an opaque handle a supervisor polls, drains and signals across many ticks, unlike the block-scoped ``popen_argv``. Free it with ``close_process``, or hold it in a ``with_process`` block. diff --git a/doc/source/stdlib/handmade/function-fio-close_process-0xf82966f695d14fec.rst b/doc/source/stdlib/handmade/function-fio-close_process-0xf82966f695d14fec.rst new file mode 100644 index 0000000000..ab14e46a59 --- /dev/null +++ b/doc/source/stdlib/handmade/function-fio-close_process-0xf82966f695d14fec.rst @@ -0,0 +1 @@ +Frees the handle. A child still running dies with it - the Windows job object closes, the POSIX process group is killed and reaped - so a supervisor never leaks a child or leaves a zombie. A null handle is ignored. diff --git a/doc/source/stdlib/handmade/function-fio-process_alive-0x90274ac9d5662c36.rst b/doc/source/stdlib/handmade/function-fio-process_alive-0x90274ac9d5662c36.rst new file mode 100644 index 0000000000..103fad2edd --- /dev/null +++ b/doc/source/stdlib/handmade/function-fio-process_alive-0x90274ac9d5662c36.rst @@ -0,0 +1 @@ +Whether a process with this id exists - any process, not only a child - for a single-instance guard reading a pid file. Probes without signalling: ``kill(pid, 0)`` on POSIX (a process that exists but is not ours to signal counts as alive), ``OpenProcess`` plus the still-active exit status on Windows. A pid of zero or less is never alive. diff --git a/doc/source/stdlib/handmade/function-fio-process_drain-0x4dd6d23e716a3381.rst b/doc/source/stdlib/handmade/function-fio-process_drain-0x4dd6d23e716a3381.rst new file mode 100644 index 0000000000..1e1ebcab4e --- /dev/null +++ b/doc/source/stdlib/handmade/function-fio-process_drain-0x4dd6d23e716a3381.rst @@ -0,0 +1 @@ +Hands ``block`` every complete line of the child's merged stdout+stderr that is ready right now, one call per line with the newline stripped, and never blocks: a partial line waits in the handle for the next drain, and a last line with no newline is delivered once the child closes its output. Returns ``false`` once that output is closed and fully delivered, so a supervisor drains on every tick until it does. diff --git a/doc/source/stdlib/handmade/function-fio-process_kill-0x430dd55ae318dbeb.rst b/doc/source/stdlib/handmade/function-fio-process_kill-0x430dd55ae318dbeb.rst new file mode 100644 index 0000000000..625adb793e --- /dev/null +++ b/doc/source/stdlib/handmade/function-fio-process_kill-0x430dd55ae318dbeb.rst @@ -0,0 +1 @@ +Kills the child and everything it spawned: ``SIGKILL`` to its process group on POSIX, ``TerminateJobObject`` with exit code 9 on Windows. Nothing in the child runs after this. diff --git a/doc/source/stdlib/handmade/function-fio-process_pid-0x349c5646dfa72325.rst b/doc/source/stdlib/handmade/function-fio-process_pid-0x349c5646dfa72325.rst new file mode 100644 index 0000000000..d242b8c046 --- /dev/null +++ b/doc/source/stdlib/handmade/function-fio-process_pid-0x349c5646dfa72325.rst @@ -0,0 +1 @@ +The child's process id, for a pid file or a log line. diff --git a/doc/source/stdlib/handmade/function-fio-process_poll-0x21f24a39d497e02e.rst b/doc/source/stdlib/handmade/function-fio-process_poll-0x21f24a39d497e02e.rst new file mode 100644 index 0000000000..bb94202cf0 --- /dev/null +++ b/doc/source/stdlib/handmade/function-fio-process_poll-0x21f24a39d497e02e.rst @@ -0,0 +1 @@ +The child's exit code if it has exited, else ``process_running``; never waits. On POSIX a child killed by a signal reports the signal number, as ``popen_argv`` does. The code is remembered, so a later call answers the same. diff --git a/doc/source/stdlib/handmade/function-fio-process_terminate-0x1227a543288285f0.rst b/doc/source/stdlib/handmade/function-fio-process_terminate-0x1227a543288285f0.rst new file mode 100644 index 0000000000..b62f17c121 --- /dev/null +++ b/doc/source/stdlib/handmade/function-fio-process_terminate-0x1227a543288285f0.rst @@ -0,0 +1 @@ +Asks the child and everything it spawned to stop: ``SIGTERM`` to its process group on POSIX, ``TerminateJobObject`` with exit code 15 on Windows, where nothing gentler reaches a console-less process. Follow with ``process_wait`` and, if still running, ``process_kill``. diff --git a/doc/source/stdlib/handmade/function-fio-process_wait-0xe5ddee492688d8dc.rst b/doc/source/stdlib/handmade/function-fio-process_wait-0xe5ddee492688d8dc.rst new file mode 100644 index 0000000000..6312c22afc --- /dev/null +++ b/doc/source/stdlib/handmade/function-fio-process_wait-0xe5ddee492688d8dc.rst @@ -0,0 +1 @@ +Waits up to ``timeout`` seconds for the child to exit and returns its exit code, or ``process_running`` when the timeout passes first. A timeout of zero or less waits forever. The code is remembered, so a later ``process_poll`` answers the same. diff --git a/doc/source/stdlib/handmade/function-fio-spawn_process-0x5eca268e4ba0b640.rst b/doc/source/stdlib/handmade/function-fio-spawn_process-0x5eca268e4ba0b640.rst new file mode 100644 index 0000000000..9fe5a10b4d --- /dev/null +++ b/doc/source/stdlib/handmade/function-fio-spawn_process-0x5eca268e4ba0b640.rst @@ -0,0 +1 @@ +Spawns ``argv`` as a long-lived child and returns its handle. No shell is involved: ``argv[0]`` is the executable (a forward-slash path spawns on every platform; a relative path naming a directory resolves against the caller's directory, not ``cwd``), the rest are its arguments verbatim. ``cwd`` empty inherits the caller's directory; each ``env`` entry is a ``KEY=VALUE`` override applied over the inherited environment. The child's stdout and stderr merge into one pipe read by ``process_drain``; its stdin is empty. On Windows the child sits in a kill-on-close job object, on POSIX it leads its own process group, so ``process_terminate`` / ``process_kill`` reach the whole tree and closing the handle kills a child still running. An unspawnable executable is a thrown error on Windows and an exit code 127 on POSIX. diff --git a/examples/games/latchpoint/start-server.ps1 b/examples/games/latchpoint/start-server.ps1 index 2bfb6521e2..43c6c279b0 100644 --- a/examples/games/latchpoint/start-server.ps1 +++ b/examples/games/latchpoint/start-server.ps1 @@ -4,12 +4,14 @@ param( $ErrorActionPreference = 'Stop' $repoRoot = (Resolve-Path "$PSScriptRoot/../../..").Path $runtime = Join-Path $repoRoot 'bin/Release/daslang.exe' +$watchdog = Join-Path $repoRoot 'bin/Release/watchdog.exe' $runRoot = Join-Path $repoRoot 'logs/latchpoint-server' if (!(Test-Path -LiteralPath $runtime)) { throw "Build this checkout's daslang runtime first: $runtime" } +if (!(Test-Path -LiteralPath $watchdog)) { throw "Build this checkout's watchdog first: $watchdog" } if (!(Test-Path -LiteralPath $Config)) { throw "Create $Config from server.example.toml with your model paths." } $Config = (Resolve-Path -LiteralPath $Config).Path New-Item -ItemType Directory -Force $runRoot | Out-Null $env:DAS_JOBQUE_THREADS = '8' $env:DASLLAMA_GPU = '0' -python "$repoRoot/utils/watchdog/watchdog.py" --name latchpoint --daslang $runtime --script "$repoRoot/utils/dasllama-server/main.das" --cwd $runRoot --log "$runRoot/watchdog.log" --pid-file "$runRoot/watchdog.pid" --health-url 'http://127.0.0.1:18082/v1/models' --shutdown-url 'http://127.0.0.1:18082/shutdown' -- --config $Config +& $watchdog --name latchpoint --daslang $runtime --script "$repoRoot/utils/dasllama-server/main.das" --cwd $runRoot --log "$runRoot/watchdog.log" --pid-file "$runRoot/watchdog.pid" --health-url 'http://127.0.0.1:18082/v1/models' --shutdown-url 'http://127.0.0.1:18082/shutdown' -- --config $Config exit $LASTEXITCODE diff --git a/examples/games/latchpoint/start-server.sh b/examples/games/latchpoint/start-server.sh index 91dbb7aff1..8191e91265 100755 --- a/examples/games/latchpoint/start-server.sh +++ b/examples/games/latchpoint/start-server.sh @@ -4,11 +4,13 @@ repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../../.." && pwd)" runtime="$repo_root/bin/daslang" config="${1:-$repo_root/examples/games/latchpoint/server.local.toml}" run_root="$repo_root/logs/latchpoint-server" +watchdog="$repo_root/bin/watchdog" [[ -x "$runtime" ]] || { echo "Build this checkout's daslang first: $runtime" >&2; exit 1; } +[[ -x "$watchdog" ]] || { echo "Build this checkout's watchdog first: $watchdog" >&2; exit 1; } [[ -f "$config" ]] || { echo "Create $config from server.example.toml with local model paths." >&2; exit 1; } config="$(cd -- "$(dirname -- "$config")" && pwd)/$(basename -- "$config")" mkdir -p "$run_root" -exec python3 "$repo_root/utils/watchdog/watchdog.py" --name latchpoint --daslang "$runtime" \ +exec "$watchdog" --name latchpoint --daslang "$runtime" \ --script "$repo_root/utils/dasllama-server/main.das" --cwd "$run_root" \ --log "$run_root/watchdog.log" --pid-file "$run_root/watchdog.pid" \ --health-url http://127.0.0.1:18082/v1/models --shutdown-url http://127.0.0.1:18082/shutdown \ diff --git a/include/daScript/simulate/aot_builtin_fio.h b/include/daScript/simulate/aot_builtin_fio.h index d13f719cc4..bea3d7dc06 100644 --- a/include/daScript/simulate/aot_builtin_fio.h +++ b/include/daScript/simulate/aot_builtin_fio.h @@ -115,6 +115,18 @@ namespace das { DAS_API bool builtin_spawn_argv ( const Array & args_arr, Context * context, LineInfoArg * at ); DAS_API int builtin_popen_argv ( const Array & args_arr, float timeout_sec, const TBlock & blk, Context * context, LineInfoArg * at ); DAS_API int builtin_popen_argv_pipe ( const Array & args_arr, const TBlock & blk, Context * context, LineInfoArg * at ); + // A long-lived child process: spawned once, polled and drained across many ticks, unlike the + // block-scoped popen_argv. The handle (das `SubProcess?`) is opaque; free it with close_process. + struct DasSubProcess; + DAS_API DasSubProcess * builtin_spawn_process ( const Array & argv, const char * cwd, const Array & env, Context * context, LineInfoArg * at ); + DAS_API bool builtin_process_drain ( DasSubProcess * p, const TBlock & blk, Context * context, LineInfoArg * at ); + DAS_API int builtin_process_poll ( DasSubProcess * p, Context * context, LineInfoArg * at ); + DAS_API int builtin_process_wait ( DasSubProcess * p, float timeout_sec, Context * context, LineInfoArg * at ); + DAS_API void builtin_process_terminate ( DasSubProcess * p, Context * context, LineInfoArg * at ); + DAS_API void builtin_process_kill ( DasSubProcess * p, Context * context, LineInfoArg * at ); + DAS_API int builtin_process_pid ( DasSubProcess * p, Context * context, LineInfoArg * at ); + DAS_API bool builtin_process_alive ( int32_t pid, Context * context, LineInfoArg * at ); + DAS_API void builtin_close_process ( DasSubProcess * p, Context * context, LineInfoArg * at ); DAS_API char * get_full_file_name ( const char * path, Context * context, LineInfoArg * ); DAS_API char * builtin_resolve_this_module_dir ( const char * baked_path, bool standalone, Context * context ); DAS_API bool builtin_remove_file ( const char * path ); diff --git a/install/CLAUDE.md b/install/CLAUDE.md index d741df0ea9..9d83ff71a1 100644 --- a/install/CLAUDE.md +++ b/install/CLAUDE.md @@ -209,7 +209,7 @@ For path/filename ops use `fio` helpers (`base_name`/`dir_name`/`path_join`/...) - `utils/benchctl/` - benchmark result database + statistical comparison (needs the sqlite module) - `utils/dasllama-server/` - OpenAI-compatible dasLLAMA inference server (JIT-only; `deploy-jit.ps1` builds a standalone bundle) - `utils/dasllama-convert/` - offline GGUF -> `.dlim` model prep -- `utils/watchdog/` - Python supervisor for long-running daslang programs (`python utils/watchdog/watchdog.py --cwd `) +- `utils/watchdog/` - supervisor for long-running daslang programs: the static `bin/watchdog --cwd ` (no compiler, no shared module, no lock on the files a deploy replaces), or `daslang utils/watchdog/main.das -- --cwd ` under the interpreter - `utils/jobque-timeline/` - per-lane jobque trace viewer (ImGui) - `utils/vscode-daslang-test/` - VSCode Test Explorer extension for dastest (source; build per its README) - `tree-sitter-daslang/` - tree-sitter grammar, shared library, highlighting queries (`sgconfig.yml` at the SDK root wires ast-grep to it) diff --git a/modules/dasLLAMA/ARCHITECTURE_ENGINE.md b/modules/dasLLAMA/ARCHITECTURE_ENGINE.md index 7f6ecdd8b5..aa423394ad 100644 --- a/modules/dasLLAMA/ARCHITECTURE_ENGINE.md +++ b/modules/dasLLAMA/ARCHITECTURE_ENGINE.md @@ -251,7 +251,7 @@ file builds an `ArchDesc` (name * `configure` * the `ArchBlocks` fn-ptr quad * ` The first-contact consent gate (GDPR) sits ahead of every lookup once a policy is on: an explicit `exchange_*` config counts as the expressed choice, otherwise the `.consent` sidecar-sibling file governs - unset asks on a real terminal, or emits - `@sidecar consent state=needed` for the watchdog dialog / control page, and no request + `@sidecar consent state=needed` for the control page, and no request leaves until a surface records "accepted". The client is meaningless without a sidecar, so every requirer takes it through the guard `require ?llvm dasllama/dasllama_exchange` (`llvm` is the C++ witness module dasLLVM compiles in exactly when the build is configured with it) diff --git a/modules/dasLLAMA/ENVIRONMENT.md b/modules/dasLLAMA/ENVIRONMENT.md index 7e4d05a936..2b377b63c9 100644 --- a/modules/dasLLAMA/ENVIRONMENT.md +++ b/modules/dasLLAMA/ENVIRONMENT.md @@ -263,7 +263,7 @@ Overrides for the dasllama.io exchange client. | Variable | Type | Default | Effect | |---|---|---|---| | `DASLLAMA_EXCHANGE_URL` | text | unset | Sidecar exchange base URL override (tests, mirrors); unset = the baked-in dasllama.io. Setting it is an expressed choice to use the exchange - the first-contact consent question is skipped (announced at boot). | -| `DASLLAMA_EXCHANGE_ACCEPT` | text | unset | One-shot exchange accept-policy override: verified | any | off; unset = the app config's exchange_accept. The watchdog arms 'any' on a relaunch when the user adopts an unverified sidecar over finishing a tune. Setting it is an expressed choice to use the exchange - the first-contact consent question is skipped (announced at boot). | +| `DASLLAMA_EXCHANGE_ACCEPT` | text | unset | One-shot exchange accept-policy override: verified | any | off; unset = the app config's exchange_accept. A host may arm 'any' for one relaunch to adopt an unverified sidecar over finishing a tune. Setting it is an expressed choice to use the exchange - the first-contact consent question is skipped (announced at boot). | ## daslang core knobs dasLLAMA honours diff --git a/modules/dasLLAMA/REVIEW_EXCHANGE.md b/modules/dasLLAMA/REVIEW_EXCHANGE.md index 0b039dcaaa..f28b4aa128 100644 --- a/modules/dasLLAMA/REVIEW_EXCHANGE.md +++ b/modules/dasLLAMA/REVIEW_EXCHANGE.md @@ -33,12 +33,11 @@ falls back to the sidecar on the box and the winners built into the binary.** **A diff that adds a tune-boot-path (`exchange_scope_resolver` / `exchange_boot_submit_check`) consent question with no terminal to ask on also emits that -question as a `@sidecar` event, in the same change** - the watchdog dialog and the control -page are the answer surfaces a supervised boot has. +question as a `@sidecar` event, in the same change** - the control page is the answer surface +a supervised boot has. **A diff that changes the exchange consent notice wording updates every checked-in copy in the same change, and a diff that adds a copy names it here in the same change: -`EXCHANGE_CONSENT_NOTICE` (`dasllama/dasllama_exchange.das`), `CONSENT_TITLE` / -`CONSENT_TEXT` (`utils/watchdog/watchdog.py`, repo root), and the captured +`EXCHANGE_CONSENT_NOTICE` (`dasllama/dasllama_exchange.das`) and the captured `utils/dasllama-server/tests/fixtures/exchange.json` (repo root).** The console prompt and the control page render the served constant, so they are not copies. diff --git a/modules/dasLLAMA/dasllama/dasllama_env.das b/modules/dasLLAMA/dasllama/dasllama_env.das index e09a738b8d..11bbddf12d 100644 --- a/modules/dasLLAMA/dasllama/dasllama_env.das +++ b/modules/dasLLAMA/dasllama/dasllama_env.das @@ -726,7 +726,7 @@ struct public ExchangeEnv { @clarg_doc = "Sidecar exchange base URL override (tests, mirrors); unset = the baked-in dasllama.io. Setting it is an expressed choice to use the exchange - the first-contact consent question is skipped (announced at boot)." exchange_url : string = "" - @clarg_doc = "One-shot exchange accept-policy override: verified | any | off; unset = the app config's exchange_accept. The watchdog arms 'any' on a relaunch when the user adopts an unverified sidecar over finishing a tune. Setting it is an expressed choice to use the exchange - the first-contact consent question is skipped (announced at boot)." + @clarg_doc = "One-shot exchange accept-policy override: verified | any | off; unset = the app config's exchange_accept. A host may arm 'any' for one relaunch to adopt an unverified sidecar over finishing a tune. Setting it is an expressed choice to use the exchange - the first-contact consent question is skipped (announced at boot)." exchange_accept : string = "" } diff --git a/modules/dasLLAMA/dasllama/dasllama_exchange.das b/modules/dasLLAMA/dasllama/dasllama_exchange.das index ef44954022..5dabfb7613 100644 --- a/modules/dasLLAMA/dasllama/dasllama_exchange.das +++ b/modules/dasLLAMA/dasllama/dasllama_exchange.das @@ -56,8 +56,7 @@ struct ExchangePolicy { //! The first-contact notice, shown before ANY request leaves for the exchange (GDPR: the //! lookup transmits the box identity, and the server sees the connection's IP like any web -//! server). PLACEHOLDER wording — Gaijin legal owns the final text; the watchdog dialog -//! (utils/watchdog/watchdog.py CONSENT_*) carries the same wording, keep the two in sync. +//! server). PLACEHOLDER wording — Gaijin legal owns the final text. let public EXCHANGE_CONSENT_NOTICE = "dasLLAMA can download a ready performance-tuning preset (a \"sidecar\") for this machine from dasllama.io, instead of tuning locally.\nThe request sends only your hardware class - platform, CPU model and OS build. No serial numbers, user names or other personal data; no cookies. Sharing your own tuning results back is always confirmed separately.\nDetails: https://legal.gaijin.net/privacypolicy" // structured events for the watchdog (same contract as @tune): folded into supervisor STATE @@ -366,8 +365,8 @@ def exchange_consent_status() : string { return (word == "accepted" || word == "declined") ? word : "" } -//! Record the first-contact choice (console prompt, watchdog dialog, control page — every -//! surface lands here). Emits the `@sidecar consent` event so the supervisor state follows. +//! Record the first-contact choice (console prompt, control page — every surface lands +//! here). Emits the `@sidecar consent` event so the supervisor state follows. def exchange_record_consent(accept : bool) : bool { let word = accept ? "accepted" : "declined" if (!fwrite(exchange_consent_path(), "{word}\n")) { @@ -402,7 +401,7 @@ def private consent_prompt_console() : bool { // The gate the resolver runs before the first request leaves. True = a request may be made. // No recorded choice: a real terminal asks inline; anything else stays offline this boot and -// surfaces the question where a human is (watchdog dialog, control page) via the event. +// surfaces the question where a human is (the control page) via the event. def private exchange_consent_gate() : bool { let status = exchange_consent_status() if (status == "accepted") { diff --git a/plans/dasllama_io_site.md b/plans/dasllama_io_site.md index f9d2a91652..7f29e73964 100644 --- a/plans/dasllama_io_site.md +++ b/plans/dasllama_io_site.md @@ -117,7 +117,7 @@ pick on the control page's exchange card; a proactive offer after repeated noise aborts (the noisy-box case); and DURING a tune via the live tray menu - where doing nothing means the tune just finishes. -**Watchdog changes** (`utils/watchdog/watchdog.py`; backoff/health/crash logic UNCHANGED): +**Watchdog changes** (`utils/watchdog/watchdog.das`; backoff/health/crash logic UNCHANGED): - The tray menu becomes a pure function of STATE - pystray re-evaluates callables on menu open, `icon.update_menu()` on transitions. Items: "Use available sidecar instead diff --git a/plans/dasweb_backend.md b/plans/dasweb_backend.md index 54bac3422a..dec65bb76c 100644 --- a/plans/dasweb_backend.md +++ b/plans/dasweb_backend.md @@ -112,7 +112,7 @@ is not. - `logger_init_tee("dasweb-playground")` in init -> ndjson to `logs/dasweb-playground.log` + stdout. No rotation in daslang; rotation is the watchdog's (20 MB x 5 - raise if request volume makes the window too short; the request log must cover at least days, not hours). -- Supervisor = the shared `utils/watchdog/watchdog.py` (same one dasllama-server and the +- Supervisor = the shared `utils/watchdog/` static executable (same one dasllama-server and the dictation bot ship). Contract the service honors: - exit **0** = intentional shutdown (watchdog stops), **4** = config-restart request, anything else = crash -> notify + bounded-backoff restart + crash bundle. @@ -121,7 +121,7 @@ is not. - `POST /shutdown` - graceful stop; flips the service's own context flag. - `watchdog.json`: `{ "name": "dasweb-playground", "health_url": "http://127.0.0.1:8101/healthz", "shutdown_url": "http://127.0.0.1:8101/shutdown" }`. -- systemd unit `dasweb-playground.service`: `ExecStart=python3 watchdog.py`, `WorkingDirectory=` +- systemd unit `dasweb-playground.service`: `ExecStart=/watchdog`, `WorkingDirectory=` the release bundle, `Restart=on-failure` (guards the watchdog itself), `User=dasweb`. Watchdog does the child restarts, stages, crash bundles; journald gets the watchdog's stdout. @@ -145,7 +145,7 @@ ln -sfn releases/ /srv/apps/dasweb-playground/current && systemctl restart - `.das_package`: `release_main("main.das")`, `release_name("dasweb-playground")`, `release_include_if_missing("dasweb-playground.toml")` (preserve deployed edits), - `release_include_from("utils/watchdog/watchdog.py")`, `release_include("watchdog.json")`. + `release_include_tool("watchdog")`, `release_include("watchdog.json")`. - Bundle exe is named `dasweb-playground.exe` even on Linux (daspkg convention; CI's sequence smoke test relies on the same). - **Launch with cwd = bundle dir** - a relocated exe's `get_das_root()` degrades to cwd diff --git a/skills/cpp_integration.md b/skills/cpp_integration.md index da8c8f7712..6d12d75e9c 100644 --- a/skills/cpp_integration.md +++ b/skills/cpp_integration.md @@ -338,10 +338,13 @@ module the context links and calls `Module::Initialize()` before constructing it by name - the runtime cannot add to an initialized registry and keep `Initialize`/`Shutdown` balanced. -**Only what the program reaches is linked** - a module the script used at compile time alone is -neither included nor registered. Worked example: `examples/standalone/06_full_runtime/` - read -it for the shape; building it needs the daslang repository, since the bundle carries no dasHV -headers or archive. The recipe above works from a bundle for any C++ module you build yourself. +**Only what the program reaches is linked, and everything it reaches is emitted** - a module the +script used at compile time alone is neither included nor registered, while every used function +of every das module the script requires (daslib, a shared module of your own, class methods +called through their slot) lands in the one generated translation unit; there is no other TU to +link. Worked example: `examples/standalone/06_full_runtime/` - read it for the shape; building +it needs the daslang repository, since the bundle carries no dasHV headers or archive. The recipe +above works from a bundle for any C++ module you build yourself. ## Diagnostics - `TextPrinter`, never `fprintf(stderr, ...)` diff --git a/skills/daslang/references/files-and-paths.md b/skills/daslang/references/files-and-paths.md index db2bcea301..f336613651 100644 --- a/skills/daslang/references/files-and-paths.md +++ b/skills/daslang/references/files-and-paths.md @@ -65,6 +65,27 @@ fopen(path, "rb") $(f) { - `run_and_capture(args, var output, timeout_sec = 0.0) : int` runs a child with no shell, capturing merged stdout+stderr. A forward-slash `args[0]` spawns on every host - the spawn hands Windows the backslash spelling its CreateProcess wants. +- A long-lived child - a supervisor's, a server's - is a `with_process` block over + `spawn_process` / `close_process`. `with_process` is safe; every `process_*` call is `unsafe`: + + ```das + with_process(["/bin/echo", "hi"]) $(var p) { + unsafe { + while (process_drain(p) $(line) { print("{line}\n") }) { sleep(1u) } + print("code {process_wait(p, 5.0)} pid {process_pid(p)}\n") + } + } + ``` + + `with_process(argv)` inherits the parent's directory and environment; `with_process(argv, cwd, + env)` sets both (`cwd` empty inherits, `env` is `KEY=VALUE` overrides) - there is no cwd-only form. + `process_drain` hands over each complete line of merged stdout+stderr ready right now and returns + false at EOF, never blocking; `process_poll(p)` is the exit code or `process_running`; + `process_wait(p, timeout_sec)` the same with a wait; `process_terminate` / `process_kill` signal + the whole tree (a Windows job, a POSIX process group); `process_pid` / `process_alive(pid)` serve + a pid file. A relative `argv[0]` naming a path resolves against the caller's directory. Closing + the handle - leaving the block - kills a child still running. A child's exit code comes from + `def main() : int` - a das `exit(N)` reports 1. ## Mutating operations and their three error forms diff --git a/skills/daspkg.md b/skills/daspkg.md index 814770924b..cf3db59a3d 100644 --- a/skills/daspkg.md +++ b/skills/daspkg.md @@ -195,8 +195,9 @@ def release() { release_name("MyApp") // optional; defaults to package_name() / root dir release_include("data/**") // ship matching files (glob; multiple calls accumulate) release_include("*.png") - release_include_from("utils/watchdog/watchdog.py") // a file OUTSIDE the package - release_include_from("utils/watchdog/watchdog.py", "tools/wd.py") // ... with an explicit dest + release_include_from("dastest/dastest.das") // a file OUTSIDE the package + release_include_from("dastest/dastest.das", "tools/dastest.das") // ... with an explicit dest + release_include_tool("watchdog") // a built tool from the build's bin/, .exe added per platform release_exclude("data/secret/**") release_shared_module("dasSQLITE") // force-include a dylib not auto-detected release_include_symbols() // ship debug symbols into /symbols/ @@ -231,13 +232,18 @@ If a module is loaded only at runtime (e.g. data files read while the .das is ne The bundle is the host platform only. Cross-compilation is deferred until daslang itself supports it; v1 has no platform-tag suffix or auto-archive (`--zip` etc.). Recipients can tar/zip the directory themselves. -### Shipping a file from outside the package - `release_include_from()` +### Shipping files from outside the package - `release_include_from()` / `release_include_tool()` `release_include` globs **downward from the package root**, so it cannot reach shared tooling that lives elsewhere in the tree. `release_include_from(source[, dest])` resolves `source` against `` and copies it to `dest` (relative to the bundle root; defaults to `source`'s file -name, and may name a subdirectory). This is how `utils/dasllama-server` (and the -dictation bot in the das-telegram package) ships the one supervisor in `utils/watchdog/`. +name, and may name a subdirectory). + +A built tool has a platform-dependent name and location - `bin/watchdog`, or the per-config +subdirectory and `.exe` on an MSVC tree - so `release_include_tool("watchdog")` names it once: +daspkg resolves the exe and copies it to the bundle root. This is how +`utils/dasllama-server` (and the dictation bot in the das-telegram package) ships the one +supervisor, `utils/watchdog/`'s static executable. A missing source **fails the release** with exit 1 rather than shipping a bundle quietly short a file - a `release_include` whose target has moved away silently ships nothing, which is the failure diff --git a/skills/internal/aot_testing.md b/skills/internal/aot_testing.md index bde11350c2..6cdd5b68fd 100644 --- a/skills/internal/aot_testing.md +++ b/skills/internal/aot_testing.md @@ -96,11 +96,19 @@ This applies to ALL test directories (e.g., `tests/fio/`, `tests/fs/`, `tests/js **Do NOT use `options no_aot`** to mask a missing CMake registration - register the tests properly instead. -**Exception - a file that genuinely can't AOT** (codegen/emitter bug, interpreted-only by design): use BOTH markers together, each with a comment + issue link: -1. `options no_aot` in the file - makes test_aot's `fail_on_no_aot` skip AOT linking for it at runtime; -2. exclude it from the directory's AOT glob in `tests/aot/CMakeLists.txt` - skips generating stubs that wouldn't compile. - -**Trap:** glob exclusion ALONE is not enough. `test_aot` runs every file under `tests/` regardless of what was stub-generated, so an excluded-but-not-`no_aot` file fails at runtime with `error[50101]` on all its functions (precedent: `tests/fixed_array/test_interop.das`, issue #3077). +**Exception - a file that genuinely can't AOT** (codegen/emitter bug, a process-spawning or +timing test, interpreted-only by design): put `options no_aot` in the file. The AOT build +silently skips such files when it generates stubs, and `test_aot`'s `fail_on_no_aot` skips AOT +linking for the file's own functions at runtime - one marker, both halves. + +**Trap:** a glob exclusion in `tests/aot/CMakeLists.txt` ALONE is not enough. `test_aot` runs every +file under `tests/` regardless of what was stub-generated, so an excluded-but-not-`no_aot` file +fails at runtime with `error[50101]` on all its functions. + +**Trap:** `options no_aot` covers the FILE, not what it requires. A test requiring a module +outside the AOT set (a `utils/` tool's module, say) still fails the link with `error[50101]` on +that module's functions under `test_aot`; gate its directory off under `--use-aot` in +`tests/.das_test` (the `watchdog` folder is the precedent). **Trap - `_`-prefixed fixture MODULES in an existing test dir.** The per-dir AOT globs exclude `_*` files (`EXCLUDE REGEX "/_"`), so a new same-dir fixture module (`require`d by a diff --git a/skills/internal/tests_in_repo.md b/skills/internal/tests_in_repo.md index 0942e700f9..76e7271e0d 100644 --- a/skills/internal/tests_in_repo.md +++ b/skills/internal/tests_in_repo.md @@ -16,13 +16,14 @@ passes PR CI and fails the nightly. Creating a new test directory => register it see `skills/internal/aot_testing.md` sec. "Registering a New Test Directory" for the irregular cases - or the nightly/preflight fails with `error[50101]: AOT link failed`. -If a specific file genuinely can't AOT (emitter bug, interpreted-only by design): put -`options no_aot` IN THE FILE **and** exclude it from the directory's AOT glob, with a -comment + issue link on both. Glob exclusion alone is NOT enough - test_aot still *runs* -the file and trips 50101 on its missing stubs; `options no_aot` is what makes the runtime -skip AOT linking for it. (2026-06-11: in-file `options no_aot` currently fails in the AOT -hash itself - fix incoming on master; until it lands, interp-only tests are gated by the -directory filter below instead.) +If a specific file genuinely can't AOT (emitter bug, a process-spawning or timing test, +interpreted-only by design): put `options no_aot` IN THE FILE. The AOT build silently skips +such files (`tests/aot/CMakeLists.txt`), and the runtime skips AOT linking for the file's own +functions. **It does not skip the modules the file requires**: `test_aot` still runs the file, +and a required module that is not in the AOT set (anything outside `daslib/` and the registered +suites - `utils/watchdog/watchdog.das`, say) fails the link with `error[50101]` on that module's +functions. A test like that is gated off under `--use-aot` by the directory filter below, and +its directory still registers in `DAS_AOT_SUITES` like any other. ## The `tests/.das_test` directory filter - and its root-path caveat diff --git a/src/builtin/module_builtin_fio.cpp b/src/builtin/module_builtin_fio.cpp index 80dfc48d13..69dbc854e6 100644 --- a/src/builtin/module_builtin_fio.cpp +++ b/src/builtin/module_builtin_fio.cpp @@ -19,7 +19,14 @@ #include #include +#ifndef _WIN32 +extern char ** environ; +#endif + #define DAS_POPEN_TIMEOUT 0x7FFFFF01 +// process_poll / process_wait return this while the child is still running: INT32_MIN, a value no +// signal number and no ordinary exit code takes (a Windows process could return 0x80000000 on purpose). +#define DAS_PROCESS_RUNNING (-2147483647-1) MAKE_TYPE_FACTORY(clock, das::Time)// use MAKE_TYPE_FACTORY out of namespace. Some compilers not happy otherwise @@ -235,6 +242,15 @@ namespace das { char * builtin_fs_create_temp_directory ( const char * prefix, char * & error, Context * context, LineInfoArg * at ) GENERATE_IO_STUB int builtin_popen_argv ( const Array & args_arr, float timeout_sec, const TBlock & blk, Context * context, LineInfoArg * at ) GENERATE_IO_STUB int builtin_popen_argv_pipe ( const Array & args_arr, const TBlock & blk, Context * context, LineInfoArg * at ) GENERATE_IO_STUB + DasSubProcess * builtin_spawn_process ( const Array & argv, const char * cwd, const Array & env, Context * context, LineInfoArg * at ) GENERATE_IO_STUB + bool builtin_process_drain ( DasSubProcess * p, const TBlock & blk, Context * context, LineInfoArg * at ) GENERATE_IO_STUB + int builtin_process_poll ( DasSubProcess * p, Context * context, LineInfoArg * at ) GENERATE_IO_STUB + int builtin_process_wait ( DasSubProcess * p, float timeout_sec, Context * context, LineInfoArg * at ) GENERATE_IO_STUB + void builtin_process_terminate ( DasSubProcess * p, Context * context, LineInfoArg * at ) GENERATE_IO_STUB + void builtin_process_kill ( DasSubProcess * p, Context * context, LineInfoArg * at ) GENERATE_IO_STUB + int builtin_process_pid ( DasSubProcess * p, Context * context, LineInfoArg * at ) GENERATE_IO_STUB + bool builtin_process_alive ( int32_t pid, Context * context, LineInfoArg * at ) GENERATE_IO_STUB + void builtin_close_process ( DasSubProcess * p, Context * context, LineInfoArg * at ) GENERATE_IO_STUB void * register_dynamic_module_silent ( const char * path, const char * mod_name, Context * context, LineInfoArg * at ) GENERATE_IO_STUB void for_each_registered_native_path ( const TBlock & block, Context * context, LineInfoArg * at ) GENERATE_IO_STUB void for_each_registered_dynamic_module ( const TBlock & block, Context * context, LineInfoArg * at ) GENERATE_IO_STUB @@ -317,6 +333,7 @@ namespace das { #include #include #include +#include // errno for non-blocking process_drain namespace das { void builtin_sleep ( uint32_t msec ) { @@ -1707,6 +1724,341 @@ namespace das { #endif } + // A long-lived child: spawn once, then poll / drain / signal across many ticks. popen_argv + // is block-scoped and blocks to EOF; this hands back an opaque handle a supervisor drives on + // its own clock. The read end (stdout+stderr merged) is non-blocking so drain never stalls + // the tick; on Windows the child sits in a kill-on-close job object and its group is signalled + // as a tree, on POSIX the child leads its own process group and killpg reaches the tree. + // process_poll / process_wait answer DAS_PROCESS_RUNNING while the child is still alive. + struct DasSubProcess { +#ifdef _WIN32 + HANDLE hProcess = nullptr; + HANDLE hJob = nullptr; + HANDLE hRead = INVALID_HANDLE_VALUE; + DWORD pid = 0; +#else + pid_t pid = -1; + int fd = -1; +#endif + string buf; // partial-line accumulator across drains + bool stdoutOpen = true; + bool reaped = false; + int exitCode = 0; + }; + +#ifdef _WIN32 + static string winBuildEnvBlock ( const Array & env ) { + vector entries; + LPCH base = GetEnvironmentStringsA(); + if ( base ) { + for ( LPCH e = base; *e; e += strlen(e) + 1 ) entries.emplace_back(e); + FreeEnvironmentStringsA(base); + } + char ** ov = (char **) env.data; + for ( uint64_t i = 0; i < env.size; ++i ) { + if ( !ov[i] ) continue; + string entry = ov[i]; + size_t eq = entry.find('='); + string key = eq == string::npos ? entry : entry.substr(0, eq); + for ( auto & e : entries ) { // replace an existing key (case-insensitive) + size_t k = e.find('='); + string ek = k == string::npos ? e : e.substr(0, k); + if ( ek.size() == key.size() && _stricmp(ek.c_str(), key.c_str()) == 0 ) { e.clear(); break; } + } + entries.push_back(entry); + } + string block; + for ( auto & e : entries ) { if ( e.empty() ) continue; block.append(e); block.push_back('\0'); } + block.push_back('\0'); // the block ends in a second NUL + return block; + } +#endif + + DasSubProcess * builtin_spawn_process ( const Array & argv_arr, const char * cwd, const Array & env, + Context * context, LineInfoArg * at ) { + if ( argv_arr.size == 0 ) { + context->throw_error_at(at, "spawn_process with empty argv"); + return nullptr; + } + char ** argv = (char **) argv_arr.data; + if ( !argv[0] ) { + context->throw_error_at(at, "spawn_process with null exe"); + return nullptr; + } + bool hasCwd = cwd && *cwd; +#ifdef _WIN32 + SECURITY_ATTRIBUTES sa; + sa.nLength = sizeof(SECURITY_ATTRIBUTES); + sa.bInheritHandle = TRUE; + sa.lpSecurityDescriptor = NULL; + HANDLE hRead = NULL, hWrite = NULL; + if ( !CreatePipe(&hRead, &hWrite, &sa, 0) ) { + context->throw_error_at(at, "spawn_process: CreatePipe failed"); + return nullptr; + } + SetHandleInformation(hRead, HANDLE_FLAG_INHERIT, 0); // parent's read end stays in-process + HANDLE hNull = CreateFileA("NUL", GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE, &sa, + OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL); + HANDLE hJob = CreateJobObjectA(NULL, NULL); + if ( hJob ) { + JOBOBJECT_EXTENDED_LIMIT_INFORMATION jeli; + memset(&jeli, 0, sizeof(jeli)); + jeli.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + SetInformationJobObject(hJob, JobObjectExtendedLimitInformation, &jeli, sizeof(jeli)); + } + STARTUPINFOA si; + memset(&si, 0, sizeof(si)); + si.cb = sizeof(si); + si.dwFlags = STARTF_USESTDHANDLES; + si.hStdInput = (hNull == INVALID_HANDLE_VALUE) ? NULL : hNull; + si.hStdOutput = hWrite; + si.hStdError = hWrite; + string cmdLine = winBuildCommandLine(argv, argv_arr.size); + string envBlock; + LPVOID lpEnv = NULL; + if ( env.size ) { envBlock = winBuildEnvBlock(env); lpEnv = (LPVOID)&envBlock[0]; } + PROCESS_INFORMATION pi; + memset(&pi, 0, sizeof(pi)); + vector cmdBuf(cmdLine.begin(), cmdLine.end()); // CreateProcess may write into the command line + cmdBuf.push_back('\0'); + BOOL ok = CreateProcessA(NULL, cmdBuf.data(), NULL, NULL, TRUE, + CREATE_NO_WINDOW | CREATE_SUSPENDED, lpEnv, hasCwd ? cwd : NULL, &si, &pi); + CloseHandle(hWrite); + if ( hNull != INVALID_HANDLE_VALUE ) CloseHandle(hNull); + if ( !ok ) { + CloseHandle(hRead); + if ( hJob ) CloseHandle(hJob); + context->throw_error_at(at, "spawn_process: CreateProcess failed"); + return nullptr; + } + if ( hJob && !AssignProcessToJobObject(hJob, pi.hProcess) ) { + CloseHandle(hJob); // a job we cannot assign (a restrictive parent job) must not shadow hProcess + hJob = NULL; + } + ResumeThread(pi.hThread); + CloseHandle(pi.hThread); + DasSubProcess * p = new DasSubProcess(); + p->hProcess = pi.hProcess; + p->hJob = hJob; + p->hRead = hRead; + p->pid = pi.dwProcessId; + return p; +#else + vector cargv; + cargv.reserve(argv_arr.size + 1); + for ( uint64_t i = 0; i < argv_arr.size; ++i ) cargv.push_back(argv[i] ? argv[i] : (char *)""); + cargv.push_back(nullptr); + // A relative argv[0] that names a path (has a '/') resolves against the caller's directory, + // not the child's cwd - so make it absolute before the child chdir's, matching Windows, where + // CreateProcess already searches the exe from the parent's directory rather than lpCurrentDirectory. + // A bare name (no '/') is a PATH lookup, which chdir does not affect - leave it alone. + string absExe; + if ( hasCwd && cargv[0][0] && cargv[0][0] != '/' && strchr(cargv[0], '/') ) { + char cwdbuf[4096]; + if ( getcwd(cwdbuf, sizeof(cwdbuf)) ) { + absExe = string(cwdbuf) + "/" + cargv[0]; + cargv[0] = (char *)absExe.c_str(); + } + } + int pipefd[2]; + if ( pipe(pipefd) == -1 ) { + context->throw_error_at(at, "spawn_process: pipe failed"); + return nullptr; + } + // the child's environment, composed here: the parent's entries minus the overridden keys, + // then the overrides (the eastl build poisons putenv/setenv, and execvpe is Linux-only) + char ** ov = (char **) env.data; + vector cenv; + if ( env.size ) { + for ( char ** e = environ; e && *e; ++e ) { + const char * eq = strchr(*e, '='); + size_t klen = eq ? (size_t)(eq - *e) : strlen(*e); + bool overridden = false; + for ( uint64_t i = 0; i < env.size && !overridden; ++i ) { + overridden = ov[i] && strncmp(ov[i], *e, klen) == 0 && ov[i][klen] == '='; + } + if ( !overridden ) cenv.push_back(*e); + } + for ( uint64_t i = 0; i < env.size; ++i ) if ( ov[i] ) cenv.push_back(ov[i]); + cenv.push_back(nullptr); + } + pid_t pid = fork(); + if ( pid == -1 ) { + close(pipefd[0]); + close(pipefd[1]); + context->throw_error_at(at, "spawn_process: fork failed"); + return nullptr; + } + if ( pid == 0 ) { + close(pipefd[0]); + int devnull = open("/dev/null", O_RDONLY); + if ( devnull >= 0 ) { dup2(devnull, STDIN_FILENO); close(devnull); } + dup2(pipefd[1], STDOUT_FILENO); + dup2(pipefd[1], STDERR_FILENO); + close(pipefd[1]); + setpgid(0, 0); // lead a group so killpg reaches the tree + if ( hasCwd && chdir(cwd) != 0 ) _exit(127); + if ( env.size ) environ = cenv.data(); + execvp(cargv[0], cargv.data()); + _exit(127); + } + close(pipefd[1]); + fcntl(pipefd[0], F_SETFL, O_NONBLOCK); // drain never blocks the tick + DasSubProcess * p = new DasSubProcess(); + p->pid = pid; + p->fd = pipefd[0]; + return p; +#endif + } + + bool builtin_process_drain ( DasSubProcess * p, const TBlock & blk, + Context * context, LineInfoArg * at ) { + if ( !p ) { context->throw_error_at(at, "process_drain on null process"); return false; } + if ( p->stdoutOpen ) { + char tmp[4096]; +#ifdef _WIN32 + for ( ;; ) { + DWORD avail = 0; + if ( !PeekNamedPipe(p->hRead, NULL, 0, NULL, &avail, NULL) ) { p->stdoutOpen = false; break; } + if ( avail == 0 ) break; + DWORD toRead = avail > sizeof(tmp) ? (DWORD)sizeof(tmp) : avail; + DWORD got = 0; + if ( !ReadFile(p->hRead, tmp, toRead, &got, NULL) || got == 0 ) { p->stdoutOpen = false; break; } + p->buf.append(tmp, got); + } +#else + for ( ;; ) { + ssize_t n = read(p->fd, tmp, sizeof(tmp)); + if ( n > 0 ) { p->buf.append(tmp, (size_t)n); continue; } + if ( n == 0 ) { p->stdoutOpen = false; break; } // EOF: child closed stdout + if ( errno == EAGAIN || errno == EWOULDBLOCK ) break; // nothing ready this tick + p->stdoutOpen = false; break; // real read error + } +#endif + } + size_t start = 0, nl; + while ( (nl = p->buf.find('\n', start)) != string::npos ) { + string line = p->buf.substr(start, nl - start); + if ( !line.empty() && line.back() == '\r' ) line.pop_back(); + char * s = context->allocateString(line.data(), (uint32_t)line.size(), at); + vec4f cargs[1]; cargs[0] = cast::from(s); + context->invoke(blk, cargs, nullptr, at); + start = nl + 1; + } + p->buf.erase(0, start); + if ( !p->stdoutOpen && !p->buf.empty() ) { // a last line with no newline + string line = p->buf; + if ( !line.empty() && line.back() == '\r' ) line.pop_back(); + char * s = context->allocateString(line.data(), (uint32_t)line.size(), at); + vec4f cargs[1]; cargs[0] = cast::from(s); + context->invoke(blk, cargs, nullptr, at); + p->buf.clear(); + } + return p->stdoutOpen; + } + + int builtin_process_poll ( DasSubProcess * p, Context * context, LineInfoArg * at ) { + if ( !p ) { context->throw_error_at(at, "process_poll on null process"); return DAS_PROCESS_RUNNING; } + if ( p->reaped ) return p->exitCode; +#ifdef _WIN32 + if ( WaitForSingleObject(p->hProcess, 0) == WAIT_TIMEOUT ) return DAS_PROCESS_RUNNING; + DWORD code = 0; GetExitCodeProcess(p->hProcess, &code); + p->reaped = true; p->exitCode = (int)code; return p->exitCode; +#else + int status = 0; + pid_t r = waitpid(p->pid, &status, WNOHANG); + if ( r == 0 ) return DAS_PROCESS_RUNNING; + p->reaped = true; + p->exitCode = r < 0 ? -1 + : WIFEXITED(status) ? WEXITSTATUS(status) : WIFSIGNALED(status) ? WTERMSIG(status) : status; + return p->exitCode; +#endif + } + + int builtin_process_wait ( DasSubProcess * p, float timeout_sec, Context * context, LineInfoArg * at ) { + if ( !p ) { context->throw_error_at(at, "process_wait on null process"); return DAS_PROCESS_RUNNING; } + if ( p->reaped ) return p->exitCode; +#ifdef _WIN32 + DWORD ms = timeout_sec <= 0.0f ? INFINITE : (DWORD)(timeout_sec * 1000.0f); + if ( WaitForSingleObject(p->hProcess, ms) == WAIT_TIMEOUT ) return DAS_PROCESS_RUNNING; + DWORD code = 0; GetExitCodeProcess(p->hProcess, &code); + p->reaped = true; p->exitCode = (int)code; return p->exitCode; +#else + auto deadline = std::chrono::steady_clock::now() + + std::chrono::milliseconds((int)(timeout_sec * 1000.0f)); + for ( ;; ) { + int status = 0; + pid_t r = waitpid(p->pid, &status, WNOHANG); + if ( r > 0 ) { + p->reaped = true; + p->exitCode = WIFEXITED(status) ? WEXITSTATUS(status) + : WIFSIGNALED(status) ? WTERMSIG(status) : status; + return p->exitCode; + } + if ( r < 0 ) { p->reaped = true; p->exitCode = -1; return -1; } + if ( timeout_sec > 0.0f && std::chrono::steady_clock::now() >= deadline ) + return DAS_PROCESS_RUNNING; + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } +#endif + } + + void builtin_process_terminate ( DasSubProcess * p, Context * context, LineInfoArg * at ) { + if ( !p ) { context->throw_error_at(at, "process_terminate on null process"); return; } +#ifdef _WIN32 + if ( p->hJob ) TerminateJobObject(p->hJob, 15); + else if ( p->hProcess ) TerminateProcess(p->hProcess, 15); +#else + killpg(p->pid, SIGTERM); +#endif + } + + void builtin_process_kill ( DasSubProcess * p, Context * context, LineInfoArg * at ) { + if ( !p ) { context->throw_error_at(at, "process_kill on null process"); return; } +#ifdef _WIN32 + if ( p->hJob ) TerminateJobObject(p->hJob, 9); + else if ( p->hProcess ) TerminateProcess(p->hProcess, 9); +#else + killpg(p->pid, SIGKILL); +#endif + } + + int builtin_process_pid ( DasSubProcess * p, Context * context, LineInfoArg * at ) { + if ( !p ) { context->throw_error_at(at, "process_pid on null process"); return 0; } + return (int)p->pid; + } + + bool builtin_process_alive ( int32_t pid, Context *, LineInfoArg * ) { + if ( pid <= 0 ) return false; +#ifdef _WIN32 + HANDLE h = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, (DWORD)pid); + if ( !h ) return false; + DWORD code = 0; BOOL ok = GetExitCodeProcess(h, &code); + CloseHandle(h); + return ok && code == STILL_ACTIVE; +#else + // signal 0 probes without delivering; EPERM means it exists but is not ours to signal. + if ( ::kill((pid_t)pid, 0) == 0 ) return true; + return errno == EPERM; +#endif + } + + void builtin_close_process ( DasSubProcess * p, Context *, LineInfoArg * ) { + if ( !p ) return; +#ifdef _WIN32 + if ( p->hRead && p->hRead != INVALID_HANDLE_VALUE ) CloseHandle(p->hRead); + if ( p->hProcess ) CloseHandle(p->hProcess); + if ( p->hJob ) CloseHandle(p->hJob); // kill-on-close reaps a still-running tree +#else + if ( p->fd >= 0 ) close(p->fd); + if ( !p->reaped ) { // never leave a zombie + int status = 0; + if ( waitpid(p->pid, &status, WNOHANG) == 0 ) { killpg(p->pid, SIGKILL); waitpid(p->pid, &status, 0); } + } +#endif + delete p; + } + int builtin_system ( const char * cmd, Context * context, LineInfoArg * at ) { if ( !cmd ) { context->throw_error_at(at, "system of null"); @@ -2456,6 +2808,7 @@ namespace das { MAKE_TYPE_FACTORY(FStat, das::FStat) MAKE_TYPE_FACTORY(FILE,FILE) +MAKE_TYPE_FACTORY(SubProcess, das::DasSubProcess) MAKE_TYPE_FACTORY(DiskSpaceInfo, das::DiskSpaceInfo) namespace das { @@ -2497,6 +2850,7 @@ namespace das { addBuiltinDependency(lib, Module::require("strings")); // type addAnnotation(new DummyTypeAnnotation("FILE", "FILE", 16, 16)); + addAnnotation(new DummyTypeAnnotation("SubProcess", "das::DasSubProcess", sizeof(void *), alignof(void *))); addAnnotation(new FStatAnnotation(lib)); // seek constants addConstant(*this, "seek_set", SEEK_SET); @@ -2674,6 +3028,35 @@ namespace das { SideEffects::modifyExternal, "builtin_popen_argv_pipe") ->args({"args","scope","context","at"})->unsafeOperation = true; addConstant(*this, "popen_timed_out", DAS_POPEN_TIMEOUT); + // long-lived child process (spawn once, poll/drain/signal across ticks) + addExtern(*this, lib, "spawn_process", + SideEffects::modifyExternal, "builtin_spawn_process") + ->args({"argv","cwd","env","context","at"})->unsafeOperation = true; + addExtern(*this, lib, "process_drain", + SideEffects::modifyExternal, "builtin_process_drain") + ->args({"process","block","context","at"})->unsafeOperation = true; + addExtern(*this, lib, "process_poll", + SideEffects::modifyExternal, "builtin_process_poll") + ->args({"process","context","at"})->unsafeOperation = true; + addExtern(*this, lib, "process_wait", + SideEffects::modifyExternal, "builtin_process_wait") + ->args({"process","timeout","context","at"})->unsafeOperation = true; + addExtern(*this, lib, "process_terminate", + SideEffects::modifyExternal, "builtin_process_terminate") + ->args({"process","context","at"})->unsafeOperation = true; + addExtern(*this, lib, "process_kill", + SideEffects::modifyExternal, "builtin_process_kill") + ->args({"process","context","at"})->unsafeOperation = true; + addExtern(*this, lib, "process_pid", + SideEffects::accessExternal, "builtin_process_pid") + ->args({"process","context","at"})->unsafeOperation = true; + addExtern(*this, lib, "process_alive", + SideEffects::accessExternal, "builtin_process_alive") + ->args({"pid","context","at"})->unsafeOperation = true; + addExtern(*this, lib, "close_process", + SideEffects::modifyExternal, "builtin_close_process") + ->args({"process","context","at"})->unsafeOperation = true; + addConstant(*this, "process_running", DAS_PROCESS_RUNNING); addExtern(*this, lib, "system", SideEffects::modifyExternal, "builtin_system") ->args({"command","context","at"})->unsafeOperation = true; diff --git a/tests/.das_test b/tests/.das_test index 2f5edab403..91fe55251f 100644 --- a/tests/.das_test +++ b/tests/.das_test @@ -13,6 +13,22 @@ def can_visit_folder(folder_name : string; var result : bool?) { *result = has_module("dashv") return } + // the supervisor polls health over dasHV's client; its tests are no_aot (they spawn and + // time children), and the utils/watchdog module they require is not in the AOT set, so + // the AOT sweep would still fail the link on that module - interp and JIT only + if (folder_name == "watchdog") { + *result = has_module("dashv") + if (*result) { + let args <- get_command_line_arguments() + for (arg in args) { + if (arg == "--use-aot") { + *result = false + return + } + } + } + return + } if (folder_name == "dasPUGIXML") { *result = has_module("pugixml") return diff --git a/tests/aot/CMakeLists.txt b/tests/aot/CMakeLists.txt index 3e1a08d586..b9833b31ff 100644 --- a/tests/aot/CMakeLists.txt +++ b/tests/aot/CMakeLists.txt @@ -273,9 +273,6 @@ FILE(GLOB AOT_METAL_FILES RELATIVE ${PROJECT_SOURCE_DIR} CONFIGURE_DEPENDS "test list(FILTER AOT_METAL_FILES EXCLUDE REGEX "/_") SET(AOT_METAL_MODULE_FILES tests/metal/_metal_common.das - # f16_cvt is a generic daslib helper the tensor-ops test requires; it used to ride - # the (deleted) dasLLAMA module lib — dasLLAMA itself is NEVER AOT'd (see above) - daslib/f16_cvt.das ) IF(APPLE) # das_metal_boost hard-requires the APPLE-gated das_metal C++ module; the GPU-behavioral @@ -290,6 +287,7 @@ SET(AOT_DASLIB_FILES tests/daslib/tty_test.das tests/daslib/test_sha_256.das tests/daslib/test_faker.das + tests/daslib/test_tune_shells.das ) FILE(GLOB AOT_MCP_FILES RELATIVE ${PROJECT_SOURCE_DIR} CONFIGURE_DEPENDS "tests/mcp/*.das") @@ -391,7 +389,7 @@ set(DAS_AOT_SUITES jobque json jsonrpc language linq lint long_array_table loops lpipe lsp macro_boost macro_call match math mcp md_boost module_cache module_tests option promote quote reader_macro regex rtti safe_addr soa spoof strings stbimage table_packed template - tests type_lattice type_traits typemacro uri with_boost delegate) + tests type_lattice type_traits typemacro uri watchdog with_boost delegate) foreach(_s IN LISTS DAS_AOT_SUITES) string(TOUPPER ${_s} _u) # suites with an irregular dir / a filter / a curated list define AOT__FILES above; diff --git a/tests/aot/_standalone_dep_call.das b/tests/aot/_standalone_dep_call.das new file mode 100644 index 0000000000..1ab1bbe45d --- /dev/null +++ b/tests/aot/_standalone_dep_call.das @@ -0,0 +1,17 @@ +options gen2 + +module _standalone_dep_call public + +require daslib/fio + +// A required module whose FUNCTIONS the entry module calls at run time - a plain one that reaches +// a C++ extern only from here, and a class method dispatched through its Func slot. +def dep_probe(x : int) : int { + return x + length(getcwd()) +} + +class DepProbe { + def method(x : int) : int { + return x * 2 + } +} diff --git a/tests/aot/_standalone_foreign_call_fixture.das b/tests/aot/_standalone_foreign_call_fixture.das new file mode 100644 index 0000000000..12104e9a13 --- /dev/null +++ b/tests/aot/_standalone_foreign_call_fixture.das @@ -0,0 +1,13 @@ +options gen2 + +require _standalone_dep_call + +[export] +def call_foreign(x : int) : int { + var probe = new DepProbe() + let result = dep_probe(x) + probe->method(x) + unsafe { + delete probe + } + return result +} diff --git a/tests/aot/test_standalone_emit.das b/tests/aot/test_standalone_emit.das index 2f6e050b36..6b7e9479ae 100644 --- a/tests/aot/test_standalone_emit.das +++ b/tests/aot/test_standalone_emit.das @@ -164,6 +164,32 @@ def test_standalone_emit(t : T?) { // nolint:STYLE038 - flat list of emit subc t |> success(find(files.source, "static_assert(sizeof(DepSpan)") < 0, "the source does not redefine the required module's struct") } + t |> run("a required module's used functions emit into the one translation unit, and link their externs' modules") @(t : T?) { + let source = generate_standalone_source(t, "_standalone_foreign_call_fixture", out_dir) + t |> success(find(source, "// @_standalone_dep_call::dep_probe ") >= 0, "the foreign function has a row in the function table") + t |> success(find(source, "// @_standalone_dep_call::DepProbe`method ") >= 0, "the foreign class method has a row: it dispatches through its Func slot") + t |> success(find(source, "__cwd_rename_at") >= 0 || find(source, "builtin_getcwd") >= 0, "the foreign function's body is emitted, not just declared") + t |> success(find(source, "\"fio_core\", &::register_Module_FIO") >= 0, "an extern reached only from the foreign function still registers its module") + // the ctor sizes context.functions by one count and fills one info row per function; with + // foreign rows in play the two are computed separately and must agree + let alloc_marker = "context.code->allocate( " + let alloc_at = find(source, alloc_marker) + t |> success(alloc_at >= 0, "the ctor allocates the function table") + if (alloc_at >= 0) { + let after = slice(source, alloc_at + length(alloc_marker), length(source)) + let declared = to_int(slice(after, 0, find(after, "/*"))) + var rows = 0 + var at = find(source, ", FunctionInfo(\"") + while (at >= 0) { + rows++ + at = find(source, ", FunctionInfo(\"", at + 1) + } + t |> equal(rows, declared, "the table is sized by exactly the rows it fills, foreign rows included") + t |> success(rows >= 4, "the foreign function, its class's method, ctor and finalizer are rows: {rows}") + } + t |> equal(brace_balance(source), 0, "unbalanced braces in the generated C++") + } + t |> run("struct and enum signatures put the type definitions in the header") @(t : T?) { let files = generate_standalone_files(t, "_standalone_struct_sig_fixture", out_dir) t |> success(find(files.header, "static_assert(sizeof(Pair)") >= 0, "the struct definition is in the header") diff --git a/tests/fio/_fixture_process_child.das b/tests/fio/_fixture_process_child.das new file mode 100644 index 0000000000..77ffdde1c9 --- /dev/null +++ b/tests/fio/_fixture_process_child.das @@ -0,0 +1,32 @@ +options gen2 +options no_aot + +require daslib/clargs +require daslib/fio + +// Child for test_process.das: prints a few known lines (env override, working directory, two +// plain lines), signals ready, waits for the parent's release event, then exits with code 7. +// Codes come back from main - a das `exit()` unwinds as an abnormal termination and the CLI +// reports 1, so it cannot carry a code. The elapsed-time check is only a deadlock guard. +[export] +def main() : int { + let args <- get_cli_arguments() + if (length(args) != 2) return 2 + let ready = args[0] + let release = args[1] + var env_val = "" + if (has_env_variable("DAS_PROC_TEST")) { + env_val = get_env_variable("DAS_PROC_TEST") + } + print("env={env_val}\n") + print("cwd={base_name(getcwd())}\n") + print("hello one\n") + print("hello two\n") + if (!fwrite(ready, "ready")) return 3 + let started = ref_time_ticks() + while (!fexist(release)) { + if (get_time_usec(started) > 30000000) return 4 + sleep(5u) + } + return 7 +} diff --git a/tests/fio/test_process.das b/tests/fio/test_process.das new file mode 100644 index 0000000000..a9fa4916c1 --- /dev/null +++ b/tests/fio/test_process.das @@ -0,0 +1,155 @@ +options gen2 +options no_aot + +require dastest/testing_boost public +require daslib/fio + +// argv[0] is the running interpreter (dastest is launched as `daslang dastest/dastest.das ...`), +// so it spawns the daslang we want the child to run. Same trick as popen_argv.das. +def das_exe() : string { + let args <- get_command_line_arguments() + return empty(args) ? "" : args[0] +} + +def has_line(lines : array; s : string) : bool { + for (l in lines) { + if (l == s) return true + } + return false +} + +def child_argv(fixture, ready, release : string) : array { + return [das_exe(), "-dasroot", get_das_root(), fixture, "--", ready, release] +} + +def make_temp_root(t : T?; prefix : string) : string { + let tmp = create_temp_directory_result(prefix) + if (!(tmp is value)) { + t |> failure("could not create temp directory: {tmp as error}") + return "" + } + return tmp as value +} + +// drain until the child's ready file appears; the file event drives it, the clock only guards +def wait_for_ready(t : T?; var p : process; ready : string) { + let started = ref_time_ticks() + while (!fexist(ready)) { + unsafe(process_drain(p) $(_line) {}) + if (get_time_usec(started) > 15000000) { + t |> failure("child never signaled ready") + return + } + sleep(5u) + } +} + +[test] +def test_process_lifecycle(t : T?) { + t |> run("spawn, drain lines, poll running, wait exit code, apply env + cwd") @(t : T?) { + let tmp = create_temp_directory_result("das process test ") + if (!(tmp is value)) { + t |> failure("could not create temp directory: {tmp as error}") + return + } + let root_dir = tmp as value + let ready = path_join(root_dir, "ready") + let release = path_join(root_dir, "release") + let fixture = path_join(get_das_root(), "tests/fio/_fixture_process_child.das") + var p = unsafe(spawn_process(child_argv(fixture, ready, release), root_dir, ["DAS_PROC_TEST=marker42"])) + t |> success(p != null, "spawn_process returned a handle") + // Drain the child's stdout until it signals ready; the file event drives completion, + // the elapsed check is only a guard. + var lines : array + let started = ref_time_ticks() + while (!fexist(ready)) { + unsafe(process_drain(p) $(line) { + lines |> push(clone_string(line)) + }) + if (get_time_usec(started) > 15000000) { + t |> failure("child never signaled ready") + break + } + sleep(5u) + } + unsafe(process_drain(p) $(line) { + lines |> push(clone_string(line)) + }) + t |> success(has_line(lines, "env=marker42"), "env override reached the child: {lines}") + t |> success(has_line(lines, "cwd={base_name(root_dir)}"), "cwd applied to the child: {lines}") + t |> success(has_line(lines, "hello one") && has_line(lines, "hello two"), + "both stdout lines drained one per line: {lines}") + t |> equal(unsafe(process_poll(p)), process_running, "child is still running while parked on release") + let pid = unsafe(process_pid(p)) + t |> success(pid > 0, "pid is positive: {pid}") + t |> success(unsafe(process_alive(pid)), "process_alive is true while running") + t |> success(fwrite(release, "go"), "wrote the release event") + t |> equal(unsafe(process_wait(p, 5.0)), 7, "child exited with its own code") + t |> success(!unsafe(process_alive(pid)), "process_alive is false after exit") + unsafe(close_process(p)) + let cleanup = rmdir_rec_result(root_dir) + t |> success(cleanup is value && cleanup as value, "temp dir removed") + } +} + +[test] +def test_close_kills_a_running_child(t : T?) { + t |> run("closing the handle - explicitly, or leaving a with_process block - kills a child still running") @(t : T?) { + let root_dir = make_temp_root(t, "das process close ") + if (empty(root_dir)) return + let fixture = path_join(get_das_root(), "tests/fio/_fixture_process_child.das") + let noenv : array + var explicit_pid = 0 + var p = unsafe(spawn_process(child_argv(fixture, path_join(root_dir, "ready1"), path_join(root_dir, "never1")), "", noenv)) + wait_for_ready(t, p, path_join(root_dir, "ready1")) + explicit_pid = unsafe(process_pid(p)) + t |> success(unsafe(process_alive(explicit_pid)), "the child is alive before close") + unsafe(close_process(p)) + t |> success(!unsafe(process_alive(explicit_pid)), "close_process killed the running child") + var scoped_pid = 0 + with_process(child_argv(fixture, path_join(root_dir, "ready2"), path_join(root_dir, "never2"))) $(var q) { + wait_for_ready(t, q, path_join(root_dir, "ready2")) + scoped_pid = unsafe(process_pid(q)) + t |> success(unsafe(process_alive(scoped_pid)), "the child is alive inside the block") + } + t |> success(!unsafe(process_alive(scoped_pid)), "leaving the with_process block killed the running child") + let cleanup = rmdir_rec_result(root_dir) + t |> success(cleanup is value && cleanup as value, "temp dir removed") + } +} + +[test] +def test_process_terminate(t : T?) { + t |> run("terminate stops a child parked on a release that never comes") @(t : T?) { + let tmp = create_temp_directory_result("das process kill ") + if (!(tmp is value)) { + t |> failure("could not create temp directory: {tmp as error}") + return + } + let root_dir = tmp as value + let ready = path_join(root_dir, "ready") + let release = path_join(root_dir, "release") // deliberately never written + let fixture = path_join(get_das_root(), "tests/fio/_fixture_process_child.das") + let noenv : array + var p = unsafe(spawn_process(child_argv(fixture, ready, release), "", noenv)) + t |> success(p != null, "spawn_process returned a handle") + let started = ref_time_ticks() + while (!fexist(ready)) { + unsafe(process_drain(p) $(_line) {}) + if (get_time_usec(started) > 15000000) { + t |> failure("child never signaled ready") + break + } + sleep(5u) + } + let pid = unsafe(process_pid(p)) + t |> equal(unsafe(process_wait(p, 0.2)), process_running, "wait answers process_running when the timeout passes first") + unsafe(process_terminate(p)) + let rc = unsafe(process_wait(p, 5.0)) + t |> success(rc != process_running, "child stopped after terminate (rc={rc})") + t |> success(!unsafe(process_alive(pid)), "process_alive is false after terminate") + unsafe(close_process(p)) + let cleanup = rmdir_rec_result(root_dir) + t |> success(cleanup is value && cleanup as value, "temp dir removed") + } +} diff --git a/tests/watchdog/_fixture_watchdog_child.das b/tests/watchdog/_fixture_watchdog_child.das new file mode 100644 index 0000000000..5c1d4af7d7 --- /dev/null +++ b/tests/watchdog/_fixture_watchdog_child.das @@ -0,0 +1,69 @@ +options gen2 +options no_aot + +require daslib/clargs +require daslib/fio + +// The supervised child for test_watchdog.das. `state_dir mode`: a run counter in state_dir +// tells the child which launch it is, and `mode` picks the story it acts out: +// crash-then-ok run 1 prints the ready line and exits 9; run 2 prints it and exits 0 +// tune-then-ok run 1 prints the tune marker and exits 3; run 2 exits 0 +// config-restart run 1 exits 4; run 2 exits 0 +// tune-abort run 1 exits 3 with no restart marker; run 2 exits 0 +// park prints a line, waits for the stop file named by CADMUS_STOP_FILE (or, with +// none named, to be terminated), exits 0 +// Codes return from main: a das exit() is an abnormal termination and reports 1. +def run_number(state_dir : string) : int { + let path = path_join(state_dir, "runs") + var runs = 0 + if (fexist(path)) { + fopen(path, "rb") $(f) { + if (f != null) { + runs = to_int(fread(f)) + } + } + } + runs++ + fwrite(path, "{runs}") + return runs +} + +[export] +def main() : int { + let args <- get_cli_arguments() + if (length(args) != 2) return 2 + let run = run_number(args[0]) + let mode = args[1] + if (mode == "crash-then-ok") { + print("[I] LLVM JIT: DLL cache hit .jitted_scripts/sub/fake.dll\n") + print("listening on http://127.0.0.1:1/\n") + return run == 1 ? 9 : 0 + } + if (mode == "tune-then-ok") { + if (run == 1) { + print("llvm_tune: tuning scope 'fixture'\n") + print("llvm_tune: restart to apply the winners\n") + return 3 + } + return 0 + } + if (mode == "config-restart") { + return run == 1 ? 4 : 0 + } + if (mode == "tune-abort") { + return run == 1 ? 3 : 0 + } + if (mode == "park") { + print("parked\n") + // parks until the stop file the supervisor named appears; with none named it parks until + // terminated (the deadlock guard is the only other way out) + let stop_file = has_env_variable("CADMUS_STOP_FILE") ? get_env_variable("CADMUS_STOP_FILE") : "" + let started = ref_time_ticks() + while (empty(stop_file) || !fexist(stop_file)) { + if (get_time_usec(started) > 30000000) return 6 + sleep(5u) + } + return 0 + } + return 7 +} diff --git a/tests/watchdog/test_watchdog.das b/tests/watchdog/test_watchdog.das new file mode 100644 index 0000000000..a5984b5416 --- /dev/null +++ b/tests/watchdog/test_watchdog.das @@ -0,0 +1,483 @@ +options gen2 +options no_aot + +require dastest/testing_boost public +require daslib/fio +require daslib/json_boost +require daslib/strings_boost +require ../../utils/watchdog/watchdog.das + +// argv[0] is the daslang running dastest; the supervised "program" is that binary running the +// fixture, so program mode and script mode both spawn a real daslang child. Absolute, because a +// relative --program resolves against --cwd, which is a temp dir here. +def das_exe() : string { + let args <- get_command_line_arguments() + return empty(args) ? "" : get_full_file_name(args[0]) +} + +def fixture_path() : string { + return path_join(get_das_root(), "tests/watchdog/_fixture_watchdog_child.das") +} + +def make_temp(t : T?; prefix : string) : string { + let tmp = create_temp_directory_result(prefix) + if (!(tmp is value)) { + t |> failure("could not create temp directory: {tmp as error}") + return "" + } + return tmp as value +} + +def read_events(log_path : string) : array { + var events : array + if (!fexist(log_path)) return <- events + var text : string + fopen(log_path, "rb") $(f) { + if (f != null) { + text = fread(f) + } + } + for (line in split(text, "\n")) { + if (empty(strip(line))) continue + var error : string + var js = read_json(line, error) + if (js != null) { + events |> push(js) + } + } + return <- events +} + +def event_names(events : array) : array { + return <- [for (ev in events); ev?["event"] ?? ""] +} + +def count_event(events : array; name : string) : int { + var n = 0 + for (ev in events) { + if ((ev?["event"] ?? "") == name) { + n++ + } + } + return n +} + +def event_int(events : array; name, key : string; nth : int = 0) : int { + var seen = 0 + for (ev in events) { + if ((ev?["event"] ?? "") != name) continue + if (seen == nth) return int(ev?[key] ?? -999999l) + seen++ + } + return -999999 +} + +def child_args(state_dir, mode : string) : array { + return ["-dasroot", get_das_root(), fixture_path(), "--", state_dir, mode] +} + +def program_mode_args(root, state_dir, mode : string) : array { + var args <- ["--program", das_exe(), "--name", "wdtest", "--cwd", root, "--no-health", + "--stable-seconds", "0.1", "--max-restart-delay", "0.5", "--"] + args |> push_from(child_args(state_dir, mode)) + return <- args +} + +// A supervision that never ends fails the test instead of hanging the sweep: tick with a +// deadline, and past it stop the supervisor and name the events it logged. +def run_bounded(t : T?; var sup : Supervisor?; seconds : float) : int { + let started = ref_time_ticks() + while (!sup->tick()) { + if (float(get_time_usec(started)) > seconds * 1000000.0) { + t |> failure("supervision did not end within {seconds} s: {event_names(read_events(sup.cfg.log))}") + sup->request_stop() + let grace = ref_time_ticks() + while (!sup->tick() && get_time_usec(grace) < 30000000) { + sleep(50u) + } + break + } + sleep(10u) + } + return sup.result +} + +// Script mode is `daslang -jit