Skip to content

stdlib: threading.Thread edges diverge — join() before start() escapes as System.Threading.ThreadStateException, a default name is None #2261

Closed

Description

@antonsynd

Class

threading.Thread's surface diverges from CPython at its edges. Found while making threading.Timer a Thread (#2251, 474894f). Contract: every Thread operation gives CPython's value, or raises CPython's exception type, which a Sharpy except clause can catch.

Cells

cell program observed @ sha expected (python3 3.12) lane reach found by
join before start t = threading.Thread(target=f) · try: t.join() · except RuntimeError: print("caught") unhandled System.Threading.ThreadStateException: Thread has not been started. — except RuntimeError does not catch it @ b44111020 RuntimeError: cannot join thread before it is started (caught) runtime-throw probe #2251 chore
default name print(threading.Thread(target=f).name) None @ b44111020 Thread-1 (f) silent-wrong probe #2251 chore

The name is a value (a COMPARED surface), not message text, so it is a bug unless a ruling records a deviation. Timer inherits both cells.

Labels: class-tracker, lane:runtime-throw, reach:probe. Found by the 2026-10-05 chores round (#2251).

Activity

  1. added
    lane:runtime-throwThrows at runtime where python does not, or the wrong exception type
    reach:probeNeeds an escaped spelling, a wrong-annotation probe or a python3 message diff
    class-trackerThe one issue per class: carries the contract and the cell table
    on Oct 6, 2026
  2. antonsynd commented on Oct 6, 2026

    @antonsynd
    OwnerAuthor

    Batching 2026-10-06 @ 5942b6d — charter .claude/plans/remediation-round-2026-09-20-batching.md §3.0, chores (order 0; no plan) — in TWO parts: part 1 now, part 2 directly after P54 (#2250). This issue is the tracker and stays open across both. Relabelled reach:program (an ordinary t.is_alive() is refused). Rulings R-FC, R-FD, R-FE, R-FF (owner, 2026-10-06).

    Contract: every Thread operation gives CPython's value, or raises CPython's exception type, which a Sharpy except clause can catch. Lane / reach: runtime-throw · program. Matrix: operation {start, join, join(timeout), daemon get/set, name get/set, is_alive, ident} × state {initial, alive, finished, current thread} × host {Thread, Timer, subclass, the current_thread() / main_thread() wrapper}, plus module function {current_thread, main_thread, enumerate, active_count} × caller {main thread, worker}. Expectations are python3 3.12 literals in one table-driven theory.

    Cells measured @ 5942b6d (Debug binary built 2026-10-06 01:26; python3 3.12.13):

    cell program observed python3 part
    join before start t.join() inside try … except RuntimeError unhandled System.Threading.ThreadStateException RuntimeError (caught) 1
    new: daemon on a finished thread t.start(); t.join(); t.daemon = True unhandled ThreadStateException: Thread is dead RuntimeError: cannot set daemon status of active thread 1
    new: join the current thread threading.current_thread().join() hangs RuntimeError: cannot join current thread 1
    start twice t.start() after a join RuntimeError (caught) same matches
    new: is_alive kind t.is_alive() SPY0230 "Expression of type 'bool' is not callable" (t.is_alive prints False; Event.is_set() is a method) a method 1
    new: args= / kwargs= Thread(target=g, args=(3,)) SPY0234 (the target=lambda: g(3) twin runs and prints 3) runs 1 — ledger row, R-FC
    new: ident before start print(t.ident) 4 None 1 — ledger row, R-FE
    default name Thread(target=f).name; a subclass; current_thread().name; main_thread().name None ×4 Thread-1 (f); Thread-N; MainThread; MainThread 2 — R-FD
    new: main_thread() from a worker main_thread().ident == current_thread().ident True False 2
    new: enumerate() from a worker len(threading.enumerate()) 1 2 2
    new: active_count() after the join threading.active_count() 9 1 2

    A subclass's run needs @override (the #2138 ruling) — not a cell.

    Part 1 (now; adds no state): the three exception-type cells; is_alive becomes a method; the two ledger rows. Part 2 (directly after P54; adds process-wide state): the default name and MainThread; main_thread(), enumerate(), active_count(). Timer inherits every cell.

    Ruling R-FC — "a: deviation row with the lambda steer". Thread(target=…, args=…, kwargs=…) is the row no-thread-args-kwargs; its executed example is threading.Thread(target=lambda: g(3)). Rejected: (b) typed generic factories for arity 1–N — C# constructors cannot be generic, so they would be module-level factories beside the type and a subclass would not get them; it stacks later (additive); (c) untyped args: tuple invoked dynamically — runtime type checking.

    Ruling R-FD — "a: Thread-N now, MainThread for the main thread, the (target) suffix as a cell on #2105". name becomes a non-null str. Rejected: (b) the full Thread-N (f) in one step after P11g (name stays None until then); (c) a deviation row with name left None (a str? narrowed at every use). The printed value changes twice for target threads.

    Ruling R-FE — "a: ledger row". ident before start() is the row thread-ident-before-start; the id's value is an implementation detail. Rejected: (b) int | None (narrowing at every use; C# int → int?).

    Ruling R-FF — "b: split at the state boundary". Rejected: (a) one chore now (adds process-wide state before P54's harness exists); (c) an own group after P54 with P38's roster extended to the threading family (a verify round for a day of work).

    Probe note: the join-current-thread cell deadlocks the compiled program; run it under a timeout.

  3. added
    reach:programAn ordinary program a Python programmer would write hits it
    and removed
    reach:probeNeeds an escaped spelling, a wrong-annotation probe or a python3 message diff
    on Oct 6, 2026
  4. antonsynd commented on Oct 6, 2026

    @antonsynd
    OwnerAuthor

    Part 1 landed (R-FF), in commit 7ab7a97 on dev. The issue stays open for part 2, which comes directly after P54 (R-FD: default names Thread-N / MainThread, main_thread(), enumerate(), active_count()).

    What changed. Thread keeps a started flag and a cached daemon flag, and checks those instead of the .NET thread's state. The flag is set atomically by start(), and the current_thread() wrapper is created started. So start() twice, join() before start(), join() on the current thread, and setting daemon once started each raise RuntimeError with python's message, and a Sharpy except RuntimeError catches it. is_alive() is now a method, like Event.is_set().

    Guard. ThreadStateMatrixTests (Stdlib.Tests) runs one Sharpy program and compares its 81 output lines with the output of the same program written in python, run by python3 3.12.13. The matrix:

    • operation {start(), join(), join(0.01), daemon, daemon = True, is_alive(), name = "x" read back}
    • × state {initial, alive, finished}
    • × host {Thread, Timer, a subclass}
    • plus current_thread() on the main thread and on a worker, and a worker acting on its own Thread.

    Alive threads are held by an Event, so no cell depends on timing.

    Mutations. All five go red: dropping each of the three state checks, reverting the daemon cache, and reverting is_alive to a property. Details are in the commit body. The gate result is below.

    New cell found by the matrix. Reading t.daemon on a finished thread also escaped ThreadStateException, because .NET's IsBackground getter throws once a thread is dead. Fixed here.

    Ledger rows. Every example in both rows was executed on both sides at this tree.

    • no-thread-args-kwargs (R-FC). Correction to the batching note: kwargs= is refused with SPY0234 alone. The SPY0227 in the earlier probe came from its mixed int/str dict literal, not from kwargs=.
    • thread-ident-before-start (R-FE).

    Out of scope, filed by class: #2263. A threading timeout or interval is converted to a .NET wait with (int)(timeout * 1000), so the result is not python's verdict:

    • a negative join / Event.wait / Barrier.wait / Timer value escapes ArgumentOutOfRangeException;
    • a negative Lock / Semaphore timeout hangs;
    • 1e10 hangs or saturates instead of raising OverflowError.

    This was found on the prober budget's timeout-value axis.

    Gate: whole solution 57951/0/4 @ 5942b6d + both slices (measured); spy-stdlib, spy-test, stdlib-doc staleness, whitespace and allowlist issue-state clean. Pushed dev + mainline @ 90f7ff9.

  5. antonsynd commented on Oct 6, 2026

    @antonsynd
    OwnerAuthor

    Cell (found by the #2263 chore, 2026-10-06 @ 90f7ff9): an exception raised in a thread's target ends the whole process.

    import threading
    
    def boom() -> None:
        raise ValueError("boom")
    
    def main():
        t = threading.Thread(boom)
        t.start()
        t.join()
        print("main continues after thread exception")

    → observed: Unhandled exception. Sharpy.ValueError: boom, exit 134, and the last line never prints. Expected (python3 3.12.13, the same program with target=boom and a main() call): Exception in thread Thread-1 (boom): plus the traceback on stderr (threading.excepthook), then main continues after thread exception, exit 0. Lane silent-wrong, reach program. Operation axis: target raises × host {Thread, Timer, subclass run}.

    A member of the cell after #2263: threading.Timer(1e10, f).start() now raises OverflowError on the timer thread as python does, and that ends the process. Python prints Exception in thread Thread-1: / OverflowError: …, never calls f, and the main thread continues. Before #2263 the timer waited about 24.8 days. The #2263 guard leaves out Timer(1e10) and cites this cell.

  6. antonsynd commented on Oct 22, 2026

    @antonsynd
    OwnerAuthor

    Landed (chores band 2026-10-22) on dev @ 4d22212, PR #2346 to mainline. Whole-solution gate 81669/0/7 @ 4d22212 (measured), against 81093/0/7 @ be4664e.

    Part 2, commit bdd442f (R-FD a, R-FF b).

    Direction, from a worker (python3 3.12.13 in brackets):

    • main ident == current ident: True → False [False].
    • len(enumerate()): 1 → 2 [2].
    • active_count() after the join: 9 → 1 [1].

    Guards:

    • ThreadStateMatrixTests is extended to 126 lines checked against python.
    • The P54 roster gains ThreadRegistry._counter and ThreadRegistry._active.
    • Seven mutations, each red.

    Stays open. The 2026-10-06 cell (an exception raised in a thread's target ends the whole process; Timer(1e10) is a member) was not in either part. StdlibTimeoutMatrixTests cites it. This issue is now its tracker until /batch-issues places it.

  7. added a commit that references this issue on Oct 22, 2026
  8. antonsynd commented on Oct 22, 2026

    @antonsynd
    OwnerAuthor

    Batching 2026-10-22 @ 4d22212 — charter .claude/plans/remediation-round-2026-09-20-batching.md §3.0, chores — call-28 band: #2261 part 3 (order 0, one commit, no plan; rides P21e's gate; runs under P54's harness). Ruling R-IG (owner, 2026-10-22): D7 a — a Stdlib Threading/ chore; this issue CLOSES on the python-twin table.

    Cell (the 2026-10-06 comment): t = threading.Thread(target=boom); t.start(); t.join(); print("main continues") with boom raising ValueError("boom") → observed Unhandled exception. Sharpy.ValueError: boom at Sharpy.Thread.<>c__DisplayClass5_0.<.ctor>b__0(), exit 134, the last line never printed @ be4664e; expected (python3 3.12) Exception in thread Thread-1 (boom): + traceback on stderr via threading.excepthook, the last line printed, exit 0. Lane runtime-throw, reach program. Sibling cells @ be4664e: a daemon thread's child reports daemon False (python True — daemon=None inherits the creator's flag; Thread.cs:62 sets IsBackground from a ctor parameter defaulting to false); Thread.cs:71,79 expose settable Name/Daemon while docs/stdlib/threading.md's Properties table lists only ident (docs generator or doc-comment gap — the chore finds which); the threading_stub.spy docstring staleness is the band agent's note (unverified).

    R-IG (a): the target — and a subclass's run — executes under a wrapper that writes Exception in thread <name>: plus the python-spelled exception type and message to stderr and returns, so the process continues; matrix target raises × host {Thread, Timer, subclass run} × exception kind {Sharpy exception, CLR exception}; exit code and "main continues" are COMPARED (control flow), the stderr text beyond the type name is deviation (R-CI); ThreadStateMatrixTests extended; the Timer(1e10) cell joins and the StdlibTimeoutMatrixTests cite drains in the same commit (check_allowlist_issue_state.sh reddens if this issue closes first); the daemon-inheritance row, the docs table and the stub docstring ride; a hanging cell runs in its own process group under a timeout. Rejected: (b) a P33 cell (P33's contract is the exception TYPE hierarchy and except*; this is where an unhandled exception goes, not what type it is; row 8 behind the keyword codemods); (c) an own group (one seam, three hosts). A program cannot depend on a crash — two-way in practice. R-FC/R-FD/R-FE/R-FF stand.

  9. antonsynd commented on Oct 22, 2026

    @antonsynd
    OwnerAuthor

    Landed: part 3 @ 5782aa8 (R-IG).

    An exception escaping a thread's target, run() or Timer function is reported on stderr and the process continues:

    • The report is Exception in thread NAME: + Type: message, CPython's first and last lines. The traceback frames are R-CI deviation text.
    • SystemExit is silent.
    • Before: Unhandled exception. Sharpy.ValueError: boom, exit 134.

    Daemon inheritance: daemon is bool? = None and inherits the creator's flag. A child of a daemon thread reports True here and in python3.

    The Timer(1e10) cell: it joins the matrix (OverflowError in the timer thread, reported, main continues). StdlibTimeoutMatrixTests no longer cites this issue.

    Docs: the Thread Properties table now lists name and daemon. The cause was the docs generator, which skipped every property whose accessor block opens on the next line; fixing it added 133 lines over 19 pages. The newly rendered re.Pattern.regex (.NET Regex) is posted on #2164.

    Stub: threading_stub.spy's enumerate() docstring and body now delegate to ThreadingModule.enumerate().

    Guard: ThreadStateMatrixTests.AnExceptionInAThread_IsReportedOnStderr_AndTheProcessContinues compares the exit code, the main lines and the report lines against python3 3.12.13.

    Mutation records:

    • catch removed: red, exit 134.
    • daemon ?? false: red.
    • Generator fix reverted: the new pytest goes red.

    Gate follow-up @ c771866: inheriting the flag reads current_thread(), which on a thread the threading module did not start makes a Dummy-N that draws a counter number. python3 3.12 does the same (Thread() on a _thread thread: ['MainThread', 'Dummy-2'], next name Thread-3). The module-global concurrency harness's own threads are such threads, so 15 of its cells went red. Its constructions now pass daemon explicitly, as python's Thread(daemon=False) skips current_thread(). Expected values are unchanged.

    Closing: every part is done (part 1 @ 7ab7a97, part 2 @ bdd442f, part 3 here).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    class-trackerThe one issue per class: carries the contract and the cell tablelane:runtime-throwThrows at runtime where python does not, or the wrong exception typereach:programAn ordinary program a Python programmer would write hits it

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions