diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e66f7f..6501597 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,10 @@ breaking changes to the contract were allowed where they bought correctness (D5 `docs/PRODUCTION-PLAN.md`); each is recorded here. From 1.0.0, a change to the class contract is a major version. -## 1.0.1 — 2026-10-07 +## 1.0.2 — 2026-10-08 + +1.0.1 was tagged but never released: its Windows package still had the first fault below. Its +fixes are here, with that one completed. ### Fixed @@ -15,8 +18,12 @@ a major version. not have, Max returns `method_false()`, a function, as the SDK documents, so every field and method of every class was "reserved by the host" and the console said so for each; only audio still worked. The mock kernel the unit tests run against answers null, which is why they passed. - The guard now recognizes `method_false()`, the unit test's kernel answers as Max does, and the - Mac session (8.8) is what would have caught it before the tag. + The guard now asks Max what it answers for a name the object does not have, and compares with + that — first (in 1.0.1) it compared with the address of `method_false` as the external sees it, + which on Windows is the external's own import thunk (the SDK declares the function without + `dllimport`), never what Max returns, so on Windows every name was still reserved. The unit + test's kernel answers as Max does on Windows too, and the Mac session (8.8) is what would have + caught it on the Mac before the tag; on Windows, trying the package did. - **A reload lost every attribute.** With that fixed, the guard still took the attributes the object had added for the class's fields — which answer their names in Max, as messages do — for Max's own, so a save left the object with no attributes ("the field … is reserved by the host", diff --git a/docs/PRODUCTION-PLAN.md b/docs/PRODUCTION-PLAN.md index 8fe6199..73da5af 100644 --- a/docs/PRODUCTION-PLAN.md +++ b/docs/PRODUCTION-PLAN.md @@ -620,7 +620,15 @@ down rather than discovered again. *This plan was itself audited before being ad and the glue test's kernel now answers as Max does (`method_false()` for an unknown name, a method for the names min registers and for one the test's class defines on purpose), so the guard's effect is pinned without Max. The lesson for 8.8: a tag with a Mac session outstanding - ships what only Max can show. + ships what only Max can show. *And on Windows it still did (found 2026-10-08 on the 1.0.1 + package, which was not released):* 1.0.1 compared with `&method_false`, which in a Windows DLL + is its own import thunk — the SDK declares the function without `dllimport` — never the function + Max returns, so every name was still "found". Fixed in 1.0.2: `not_found_method()` asks Max what + it answers for a name no class can have (it has spaces), and `found_method()` compares with + that; the glue test's kernel answers an unknown name with a function other than the + `method_false` the module can take the address of, as Windows does, and every attribute and + message scenario fails without the fix. Verified on Windows by the maintainer with the branch's + package. - [x] **8.3 Worker mode never hangs Max (A1, A6 — `worker.h`).** *A1:* `stop()` bounds its join. The worker records its OS thread identifier (`PyThread_get_thread_ident()`, no GIL needed) as it starts; if the thread has not finished diff --git a/source/projects/tap.python_tilde/tap.python_tilde.h b/source/projects/tap.python_tilde/tap.python_tilde.h index 39eada2..4090f68 100644 --- a/source/projects/tap.python_tilde/tap.python_tilde.h +++ b/source/projects/tap.python_tilde/tap.python_tilde.h @@ -386,12 +386,21 @@ class python : public object, public vector_operator<> { return names; } - /// Whether object_getmethod() found a method. For a name the object does not answer, Max - /// returns method_false() — a function, as the SDK documents — not null; the mock kernel - /// returns null. 1.0.0 tested for null alone, so in Max every field and method of a class was - /// "answered by Max", and the object had no attributes and no messages (1.0.1). - static bool found_method(const c74::max::method found) { - return found != nullptr && found != reinterpret_cast(c74::max::method_false); + /// Whether object_getmethod() found a method: neither null nor `not_found`, what it answers for a + /// name the object does not have — Max's method_false(), as the SDK documents. 1.0.0 tested for + /// null alone, and 1.0.1 compared with the address of method_false as this module sees it, which + /// on Windows is the external's own import thunk (the SDK declares it without dllimport), never + /// what Max returns: either way every field and method of a class was "answered by Max", and the + /// object had no attributes and no messages. So `not_found` comes from Max (not_found_method()). + static bool found_method(const c74::max::method found, const c74::max::method not_found) { + return found != nullptr && found != not_found; + } + + /// What object_getmethod() answers for a name the object does not have, asked of Max for one + /// no class can have (it has spaces): Max's method_false(), whatever its address looks like from + /// here. Main thread. + c74::max::method not_found_method() { + return c74::max::object_getmethod(maxobj(), c74::max::gensym("tap.python~ answers no such name")); } private: @@ -487,7 +496,7 @@ class python : public object, public vector_operator<> { || m_python_attributes.find(name) != m_python_attributes.end()) { return false; } - return found_method(c74::max::object_getmethod(maxobj(), c74::max::gensym(name.c_str()))); + return found_method(c74::max::object_getmethod(maxobj(), c74::max::gensym(name.c_str())), not_found_method()); } /// The worker thread's scheduling (plan 2.5): the real-time class an audio thread has, so that a diff --git a/source/projects/tap.python_tilde/tap.python_tilde_test.cpp b/source/projects/tap.python_tilde/tap.python_tilde_test.cpp index 8c640eb..71aa753 100644 --- a/source/projects/tap.python_tilde/tap.python_tilde_test.cpp +++ b/source/projects/tap.python_tilde/tap.python_tilde_test.cpp @@ -86,6 +86,16 @@ namespace c74 { t_atom_long method_false(void*) { return 0; } + // What the pretend Max answers for a name the object does not have: its own method_false(), + // which is not the method_false whose address this module takes. On Windows that address is + // the external's import thunk (the SDK declares method_false without dllimport), so 1.0.1, + // comparing with it, took every name for found and reserved it (found on Windows). The body + // is its own, so that no linker folds it into method_false. + t_atom_long max_method_false(void*) { + static volatile int s_calls; + s_calls = s_calls + 1; + return 0; + } // The method the pretend class answers with. Its body must be its own: a linker that folds // identical functions (MSVC's /OPT:ICF, on by default in Release; lld's --icf=all) gave a // `return 0` here method_false's address, so every name read as unanswered on Windows. @@ -104,7 +114,7 @@ namespace c74 { if (attributes::added.count(s->s_name) != 0) { // an attribute the object added answers its name return reinterpret_cast(answered_method); } - return reinterpret_cast(method_false); + return reinterpret_cast(max_method_false); } void* qelem_new(void*, method) { static int s_qelem; @@ -472,9 +482,11 @@ SCENARIO("A save that changes process()'s inputs or outputs changes the object's SCENARIO("Methods named like messages Max sends with C arguments are not exposed, and the promised ones are " "(plan 8.2)") { ext_main(nullptr); - // the test's kernel must tell its two answers apart, or what follows tests the linker - REQUIRE(c74::max::object_getmethod(nullptr, c74::max::gensym("maxtest_host_answers")) - != reinterpret_cast(c74::max::method_false)); + // the test's kernel must tell its answers apart, or what follows tests the linker; and its + // "not found" must not be the method_false this module can take the address of (Windows) + const auto not_found = c74::max::object_getmethod(nullptr, c74::max::gensym("maxtest_no_such_name")); + REQUIRE(c74::max::object_getmethod(nullptr, c74::max::gensym("maxtest_host_answers")) != not_found); + REQUIRE(not_found != reinterpret_cast(c74::max::method_false)); const auto file = tap::python::package_root() / "python" / "maxtest_mock_reserved.py"; write_file(file, "class maxtest_mock_reserved:\n" // a field, and a method, which must be exposed whatever the guard answers @@ -509,10 +521,12 @@ SCENARIO("Methods named like messages Max sends with C arguments are not exposed CHECK(c74::max::atom_gettype(&got) == c74::max::A_FLOAT); CHECK(c74::max::atom_getfloat(&got) == 0.75); } - THEN("the method's name decides nothing by itself: what object_getmethod() answers does") { - CHECK(python::found_method(nullptr) == false); - CHECK(python::found_method(reinterpret_cast(c74::max::method_false)) == false); - CHECK(python::found_method(reinterpret_cast(c74::max::object_getmethod)) == true); + THEN("found means neither null nor what Max answers for a name it does not have (1.0.2)") { + const auto answered = c74::max::object_getmethod(nullptr, c74::max::gensym("maxtest_host_answers")); + CHECK(python::found_method(nullptr, not_found) == false); + CHECK(python::found_method(not_found, not_found) == false); + CHECK(python::found_method(answered, not_found) == true); + CHECK(my_object.not_found_method() == not_found); // asked of Max, not taken from &method_false } THEN("audio is bound regardless") { CHECK(all_equal(render(my_object, 0.5), 0.5));