Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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",
Expand Down
10 changes: 9 additions & 1 deletion docs/PRODUCTION-PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 16 additions & 7 deletions source/projects/tap.python_tilde/tap.python_tilde.h
Original file line number Diff line number Diff line change
Expand Up @@ -386,12 +386,21 @@ class python : public object<python>, 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>(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:
Expand Down Expand Up @@ -487,7 +496,7 @@ class python : public object<python>, 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
Expand Down
30 changes: 22 additions & 8 deletions source/projects/tap.python_tilde/tap.python_tilde_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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<method>(answered_method);
}
return reinterpret_cast<method>(method_false);
return reinterpret_cast<method>(max_method_false);
}
void* qelem_new(void*, method) {
static int s_qelem;
Expand Down Expand Up @@ -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>(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>(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
Expand Down Expand Up @@ -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>(c74::max::method_false)) == false);
CHECK(python::found_method(reinterpret_cast<c74::max::method>(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));
Expand Down
Loading