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.
task.spawn(function()
while humanoid.Parent do
local seatPart = humanoid.SeatPart
local seated = seatPart
and seatPart:IsA("VehicleSeat")
and seatPart.Name == Chassis.SEAT_NAME
-- A live connection bound to a different seat (or to a car that
-- has since been destroyed) is STALE: it keeps driving the old
-- car's motors, so heartbeatConn stays non-nil and the new car
-- never gets armed. Re-arm whenever the bound seat isn't the
-- one we're actually sitting in, or its car left the world.
local stale = heartbeatConn ~= nil
and (drivingSeat ~= seatPart or not seatPart:IsDescendantOf(workspace))
if seated and (not heartbeatConn or stale) then
stopDriving()
startDriving(seatPart :: VehicleSeat)
elseif not seated and heartbeatConn then
stopDriving()
-- The whole body is pcall-wrapped: this watchdog is the ONLY
-- self-healing path. If it ever errors the thread dies silently
-- and the car becomes permanently undrivable, with the failure
-- appearing intermittent because the edge-triggered seat signal
-- still handles the easy nil -> seat case. Never let it die.
local ok, err = pcall(function()
local seatPart = humanoid.SeatPart
local seated = seatPart ~= nil
and seatPart:IsA("VehicleSeat")
and seatPart.Name == Chassis.SEAT_NAME
-- A live connection bound to a different seat (or to a car
-- that has since been destroyed) is STALE: it keeps driving
-- the old car's motors, so heartbeatConn stays non-nil and
-- the new car never gets armed. Re-arm whenever the bound
-- seat isn't the one we're sitting in, or it left the world.
-- 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
task.wait(0.5)
end