Compare commits

..

No commits in common. "0a3c48fc57989a12fb17baf1072c67834f7287c8" and "37e7470f265dfbaed996ebe45404bfefb7f6478b" have entirely different histories.

12 changed files with 78 additions and 208 deletions

View File

@ -78,7 +78,6 @@ Bug Fixes
for ``asyncio``-side errors to not propagate due to a race condition. for ``asyncio``-side errors to not propagate due to a race condition.
The implementation fix summary is: The implementation fix summary is:
- add state to signal the end of the ``trio`` side task to be - add state to signal the end of the ``trio`` side task to be
read by the ``asyncio`` side and always cancel any ongoing read by the ``asyncio`` side and always cancel any ongoing
task in such cases. task in such cases.

View File

@ -16,14 +16,6 @@ Rendering policy,
emitted as a literal block so no content is ever emitted as a literal block so no content is ever
silently dropped. silently dropped.
A render that is *attempted and fails* (a ``d2`` bin is
present but errors on the source) is NOT silently
degraded to the stale committed SVG: it raises a
docutils error (so ``sphinx-build -W`` fails the
build). The render is also atomic a failed/torn
render can never clobber a good committed SVG so the
last-good diagram survives a bad edit.
Binary discovery order, Binary discovery order,
- the ``D2_BIN`` env var; may contain args which are - the ``D2_BIN`` env var; may contain args which are
@ -45,13 +37,11 @@ Usage,
''' '''
from __future__ import annotations from __future__ import annotations
import enum
import os import os
from pathlib import Path from pathlib import Path
import shlex import shlex
import shutil import shutil
import subprocess as sp import subprocess as sp
import tempfile
from docutils import nodes from docutils import nodes
from docutils.parsers.rst import directives from docutils.parsers.rst import directives
@ -65,22 +55,6 @@ log = logging.getLogger(__name__)
# git-committed, fallback SVG outputs. # git-committed, fallback SVG outputs.
_outdir: str = '_diagrams' _outdir: str = '_diagrams'
# per-build map of {output-svg-name: source-relpath} used
# to detect 2 distinct `.d2` sources colliding on a single
# output stem; reset on each `builder-inited` (see `setup`).
_seen_outputs: dict[str, str] = {}
class RenderResult(enum.Enum):
'''
Outcome of a `render_svg()` call.
'''
OK = 'ok' # fresh SVG exists on disk
FELL_BACK = 'fell_back' # no bin; serving committed SVG
NO_OUTPUT = 'no_output' # no bin AND no committed SVG
FAILED = 'failed' # bin present, render errored
def find_d2( def find_d2(
app: Sphinx, app: Sphinx,
@ -103,54 +77,37 @@ def render_svg(
app: Sphinx, app: Sphinx,
src: Path, src: Path,
out: Path, out: Path,
) -> RenderResult: ) -> bool:
''' '''
Maybe (re)render `src` -> `out` and report the Maybe (re)render `src` -> `out`, returning
outcome as a `RenderResult`. `True` when an up-to-date SVG exists after the
call.
A render is performed only when `out` is missing or
older than `src` AND a `d2` binary is available. The
write is atomic (temp-file + `os.replace`) so a
failed or torn render never clobbers an existing
(committed) SVG.
''' '''
fresh: bool = ( stale: bool = (
out.exists() not out.exists()
and or
src.stat().st_mtime <= out.stat().st_mtime src.stat().st_mtime > out.stat().st_mtime
) )
if fresh: if not stale:
return RenderResult.OK return True
d2cmd: list[str]|None = find_d2(app) d2cmd: list[str]|None = find_d2(app)
if d2cmd is None: if d2cmd is None:
# no binary: fall back to whatever is committed,
# else signal the caller to emit the raw source.
if out.exists(): if out.exists():
log.info( log.info(
f'no d2 binary; using committed svg ' f'no d2 binary; using committed svg '
f'for {src.name}' f'for {src.name}'
) )
return RenderResult.FELL_BACK return True
return RenderResult.NO_OUTPUT return False
out.parent.mkdir( out.parent.mkdir(
parents=True, parents=True,
exist_ok=True, exist_ok=True,
) )
# render into a sibling temp-file first so a bad
# `d2` exit (or a crash mid-write) leaves any prior
# committed SVG fully intact.
fd, tmpname = tempfile.mkstemp(
dir=str(out.parent),
prefix=f'.{out.stem}.',
suffix='.svg.tmp',
)
os.close(fd)
tmp = Path(tmpname)
argv: list[str] = ( argv: list[str] = (
d2cmd d2cmd
+ list(app.config.d2_args) + list(app.config.d2_args)
+ [str(src), str(tmp)] + [str(src), str(out)]
) )
try: try:
proc = sp.run( proc = sp.run(
@ -163,22 +120,18 @@ def render_svg(
OSError, OSError,
sp.TimeoutExpired, sp.TimeoutExpired,
) as err: ) as err:
tmp.unlink(missing_ok=True)
log.warning( log.warning(
f'd2 invocation failed for {src.name}: ' f'd2 invocation failed for {src.name}: '
f'{err}' f'{err}'
) )
return RenderResult.FAILED return out.exists()
if proc.returncode != 0: if proc.returncode != 0:
tmp.unlink(missing_ok=True)
log.warning( log.warning(
f'd2 render error for {src.name}:\n' f'd2 render error for {src.name}:\n'
f'{proc.stderr}' f'{proc.stderr}'
) )
return RenderResult.FAILED return out.exists()
# atomic swap-in of the freshly rendered SVG. return True
os.replace(tmp, out)
return RenderResult.OK
class D2Diagram(SphinxDirective): class D2Diagram(SphinxDirective):
@ -216,54 +169,18 @@ class D2Diagram(SphinxDirective):
/ _outdir / _outdir
/ f'{src.stem}.svg' / f'{src.stem}.svg'
) )
# collision guard: two distinct sources must not if not render_svg(self.env.app, src, out):
# map onto the same output stem within a build. # last resort: emit the raw d2 source.
prior: str|None = _seen_outputs.get(out.name)
if (
prior is not None
and
prior != relsrc
):
err = self.state_machine.reporter.error(
f'd2 output collision: {relsrc!r} and '
f'{prior!r} both render to '
f'{_outdir}/{out.name}; rename one '
f'`.d2` stem',
line=self.lineno,
)
return [err]
_seen_outputs[out.name] = relsrc
result: RenderResult = render_svg(
self.env.app,
src,
out,
)
if result is RenderResult.FAILED:
# loud, build-failing (under `-W`) signal; the
# prior committed SVG, if any, is untouched.
err = self.state_machine.reporter.error(
f'd2 render failed for {relsrc} (see '
f'build log); the committed svg, if any, '
f'was left untouched',
line=self.lineno,
)
return [err]
if result is RenderResult.NO_OUTPUT:
# last resort: emit the raw d2 source so no
# content is ever silently dropped.
log.warning( log.warning(
f'no svg available for {relsrc}; ' f'no svg available for {relsrc}; '
f'emitting d2 source as literal block' f'emitting d2 source as literal block'
) )
src_text: str = src.read_text()
literal = nodes.literal_block( literal = nodes.literal_block(
src_text, src.read_text(),
src_text, src.read_text(),
) )
literal['language'] = 'text' literal['language'] = 'text'
return [literal] return [literal]
# OK | FELL_BACK -> embed the SVG figure.
img = nodes.image( img = nodes.image(
uri=f'/{_outdir}/{out.name}', uri=f'/{_outdir}/{out.name}',
alt=self.options.get( alt=self.options.get(
@ -307,29 +224,12 @@ class D2Diagram(SphinxDirective):
return [fig] return [fig]
def _reset_seen(
app: Sphinx,
) -> None:
'''
Clear the per-build output-collision map at the
start of each build.
'''
_seen_outputs.clear()
def setup(app: Sphinx) -> dict: def setup(app: Sphinx) -> dict:
app.add_config_value('d2_bin', None, 'env') app.add_config_value('d2_bin', None, 'env')
app.add_config_value('d2_args', [], 'env') app.add_config_value('d2_args', [], 'env')
app.add_directive('d2', D2Diagram) app.add_directive('d2', D2Diagram)
app.connect('builder-inited', _reset_seen)
return { return {
'version': '0.1.0', 'version': '0.1.0',
# NB: run() writes the rendered SVG during the
# READ phase; the temp-file + `os.replace` swap
# keeps that atomic so concurrent renders of the
# same diagram (under `sphinx-build -j`) can't
# tear the output file.
'parallel_read_safe': True, 'parallel_read_safe': True,
'parallel_write_safe': True, 'parallel_write_safe': True,
} }

View File

@ -25,11 +25,11 @@
cpython = "python313"; cpython = "python313";
venv_dir = "py313"; venv_dir = "py313";
pypkgs = pkgs."${cpython}Packages"; pypkgs = pkgs."${cpython}Packages";
in
{
default = pkgs.mkShell {
# shared base toolchain for every dev-shell variant; packages = [
# extra-tooling shells (eg. `docs`, below) extend it
# so heavy/niche deps stay OUT of the `default` shell.
basePkgs = [
# XXX, ensure sh completions activate! # XXX, ensure sh completions activate!
pkgs.bashInteractive pkgs.bashInteractive
pkgs.bash-completion pkgs.bash-completion
@ -42,7 +42,7 @@
pkgs.${cpython}# ?TODO^ how to set from `cpython` above? pkgs.${cpython}# ?TODO^ how to set from `cpython` above?
]; ];
baseHook = '' shellHook = ''
# unmask to debug **this** dev-shell-hook # unmask to debug **this** dev-shell-hook
# set -e # set -e
@ -63,26 +63,6 @@
# run the `nix develop` cmd with, # run the `nix develop` cmd with,
# >> nix develop -c uv run xonsh # >> nix develop -c uv run xonsh
''; '';
in
{
default = pkgs.mkShell {
packages = basePkgs;
shellHook = baseHook;
};
# OPT-IN docs shell: `default` + the `d2` diagram
# renderer, kept OUT of `default` so casual dev shells
# don't pull it in. Enter with,
# >> nix develop .#docs
# then build (the `.. d2::` directive auto-detects the
# `d2` bin now on PATH; see `docs/_ext/d2diagrams.py`),
# >> uv run --group docs make -C docs html
docs = pkgs.mkShell {
packages = basePkgs ++ [ pkgs.d2 ];
shellHook = baseHook + ''
echo "[docs] d2 $(d2 --version) on PATH \
build via: uv run --group docs make -C docs html"
'';
}; };
} }
); );

View File

@ -135,7 +135,6 @@ class Context:
communication context. communication context.
(We've also considered other names and ideas: (We've also considered other names and ideas:
- "communicating tasks scope": cts - "communicating tasks scope": cts
- "distributed task scope": dts - "distributed task scope": dts
- "communicating tasks context": ctc - "communicating tasks context": ctc
@ -145,7 +144,6 @@ class Context:
NB: This class should **never be instatiated directly**, it is NB: This class should **never be instatiated directly**, it is
allocated by the runtime in 2 ways: allocated by the runtime in 2 ways:
- by entering `Portal.open_context()` which is the primary - by entering `Portal.open_context()` which is the primary
public API for any "parent" task or, public API for any "parent" task or,
- by the RPC machinery's `._rpc._invoke()` as a `ctx` arg - by the RPC machinery's `._rpc._invoke()` as a `ctx` arg
@ -445,7 +443,6 @@ class Context:
''' '''
Records whether cancellation has been requested for this context Records whether cancellation has been requested for this context
by a call to `.cancel()` either due to, by a call to `.cancel()` either due to,
- an explicit call by some local task, - an explicit call by some local task,
- or an implicit call due to an error caught inside - or an implicit call due to an error caught inside
the `Portal.open_context()` block. the `Portal.open_context()` block.
@ -557,7 +554,6 @@ class Context:
Only as an FYI, in the "child" side case it can also be Only as an FYI, in the "child" side case it can also be
set but never is readable by any task outside the RPC set but never is readable by any task outside the RPC
machinery in `._invoke()` since,: machinery in `._invoke()` since,:
- when a child side calls `.cancel()`, `._scope.cancel()` - when a child side calls `.cancel()`, `._scope.cancel()`
is called immediately and handled specially inside is called immediately and handled specially inside
`._invoke()` to raise a `ContextCancelled` which is then `._invoke()` to raise a `ContextCancelled` which is then
@ -1037,8 +1033,8 @@ class Context:
`._scope.cancel()` and delivering an `ContextCancelled` `._scope.cancel()` and delivering an `ContextCancelled`
ack msg in reponse. ack msg in reponse.
**Behaviour:** Behaviour:
---------
- after the far end cancels, the `.cancel()` calling side - after the far end cancels, the `.cancel()` calling side
should receive a `ContextCancelled` with the should receive a `ContextCancelled` with the
`.canceller: tuple` uid set to the current `Actor.aid.uid`. `.canceller: tuple` uid set to the current `Actor.aid.uid`.
@ -1509,7 +1505,6 @@ class Context:
TODO->( this is doc-driven-dev content not yet actual ;P ) TODO->( this is doc-driven-dev content not yet actual ;P )
The final "outcome" from an IPC context which can be any of: The final "outcome" from an IPC context which can be any of:
- some `outcome.Value` which boxes the returned output from the peer task's - some `outcome.Value` which boxes the returned output from the peer task's
`@context`-decorated remote task-as-func, or `@context`-decorated remote task-as-func, or
- an `outcome.Error` wrapping an exception raised that same RPC task - an `outcome.Error` wrapping an exception raised that same RPC task
@ -2071,7 +2066,7 @@ async def open_context_from_portal(
allows for deterministic setup and teardown of a remotely allows for deterministic setup and teardown of a remotely
scheduled task in another remote actor. Once opened, the 2 now scheduled task in another remote actor. Once opened, the 2 now
"linked" tasks run completely in parallel in each actor's "linked" tasks run completely in parallel in each actor's
runtime with their enclosing `trio.CancelScope`\\ s kept in runtime with their enclosing `trio.CancelScope`s kept in
a synced state wherein if either side errors or cancels an a synced state wherein if either side errors or cancels an
equivalent error is relayed to the other side via an SC-compat equivalent error is relayed to the other side via an SC-compat
IPC protocol. IPC protocol.

View File

@ -824,8 +824,7 @@ class ContextCancelled(RemoteActorError):
- (simulating) an IPC transport network outage - (simulating) an IPC transport network outage
- a (malicious) pkt sent specifically to cancel an actor's - a (malicious) pkt sent specifically to cancel an actor's
runtime non-gracefully without ensuring ongoing RPC tasks are runtime non-gracefully without ensuring ongoing RPC tasks are
incrementally cancelled as is done with:: incrementally cancelled as is done with:
`Actor` `Actor`
|_`.cancel()` |_`.cancel()`
|_`.cancel_soon()` |_`.cancel_soon()`

View File

@ -321,7 +321,6 @@ def open_crash_handler(
`typer` users so they can quickly wrap cmd endpoints which get `typer` users so they can quickly wrap cmd endpoints which get
automatically wrapped to use the runtime's `debug_mode: bool` automatically wrapped to use the runtime's `debug_mode: bool`
AND `pdbp.pm()` around any code that is PRE-runtime entry AND `pdbp.pm()` around any code that is PRE-runtime entry
- any sync code which runs BEFORE the main call to - any sync code which runs BEFORE the main call to
`trio.run()`. `trio.run()`.

View File

@ -187,7 +187,7 @@ def mk_pdb() -> PdbREPL:
FURTHER, the `pdbp.Pdb` instance is configured to be `trio` FURTHER, the `pdbp.Pdb` instance is configured to be `trio`
"compatible" from a SIGINT handling perspective; we mask out "compatible" from a SIGINT handling perspective; we mask out
the default `pdb` handler and instead apply `trio`\\ s default the default `pdb` handler and instead apply `trio`s default
which mostly addresses all issues described in: which mostly addresses all issues described in:
- https://github.com/python-trio/trio/issues/1155 - https://github.com/python-trio/trio/issues/1155

View File

@ -253,7 +253,6 @@ async def query_actor(
listening @ `regaddr`. listening @ `regaddr`.
Yields a `tuple` of `(addr, reg_portal)` where, Yields a `tuple` of `(addr, reg_portal)` where,
- `addr` is the transport protocol (socket) address or `None` if - `addr` is the transport protocol (socket) address or `None` if
no entry under that name exists, no entry under that name exists,
- `reg_portal` is the `Portal` (or `LocalPortal` when the - `reg_portal` is the `Portal` (or `LocalPortal` when the
@ -440,7 +439,7 @@ async def wait_for_actor(
) -> AsyncGenerator[Portal, None]: ) -> AsyncGenerator[Portal, None]:
''' '''
Wait on at least one peer actor to register `name` with the Wait on at least one peer actor to register `name` with the
registrar, yield a `Portal` to the first registree. registrar, yield a `Portal to the first registree.
''' '''
actor: Actor = current_actor() actor: Actor = current_actor()

View File

@ -371,7 +371,7 @@ class MsgCodec(Struct):
A IPC msg interchange format lib's encoder + decoder pair. A IPC msg interchange format lib's encoder + decoder pair.
Pretty much nothing more then delegation to underlying Pretty much nothing more then delegation to underlying
`msgspec.<interchange-protocol>.Encoder/Decoder`\\ s for now. `msgspec.<interchange-protocol>.Encoder/Decoder`s for now.
''' '''
_enc: msgpack.Encoder _enc: msgpack.Encoder

View File

@ -453,7 +453,7 @@ class Error(
# omit_defaults=True, # omit_defaults=True,
): ):
''' '''
A pkt that wraps `RemoteActorError`\\ s for relay and raising. A pkt that wraps `RemoteActorError`s for relay and raising.
Fields are 1-to-1 meta-data as needed originally by Fields are 1-to-1 meta-data as needed originally by
`RemoteActorError.msgdata: dict` but now are defined here. `RemoteActorError.msgdata: dict` but now are defined here.

View File

@ -252,7 +252,6 @@ class ActorNursery:
''' '''
Records whether cancellation has been requested for this Records whether cancellation has been requested for this
actor-nursery by a call to `.cancel()` either due to, actor-nursery by a call to `.cancel()` either due to,
- an explicit call by some actor-local-task, - an explicit call by some actor-local-task,
- an implicit call due to an error/cancel emited inside - an implicit call due to an error/cancel emited inside
the `tractor.open_nursery()` block. the `tractor.open_nursery()` block.

View File

@ -120,12 +120,12 @@ async def gather_contexts(
None, None,
]: ]:
''' '''
Concurrently enter a sequence of async context managers (`acm`\\ s), Concurrently enter a sequence of async context managers (`acm`s),
each scheduled in a separate `trio.Task` and deliver their each scheduled in a separate `trio.Task` and deliver their
unwrapped `yield`-ed values in the same order once all `@acm`\\ s unwrapped `yield`-ed values in the same order once all `@acm`s
in every task have entered. in every task have entered.
On exit, all `acm`\\ s are subsequently and concurrently exited with On exit, all `acm`s are subsequently and concurrently exited with
**no order guarantees**. **no order guarantees**.
This function is somewhat similar to a batch of non-blocking This function is somewhat similar to a batch of non-blocking