Extend and Improve Interruption#899
Conversation
7de3c36 to
0765727
Compare
| commandline, | ||
| params, | ||
| need_ack, | ||
| interrupt_token: Optional[InterruptToken] = None, |
There was a problem hiding this comment.
This seems wrong since self._interrupt_token gets assigned to this with a type cast to : InterruptToken. Either here or at the assignment site, an "inert" InterruptToken should be picked. Basically one that will never trigger but otherwise work completely normally. Since this could just be a global that everyone shares, I'd lean towards putting a default here instead of at the assignment site further down.
There was a problem hiding this comment.
yeah, I had it be None to see if that would get it past the unit tests, but ill just have to fix the test.
| if value is None: | ||
| if default is self.sentinel: | ||
| raise self.error( | ||
| raise klippy_ex.CommandError( |
There was a problem hiding this comment.
For compatibility with Klipper modules we need to keep self.error around anyway, so keep the existing self.error usage instead of using the new name.
There was a problem hiding this comment.
self.error still exist. I just didn't use to try and convey to readers that its not the original.
| ) | ||
|
|
||
| # if the command was interrupted, throws WaitInterruption | ||
| def check_interrupted(self): |
There was a problem hiding this comment.
Would be good with a was_interrupted() as well, a non-throwing version of this.
There was a problem hiding this comment.
sure, I can put a test() wrapper there and call it was_interrupted().
| """ | ||
| Wait until waketime, return the value of test() | ||
| """ | ||
| if not self.test(): |
There was a problem hiding this comment.
Can this hit a race? ReactorCompletion is known to be racy. Might be better to fix the race in ReactorCompletion.wait but I don't know if anything somehow relies on that race.
There was a problem hiding this comment.
I don't think so. test() just checks if the completion is assigned any value.
The only "racy" thing in ReactorCompletion that I know of is that it allows the value being overwritten by multiple callers of complete(). I think that's a bug/defect (at least vs other systems I've worked with). This completion is only completed from 1 place and the complete() method is wrapped so holders of InterruptToken cant call complete().
Even if you spam INTERRUPT commands, they still end up executing sequentially.
There was a problem hiding this comment.
ReactorCompletion I believe is racy today because the waiter checks for completion, and if not done it adds itself to the completion list. But the resolve side sets the value and then goes through the list. So I believe the following race can happen:
Waiter: check value, find not set
Completer: set value
Completer: get and invoke all waiters
Waiter: register in wait list
There's no documentation or other mechanism that prevents using ReactorCompletion on different threads as far as I'm aware.
There was a problem hiding this comment.
The example you are giving here is possible only if ReactorCompletion.complete() is called directly from another OS thread.
Inside the reactor/greenlet model, there is no yield between:
if self.result is self.sentinel:
wait = greenlet.getcurrent()
self.waiting.append(wait)
self.reactor.pause(waketime)The greenlet does not give up control until reactor.pause(), and by that point the waiter has already been appended. Similarly, complete() does not yield. It sets the result and updates the waiting timers synchronously.
For cases where a separate OS thread needs to complete a completion, there is the async_complete() method. That queues the completion back onto the reactor thread via the async pipe. Existing cross-thread users are using that path, for example serial/mcu response handling.
So I agree that ReactorCompletion.complete() should not be called directly from arbitrary threads. But I don’t think this PR introduces that race: HEATER_INTERRUPT runs through gcode dispatch on the reactor/greenlet side, and the interrupt completion is completed from that same cooperative context.
If we want to make this harder to misuse, GCodeDispatch.trigger_interrupt() could be made private so callers don’t treat it as a thread-safe entry point.
|
|
||
| def trigger_interrupt(self): | ||
| self._interrupt_completion.complete(InterruptedSentinel) | ||
| self._interrupt_completion = self._printer.get_reactor().completion() |
There was a problem hiding this comment.
Could this race somehow? What if a new gcode command samples self._interrupt_completion after the complete call but before the new assignment? Will this new gcode command be un-interruptible or one that immediately interupts?
There was a problem hiding this comment.
So in normal threads that instinct is correct. This would have to be a synchronized method or you would have to grab a mutex to make the switch.
But in greenlets only 1 of them is running at a time. So there is no other way in here at the same time as another greenlet.
complete() doesn't yield. It just updates the timers of the associated list of waiting greeenlets. You would need something in here to yield to get a race.
There was a problem hiding this comment.
Can we somehow mark or comment that calling this outside of a greenlet running on the reactor is prohibited? There's really nothing preventing calling this from e.g. an API handler, which doesn't have to be in a greenlet necessarily.
There was a problem hiding this comment.
Agreed. I was just writing this in the other reply.
I feel that this function on line 280 is OK, because its inside a private variable inside GCodeDispatch. If you reach inside a private variable to call a method from another OS thread then... really you can do any number of other bad things.
But the public method on GCodeDispatch itself looks like it should be made private to protect against this.
There was a problem hiding this comment.
I could see us wanting to at some point want to do this via the API as well and maybe we end up doing something with threads there, so really I just want to be sure that we can't end up shooting ourselves in the foot down the line when this specific detail is lost to time. Assuming all of these function(this one and the other racy one I was concerned about) are only ever called within the context of the reactor, I agree they should be safe.
There was a problem hiding this comment.
Good point. We can have a public API like GCodeDispatch.async_trigger_interrupt() that calls this:
def async_trigger_interrupt(self):
self._printer.get_reactor().register_callback(lambda: self.trigger_interrupt())That way the interrupt signal from the OS thread is passed into the reactor and run on a greenlet. That's the same pattern that Printer uses for invoke_async_shutdown.
|
I don't agree that the interruption context is tied to the gcode command. Interruption context is printer wide, whenever an interruption is triggered all commands preceeding the interruption command must abort. This is why the counter approach worked, everything was sampling the same counter and if it changed, everything took that to be an interruption. I think the entire stacks system can be done away with and replaced with a single "current completion" that gets replaced every time an interrupt is called. Your example interrupt macro example should still work in that case, because the interrupt command is called before the following commands sample the current completion and so they will correctly be allowed to run. We do need to look at how we control the gcode command mutex though, because right now we could easily end up with running e.g. multiple commands in parallel just because the text "interrupt" exists somewhere in the macro. That's a separate issue from what you are working on here, but thought I'd note it. /Lasse |
Agreed, I don't love it. I would make 3 small change:
|
Completely agree!
I think a big part of the reason we have these issues is that I think the correct option is actually to do away with taking the mutex in With this, instead of making interruptions special, we can give An example macro like the one we've been working with here, that starts with a Does that make sense? /Lasse |
Yes, that makes total sense. There are a lot of commands you want to run now, even if they do alter printer state. Babystepping come to mind. That could be directly handled by the toolhead to alter the next thing it sends to the MCU. We have a "bug" in klipper were you try to adjust your Z height on layer 1 and the commands all get piled up at the end of a long print line. Edit: taking the mutex in Something else that dawned on me this morning: all the threads that are waiting on the mutex when |
The baby stepping issue is a very good example of where this could be useful, I agree!
It doesn't need to, maybe I explained badly. This way, once the lock is taken, it will be held either until we get to a command that doesn't require the lock, or we are done with all of our commands. This prevents the ping-pong issue. Alternatively, we could say that once the lock has been requested by a command, it will be held for the remainder of the command sequence. Either can work I think.
This is a pretty nice observation, and I think we can actually avoid swapping the mutex and simply augment the /Lasse |
|
Ok, if I combined our ideas together I can do the interruption without having to get into the baby stepping use case. If I move the mutex logic inside The interrupting behavior needs to follow any command override. Consider this: if We can leave the bypass-mutex-run-in-parallel idea until we do a use-case like baby stepping where we want to think about how that would work. I've been thinking about a move transformer that would split long slow moves into chunks that are shorter than 1/10th of a second. That way baby stepping could take effect "mid-move" and this would still not impact step compression too much in real prints. |
What happens when an inner recursive command releases the lock? Lets say a module calls some user injected gcode and its last command releases the lock. When that execution returns, the mutex has been release and it wont be locked again until the next command. This opens up a window for the module that ran the script to alter printer state while it no longer owns the mutex. That is something that is not safe e.g.: def cmd_CUSTOM(self, gcmd):
# this command acquired the mutex to run
# execute some user configured gcode that releases the mutex:
self.gcode.run_script_from_command('SET_GCODE_OFFSET Z_ADJUST=0.1 MOVE=1')
# mutex unlocked for command CUSTOM, unsafe state modification:
self.toolhead.set_position(...)To be safe, if the stack frame that called I'm just going to avoid this in this PR. I think we need to do it, but its not required, yet. |
|
I'm not sure what you mean. As I said earlier, |
|
ok, got it |
49557e9 to
db17c28
Compare
|
I decided the variable tracking was just too tricky. So I put a method on ReactorMutex so you can just test if the current greenlet owns the lock: |
Move CommandError and WaitInterruption to a file that can be imported anywhere in the codebase without creating circular dependencies Signed-off-by: Gareth Farrington <gareth@waves.ky>
Use RactorCompletion to trigger an interrupt of a waiting process as soon as the HEATER_INTERRUPT gcode is processed. GcodeDispatch now implements the same contract as VirtualSD: interruption is checked between each gcode command being executed. This means at most the user will have to wait for 1 gcode command to complete before their INTERUPT command is actued on. This also means gcode macros can now be interrupted. The counter mechanisim was replaced with a ReactorCompletion based InterruptToken that supports waking from wait() immediatly after trigger_interrupt(). Signed-off-by: Gareth Farrington <gareth@waves.ky>
db17c28 to
ec06801
Compare
Toolhead looks up the Interrupt token in move() to absort moves if the token is in the interrupted state. _check_pause() uses the Interrupt token for pauses so it can be instantly woken on interrupt. This stops the toolhead from blocking and from executing the last move it recieved before it blocked. `drip_move` is gracefully aborted by the InterruptToken. A new completion_any() method in reactor combines the existing drip move complation with the interrupt token and complectes when either one is completed. Signed-off-by: Gareth Farrington <gareth@waves.ky>
Add a method that will throw an exception into all of the greenlets currently waiting on a mutex. If an exception is raised on one of the waiting greeenlets and the lock was not claimed, the next greenlet in the queue is woken up to claim the lock. owns_lock() reportes true if the current greenlet own the lock. Since we are getting rid of some `with` blocks, it simplifies bookeeping and makes expressing intent more clear. Signed-off-by: Gareth Farrington <gareth@waves.ky>
This causes all waiting threads to recieve an interrupt and never get the lock. Signed-off-by: Gareth Farrington <gareth@waves.ky>
Add a property to gcode commands so the interrupt can be cleanly executed before the command requiring the interrupt runs. Signed-off-by: Gareth Farrington <gareth@waves.ky>
Move the mutex acquision inside the _process_commands loop. Mutex is now acquired after we understand what the command is and if it will require an interrupt before it runs. In the case of an interrupt the mutex is released to allow the other waiting threads to run any exception handlers or finally blocks before this thread runs. Signed-off-by: Gareth Farrington <gareth@waves.ky>
Now when the CANCEL_PRINT command is submitted, all activve and buffered commands are interrupted and then CANCEL_PRINT runs. Signed-off-by: Gareth Farrington <gareth@waves.ky>
ec06801 to
2ad0255
Compare
This is a set of changes that causes the printer to stop what its doing when the existing
HEATER_INTERRUPTgcode is run. This gcode triggers the internal interrupt mechanism insideGCodeDispatch. This is now extended to give a contract that much more similar to howVirtualSDworks: between each gcode command there is a check to see if the interrupt has been triggered and this stops further execution of the gcode script.This results in the following improvements to the cancel experience:
bed_meshThe existing counter based mechanism was replaced with an
InterruptTokenthat wraps aReactorCompletion.InterruptTokenis a view/observer of the underlying completion. Whentrigger_interrupt()happens the ReactorCompletion is completed and all of its views subsequently wake if they are being waited upon.Explanation
The behavior that we want to achieve is interrupting and cancelling whatever the printer is currently doing while allowing a script of cleanup commands to continue to run successfully. E.g. the user might write a macros like:
In this case we want any ongoing
GCodeCommands to be interrupted whenHEATER_INTERRUPTruns. But crucially, the subsequent command to shut off the heaters and park the toolhead should be run normally.This means the interruption state is tied to gcode commands, represented by
gcmd/GCodeCommandobjects. Gcode commands are processed through theGCodeDispatch._process_commands()method. This is recursive, both macros and extras can invoke additional gcode commands creating a call stack into_process_commands().When
HEATER_INTERRUPTruns, its important that commands up the stack from that point uniformly observe that they are interrupted. This includes when the final gcode in the stack eventually returns and the stack begins to unwind. So the current global state is not the correct state to observe, the right state is the one tied to thegcmdthat is currently at the top of the stack.To make this more difficult, the
gcmdobject is not universally available in all the places where we want to get that context.Printer.wait_while()doesn't have a gcode command context.gcode_move.pyand the system of movement transforms don't pass the activegcmdthrough that stack totoolhead.move(). It would be an invasive change to thread this state through all of these paths . So we need some way to get the interruption state for the currentgcmdon top of the stack without having thegcmdobject.To make this more complicated, to get
CANCEL_INTERRUPTto run immediately, we bypass the mutex that protectsGCodeDispatch._process_commands(). This allows 2 greenlets to concurrently enter that block of code. They have independent stacks, so now there are 2 commands at the top of 2 different stacks. So its not good enough to simply look at entry and exit from_process_commands()as the boundaries of a stack frame, we have to track which greenlet (green thread) is actually in the method.The new
GCodeCommandStackstracks a stack ofGCodeCommands for eachgreenlet. Via this class its possible to get the correctInterruptTokenfrom any context in the program. This class is doing the state tracking that would have been required by individual call sites. Its entirely encapsulated inside GCodeDispatch so extra/plugin developers don't have to think about how it works.In the future if everywhere was passed a
gcmdobject and theGCodeDispatch.get_interrupt_token()method was deleted, thenGCodeCommandStackscould also be deleted. Thegcmdobject has the right state internally and will always behave correctly. But right now that change looks invasive and impractical.Checklist