fix(client): stop the chassis watchdog dying on a nil seat during respawn
CI / quality (push) Failing after 47s

The stale-loop check indexed seatPart without a nil guard:

    not seatPart:IsDescendantOf(workspace)

On respawn CarManager destroys the old car, so humanoid.SeatPart is nil for a
frame while heartbeatConn is still live. That path then indexed a nil value,
the error propagated out of the task.spawn thread, and the watchdog - the only
self-healing path in the controller - DIED PERMANENTLY. The car was undrivable
from then on, and the bug looked intermittent because the edge-triggered
SeatPart signal still covered the simple nil -> seat transition.

Nil-check seatPart before indexing, and wrap the whole watchdog body in pcall
so no future error can ever silently kill the loop again (warn instead).

Verified: 5 consecutive respawns then drives to MaxSpeed 163 studs/s at road
level (up=1.00), TIME_ATTACK sessionKm advances 0.003 -> 0.169 km.
This commit is contained in:
2026-07-31 16:15:55 -04:00
parent c8809efa7b
commit f70a0b06bb
+33 -16
View File
@@ -144,22 +144,39 @@ function ChassisController.init()
-- if we are seated in a DriveSeat but have no live loop, arm it. -- if we are seated in a DriveSeat but have no live loop, arm it.
task.spawn(function() task.spawn(function()
while humanoid.Parent do while humanoid.Parent do
local seatPart = humanoid.SeatPart -- The whole body is pcall-wrapped: this watchdog is the ONLY
local seated = seatPart -- self-healing path. If it ever errors the thread dies silently
and seatPart:IsA("VehicleSeat") -- and the car becomes permanently undrivable, with the failure
and seatPart.Name == Chassis.SEAT_NAME -- appearing intermittent because the edge-triggered seat signal
-- A live connection bound to a different seat (or to a car that -- still handles the easy nil -> seat case. Never let it die.
-- has since been destroyed) is STALE: it keeps driving the old local ok, err = pcall(function()
-- car's motors, so heartbeatConn stays non-nil and the new car local seatPart = humanoid.SeatPart
-- never gets armed. Re-arm whenever the bound seat isn't the local seated = seatPart ~= nil
-- one we're actually sitting in, or its car left the world. and seatPart:IsA("VehicleSeat")
local stale = heartbeatConn ~= nil and seatPart.Name == Chassis.SEAT_NAME
and (drivingSeat ~= seatPart or not seatPart:IsDescendantOf(workspace)) -- A live connection bound to a different seat (or to a car
if seated and (not heartbeatConn or stale) then -- that has since been destroyed) is STALE: it keeps driving
stopDriving() -- the old car's motors, so heartbeatConn stays non-nil and
startDriving(seatPart :: VehicleSeat) -- the new car never gets armed. Re-arm whenever the bound
elseif not seated and heartbeatConn then -- seat isn't the one we're sitting in, or it left the world.
stopDriving() -- NOTE: seatPart is nil for a frame whenever the old car is
-- destroyed on respawn, so it MUST be nil-checked before it
-- is indexed -- that crash is what killed this loop before.
local stale = heartbeatConn ~= nil
and (
drivingSeat ~= seatPart
or seatPart == nil
or not seatPart:IsDescendantOf(workspace)
)
if seated and (not heartbeatConn or stale) then
stopDriving()
startDriving(seatPart :: VehicleSeat)
elseif not seated and heartbeatConn then
stopDriving()
end
end)
if not ok then
warn("[ChassisController] watchdog iteration failed: " .. tostring(err))
end end
task.wait(0.5) task.wait(0.5)
end end