Repository navigation
module load / compiler fixes - #490
Conversation
bc66051 to
f91eeae
Compare
|
Need to look into this in more detail but I am not entirely happy with the direction this goes into. We keep piling up complexity (introducing new caches, fstats, pid based comparisons, different load types, ...). My gut feeling is that we can vastly simplify all that if we stop retaining open fds on sources. Keeping a handle on running sources was an ad-hoc decision at some point to ensure that source text remains accessible for error reporting purposes in case the source file has been removed in the meanwhile, but this is just a cosmetic corner case. Maybe we should simply attempt to reopen the source file by path when we want to extract source context and accept the possibility of a missing or mismatching file or a failing fopen. |
|
If you want, I can rework this PR along that angle |
|
If you don't mind, please do - at least to gauge whether I missed some vital detail or whether it brings substantial simplifications after all. |
|
Now that I think about it, this does add some overhead, because it means that the file has to be re-opened on every exception, even if handled. Do you think the memory overhead of caching the source text would be too big? Because that would allow us to sidestep the fd issue entirely. |
|
Yes, I believe the heap memory overhead would not be tolerable (think large, auto generated files etc.). Is the file reopen overhead that severe? In doubt I'd trade speed for memory. |
|
I guess people shouldn't use exceptions excessively anyway, so let's ignore the overhead. I'll push a reworked set soon. |
f91eeae to
5474e4c
Compare
5474e4c to
3007f23
Compare
3007f23 to
35b5fdd
Compare
|
To avoid stalling those PRs for too long I'll cherry-pick single non-controversial commits and rebase the remainder onto main, should make review and continuous adaptations easier. |
|
I was not entirely happy with the uc_source_t api surface, they way the internal properties ->fp and ->pos got accessed and the leaky abstractions, so I redesigned the API and rebased your work on top. The result is in https://github.com/ucode-lang/ucode/tree/fix/pr490-module-and-compiler-fixes - if you're fine with it, I'll force push into this PR and merge from here. |
35b5fdd to
3c511ef
Compare
|
I'm fine with your branch, I just have a few small fixups - please check them and I can fold them in |
The lexer, the error context formatter, the callee recovery, the debugger and the loader of precompiled programs all reached into the source object, using its file pointer and read offset directly. Give the source an interface for reading its text instead: - uc_source_read_block() lends a block of text and its offset to the caller, which lets the lexer drop its own read buffer and keep its own byte counter. - uc_source_read_line() returns the line holding a given offset and uc_source_callee_expr() reads the bytes in front of a call into a window and scans that in memory. Both leave the read position as they found it. - uc_source_read() with uc_source_offset() and uc_source_remaining() serve the loader of precompiled programs, which no longer needs a FILE pointer. - uc_source_read_content() returns the whole text for the debugger. - uc_source_type_test() is renamed to uc_source_begin(), as it positions the source on its first byte of code. The callee recovery no longer keeps its state in static variables. It reads a window of the source in front of a call into a heap buffer, which starts at 256 bytes and doubles up to 4096 bytes while the scan runs into its start, finds the start of the invoked expression, then collapses and copies the expression in place, and returns the buffer to the caller, which releases it using free(). The scan now also follows an expression which is broken across lines in front of a subscript or nested call. Signed-off-by: Jo-Philipp Wich <jo@mein.io>
A source keeps its file open until the source is released, and a program holds its sources for as long as it lives. Each live program thus holds one open file per source file, which is what runs an application with many loaded files into the limit on open files. After the compilation, the file only serves the text for error messages. Open the file when its text is first read, and release it with uc_source_reset() when the compilation or the load of a precompiled program ends, or when the reading of the text reaches its end. Read the text for the error context, for the name of a failed call and for the debugger from the path of the source. Keep the absolute path for that, since a script can change its working directory. That file can be gone or changed by then; the message then has no source context or shows the text of the current file. A buffer source keeps its text in memory as before. Do not open the source files that a precompiled program names when it is loaded. The warning about a file that does not exist goes away with that. Signed-off-by: Felix Fietkau <nbd@nbd.name> [adapted to the reworked source API: implicit open, uc_source_reset()] Signed-off-by: Jo-Philipp Wich <jo@mein.io>
Since commit f2d62f8 ("lib: deduplicate source files by resolved path in vm->sources cache"), uc_loadfile() and thus include() hand the same source object to uc_compile() on each call for one file. A compilation records its read position, its line information and its export table in the source, and the root source also holds the export count of the program. The next compilation of that source continues from this state, so the export slots of its imports lie behind the export table of the new program. A named import then yields null and a wildcard import leaves its name operands unread, which the VM decodes as instructions: Runtime error: unknown opcode 0 A file without imports is affected as well: the error locations of a later compilation are wrong, and the cache keeps each source until the VM is freed. The cache was added to limit the number of open files. A source no longer holds its file open after the compilation, so the cache has no purpose. Revert that commit: each call compiles a source of its own again, and each program keeps the line information and the export table of its own compilation. Keep the field of the VM as a reserved pointer, since nothing uses it. Fixes: f2d62f8 ("lib: deduplicate source files by resolved path in vm->sources cache") Signed-off-by: Felix Fietkau <nbd@nbd.name>
A compilation records its read position, its line information and its export table in the source. uc_compile() does not reset that state, so a second uc_compile() call for one source object continues from it. The error locations of the new program are wrong, and the export slots of its imports lie behind the export table of the new program. uc_loadfile() no longer passes a source twice, but uc_compile() and the source objects are public API and an embedder can do so. Drop the state of the previous compilation in uc_source_begin(), which every compilation of a source starts with. The sources of statically imported modules are created per program and need no reset. The source then describes its latest compilation only. A program of an earlier compilation that is still in use needs a source of its own. Signed-off-by: Felix Fietkau <nbd@nbd.name> [adapted to the reworked source API: state is cleared in uc_source_begin()] Signed-off-by: Jo-Philipp Wich <jo@mein.io>
uc_source_begin() reads the first bytes of a source to look for an interpreter line and for the bytecode magic, and seeks back afterwards. The seek fails on a pipe, so the compiler never sees these bytes. A script that is passed as /dev/stdin from a pipe, or loaded from there with loadfile(), fails: Failed to rewind source buffer: Illegal seek Syntax error: Unexpected token The content of a pipe is also gone when an error message needs the source text afterwards. Read such a file into a buffer in uc_source_new_file() and create a buffer source from it, like main.c already does for the "-" argument. Move that code to the new uc_source_new_slurp(), which both use. While here, terminate the buffer of uc_source_new_slurp(), since uc_program_write() stores it as a string, and let uc_load_common() name the file in a compile error of a file that was read into a buffer. Signed-off-by: Felix Fietkau <nbd@nbd.name> [adapted to the reworked source API: uc_source_new_stream() is uc_source_new_slurp()] Signed-off-by: Jo-Philipp Wich <jo@mein.io>
uc_compiler_compile_import() allocates the list of imported names before it checks for the call form. A statement that consists of an import() call returns early and leaks that list on each compilation. Release the list on that path. Fixes: 926f20b ("compiler: add support for runtime module imports") Signed-off-by: Felix Fietkau <nbd@nbd.name>
uc_require_path() stores the result of a load in the module table as soon as the file exists, also if the module raised an exception. The next import() or require() of that name then returns the incomplete result of the failed load, for example an empty namespace, and raises no error. Store the module only if the load raised no exception, so that a later load runs the module again. Signed-off-by: Felix Fietkau <nbd@nbd.name>
uc_require_library() returns the incomplete namespace of a module whose body raised an exception. uc_vm_insn_dynload() tests the return value only and goes on to bind the imported names. A name that the module did not export before it failed replaces the exception of the module: Reference error: Module does not export b Return if the load raised an exception. Signed-off-by: Felix Fietkau <nbd@nbd.name>
uc_require_ucode() points vm->config at a local configuration with compile_module set, and keeps it there while the body of the module runs. uc_load_common() copies that configuration, so a loadfile() or loadstring() call in the body of a dynamically loaded module compiles its argument as a module. A script that returns a value then fails: Syntax error: return must be inside function body Reset compile_module once the module itself is compiled. Signed-off-by: Felix Fietkau <nbd@nbd.name>
uc_require_ucode() pushes the closure that uc_loadfile() returns on the stack, runs it, and pops it again without a release. Each include(), render(), require() and import() thus leaks one closure, which keeps the program of the file, until a collection or the end of the VM. Release the closure after the run. Signed-off-by: Felix Fietkau <nbd@nbd.name>
uc_vm_insn_import() and uc_vm_insn_dynload() store the binding of an imported name in an upvalue slot of the running closure, and overwrite what the slot holds. A script can call the function that loadfile() returns more than once. Each call after the first one runs the import instructions of the same closure again and leaks the upvalue reference of the call before it, for a dynamically loaded module together with the reference to the imported value. Release the binding that a slot holds before it gets a new one. Signed-off-by: Felix Fietkau <nbd@nbd.name>
Since commit d64d5d6 ("vm: maintain export symbol tables per program"), uc_vm_insn_export() stores the exports of statically linked modules in the program and keeps them until the program is freed. This has several defects: - An exported function is a closure that holds the program, so the table keeps the program alive. Without the collector, each loadfile() or include() of a file that imports such a module keeps its memory. - The collector does not treat the table as a root. A collection between two uc_vm_execute() calls of one program frees the exports, and the second call fails: Type error: `inc` is not a function, got null instead - A program that runs in a second VM binds the values of the first VM, also after that VM is freed. - Each run of a program appends its exports to the table again. - A call that starts while another call of the same main function is active shifts the entries, so an import that the outer call resolves afterwards binds an unrelated export. Keep the exports per closure of the entry function of a program, release them when that closure is freed, and mark them in the collector from that closure. A program that is loaded and run once is thus freed with its last closure. A function from loadfile() keeps its module instances for as long as the script holds it, so module state still carries over from one call to the next. A later or nested run of a closure runs the module bodies again, as before, but adds no entries: its imports resolve to the instances of the first run. For uc_vm_execute(), keep the closure that runs in the registry of the VM once it has module instances. A later execution of the program in that VM runs the same closure. Only the caller can tell that the program is still in use, and its reference is the one that no closure accounts for. Count the closures of a program and drop the registry entry in the collector once no other reference exists. Read the exports of a dynamically loaded module from its closure in uc_require_imports(). While here, read all name operands of a wildcard import if an export is missing. Signed-off-by: Felix Fietkau <nbd@nbd.name>
The VM runs the handlers of pending signals after each instruction, and uc_vm_insn_export() adds one export to the module instance of the running closure. A signal handler that raises an exception between two export instructions of one module leaves a part of that module instance for the next run of the closure. The compiler emits the exports of a module as one block. Do not run the signal handlers between two export instructions, so that no handler runs inside that block. Signed-off-by: Felix Fietkau <nbd@nbd.name> [reworked: the main loop skips the signal dispatch between exports] Signed-off-by: Jo-Philipp Wich <jo@mein.io>
uc_require_imports() takes the position of the own exports of a module from the export offset of its source. uc_program_write() does not store that offset, so it is zero for a precompiled module. If such a module links another module statically, its namespace object holds the exports of the linked module under the names of its own exports. A module exports its own symbols last, after those of the modules that it links. Take the position from the number of own exports, which the source and the bytecode both record. Signed-off-by: Felix Fietkau <nbd@nbd.name>
Commit f2d62f8 made every load of one path share one source object. This series removes that cache and fixes the defects that followed from it. It also fixes older defects in the lifetime of modules and their exports.