A reference is a reference whatever feature holds it

Port of snaporca 1bb9825db0. Four defects from an independent 20-agent audit,
each verified in the code first; two further findings from the same report were
verified OUT and are not in this commit.

remove_feature()/move_feature() remapped Extrude::sketch_ref and a Mate's two
connectors and nothing else, leaving seven of the nine index-bearing fields —
sweep_path_ref, loft_profile_refs[], pattern_curve_sketch, rib_sketch_ref, and
sketch_ref on Revolve, Sweep, Rib and the Surface* family — pointing at whatever
slid into the slot. Quiet by construction: the shifted index still names a real
feature, recompute() succeeds, the solid is built from the wrong profile. The
comment above the loop already required "EVERY field holding a feature index"; the
code under it handled two, because a type switch is only correct on the day it is
written. for_each_feature_ref() visits the FIELDS instead, so a feature type added
later is covered the moment it reuses one. plane_base and axis_plane_a/b are
excluded on purpose and documented at the helper — they encode an ordinal into the
datum-plane list, not an index into features[], and are filed separately. The
delete cascade got the same field-based treatment.

The regression test was run against the pre-fix code to prove it bites: all three
sections fail there, and move_feature returns TRUE while leaving sketch_ref == 1
where it must be 0 — success with the wrong answer, which is what makes this class
expensive.

apply_constraint, commit_entity_constraints and delete_constraint mutated the
recipe with no checkpoint() and no sync_recipe_to_model(), alone among seventeen
mutation sites in that file: Ctrl+Z reached past the constraint edit and discarded
unrelated work, and saving persisted the pre-constraint blob. A rejected constraint
now calls abandon_checkpoint() rather than leaving an undo step that does nothing.

MCP: params["generation"].get<uint64_t>() sat outside the try inside a bare
CallAfter lambda, so one malformed string terminated the process through the wx
event loop; it is type-checked now and the lambda lets nothing escape. The socket
bound with no mode of its own in a world-writable directory — umask around bind()
plus chmod, and it refuses to listen rather than listen wide. The reply write is no
longer a bare write(), which could SIGPIPE the app when a client hung up.

Kernel suite on this fork: 190 cases / 2562 assertions, green. GUI target compiles.
The full ladder gate ran on snaporca (ALL LADDERS HELD — gestures 98/98, offer
108/108, corpus and corpus-scale green) and fork-check parity holds at 17 identical
/ 8 diverging as expected, which is what makes that gate transferable here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MrMzTpAf78U4NG2M8jfvHY
This commit is contained in:
Tommaso Bianchi
2026-08-24 19:00:45 +02:00
co-authored by Claude Opus 5
parent 6e7f6429fa
commit 3fd5c3353a
5 changed files with 188 additions and 23 deletions
+16
View File
@@ -7850,16 +7850,25 @@ void DesignPanel::commit_entity_constraints(const std::vector<SketchEntityConstr
// (Symmetric on two lines) must solve together, so push all then resize back.
const std::vector<SketchEntity> saved = feat.entities;
const size_t before = feat.entity_constraints.size();
// Undo boundary: adding a constraint. Without it Ctrl+Z reached PAST this edit to the
// previous boundary and threw away whatever happened in between — a constraint was the
// one document mutation the user could not take back on its own.
m_doc.checkpoint();
for (const auto& d : defs) feat.entity_constraints.push_back(d);
if (!m_doc.solve_sketch_feature(m_constrain_feat)) {
feat.entity_constraints.resize(before);
feat.entities = saved;
m_doc.abandon_checkpoint(); // fully restored above: nothing happened, so nothing to undo
m_status->SetForegroundColour(wxColour(235, 110, 110));
set_status(_L("Constraint rejected (over-constrained)"));
m_status->Refresh();
return;
}
m_doc.recompute();
// The constraint lives in the recipe, so the save path has to be told the recipe moved;
// otherwise saving after a constraint edit wrote the blob from before it.
sync_recipe_to_model();
update_undo_redo_buttons();
m_viewport->update_constrain_entities(m_doc.features[m_constrain_feat].entities);
if (!m_doc.display_mesh.its.indices.empty())
feed_bodies();
@@ -8029,11 +8038,14 @@ void DesignPanel::delete_constraint(int idx)
CadFeature& feat = m_doc.features[m_constrain_feat];
if (idx < 0 || idx >= int(feat.entity_constraints.size()))
return;
m_doc.checkpoint(); // undo boundary: deleting a constraint
feat.entity_constraints.erase(feat.entity_constraints.begin() + idx);
// Re-solve the remaining system (deleting a constraint can only free DoF, so it
// cannot fail for over-constraint; ignore the bool and refresh either way).
m_doc.solve_sketch_feature(m_constrain_feat);
m_doc.recompute();
sync_recipe_to_model(); // the removal is part of the recipe
update_undo_redo_buttons();
m_viewport->set_constraint_highlight({});
m_viewport->update_constrain_entities(m_doc.features[m_constrain_feat].entities);
if (!m_doc.display_mesh.its.indices.empty())
@@ -8867,16 +8879,20 @@ void DesignPanel::apply_constraint(SketchConstraintType type)
// solve_sketch_feature rewrites profile.points even on failure, so snapshot
// the geometry to roll back a rejected constraint cleanly.
const std::vector<Vec2d> saved_pts = feat.profile.points;
m_doc.checkpoint(); // undo boundary: adding a constraint (profile sketches)
feat.constraints.push_back(SketchConstraintDef{type, a, b, -1, -1, 0.0});
if (!m_doc.solve_sketch_feature(m_constrain_feat)) {
feat.constraints.pop_back(); // reject the non-converging addition
feat.profile.points = saved_pts; // and restore the pre-solve geometry
m_doc.abandon_checkpoint(); // restored: no state change, so no undo step
m_status->SetForegroundColour(wxColour(235, 110, 110));
set_status(_L("Constraint rejected (over-constrained)"));
m_status->Refresh();
return;
}
m_doc.recompute();
sync_recipe_to_model();
update_undo_redo_buttons();
m_viewport->update_constrain_profile(m_doc.features[m_constrain_feat].profile.points);
if (!m_doc.display_mesh.its.indices.empty())
feed_bodies();
+45 -3
View File
@@ -4,6 +4,7 @@
#include <sys/socket.h>
#include <sys/un.h>
#include <sys/stat.h> // umask/chmod: the socket's file mode IS its access control
#include <unistd.h>
#include <cstdlib>
#include <cstring>
@@ -2008,7 +2009,14 @@ std::string handle_on_main(const std::string& method, const json& params, const
// keeps every existing script working exactly as before, and supplying it is what buys the
// guarantee. One check at the dispatcher rather than one per handler, so a method added
// later cannot forget it.
//
// The type check is not decoration: this runs OUTSIDE the try below, and a bare
// get<uint64_t>() on `"generation": "x"` throws nlohmann::type_error straight out of
// the CallAfter lambda that invoked us — through a wx event loop, which does not catch,
// so the whole GUI went down on one malformed line. Refuse it as a parameter error.
if (params.is_object() && params.contains("generation")) {
if (!params["generation"].is_number_unsigned())
return rpc_error(id, -32602, "generation must be an unsigned integer");
const uint64_t want = params["generation"].get<uint64_t>();
const uint64_t have = panel->mcp_doc().topo_generation;
if (want != have)
@@ -2099,8 +2107,18 @@ std::string dispatch_request(const std::string& line)
auto prom = std::make_shared<std::promise<std::string>>();
auto fut = prom->get_future();
// Nothing may escape this lambda. It is invoked by the wx event loop, which has no
// handler of its own, so an escaping exception is std::terminate — the socket would
// become a way for any client to kill the application. handle_on_main() catches what
// it knows about; this catches what it does not, and still answers the caller.
wxGetApp().CallAfter([prom, method, params, id]() {
prom->set_value(handle_on_main(method, params, id));
try {
prom->set_value(handle_on_main(method, params, id));
} catch (const std::exception& ex) {
prom->set_value(rpc_error(id, -32000, std::string("internal error: ") + ex.what()));
} catch (...) {
prom->set_value(rpc_error(id, -32000, "internal error: unknown exception"));
}
});
if (fut.wait_for(std::chrono::seconds(15)) != std::future_status::ready)
return rpc_error(id, -32000, "main-thread timeout");
@@ -2123,7 +2141,14 @@ void serve_client(int cfd)
if (line.empty()) continue;
std::string reply = dispatch_request(line);
reply.push_back('\n');
if (::write(cfd, reply.data(), reply.size()) < 0) return;
// Never a bare write(): a client that hangs up between its request and our
// reply raises SIGPIPE, whose default action kills the process — so closing
// a socket mid-call would take the GUI with it.
#ifdef MSG_NOSIGNAL
if (::send(cfd, reply.data(), reply.size(), MSG_NOSIGNAL) < 0) return;
#else
if (::write(cfd, reply.data(), reply.size()) < 0) return; // SO_NOSIGPIPE set at accept
#endif
}
}
}
@@ -2137,16 +2162,33 @@ void server_thread(std::string sock_path)
sockaddr_un addr{};
addr.sun_family = AF_UNIX;
std::strncpy(addr.sun_path, sock_path.c_str(), sizeof(addr.sun_path) - 1);
if (::bind(sfd, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) < 0) {
// The socket is the full CAD command surface, including import_step on absolute paths.
// It lands in a world-writable directory by default (/tmp), so its access control is
// its file mode and nothing else — leaving that to the ambient umask means any local
// process may drive the modeller. umask around bind() makes it 0600 with no window in
// which a wider mode exists; the chmod afterwards covers platforms that do not apply
// umask to sockets.
const mode_t old_umask = ::umask(0177);
const int bind_rc = ::bind(sfd, reinterpret_cast<sockaddr*>(&addr), sizeof(addr));
::umask(old_umask);
if (bind_rc < 0) {
BOOST_LOG_TRIVIAL(error) << "MCP: bind() failed on " << sock_path;
::close(sfd); return;
}
if (::chmod(sock_path.c_str(), S_IRUSR | S_IWUSR) < 0) {
BOOST_LOG_TRIVIAL(error) << "MCP: cannot restrict " << sock_path << " to the owner; refusing to listen";
::close(sfd); ::unlink(sock_path.c_str()); return;
}
if (::listen(sfd, 1) < 0) { BOOST_LOG_TRIVIAL(error) << "MCP: listen() failed"; ::close(sfd); return; }
BOOST_LOG_TRIVIAL(info) << "MCP control listening on " << sock_path;
for (;;) {
int cfd = ::accept(sfd, nullptr, nullptr);
if (cfd < 0) continue;
#if !defined(MSG_NOSIGNAL) && defined(SO_NOSIGPIPE)
const int on = 1; // macOS/BSD equivalent of MSG_NOSIGNAL
::setsockopt(cfd, SOL_SOCKET, SO_NOSIGPIPE, &on, sizeof(on));
#endif
serve_client(cfd);
::close(cfd);
}