tricu

An interpreted language for exploring Tree Calculus
Log | Files | Refs | README | LICENSE

commit 207ccd39e38bf71b36dfa2f36cad4f3f33b51c0c
parent de9c334dfaf01264cca9a1ed4c59c620c622c710
Author: James Eversole <james@eversole.co>
Date:   Sat, 24 Oct 2026 16:42:17 -0500

Consolidate current IO implementation names and canonical ownership

Preserve wire tokens, protocol versions, package coordinates and authenticated historical cohorts. Canonical support ownership uses a new v6 fixture contract. Candidate validation: 160 Python controls, 920/922 reference tests (two require the new IO HTTP fixture), scoped generation controls, and full-library frozen-parser formatting equivalence. Compiler/support/site publication and fresh-pair acceptance remain pending.

Diffstat:
Dbootstrap/compiler-source-map.io-v2.v1 | 32--------------------------------
Mbootstrap/compiler-source-map.v1 | 8++++----
Mdemos/arboricx-inspection/main.tri | 4++--
Mdemos/interactionTrees.tri | 6+++---
Mdemos/interactionTrees/arboricxServer.tri | 8++++----
Mdemos/interactionTrees/echoServer.tri | 2+-
Mdemos/interactionTrees/environment.tri | 6+++---
Mdemos/interactionTrees/forkAwait.tri | 4++--
Mdemos/interactionTrees/getLineAsync.tri | 4++--
Mdemos/interactionTrees/greet.tri | 4++--
Mdemos/interactionTrees/httpServer.tri | 4++--
Mdemos/interactionTrees/safeRead.tri | 4++--
Mdemos/interactionTrees/shout.tri | 4++--
Mdemos/interactionTrees/state.tri | 4++--
Mdemos/interactionTrees/writeThenRead.tri | 4++--
Mdemos/interactionTrees/yield.tri | 6+++---
Ddemos/io-v2/clock.tri | 32--------------------------------
Ddemos/io-v2/context.tri | 5-----
Ddemos/io-v2/copy.tri | 78------------------------------------------------------------------------------
Ddemos/io-v2/directory.tri | 46----------------------------------------------
Ddemos/io-v2/indexed-read.tri | 16----------------
Ddemos/io-v2/indexed-write.tri | 44--------------------------------------------
Ddemos/io-v2/lines.tri | 50--------------------------------------------------
Ddemos/io-v2/objects.tri | 14--------------
Ddemos/io-v2/positioned.tri | 39---------------------------------------
Ddemos/io-v2/process.tri | 38--------------------------------------
Ddemos/io-v2/publish.tri | 76----------------------------------------------------------------------------
Ddemos/io-v2/registry.tri | 41-----------------------------------------
Ddemos/io-v2/streams.tri | 31-------------------------------
Ddemos/io-v2/tcp.tri | 74--------------------------------------------------------------------------
Ddemos/io-v2/text.tri | 24------------------------
Ademos/io/clock.tri | 32++++++++++++++++++++++++++++++++
Ademos/io/context.tri | 5+++++
Ademos/io/copy.tri | 78++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ademos/io/directory.tri | 46++++++++++++++++++++++++++++++++++++++++++++++
Ademos/io/indexed-read.tri | 16++++++++++++++++
Ademos/io/indexed-write.tri | 44++++++++++++++++++++++++++++++++++++++++++++
Ademos/io/lines.tri | 50++++++++++++++++++++++++++++++++++++++++++++++++++
Ademos/io/objects.tri | 14++++++++++++++
Ademos/io/positioned.tri | 39+++++++++++++++++++++++++++++++++++++++
Ademos/io/process.tri | 38++++++++++++++++++++++++++++++++++++++
Ademos/io/publish.tri | 76++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ademos/io/registry.tri | 41+++++++++++++++++++++++++++++++++++++++++
Ademos/io/streams.tri | 31+++++++++++++++++++++++++++++++
Ademos/io/tcp.tri | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ademos/io/text.tri | 24++++++++++++++++++++++++
Ademos/package-inspector-candidate/main.tri | 18++++++++++++++++++
Rdemos/package-inspector-v2/tricu.project -> demos/package-inspector-candidate/tricu.project | 0
Ddemos/package-inspector-v2/main.tri | 18------------------
Mdemos/python-filecheck/README.md | 4++--
Mdemos/python-filecheck/worker.tri | 2+-
Mdemos/runArboricxBundle.tri | 6+++---
Mdocs/build-and-run.md | 14+++++++-------
Mdocs/compiler-rebuilding.md | 28++++++++++++++--------------
Mdocs/contracts.md | 14+++++++-------
Mdocs/host-abi.md | 8++++----
Mdocs/language-features.md | 18+++++++++---------
Mdocs/modules.md | 2+-
Mdocs/storage.md | 2+-
Mdocs/tri-tools.md | 24+++++++++++++++---------
Mdocs/validation.md | 39++++++++++++++++++++-------------------
Mext/demo-site/README.md | 74+++++++++++++++++++++++++++++++++++++-------------------------------------
Mext/demo-site/dist/app.js | 52++++++++++++++++++++++++++--------------------------
Mext/demo-site/dist/definition-inspector.js | 8++++----
Mext/demo-site/dist/execution.js | 2+-
Mext/demo-site/dist/haskell-session.js | 26+++++++++++++-------------
Aext/demo-site/dist/io-core.js | 335+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aext/demo-site/dist/io-driver.js | 102+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rext/demo-site/dist/io-v2-events.js -> ext/demo-site/dist/io-events.js | 0
Aext/demo-site/dist/io-host-work.js | 97+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aext/demo-site/dist/io-output.js | 69+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aext/demo-site/dist/io-private.js | 49+++++++++++++++++++++++++++++++++++++++++++++++++
Aext/demo-site/dist/io-profile.js | 47+++++++++++++++++++++++++++++++++++++++++++++++
Rext/demo-site/dist/io-v2-protocol.js -> ext/demo-site/dist/io-protocol.js | 0
Aext/demo-site/dist/io-resources.js | 225+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aext/demo-site/dist/io-session.js | 216+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rext/demo-site/dist/io-v2-tasks.js -> ext/demo-site/dist/io-tasks.js | 0
Aext/demo-site/dist/io-terminal-view.js | 125+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aext/demo-site/dist/io-terminal.js | 77+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rext/demo-site/dist/io-v2-utf8.js -> ext/demo-site/dist/io-utf8.js | 0
Dext/demo-site/dist/io-v2-core.js | 335-------------------------------------------------------------------------------
Dext/demo-site/dist/io-v2-driver.js | 102-------------------------------------------------------------------------------
Dext/demo-site/dist/io-v2-host-work.js | 97-------------------------------------------------------------------------------
Dext/demo-site/dist/io-v2-output.js | 69---------------------------------------------------------------------
Dext/demo-site/dist/io-v2-private.js | 49-------------------------------------------------
Dext/demo-site/dist/io-v2-profile.js | 47-----------------------------------------------
Dext/demo-site/dist/io-v2-resources.js | 225-------------------------------------------------------------------------------
Dext/demo-site/dist/io-v2-session.js | 216-------------------------------------------------------------------------------
Dext/demo-site/dist/io-v2-terminal-view.js | 125-------------------------------------------------------------------------------
Dext/demo-site/dist/io-v2-terminal.js | 77-----------------------------------------------------------------------------
Mext/demo-site/dist/io.js | 2+-
Mext/demo-site/dist/wasm-worker.js | 18+++++++++---------
Mext/demo-site/src/app.ts | 32++++++++++++++++----------------
Mext/demo-site/src/definition-inspector.ts | 8++++----
Mext/demo-site/src/execution.ts | 2+-
Mext/demo-site/src/haskell-session.ts | 22+++++++++++-----------
Aext/demo-site/src/io-core.ts | 233+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aext/demo-site/src/io-driver.ts | 82+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rext/demo-site/src/io-v2-events.ts -> ext/demo-site/src/io-events.ts | 0
Aext/demo-site/src/io-host-work.ts | 80+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aext/demo-site/src/io-output.ts | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aext/demo-site/src/io-private.ts | 37+++++++++++++++++++++++++++++++++++++
Aext/demo-site/src/io-profile.ts | 43+++++++++++++++++++++++++++++++++++++++++++
Rext/demo-site/src/io-v2-protocol.ts -> ext/demo-site/src/io-protocol.ts | 0
Aext/demo-site/src/io-resources.ts | 169+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aext/demo-site/src/io-session.ts | 157+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rext/demo-site/src/io-v2-tasks.ts -> ext/demo-site/src/io-tasks.ts | 0
Aext/demo-site/src/io-terminal-view.ts | 82+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aext/demo-site/src/io-terminal.ts | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rext/demo-site/src/io-v2-utf8.ts -> ext/demo-site/src/io-utf8.ts | 0
Dext/demo-site/src/io-v2-core.ts | 233-------------------------------------------------------------------------------
Dext/demo-site/src/io-v2-driver.ts | 82-------------------------------------------------------------------------------
Dext/demo-site/src/io-v2-host-work.ts | 80-------------------------------------------------------------------------------
Dext/demo-site/src/io-v2-output.ts | 61-------------------------------------------------------------
Dext/demo-site/src/io-v2-private.ts | 37-------------------------------------
Dext/demo-site/src/io-v2-profile.ts | 43-------------------------------------------
Dext/demo-site/src/io-v2-resources.ts | 169-------------------------------------------------------------------------------
Dext/demo-site/src/io-v2-session.ts | 157-------------------------------------------------------------------------------
Dext/demo-site/src/io-v2-terminal-view.ts | 82-------------------------------------------------------------------------------
Dext/demo-site/src/io-v2-terminal.ts | 60------------------------------------------------------------
Mext/demo-site/src/io.ts | 4++--
Mext/demo-site/src/protocol.ts | 4++--
Mext/demo-site/src/wasm-worker.ts | 16++++++++--------
Mext/demo-site/test/backend-browser.mjs | 6+++---
Mext/demo-site/test/backend-resume-probe.mjs | 12++++++------
Mext/demo-site/test/backend-server.mjs | 14+++++++-------
Mext/demo-site/test/graph.test.mjs | 10+++++-----
Mext/demo-site/test/inline-io-probe.mjs | 16++++++++--------
Aext/demo-site/test/io-core-probe.mjs | 22++++++++++++++++++++++
Aext/demo-site/test/io-core.test.mjs | 111+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aext/demo-site/test/io-events.test.mjs | 33+++++++++++++++++++++++++++++++++
Aext/demo-site/test/io-host-work.test.mjs | 94+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aext/demo-site/test/io-lifecycle-probe.mjs | 49+++++++++++++++++++++++++++++++++++++++++++++++++
Aext/demo-site/test/io-output.test.mjs | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Aext/demo-site/test/io-protocol-probe.mjs | 25+++++++++++++++++++++++++
Aext/demo-site/test/io-protocol.test.mjs | 37+++++++++++++++++++++++++++++++++++++
Aext/demo-site/test/io-public-probe.mjs | 86+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aext/demo-site/test/io-public.test.mjs | 104+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aext/demo-site/test/io-resources.test.mjs | 144+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aext/demo-site/test/io-session-probe.mjs | 47+++++++++++++++++++++++++++++++++++++++++++++++
Aext/demo-site/test/io-session.test.mjs | 73+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aext/demo-site/test/io-source-probe.mjs | 104+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aext/demo-site/test/io-tasks.test.mjs | 132+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aext/demo-site/test/io-terminal.test.mjs | 37+++++++++++++++++++++++++++++++++++++
Aext/demo-site/test/io-utf8.test.mjs | 36++++++++++++++++++++++++++++++++++++
Dext/demo-site/test/io-v2-core-probe.mjs | 22----------------------
Dext/demo-site/test/io-v2-core.test.mjs | 111-------------------------------------------------------------------------------
Dext/demo-site/test/io-v2-events.test.mjs | 33---------------------------------
Dext/demo-site/test/io-v2-host-work.test.mjs | 94-------------------------------------------------------------------------------
Dext/demo-site/test/io-v2-lifecycle-probe.mjs | 49-------------------------------------------------
Dext/demo-site/test/io-v2-output.test.mjs | 51---------------------------------------------------
Dext/demo-site/test/io-v2-protocol-probe.mjs | 25-------------------------
Dext/demo-site/test/io-v2-protocol.test.mjs | 37-------------------------------------
Dext/demo-site/test/io-v2-public-probe.mjs | 86-------------------------------------------------------------------------------
Dext/demo-site/test/io-v2-public.test.mjs | 104-------------------------------------------------------------------------------
Dext/demo-site/test/io-v2-resources.test.mjs | 144-------------------------------------------------------------------------------
Dext/demo-site/test/io-v2-session-probe.mjs | 47-----------------------------------------------
Dext/demo-site/test/io-v2-session.test.mjs | 73-------------------------------------------------------------------------
Dext/demo-site/test/io-v2-source-probe.mjs | 104-------------------------------------------------------------------------------
Dext/demo-site/test/io-v2-tasks.test.mjs | 132-------------------------------------------------------------------------------
Dext/demo-site/test/io-v2-terminal.test.mjs | 37-------------------------------------
Dext/demo-site/test/io-v2-utf8.test.mjs | 36------------------------------------
Mext/demo-site/test/io-version-boundary.test.mjs | 2+-
Mext/demo-site/test/io-version-probe.mjs | 2+-
Mext/demo-site/test/live-ux-probe.mjs | 6+++---
Mext/demo-site/test/module-probe.mjs | 4++--
Mext/demo-site/test/probe-api.mjs | 10+++++-----
Mext/demo-site/test/site.test.mjs | 2+-
Mext/demo-site/test/terminal-host.mjs | 2+-
Mext/haskell-wasm/BrowserRuntime.hs | 6+++---
Mext/haskell-wasm/README.md | 10+++++-----
Mext/haskell-wasm/Reactor.hs | 8++++----
Mext/haskell-wasm/browser-parity.mjs | 26+++++++++++++-------------
Mext/haskell-wasm/browser-worker.mjs | 36++++++++++++++++++------------------
Mext/haskell/README.md | 12++++++------
Mext/haskell/src/Embedding.hs | 4++--
Mext/js/README.md | 2+-
Mflake.nix | 43+++++++++++++++++++++++++++++--------------
Mlib/arboricx/handlers.tri | 54+++++++++++++++++++++++++++---------------------------
Dlib/arboricx/streaming-v2.tri | 158-------------------------------------------------------------------------------
Mlib/arboricx/streaming.tri | 160+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mlib/handlers.tri | 42++++++++++++++++++++----------------------
Mlib/handlers/artifacts.tri | 82++++++++++++++++++++++++++++++++++++++++----------------------------------------
Mlib/handlers/upload.tri | 2+-
Dlib/io-v2.tri | 193-------------------------------------------------------------------------------
Dlib/io-v2/artifacts.tri | 90-------------------------------------------------------------------------------
Dlib/io-v2/http-transport.tri | 61-------------------------------------------------------------
Dlib/io-v2/objects.tri | 85-------------------------------------------------------------------------------
Dlib/io-v2/storage.tri | 172-------------------------------------------------------------------------------
Dlib/io-v2/text.tri | 153-------------------------------------------------------------------------------
Mlib/io.tri | 195+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Alib/io/artifacts.tri | 90+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alib/io/http-transport.tri | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alib/io/objects.tri | 85+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alib/io/storage.tri | 172+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alib/io/text.tri | 153+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dlib/packages/io-v2.tri | 17-----------------
Mlib/packages/io.tri | 8++++----
Mlib/socket.tri | 2+-
Mlib/source/export-cache.tri | 4++--
Dlib/source/io-v2.tri | 51---------------------------------------------------
Alib/source/io.tri | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/IO/ByteStream.hs | 84+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/IO/Clock.hs | 28++++++++++++++++++++++++++++
Asrc/IO/Codec.hs | 112+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/IO/Context.hs | 26++++++++++++++++++++++++++
Asrc/IO/Descriptor.hs | 29+++++++++++++++++++++++++++++
Asrc/IO/Digest.hs | 30++++++++++++++++++++++++++++++
Asrc/IO/Driver.hs | 467+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/IO/Filesystem.hs | 109+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/IO/Network.hs | 97+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/IO/Process.hs | 173+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/IO/Protocol.hs | 82+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/IO/Resolver.hs | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/IO/Resources.hs | 612+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/IO/Staging.hs | 149+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/IO/Tasks.hs | 323+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/IO/UTF8.hs | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/IO/Wire.hs | 45+++++++++++++++++++++++++++++++++++++++++++++
Msrc/IODriver.hs | 6+++---
Dsrc/IOV2/ByteStream.hs | 84-------------------------------------------------------------------------------
Dsrc/IOV2/Clock.hs | 28----------------------------
Dsrc/IOV2/Codec.hs | 112-------------------------------------------------------------------------------
Dsrc/IOV2/Context.hs | 26--------------------------
Dsrc/IOV2/Descriptor.hs | 29-----------------------------
Dsrc/IOV2/Digest.hs | 30------------------------------
Dsrc/IOV2/Driver.hs | 467-------------------------------------------------------------------------------
Dsrc/IOV2/Filesystem.hs | 109-------------------------------------------------------------------------------
Dsrc/IOV2/Network.hs | 97-------------------------------------------------------------------------------
Dsrc/IOV2/Process.hs | 173-------------------------------------------------------------------------------
Dsrc/IOV2/Protocol.hs | 82-------------------------------------------------------------------------------
Dsrc/IOV2/Resolver.hs | 58----------------------------------------------------------
Dsrc/IOV2/Resources.hs | 612-------------------------------------------------------------------------------
Dsrc/IOV2/Staging.hs | 149-------------------------------------------------------------------------------
Dsrc/IOV2/Tasks.hs | 323-------------------------------------------------------------------------------
Dsrc/IOV2/UTF8.hs | 63---------------------------------------------------------------
Dsrc/IOV2/Wire.hs | 45---------------------------------------------
Msrc/InteractionInspection.hs | 6+++---
Msrc/Main.hs | 4++--
Mtest/IOBoundaryTests.hs | 2+-
Atest/IOByteStreamTests.hs | 150+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atest/IOClockTests.hs | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atest/IOContextTests.hs | 39+++++++++++++++++++++++++++++++++++++++
Atest/IOFilesystemTests.hs | 158+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atest/IONetworkTests.hs | 220+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atest/IOPositionTests.hs | 198+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atest/IOProcessTests.hs | 181+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atest/IOProtocolTests.hs | 83+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atest/IOResolverTests.hs | 71+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atest/IOStandardTests.hs | 133+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atest/IOStorageTests.hs | 455+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atest/IOSyncTests.hs | 113+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atest/IOTaskTests.hs | 328+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atest/IOTests.hs | 356+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atest/IOUTF8Tests.hs | 77+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dtest/IOV2ByteStreamTests.hs | 150-------------------------------------------------------------------------------
Dtest/IOV2ClockTests.hs | 68--------------------------------------------------------------------
Dtest/IOV2ContextTests.hs | 39---------------------------------------
Dtest/IOV2FilesystemTests.hs | 158-------------------------------------------------------------------------------
Dtest/IOV2NetworkTests.hs | 220-------------------------------------------------------------------------------
Dtest/IOV2PositionTests.hs | 198-------------------------------------------------------------------------------
Dtest/IOV2ProcessTests.hs | 181-------------------------------------------------------------------------------
Dtest/IOV2ProtocolTests.hs | 83-------------------------------------------------------------------------------
Dtest/IOV2ResolverTests.hs | 71-----------------------------------------------------------------------
Dtest/IOV2StandardTests.hs | 133-------------------------------------------------------------------------------
Dtest/IOV2StorageTests.hs | 455-------------------------------------------------------------------------------
Dtest/IOV2SyncTests.hs | 113-------------------------------------------------------------------------------
Dtest/IOV2TaskTests.hs | 328-------------------------------------------------------------------------------
Dtest/IOV2Tests.hs | 356-------------------------------------------------------------------------------
Dtest/IOV2UTF8Tests.hs | 77-----------------------------------------------------------------------------
Mtest/IndexedWriterTests.hs | 4++--
Mtest/InteractionInspectionTests.hs | 2+-
Mtest/PortableCompilerTests.hs | 2+-
Mtest/ReferenceFixtures.hs | 4++--
Mtest/SnapshotTests.hs | 6+++---
Mtest/Spec.hs | 101++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
Mtest/SupportFixture.hs | 43+++++++++++++++++++++++++++++++++----------
Mtest/arboricx_deployment.py | 2+-
Mtest/arboricx_server_inspection.py | 10+++++-----
Mtest/body_retention.py | 6+++---
Mtest/bootstrap_archive.py | 2+-
Mtest/bootstrap_retention_probe.py | 4++--
Atest/current_io_names.py | 37+++++++++++++++++++++++++++++++++++++
Mtest/effect_interfaces.py | 4++--
Mtest/effect_interfaces/README.md | 2+-
Mtest/effect_interfaces/server.tri | 6+++---
Mtest/effect_interfaces/workflow.tri | 2+-
Mtest/effect_server.py | 4++--
Mtest/executable_arguments.py | 2+-
Mtest/fixture_sources.py | 2+-
Mtest/generation_io.py | 4++--
Mtest/handler_library.py | 2+-
Mtest/handlers/README.md | 2+-
Mtest/handlers/library.tri | 40++++++++++++++++++++--------------------
Mtest/handlers/observe.tri | 2+-
Mtest/handlers/state.tri | 4++--
Mtest/haskell_generation_executor.py | 2+-
Mtest/interaction_inspection.py | 4++--
Dtest/io-v2/README.md | 1475-------------------------------------------------------------------------------
Dtest/io-v2/artifact-service.tri | 63---------------------------------------------------------------
Dtest/io-v2/browser.tri | 64----------------------------------------------------------------
Dtest/io-v2/core-controls.tri | 58----------------------------------------------------------
Dtest/io-v2/core-retention.tri | 26--------------------------
Dtest/io-v2/indexed-transport.tri | 14--------------
Dtest/io-v2/protocol.tri | 24------------------------
Dtest/io-v2/registry-log-failure.tri | 10----------
Dtest/io-v2/tasks.tri | 129-------------------------------------------------------------------------------
Dtest/io-v2/utf8-boundaries.tri | 17-----------------
Atest/io/README.md | 1475+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atest/io/artifact-service.tri | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atest/io/browser.tri | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atest/io/core-controls.tri | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atest/io/core-retention.tri | 26++++++++++++++++++++++++++
Atest/io/indexed-transport.tri | 14++++++++++++++
Atest/io/protocol.tri | 24++++++++++++++++++++++++
Atest/io/registry-log-failure.tri | 10++++++++++
Atest/io/tasks.tri | 129+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atest/io/utf8-boundaries.tri | 17+++++++++++++++++
Atest/io_artifact_service.py | 201+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atest/io_callers.py | 67+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atest/io_clock.py | 56++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atest/io_compiler_store.py | 277+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atest/io_context.py | 37+++++++++++++++++++++++++++++++++++++
Atest/io_demo_servers.py | 84+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtest/io_dependencies.py | 4++--
Atest/io_directory.py | 165+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atest/io_files.py | 107+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atest/io_generation.py | 209+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atest/io_http.py | 243+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atest/io_indexed.py | 103+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atest/io_indexed_write.py | 137+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atest/io_lines.py | 119+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atest/io_objects.py | 123+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atest/io_positioned.py | 107+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atest/io_process.py | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atest/io_protocol.py | 90+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rtest/io_v2_provenance.py -> test/io_provenance.py | 0
Atest/io_publication.py | 154+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atest/io_storage_cost.py | 112+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atest/io_streams.py | 109+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atest/io_tasks.py | 129+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atest/io_tcp.py | 296+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atest/io_text.py | 126+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dtest/io_v2_artifact_service.py | 201-------------------------------------------------------------------------------
Dtest/io_v2_callers.py | 67-------------------------------------------------------------------
Dtest/io_v2_clock.py | 56--------------------------------------------------------
Dtest/io_v2_compiler_store.py | 277-------------------------------------------------------------------------------
Dtest/io_v2_context.py | 37-------------------------------------
Dtest/io_v2_demo_servers.py | 84-------------------------------------------------------------------------------
Dtest/io_v2_directory.py | 165-------------------------------------------------------------------------------
Dtest/io_v2_files.py | 107-------------------------------------------------------------------------------
Dtest/io_v2_generation.py | 209-------------------------------------------------------------------------------
Dtest/io_v2_http.py | 243-------------------------------------------------------------------------------
Dtest/io_v2_indexed.py | 103-------------------------------------------------------------------------------
Dtest/io_v2_indexed_write.py | 137-------------------------------------------------------------------------------
Dtest/io_v2_lines.py | 119-------------------------------------------------------------------------------
Dtest/io_v2_objects.py | 123-------------------------------------------------------------------------------
Dtest/io_v2_positioned.py | 107-------------------------------------------------------------------------------
Dtest/io_v2_process.py | 61-------------------------------------------------------------
Dtest/io_v2_protocol.py | 90-------------------------------------------------------------------------------
Dtest/io_v2_publication.py | 154-------------------------------------------------------------------------------
Dtest/io_v2_storage_cost.py | 112-------------------------------------------------------------------------------
Dtest/io_v2_streams.py | 109-------------------------------------------------------------------------------
Dtest/io_v2_tasks.py | 129-------------------------------------------------------------------------------
Dtest/io_v2_tcp.py | 296-------------------------------------------------------------------------------
Dtest/io_v2_text.py | 126-------------------------------------------------------------------------------
Atest/library_format.py | 29+++++++++++++++++++++++++++++
Mtest/library_inspector.py | 2+-
Mtest/native_indexed_store.py | 2+-
Mtest/native_library_migration.py | 18+++++++++---------
Mtest/native_source_utf8.py | 2+-
Mtest/package_registry.py | 6+++---
Mtest/prepare_compiler_kit.py | 4+++-
Mtest/repair_support_fixture.py | 32++++++++++++++------------------
Mtest/shared_library_generation.py | 4++--
Mtest/streaming_protocol.py | 2+-
Mtest/unit.py | 16++++++++--------
Mtest/verified_tree_loading.py | 2+-
Mtricu-apps/arboricxServer.tri | 6+++---
Atricu-apps/arboricxStreaming.tri | 8++++++++
Dtricu-apps/arboricxStreamingV2.tri | 8--------
Mtricu.cabal | 98++++++++++++++++++++++++++++++++++++++++----------------------------------------
Mtricu.workspace | 14++++++--------
383 files changed, 16048 insertions(+), 15973 deletions(-)

diff --git a/bootstrap/compiler-source-map.io-v2.v1 b/bootstrap/compiler-source-map.io-v2.v1 @@ -1,32 +0,0 @@ -arboricx.source-map.v1 -native source_index source.index lib/source/index.tri prelude -native source_map source.map lib/source/map.tri prelude,compiler_io,source_object,source_module,source_resolve,source_build -native source_unit source.unit lib/source/unit.tri prelude,parser,source_order,source_compile,source_program,source_resolve,source_ast,source_stable,source_index -native prelude prelude lib/prelude.tri base,bytes,conversions -native compiler_io compiler.io lib/source/io-v2.tri base,io_v2,io_v2_storage,io_v2_text -native source_object source.object lib/source/object.tri prelude,compiler_io,native_math -native source_module source.module lib/source/module.tri prelude,compiler_io,source_object,source_unit,source_resolve,source_lexer,parser -native source_resolve source.resolve lib/source/resolve.tri prelude,parser,source_parser,source_ast,source_lexer,source_core,source_equations,source_pattern -native source_build source.build lib/source/build.tri prelude,compiler_io,source_object,source_module,source_lock,source_bundle,source_resolve,source_unit -native parser parser lib/parser.tri prelude -native source_order source.order lib/source/order.tri prelude,parser,graph,source_program,source_resolve,source_ast,source_equations,source_index -native source_compile source.compile lib/source/compile.tri prelude,parser,source_resolve,source_core -native source_program source.program lib/source/program.tri prelude,parser,source_parser,source_lexer,source_ast,source_equations -native source_ast source.ast lib/source/ast.tri prelude,source_lexer -native base base lib/base.tri native_math -native bytes bytes lib/bytes.tri base -native conversions conversions lib/conversions.tri base,native_math -native source_lexer source.lexer lib/source/lexer.tri prelude,parser,lexer,native_math -native source_parser source.parser lib/source/parser.tri prelude,parser,source_lexer,source_ast,source_equations -native source_core source.core lib/source/core.tri prelude -native source_lock source.lock lib/source/lock.tri prelude,compiler_io,source_module,source_object,source_resolve -native source_bundle source.bundle lib/source/bundle.tri prelude,compiler_io,source_object,source_module,source_resolve -native graph graph lib/graph.tri base -native lexer lexer lib/lexer.tri base,parser -native source_pattern source.pattern lib/source/pattern.tri prelude,parser,source_core -native source_equations source.equations lib/source/equations.tri prelude,parser,source_lexer,source_ast,source_pattern,source_core -native source_stable source.stable lib/source/stable.tri prelude,parser,source_core,source_ast,source_compile,source_resolve,source_program,source_order -native native_math math.native lib/math/native.tri - -native io_v2 io.v2 lib/io-v2.tri base,native_math -native io_v2_storage io.v2.storage lib/io-v2/storage.tri base,native_math,io_v2 -native io_v2_text io.v2.text lib/io-v2/text.tri base,native_math,io_v2 diff --git a/bootstrap/compiler-source-map.v1 b/bootstrap/compiler-source-map.v1 @@ -3,7 +3,7 @@ native source_index source.index lib/source/index.tri prelude native source_map source.map lib/source/map.tri prelude,compiler_io,source_object,source_module,source_resolve,source_build native source_unit source.unit lib/source/unit.tri prelude,parser,source_order,source_compile,source_program,source_resolve,source_ast,source_stable,source_index native prelude prelude lib/prelude.tri base,bytes,conversions -native compiler_io compiler.io lib/source/io-v2.tri base,io_v2,io_v2_storage,io_v2_text +native compiler_io compiler.io lib/source/io.tri base,io,io_storage,io_text native source_object source.object lib/source/object.tri prelude,compiler_io,native_math native source_module source.module lib/source/module.tri prelude,compiler_io,source_object,source_unit,source_resolve,source_lexer,parser native source_resolve source.resolve lib/source/resolve.tri prelude,parser,source_parser,source_ast,source_lexer,source_core,source_equations,source_pattern @@ -27,6 +27,6 @@ native source_pattern source.pattern lib/source/pattern.tri prelude,parser,sourc native source_equations source.equations lib/source/equations.tri prelude,parser,source_lexer,source_ast,source_pattern,source_core native source_stable source.stable lib/source/stable.tri prelude,parser,source_core,source_ast,source_compile,source_resolve,source_program,source_order native native_math math.native lib/math/native.tri - -native io_v2 io.v2 lib/io-v2.tri base,native_math -native io_v2_storage io.v2.storage lib/io-v2/storage.tri base,native_math,io_v2 -native io_v2_text io.v2.text lib/io-v2/text.tri base,native_math,io_v2 +native io io lib/io.tri base,native_math +native io_storage io.storage lib/io/storage.tri base,native_math,io +native io_text io.text lib/io/text.tri base,native_math,io diff --git a/demos/arboricx-inspection/main.tri b/demos/arboricx-inspection/main.tri @@ -1,5 +1,5 @@ !import arboricx_app as App -!import io_v2 as IO +!import io as IO !import handlers as H !import handler_artifacts as Artifacts !export main @@ -22,6 +22,6 @@ startupBody = payload startupAction -- opaque: inspection never applies it to invent an owner or follow its effects. storeRoot = "./store" createRootDirectory = IO.createDirectory storeRoot -initializeStore = H.runIOV2 (Artifacts.prepareFilesystemV2 storeRoot) +initializeStore = H.runIO (Artifacts.prepareFilesystem storeRoot) createView = IO.io createRootDirectory initializeView = IO.io initializeStore diff --git a/demos/interactionTrees.tri b/demos/interactionTrees.tri @@ -1,10 +1,10 @@ !import base as B -!import io_v2 as IO -!import io_v2_text as Text +!import io as IO +!import io_text as Text !export main !export asyncDemo --- Explicit IO v2 sequencing, scoped children and consumed join tickets. Sleep +-- Explicit IO sequencing, scoped children and consumed join tickets. Sleep -- permits other work, but promises neither exact wakeup time nor task ordering. -- Only the parent writes stdout: concurrent writers need explicit coordination, -- not an assumption that yield/sleep makes a multi-write message atomic. diff --git a/demos/interactionTrees/arboricxServer.tri b/demos/interactionTrees/arboricxServer.tri @@ -1,7 +1,7 @@ !import base as B !import conversions as C -!import io_v2 as IO -!import arboricx_streaming_v2 as Server +!import io as IO +!import arboricx_streaming as Server !import arboricx_handlers as Registry !export main !export server @@ -13,5 +13,5 @@ ready listener = do IO.require address <- IO.localAddress listener writer <- IO.stdout IO.require (IO.writeAll writer (B.append (C.showNumber (port address)) [10])) IO.pure -server root address count = IO.io (Server.serveCount root 1048576 Registry.stdoutLogV2 address 16 count ready) -main = IO.io (Server.serve "./store" 1048576 Registry.stdoutLogV2 (IO.ipv4 [127 0 0 1] 9050) 16 ready) +server root address count = IO.io (Server.serveCount root 1048576 Registry.stdoutLog address 16 count ready) +main = IO.io (Server.serve "./store" 1048576 Registry.stdoutLog (IO.ipv4 [127 0 0 1] 9050) 16 ready) diff --git a/demos/interactionTrees/echoServer.tri b/demos/interactionTrees/echoServer.tri @@ -1,7 +1,7 @@ !import base as B !import conversions as C !import native_math as N -!import io_v2 as IO +!import io as IO !export main !export server -- A deliberately sequential, bounded-stream echo demonstration. A stalled peer diff --git a/demos/interactionTrees/environment.tri b/demos/interactionTrees/environment.tri @@ -1,7 +1,7 @@ !import base as B !import handlers as H -!import io_v2 as IO -!import io_v2_text as Text +!import io as IO +!import io_text as Text !export main -- Lexical application configuration, NOT the OS environment or a sandbox. ask = H.request "configuration" "ask" t @@ -13,7 +13,7 @@ inside = do H.bind value <- ask line (B.append "working in env: " value) H.pure "inside-done" -main = IO.io (H.runIOV2 (H.handle "configuration" (reader "outer") (do H.bind +main = IO.io (H.runIO (H.handle "configuration" (reader "outer") (do H.bind value <- H.handle "configuration" (reader "demo") inside line (B.append "local returned: " value) outside <- ask diff --git a/demos/interactionTrees/forkAwait.tri b/demos/interactionTrees/forkAwait.tri @@ -1,6 +1,6 @@ !import base as B -!import io_v2 as IO -!import io_v2_text as Text +!import io as IO +!import io_text as Text !export main !export worker -- Structured children return work records; the parent serializes their output. diff --git a/demos/interactionTrees/getLineAsync.tri b/demos/interactionTrees/getLineAsync.tri @@ -1,6 +1,6 @@ !import base as B -!import io_v2 as IO -!import io_v2_text as Text +!import io as IO +!import io_text as Text !export main -- One child reads a bounded line; only the parent writes. Prompt availability diff --git a/demos/interactionTrees/greet.tri b/demos/interactionTrees/greet.tri @@ -1,5 +1,5 @@ -!import io_v2 as IO -!import io_v2_text as Text +!import io as IO +!import io_text as Text !export main -- Explicit v2 output, with ordinary write failure checked before success. main = IO.io (IO.require IO.stdout (writer : diff --git a/demos/interactionTrees/httpServer.tri b/demos/interactionTrees/httpServer.tri @@ -1,8 +1,8 @@ !import base as B !import conversions as C !import native_math as N -!import io_v2 as IO -!import io_v2_http as Transport +!import io as IO +!import io_http as Transport !import http as HTTP !export main !export server diff --git a/demos/interactionTrees/safeRead.tri b/demos/interactionTrees/safeRead.tri @@ -1,5 +1,5 @@ -!import io_v2 as IO -!import io_v2_text as Text +!import io as IO +!import io_text as Text !export main !export run !export safeRead diff --git a/demos/interactionTrees/shout.tri b/demos/interactionTrees/shout.tri @@ -1,6 +1,6 @@ !import base as B -!import io_v2 as IO -!import io_v2_text as Text +!import io as IO +!import io_text as Text !export main !export run !export shout diff --git a/demos/interactionTrees/state.tri b/demos/interactionTrees/state.tri @@ -1,6 +1,6 @@ !import base as B -!import io_v2 as IO -!import io_v2_text as Text +!import io as IO +!import io_text as Text !export main !export runState -- A sequential state machine passes its state explicitly. There is no implicit diff --git a/demos/interactionTrees/writeThenRead.tri b/demos/interactionTrees/writeThenRead.tri @@ -1,6 +1,6 @@ !import base as B -!import io_v2 as IO -!import io_v2_text as Text +!import io as IO +!import io_text as Text !export main !export run !export writeThenRead diff --git a/demos/interactionTrees/yield.tri b/demos/interactionTrees/yield.tri @@ -1,6 +1,6 @@ !import base as B -!import io_v2 as IO -!import io_v2_text as Text +!import io as IO +!import io_text as Text !import handlers as H !import handler_log as Log !export main @@ -9,7 +9,7 @@ -- Yield offers scheduling opportunities, never guaranteed alternation. Each -- child installs its own collection; the parent alone writes the observed events. -- No implicit shared state slot, inherited collector or concurrent stdout writes. -chatter name result = H.runIOV2 (Log.capture (do H.bind +chatter name result = H.runIO (Log.capture (do H.bind Log.info (B.append name " says 1") H.liftIO (IO.require IO.yield IO.pure) Log.info (B.append name " says 2") diff --git a/demos/io-v2/clock.tri b/demos/io-v2/clock.tri @@ -1,32 +0,0 @@ -!import base as B -!import native_math as N -!import io_v2 as IO -!export elapsed -!export yielded -!export invalidSleep -!export cancelledWait -!export scopeWait -elapsed milliseconds = IO.io (do IO.require - before <- IO.monotonicNanoseconds - IO.sleepMilliseconds milliseconds - after <- IO.monotonicNanoseconds - IO.pure (N.subtract after before)) -yielded = IO.io IO.yield -invalidSleep value = IO.io (IO.sleepMilliseconds value) -cancelledWait = IO.io (IO.scope (_ : IO.require - (IO.spawn 1 1 [] (_ : IO.require (IO.sleepMilliseconds 9223372036854775807) (_ : IO.pure 99))) - (spawned : case spawned of - [task ticket] = do IO.require - IO.sleepMilliseconds 20 - IO.cancel task "timer-stop" - outcome <- IO.join ticket - IO.pure outcome - _ = IO.fail "Invalid timer spawn reply"))) --- Normal scope exit waits for its child even without a retained result ticket. -scopeWait = IO.io (do IO.require - before <- IO.monotonicNanoseconds - IO.bind (IO.scope (_ : IO.require - (IO.spawn 0 0 [] (_ : IO.require (IO.sleepMilliseconds 20) (_ : IO.pure t))) - (_ : IO.pure t))) (_ : do IO.require - after <- IO.monotonicNanoseconds - IO.pure (N.subtract after before))) diff --git a/demos/io-v2/context.tri b/demos/io-v2/context.tri @@ -1,5 +0,0 @@ -!import io_v2 as IO -!export directory -!export variable -directory = IO.io IO.workingDirectory -variable name = IO.io (IO.environmentVariable name) diff --git a/demos/io-v2/copy.tri b/demos/io-v2/copy.tri @@ -1,78 +0,0 @@ -!import base as B -!import io_v2 as IO -!import handlers as H -!export cancelledCopy -!export copy -!export handledCopy -!export borrowedCopy -!export transferredCopy -!export joinedOnExitCopy -!export handledChildCopy - -pump reader writer = IO.copyStream reader writer - -copy source destination = IO.io <| IO.scope (owner : do IO.onOk - reader <- IO.openReader owner source - writer <- IO.openWriter owner destination - pump reader writer) - --- A captured service supplies the implementation, while the caller requests --- policy-level work. The host scope, not handler removal, owns both resources. -service reader writer "run" _ = H.liftIO (pump reader writer) -service _ _ operation argument = H.forward "copy" operation argument -handledCopy source destination = IO.io <| IO.scope (owner : do IO.onOk - reader <- IO.openReader owner source - writer <- IO.openWriter owner destination - H.runIO (H.handle "copy" (service reader writer) (H.request "copy" "run" t))) - - -joinCopy spawned = case spawned of - [task ticket] = IO.require (IO.join ticket) (outcome : case outcome of - ["completed" value] = IO.pure value - ["failed" diagnostic cleanup] = IO.fail [diagnostic cleanup] - ["cancelled" reason cleanup] = IO.fail [reason cleanup] - _ = IO.fail "Invalid task outcome") - _ = IO.fail "Invalid spawn reply" - -borrowedCopy source destination = IO.io <| IO.scope (owner : do IO.require - reader <- IO.openReader owner source - writer <- IO.openWriter owner destination - spawned <- IO.spawn 0 1 [] (_ : pump reader writer) - joinCopy spawned) - -transferredPump [reader writer] = pump reader writer -transferredPump _ = IO.fail "Invalid transferred handles" -transferredCopy source destination = IO.io <| IO.scope (owner : do IO.require - reader <- IO.openReader owner source - writer <- IO.openWriter owner destination - spawned <- IO.spawn 0 1 [reader writer] transferredPump - joinCopy spawned) - --- No ticket and no explicit join: scope return still waits for complete copying. -joinedOnExitCopy source destination = IO.io <| IO.scope (owner : do IO.require - reader <- IO.openReader owner source - writer <- IO.openWriter owner destination - IO.spawn 0 0 [] (_ : pump reader writer) - IO.pure (B.ok t t)) - -handledChildCopy source destination = IO.io <| IO.scope (owner : do IO.require - reader <- IO.openReader owner source - writer <- IO.openWriter owner destination - H.runIOV2 (H.handle "copy" (service reader writer) (do H.bind - reply <- H.spawnV2 0 1 [] (_ : H.request "copy" "run" t) - H.liftIO (IO.require (IO.pure reply) joinCopy)))) - --- Cancellation after one acknowledged chunk is not rollback. The supervisor --- observes the outcome only after the transferred handles have been released. -cancelPump [reader writer] = IO.require (IO.readSome reader 4096) (reply : case reply of - ("eof", t) = IO.cancelled "requested" - ("data", bytes) = IO.require (IO.writeAll writer bytes) (_ : IO.cancelled "requested") - _ = IO.fail "Invalid read reply") -cancelPump _ = IO.fail "Invalid transferred handles" -cancelledCopy source destination = IO.io <| IO.scope (owner : do IO.require - reader <- IO.openReader owner source - writer <- IO.openWriter owner destination - spawned <- IO.spawn 1 1 [reader writer] cancelPump - case spawned of - [task ticket] = IO.require (IO.join ticket) IO.pure - _ = IO.fail "Invalid spawn reply") diff --git a/demos/io-v2/directory.tri b/demos/io-v2/directory.tri @@ -1,46 +0,0 @@ -!import io_v2 as IO -!import base as B -!import native_math as N -!export inventory -!export count -!export initialize -!export removeFile -!export removeDirectory -!export rename -!export metadata - --- Caller-owned accumulation: inventory intentionally retains all entries, count --- consumes each entry without retaining names. Neither assumes enumeration order. -walk directory root entries = IO.require (IO.nextDirectory directory) (reply : case reply of - ("eof", t) = IO.pure entries - ("entry", name) = IO.require (IO.lstatPath (B.append root (B.append "/" name))) - (status : walk directory root (B.pair [name status] entries)) - _ = IO.fail "Invalid directory reply") -inventory root = IO.io <| IO.scope (owner : IO.require (IO.openDirectory owner root) - (directory : walk directory root [])) - --- Force the small counter before the next host request; lazy successor chains --- would retain pending arithmetic even though directory names are discarded. -forceCounter t next = next t -forceCounter (t bit rest) next = case bit of - t = forceCounter rest next - (t t) = forceCounter rest next - _ = IO.fail "Invalid counter" -forceCounter _ _ = IO.fail "Invalid counter" -countLoop directory total = forceCounter total (_ : IO.require (IO.nextDirectory directory) (reply : case reply of - ("eof", t) = IO.pure total - ("entry", _) = countLoop directory (N.successor total) - _ = IO.fail "Invalid directory reply")) -count root = IO.io <| IO.scope (owner : IO.require (IO.openDirectory owner root) - (directory : countLoop directory 0)) - --- Ordered nonrecursive initialization like the compiler's store/cache callers. -initialize root = IO.io <| IO.onOk (IO.ensureDirectory root) (_ : - IO.ensureDirectory (B.append root "/cache")) -removeFile path = IO.io (IO.removeFile path) -removeDirectory path = IO.io (IO.removeDirectory path) -rename source destination = IO.io (IO.renamePath source destination) -metadata path = IO.io <| do IO.bind - followed <- IO.statPath path - unfollowed <- IO.lstatPath path - IO.pure [followed unfollowed] diff --git a/demos/io-v2/indexed-read.tri b/demos/io-v2/indexed-read.tri @@ -1,16 +0,0 @@ -!import io_v2 as IO -!export read -!export probe - --- No store layout or publication policy is native. Supply an exact byte frame, --- expected indexed-object digest, private-spool directory, and disk-size budget. -read path directory limit size digest = IO.io <| IO.scope (owner : do IO.require - reader <- IO.openReader owner path - IO.readIndexedTree reader directory limit size digest) - --- Preserve ordinary failures and observe consumption without retrying a frame. -probe path directory limit size digest = IO.io <| IO.scope (owner : do IO.require - reader <- IO.openReader owner path - IO.bind (IO.readIndexedTree reader directory limit size digest) (decoded : do IO.require - position <- IO.filePosition reader - IO.pure [decoded position])) diff --git a/demos/io-v2/indexed-write.tri b/demos/io-v2/indexed-write.tri @@ -1,44 +0,0 @@ -!import io_v2 as IO -!import base as B -!export hash -!export write -!export publish -!export roundTrip - -hash limit tree = IO.io (IO.hashIndexedTree limit tree) - --- In-place transport, deliberately not atomic replacement. Failed preflight does --- not touch this already-open writer; opening an absent update file creates it. -write path limit digest tree = IO.io <| IO.scope (owner : do IO.onOk - writer <- IO.openUpdate owner path - IO.writeIndexedTree writer limit digest tree) - --- Source owns naming/publication and exact winner verification. "exists" means --- byte-verified at observation time, not crash durability or future immutability. -publish directory limit tree = IO.io <| IO.scope (owner : do IO.onOk - info <- IO.hashIndexedTree limit tree - case info of - [size digest] = do IO.onOk - stage <- IO.openStage owner directory - IO.writeIndexedTree stage limit digest tree - IO.sealStage stage - state <- IO.publishNew stage (B.append directory (B.append "/" digest)) - case state of - "published" = IO.pure (B.ok [state size digest] t) - "exists" = do IO.onOk - reader <- IO.openReader owner (B.append directory (B.append "/" digest)) - IO.verifyIndexedTree reader limit tree - end <- IO.readSome reader 1 - case end of - ("eof", t) = IO.pure (B.ok [state size digest] t) - _ = IO.pure (B.err ["invalid-data" "indexed-file/verify" "trailing bytes" "unknown"] t) - _ = IO.fail "Invalid publication reply" - _ = IO.fail "Invalid indexed hash reply") - --- A shared DAG enters and leaves without ever becoming an expanded source byte --- list. This fixture expects canonical input; the writer checks its digest again. -roundTrip input output directory limit size digest = IO.io <| IO.scope (owner : do IO.onOk - reader <- IO.openReader owner input - tree <- IO.readIndexedTree reader directory limit size digest - writer <- IO.openWriter owner output - IO.writeIndexedTree writer limit digest tree) diff --git a/demos/io-v2/lines.tri b/demos/io-v2/lines.tri @@ -1,50 +0,0 @@ -!import io_v2 as IO -!import io_v2_text as Text -!import base as B -!import native_math as N -!export collect -!export count -!export first -!export stdinLines -!export cancelled -!export closed - --- Collection is only a test/demo convenience; count discards each line instead. -walk cursor limit events = IO.bind (Text.readLine cursor limit) (reply : case reply of - ((t t), ([event next], t)) = case event of - ["eof"] = IO.pure (B.ok (B.reverse events) t) - _ = walk next limit (B.pair event events) - (t, (cause, t)) = IO.pure (B.err [(B.reverse events) cause] t) - _ = IO.fail "Invalid line Result") -force t next = next t -force (t bit rest) next = case bit of - t = force rest next - (t t) = force rest next - _ = IO.fail "Invalid count" -force _ _ = IO.fail "Invalid count" -countLoop cursor limit n = force n (_ : IO.require (Text.readLine cursor limit) (reply : case reply of - [["eof"] _] = IO.pure n - [_ next] = case N.add n 1 of - number = countLoop next limit number - _ = IO.fail "Invalid line Result")) -collect path limit = IO.io (IO.scope (owner : IO.require (IO.openReader owner path) - (reader : walk (Text.lineCursor reader) limit []))) -count path limit = IO.io (IO.scope (owner : IO.require (IO.openReader owner path) - (reader : countLoop (Text.lineCursor reader) limit 0))) -first path limit = IO.io (IO.scope (owner : IO.require (IO.openReader owner path) - (reader : IO.bind (Text.readLine (Text.lineCursor reader) limit) (reply : case reply of - ((t t), ([event _], t)) = IO.pure (B.ok event t) - _ = IO.pure reply)))) -stdinLines limit = IO.io (IO.require IO.stdin (reader : walk (Text.lineCursor reader) limit [])) -cancelled = IO.io (IO.scope (_ : do IO.require - reader <- IO.stdin - spawned <- IO.spawn 1 1 [] (_ : Text.readLine (Text.lineCursor reader) 4096) - case spawned of - [task ticket] = do IO.require - IO.sleepMilliseconds 50 - IO.cancel task "line-stop" - outcome <- IO.join ticket - IO.pure outcome - _ = IO.fail "Invalid line task")) -closed path limit = IO.io (IO.bind (IO.scope (owner : IO.require (IO.openReader owner path) - (reader : IO.pure (Text.lineCursor reader)))) (cursor : Text.readLine cursor limit)) diff --git a/demos/io-v2/objects.tri b/demos/io-v2/objects.tri @@ -1,14 +0,0 @@ -!import io_v2 as IO -!import io_v2_objects as O -!export put -!export get -!export copy -!export putCategory - -put root limit tree = IO.io (O.putTree root limit tree) -get root limit digest = IO.io (O.getTree root limit digest) -copy source destination limit digest = IO.io <| IO.onOk (O.getTree source limit digest) - (tree : O.putTree destination limit tree) -putCategory root limit tree = IO.io <| IO.bind (O.putTree root limit tree) (reply : case reply of - (t, ([category _ _ _], t)) = IO.pure category - _ = IO.fail "Expected operation failure") diff --git a/demos/io-v2/positioned.tri b/demos/io-v2/positioned.tri @@ -1,39 +0,0 @@ -!import io_v2 as IO -!export patch -!export append -!export appendGuards -!export badOffset - --- Bounded edits and inspection, not atomic publication. Scope owns the file; --- every operation uses the same logical cursor, including read/write switches. -patch path offset bytes size = IO.io <| IO.scope (owner : do IO.require - file <- IO.openUpdate owner path - original <- IO.fileSize file - IO.seekFile file offset - start <- IO.filePosition file - IO.writeAll file bytes - after <- IO.filePosition file - IO.resizeFile file size - afterResize <- IO.filePosition file - resized <- IO.fileSize file - IO.seekFile file 0 - prefix <- IO.readSome file 4096 - IO.pure [original start after afterResize resized prefix]) - -append path bytes = IO.io <| IO.scope (owner : do IO.require - file <- IO.openAppender owner path - before <- IO.fileSize file - IO.writeAll file bytes - after <- IO.fileSize file - IO.pure [before after]) - -appendGuards path = IO.io <| IO.scope (owner : IO.require (IO.openAppender owner path) - (file : do IO.bind - seek <- IO.seekFile file 0 - position <- IO.filePosition file - resized <- IO.resizeFile file 0 - read <- IO.readSome file 1 - IO.pure [seek position resized read])) - -badOffset path offset = IO.io <| IO.scope (owner : IO.require (IO.openUpdate owner path) - (file : IO.seekFile file offset)) diff --git a/demos/io-v2/process.tri b/demos/io-v2/process.tri @@ -1,38 +0,0 @@ -!import io_v2 as IO -!export pipeline -!export normalExit -!export raw - --- Child stdout/stderr are drained concurrently while the parent pumps stdin. --- All three streams use bounded chunks; output is written to files, not collected. --- Executable and argv are caller supplied; using a shell requires naming one. -copy reader writer = IO.require (IO.copyStream reader writer) IO.pure -completed ticket = IO.require (IO.join ticket) (outcome : case outcome of - ["completed" value] = IO.pure value - _ = IO.fail outcome) -waitCopy [_ ticket] = completed ticket -waitCopy _ = IO.fail "Invalid copy task reply" -pump owner source destination errors spawned = case spawned of - [task ticket input output error] = do IO.require - reader <- IO.openReader owner source - writer <- IO.openWriter owner destination - errorWriter <- IO.openWriter owner errors - stdoutTask <- IO.spawn 0 1 [] (_ : copy output writer) - stderrTask <- IO.spawn 0 1 [] (_ : copy error errorWriter) - IO.bind (copy reader input) (_ : do IO.require - IO.close input - IO.bind (waitCopy stdoutTask) (_ : IO.bind (waitCopy stderrTask) (_ : completed ticket))) - _ = IO.fail "Invalid process reply" -pipeline [executable arguments directory environment grace source destination errors] = IO.io (IO.scope (owner : - IO.require (IO.spawnProcess executable arguments directory environment grace) - (pump owner source destination errors))) -pipeline _ = IO.io (IO.fail "Invalid pipeline arguments") -normalExit [executable arguments] = IO.io (IO.scope (_ : - IO.require (IO.spawnProcess executable arguments ["inherit"] ["inherit"] 20) - (spawned : case spawned of - [_ _ input _ _] = IO.require (IO.close input) (_ : IO.pure 42) - _ = IO.fail "Invalid process reply"))) -normalExit _ = IO.io (IO.fail "Invalid process arguments") -raw [executable arguments directory environment grace] = IO.io - (IO.spawnProcess executable arguments directory environment grace) -raw _ = IO.io (IO.fail "Invalid raw arguments") diff --git a/demos/io-v2/publish.tri b/demos/io-v2/publish.tri @@ -1,76 +0,0 @@ -!import base as B -!import io_v2 as IO -!import io_v2_artifacts as Artifacts -!import handlers as H -!export failedAfterFull -!export invalidChunk -!export badDigest -!export nestedProducer -!export forkedProducer -!export publish -!export cancelled -!export earlySuccess -!export ignoresWriteFailure -!export emptyChunk -!export overrideWrite -!export rejectedDestination -!export malformedDestination - -pump reader = H.bind (H.liftIO (IO.readSome reader 4096)) (reply : case reply of - ((t t), (("eof", t), t)) = H.pure (B.ok t t) - ((t t), (("data", bytes), t)) = H.bind (Artifacts.write bytes) (written : case written of - ((t t), (t, t)) = pump reader - _ = H.pure written) - (t, (_, t)) = H.pure reply - _ = H.fail "invalid-response" "reader" "read" "Invalid read reply") - -publish source directory expected = IO.io <| IO.scope (owner : IO.require - (IO.openReader owner source) (reader : - H.runIOV2 (Artifacts.publish directory expected (H.label "file-producer" (pump reader))))) - -cancelled directory = IO.io <| H.runIOV2 (Artifacts.publish directory 3 (do H.bind - Artifacts.write [97 98 99] - H.liftIO (IO.cancelled "producer cancelled"))) -earlySuccess directory = IO.io <| H.runIOV2 (Artifacts.publish directory 3 (H.pure (B.ok t t))) -ignoresWriteFailure directory = IO.io <| H.runIOV2 (Artifacts.publish directory 1 (do H.bind - Artifacts.write [97 98 99] - H.pure (B.ok t t))) -emptyChunk directory = IO.io <| H.runIOV2 (Artifacts.publish directory 0 (do H.bind - Artifacts.write [] - H.pure (B.ok t t))) -acknowledge _ _ = H.pure (B.ok t t) -overrideWrite directory = IO.io <| H.runIOV2 (Artifacts.publish directory 3 - (H.handle "artifact-stream" acknowledge (do H.bind - Artifacts.write [97 98 99] - H.pure (B.ok t t)))) - -failedAfterFull directory = IO.io <| H.runIOV2 (Artifacts.publish directory 3 (do H.bind - Artifacts.write [97 98 99] - H.pure (B.err "Producer failed" t))) -invalidChunk directory = IO.io <| H.runIOV2 (Artifacts.publish directory 1 (do H.bind - Artifacts.write [256] - H.pure (B.ok t t))) -corruptDigest "run" (10, ("digest", (1, ("finish", _)))) = H.pure (B.ok "../escape" t) -corruptDigest operation argument = H.forward "host.io" operation argument -observe computation = IO.io <| IO.require - (IO.spawn 1 1 [] (_ : H.runIOV2 computation)) - (spawned : case spawned of - [task ticket] = IO.require (IO.join ticket) IO.pure - _ = IO.fail "Invalid spawn reply") -badDigest directory = observe (H.handle "host.io" corruptDigest - (Artifacts.publish directory 3 (do H.bind - Artifacts.write [97 98 99] - H.pure (B.ok t t)))) - -observeBoundary directory producer = observe (H.label "outer" - (Artifacts.publish directory 0 (H.label "inner" producer))) -nestedProducer directory = observeBoundary directory (H.scopeV2 (_ : H.pure (B.ok t t))) -forkedProducer directory = observeBoundary directory (H.spawnV2 0 0 [] (_ : H.pure (B.ok t t))) -rejectedDestination directory = IO.io (H.runIOV2 (Artifacts.publishWith directory 3 - (_ : H.pure (B.err "Destination rejected" t)) (do H.bind - Artifacts.write [97 98 99] - H.pure (B.ok t t)))) -malformedDestination directory = observe (Artifacts.publishWith directory 3 - (_ : H.pure 0) (do H.bind - Artifacts.write [97 98 99] - H.pure (B.ok t t))) diff --git a/demos/io-v2/registry.tri b/demos/io-v2/registry.tri @@ -1,41 +0,0 @@ -!import base as B -!import conversions as C -!import io_v2 as IO -!import arboricx_handlers as Registry -!import arboricx_streaming_v2 as Server -!import handlers as H -!export main -!export server -!export limited -!export badLogger -!export listen -!export cancelledServer -port ["ipv4" _ value] = value -port _ = 0 -ready listener = do IO.require - address <- IO.localAddress listener - writer <- IO.stdout - IO.require (IO.writeAll writer (B.append (C.showNumber (port address)) [10])) IO.pure -quiet _ = H.pure (B.ok t t) -bad _ = H.fail "test" "logger" "write" "private logger detail" -server root count window = IO.io (Server.serveCount root 1048576 quiet - (IO.ipv4 [127 0 0 1] 0) window count ready) -badLogger root count window = IO.io (Server.serveCount root 1048576 bad - (IO.ipv4 [127 0 0 1] 0) window count ready) -listen root address = IO.io (Server.serve root 1048576 Registry.stdoutLogV2 address 16 ready) -main = listen "./store" (IO.ipv4 [0 0 0 0] 8080) -cancelledServer root count window = IO.io (IO.scope (_ : IO.require - (IO.spawn 1 1 [] (_ : Server.serve root 1048576 quiet (IO.ipv4 [127 0 0 1] 0) window ready)) - (spawned : case spawned of - [task ticket] = do IO.require - input <- IO.stdin - IO.readSome input 1 - IO.cancel task "test shutdown" - outcome <- IO.join ticket - case outcome of - ["cancelled" "test shutdown" []] = IO.pure t - _ = IO.fail "Unexpected server cancellation outcome" - _ = IO.fail "Invalid controlled server spawn"))) - -limited root count window = IO.io (Server.serveCount root 4 quiet - (IO.ipv4 [127 0 0 1] 0) window count ready) diff --git a/demos/io-v2/streams.tri b/demos/io-v2/streams.tri @@ -1,31 +0,0 @@ -!import base as B -!import io_v2 as IO -!import handlers as H -!export main -!export handled -!export child -!export diagnostic -!export closeDenied -!export transferDenied -!export broken - --- Run stored exports with --io --no-result: stdout belongs to the pipeline. -stream = do IO.require - input <- IO.stdin - output <- IO.stdout - IO.require (IO.copyStream input output) (_ : IO.pure t) -main = IO.io stream -handled = IO.io <| H.runIOV2 (H.liftIO stream) --- Root admission closes on return, but the attached child may first borrow --- inherited streams afterwards. Parent completion waits for the entire copy. -child = IO.io <| IO.require (IO.spawn 0 0 [] (_ : stream)) (_ : IO.pure t) -diagnostic = IO.io <| IO.require IO.stderr - (output : IO.require (IO.writeAll output [0 255 13 10]) (_ : IO.pure t)) -closeDenied = IO.io <| IO.require IO.stdout IO.close -transferDenied = IO.io <| IO.require IO.stdout - (output : IO.spawn 0 0 [output] (_ : IO.fail "must not start")) -broken = IO.io <| IO.require IO.stdout (output : IO.bind (IO.writeAll output [97]) - (reply : case reply of - (t, (_, t)) = IO.require IO.stderr - (diagnostic : IO.require (IO.writeAll diagnostic [102 97 105 108 101 100]) (_ : IO.pure t)) - _ = IO.fail "Expected broken pipe")) diff --git a/demos/io-v2/tcp.tri b/demos/io-v2/tcp.tri @@ -1,74 +0,0 @@ -!import io_v2 as IO -!import base as B -!import native_math as N -!import conversions as C -!import handlers as H -!export server -!export handledServer -!export supervisedHandledServer -!export client -!export namedClient -!export resolve -!export candidates -!export connectResult -!export invalidAddress - -port ["ipv4" _ value] = value -port ["ipv6" _ _ value] = value -port _ = 0 -announce listener = do IO.require - address <- IO.localAddress listener - output <- IO.stdout - IO.writeAll output (B.append (C.showNumber (port address)) [10]) -echo socket = IO.require (IO.copyStream socket socket) (_ : - IO.require (IO.shutdownWrite socket) (_ : IO.pure t)) -worker [socket] = echo socket -worker _ = IO.fail "Invalid connection handoff" -serve 0 _ _ = IO.pure t -serve count owner listener = IO.require (IO.acceptTCP listener owner) (socket : - IO.require (IO.spawn 0 0 [socket] worker) (_ : serve (N.predecessor count) owner listener)) -server address count = IO.io <| IO.scope (owner : do IO.require - listener <- IO.listenTCP owner address 128 - announce listener - IO.bind (serve count owner listener) (_ : IO.pure t)) - --- Child routing is inherited through H.spawnV2, not raw IO.spawn. The banner --- binding lives in source and is deliberately not a native networking service. -requireH action next = H.bind (H.liftIO (IO.require action IO.pure)) next -banner _ _ = H.pure [66 0 255 10] -handledWorker [socket] = H.bind (H.request "banner" "bytes" t) (bytes : - requireH (IO.writeAll socket bytes) (_ : H.liftIO (echo socket))) -handledWorker _ = H.liftIO (IO.fail "Invalid connection handoff") -handledServe _ 0 _ _ = H.pure t -handledServe supervised count owner listener = requireH (IO.acceptTCP listener owner) (socket : - H.bind (H.spawnV2 supervised 0 [socket] handledWorker) (reply : case reply of - ((t t), (_, t)) = handledServe supervised (N.predecessor count) owner listener - _ = H.liftIO (IO.fail "Failed connection spawn"))) -handledServer address count = handledServerWith 0 address count --- Explicit containment probe: discarded child failures do not stop admission. --- A production service should choose its own logging/outcome-observation policy. -supervisedHandledServer address count = handledServerWith 1 address count -handledServerWith supervised address count = IO.io <| H.runIOV2 - (H.handle "banner" banner (H.scopeV2 (owner : - requireH (IO.listenTCP owner address 128) (listener : - requireH (announce listener) (_ : handledServe supervised count owner listener))))) - --- Read and write proceed concurrently to avoid duplex backpressure deadlock. --- The source/destination files must differ, including aliases; output truncates. -client address source destination = IO.io <| IO.scope (owner : - IO.require (IO.connectTCP owner address) (socket : clientStreams owner socket source destination)) -namedClient host family port source destination = IO.io <| IO.scope (owner : - IO.require (IO.resolveTCP host family port 64) (resolved : case resolved of - [addresses _] = IO.require (IO.connectFirstTCP owner addresses) - (socket : clientStreams owner socket source destination) - _ = IO.fail "Invalid resolver reply")) -resolve host family port limit = IO.io (IO.resolveTCP host family port limit) -candidates addresses = IO.io <| IO.scope (owner : IO.connectFirstTCP owner addresses) -clientStreams owner socket source destination = do IO.require - input <- IO.openReader owner source - output <- IO.openWriter owner destination - IO.spawn 0 0 [] (_ : IO.require (IO.copyStream input socket) (_ : - IO.require (IO.shutdownWrite socket) (_ : IO.pure t))) - IO.require (IO.copyStream socket output) (_ : IO.pure t) -connectResult address = IO.io <| IO.scope (owner : IO.connectTCP owner address) -invalidAddress = connectResult ["ipv4" [127 0 0 256] 80] diff --git a/demos/io-v2/text.tri b/demos/io-v2/text.tri @@ -1,24 +0,0 @@ -!import io_v2 as IO -!import io_v2_text as Text -!export encode -!export decode -!export source -!export write -!export roundTrip - -encode limit text = IO.io (Text.encodeText limit text) -decode final bytes = IO.io (IO.decodeUTF8 final bytes) -source path limit = IO.io <| IO.scope (owner : do IO.onOk - reader <- IO.openReader owner path - Text.readSource reader limit) -write path text = IO.io <| IO.scope (owner : do IO.onOk - writer <- IO.openWriter owner path - Text.writeText writer text) -roundTrip input output limit = IO.io <| IO.scope (owner : do IO.onOk - reader <- IO.openReader owner input - received <- Text.readSource reader limit - case received of - (_, chars) = do IO.onOk - writer <- IO.openWriter owner output - Text.writeText writer chars - _ = IO.fail "Invalid source result") diff --git a/demos/io/clock.tri b/demos/io/clock.tri @@ -0,0 +1,32 @@ +!import base as B +!import native_math as N +!import io as IO +!export elapsed +!export yielded +!export invalidSleep +!export cancelledWait +!export scopeWait +elapsed milliseconds = IO.io (do IO.require + before <- IO.monotonicNanoseconds + IO.sleepMilliseconds milliseconds + after <- IO.monotonicNanoseconds + IO.pure (N.subtract after before)) +yielded = IO.io IO.yield +invalidSleep value = IO.io (IO.sleepMilliseconds value) +cancelledWait = IO.io (IO.scope (_ : IO.require + (IO.spawn 1 1 [] (_ : IO.require (IO.sleepMilliseconds 9223372036854775807) (_ : IO.pure 99))) + (spawned : case spawned of + [task ticket] = do IO.require + IO.sleepMilliseconds 20 + IO.cancel task "timer-stop" + outcome <- IO.join ticket + IO.pure outcome + _ = IO.fail "Invalid timer spawn reply"))) +-- Normal scope exit waits for its child even without a retained result ticket. +scopeWait = IO.io (do IO.require + before <- IO.monotonicNanoseconds + IO.bind (IO.scope (_ : IO.require + (IO.spawn 0 0 [] (_ : IO.require (IO.sleepMilliseconds 20) (_ : IO.pure t))) + (_ : IO.pure t))) (_ : do IO.require + after <- IO.monotonicNanoseconds + IO.pure (N.subtract after before))) diff --git a/demos/io/context.tri b/demos/io/context.tri @@ -0,0 +1,5 @@ +!import io as IO +!export directory +!export variable +directory = IO.io IO.workingDirectory +variable name = IO.io (IO.environmentVariable name) diff --git a/demos/io/copy.tri b/demos/io/copy.tri @@ -0,0 +1,78 @@ +!import base as B +!import io as IO +!import handlers as H +!export cancelledCopy +!export copy +!export handledCopy +!export borrowedCopy +!export transferredCopy +!export joinedOnExitCopy +!export handledChildCopy + +pump reader writer = IO.copyStream reader writer + +copy source destination = IO.io <| IO.scope (owner : do IO.onOk + reader <- IO.openReader owner source + writer <- IO.openWriter owner destination + pump reader writer) + +-- A captured service supplies the implementation, while the caller requests +-- policy-level work. The host scope, not handler removal, owns both resources. +service reader writer "run" _ = H.liftIO (pump reader writer) +service _ _ operation argument = H.forward "copy" operation argument +handledCopy source destination = IO.io <| IO.scope (owner : do IO.onOk + reader <- IO.openReader owner source + writer <- IO.openWriter owner destination + H.runIO (H.handle "copy" (service reader writer) (H.request "copy" "run" t))) + + +joinCopy spawned = case spawned of + [task ticket] = IO.require (IO.join ticket) (outcome : case outcome of + ["completed" value] = IO.pure value + ["failed" diagnostic cleanup] = IO.fail [diagnostic cleanup] + ["cancelled" reason cleanup] = IO.fail [reason cleanup] + _ = IO.fail "Invalid task outcome") + _ = IO.fail "Invalid spawn reply" + +borrowedCopy source destination = IO.io <| IO.scope (owner : do IO.require + reader <- IO.openReader owner source + writer <- IO.openWriter owner destination + spawned <- IO.spawn 0 1 [] (_ : pump reader writer) + joinCopy spawned) + +transferredPump [reader writer] = pump reader writer +transferredPump _ = IO.fail "Invalid transferred handles" +transferredCopy source destination = IO.io <| IO.scope (owner : do IO.require + reader <- IO.openReader owner source + writer <- IO.openWriter owner destination + spawned <- IO.spawn 0 1 [reader writer] transferredPump + joinCopy spawned) + +-- No ticket and no explicit join: scope return still waits for complete copying. +joinedOnExitCopy source destination = IO.io <| IO.scope (owner : do IO.require + reader <- IO.openReader owner source + writer <- IO.openWriter owner destination + IO.spawn 0 0 [] (_ : pump reader writer) + IO.pure (B.ok t t)) + +handledChildCopy source destination = IO.io <| IO.scope (owner : do IO.require + reader <- IO.openReader owner source + writer <- IO.openWriter owner destination + H.runIO (H.handle "copy" (service reader writer) (do H.bind + reply <- H.spawn 0 1 [] (_ : H.request "copy" "run" t) + H.liftIO (IO.require (IO.pure reply) joinCopy)))) + +-- Cancellation after one acknowledged chunk is not rollback. The supervisor +-- observes the outcome only after the transferred handles have been released. +cancelPump [reader writer] = IO.require (IO.readSome reader 4096) (reply : case reply of + ("eof", t) = IO.cancelled "requested" + ("data", bytes) = IO.require (IO.writeAll writer bytes) (_ : IO.cancelled "requested") + _ = IO.fail "Invalid read reply") +cancelPump _ = IO.fail "Invalid transferred handles" +cancelledCopy source destination = IO.io <| IO.scope (owner : do IO.require + reader <- IO.openReader owner source + writer <- IO.openWriter owner destination + spawned <- IO.spawn 1 1 [reader writer] cancelPump + case spawned of + [task ticket] = IO.require (IO.join ticket) IO.pure + _ = IO.fail "Invalid spawn reply") diff --git a/demos/io/directory.tri b/demos/io/directory.tri @@ -0,0 +1,46 @@ +!import io as IO +!import base as B +!import native_math as N +!export inventory +!export count +!export initialize +!export removeFile +!export removeDirectory +!export rename +!export metadata + +-- Caller-owned accumulation: inventory intentionally retains all entries, count +-- consumes each entry without retaining names. Neither assumes enumeration order. +walk directory root entries = IO.require (IO.nextDirectory directory) (reply : case reply of + ("eof", t) = IO.pure entries + ("entry", name) = IO.require (IO.lstatPath (B.append root (B.append "/" name))) + (status : walk directory root (B.pair [name status] entries)) + _ = IO.fail "Invalid directory reply") +inventory root = IO.io <| IO.scope (owner : IO.require (IO.openDirectory owner root) + (directory : walk directory root [])) + +-- Force the small counter before the next host request; lazy successor chains +-- would retain pending arithmetic even though directory names are discarded. +forceCounter t next = next t +forceCounter (t bit rest) next = case bit of + t = forceCounter rest next + (t t) = forceCounter rest next + _ = IO.fail "Invalid counter" +forceCounter _ _ = IO.fail "Invalid counter" +countLoop directory total = forceCounter total (_ : IO.require (IO.nextDirectory directory) (reply : case reply of + ("eof", t) = IO.pure total + ("entry", _) = countLoop directory (N.successor total) + _ = IO.fail "Invalid directory reply")) +count root = IO.io <| IO.scope (owner : IO.require (IO.openDirectory owner root) + (directory : countLoop directory 0)) + +-- Ordered nonrecursive initialization like the compiler's store/cache callers. +initialize root = IO.io <| IO.onOk (IO.ensureDirectory root) (_ : + IO.ensureDirectory (B.append root "/cache")) +removeFile path = IO.io (IO.removeFile path) +removeDirectory path = IO.io (IO.removeDirectory path) +rename source destination = IO.io (IO.renamePath source destination) +metadata path = IO.io <| do IO.bind + followed <- IO.statPath path + unfollowed <- IO.lstatPath path + IO.pure [followed unfollowed] diff --git a/demos/io/indexed-read.tri b/demos/io/indexed-read.tri @@ -0,0 +1,16 @@ +!import io as IO +!export read +!export probe + +-- No store layout or publication policy is native. Supply an exact byte frame, +-- expected indexed-object digest, private-spool directory, and disk-size budget. +read path directory limit size digest = IO.io <| IO.scope (owner : do IO.require + reader <- IO.openReader owner path + IO.readIndexedTree reader directory limit size digest) + +-- Preserve ordinary failures and observe consumption without retrying a frame. +probe path directory limit size digest = IO.io <| IO.scope (owner : do IO.require + reader <- IO.openReader owner path + IO.bind (IO.readIndexedTree reader directory limit size digest) (decoded : do IO.require + position <- IO.filePosition reader + IO.pure [decoded position])) diff --git a/demos/io/indexed-write.tri b/demos/io/indexed-write.tri @@ -0,0 +1,44 @@ +!import io as IO +!import base as B +!export hash +!export write +!export publish +!export roundTrip + +hash limit tree = IO.io (IO.hashIndexedTree limit tree) + +-- In-place transport, deliberately not atomic replacement. Failed preflight does +-- not touch this already-open writer; opening an absent update file creates it. +write path limit digest tree = IO.io <| IO.scope (owner : do IO.onOk + writer <- IO.openUpdate owner path + IO.writeIndexedTree writer limit digest tree) + +-- Source owns naming/publication and exact winner verification. "exists" means +-- byte-verified at observation time, not crash durability or future immutability. +publish directory limit tree = IO.io <| IO.scope (owner : do IO.onOk + info <- IO.hashIndexedTree limit tree + case info of + [size digest] = do IO.onOk + stage <- IO.openStage owner directory + IO.writeIndexedTree stage limit digest tree + IO.sealStage stage + state <- IO.publishNew stage (B.append directory (B.append "/" digest)) + case state of + "published" = IO.pure (B.ok [state size digest] t) + "exists" = do IO.onOk + reader <- IO.openReader owner (B.append directory (B.append "/" digest)) + IO.verifyIndexedTree reader limit tree + end <- IO.readSome reader 1 + case end of + ("eof", t) = IO.pure (B.ok [state size digest] t) + _ = IO.pure (B.err ["invalid-data" "indexed-file/verify" "trailing bytes" "unknown"] t) + _ = IO.fail "Invalid publication reply" + _ = IO.fail "Invalid indexed hash reply") + +-- A shared DAG enters and leaves without ever becoming an expanded source byte +-- list. This fixture expects canonical input; the writer checks its digest again. +roundTrip input output directory limit size digest = IO.io <| IO.scope (owner : do IO.onOk + reader <- IO.openReader owner input + tree <- IO.readIndexedTree reader directory limit size digest + writer <- IO.openWriter owner output + IO.writeIndexedTree writer limit digest tree) diff --git a/demos/io/lines.tri b/demos/io/lines.tri @@ -0,0 +1,50 @@ +!import io as IO +!import io_text as Text +!import base as B +!import native_math as N +!export collect +!export count +!export first +!export stdinLines +!export cancelled +!export closed + +-- Collection is only a test/demo convenience; count discards each line instead. +walk cursor limit events = IO.bind (Text.readLine cursor limit) (reply : case reply of + ((t t), ([event next], t)) = case event of + ["eof"] = IO.pure (B.ok (B.reverse events) t) + _ = walk next limit (B.pair event events) + (t, (cause, t)) = IO.pure (B.err [(B.reverse events) cause] t) + _ = IO.fail "Invalid line Result") +force t next = next t +force (t bit rest) next = case bit of + t = force rest next + (t t) = force rest next + _ = IO.fail "Invalid count" +force _ _ = IO.fail "Invalid count" +countLoop cursor limit n = force n (_ : IO.require (Text.readLine cursor limit) (reply : case reply of + [["eof"] _] = IO.pure n + [_ next] = case N.add n 1 of + number = countLoop next limit number + _ = IO.fail "Invalid line Result")) +collect path limit = IO.io (IO.scope (owner : IO.require (IO.openReader owner path) + (reader : walk (Text.lineCursor reader) limit []))) +count path limit = IO.io (IO.scope (owner : IO.require (IO.openReader owner path) + (reader : countLoop (Text.lineCursor reader) limit 0))) +first path limit = IO.io (IO.scope (owner : IO.require (IO.openReader owner path) + (reader : IO.bind (Text.readLine (Text.lineCursor reader) limit) (reply : case reply of + ((t t), ([event _], t)) = IO.pure (B.ok event t) + _ = IO.pure reply)))) +stdinLines limit = IO.io (IO.require IO.stdin (reader : walk (Text.lineCursor reader) limit [])) +cancelled = IO.io (IO.scope (_ : do IO.require + reader <- IO.stdin + spawned <- IO.spawn 1 1 [] (_ : Text.readLine (Text.lineCursor reader) 4096) + case spawned of + [task ticket] = do IO.require + IO.sleepMilliseconds 50 + IO.cancel task "line-stop" + outcome <- IO.join ticket + IO.pure outcome + _ = IO.fail "Invalid line task")) +closed path limit = IO.io (IO.bind (IO.scope (owner : IO.require (IO.openReader owner path) + (reader : IO.pure (Text.lineCursor reader)))) (cursor : Text.readLine cursor limit)) diff --git a/demos/io/objects.tri b/demos/io/objects.tri @@ -0,0 +1,14 @@ +!import io as IO +!import io_objects as O +!export put +!export get +!export copy +!export putCategory + +put root limit tree = IO.io (O.putTree root limit tree) +get root limit digest = IO.io (O.getTree root limit digest) +copy source destination limit digest = IO.io <| IO.onOk (O.getTree source limit digest) + (tree : O.putTree destination limit tree) +putCategory root limit tree = IO.io <| IO.bind (O.putTree root limit tree) (reply : case reply of + (t, ([category _ _ _], t)) = IO.pure category + _ = IO.fail "Expected operation failure") diff --git a/demos/io/positioned.tri b/demos/io/positioned.tri @@ -0,0 +1,39 @@ +!import io as IO +!export patch +!export append +!export appendGuards +!export badOffset + +-- Bounded edits and inspection, not atomic publication. Scope owns the file; +-- every operation uses the same logical cursor, including read/write switches. +patch path offset bytes size = IO.io <| IO.scope (owner : do IO.require + file <- IO.openUpdate owner path + original <- IO.fileSize file + IO.seekFile file offset + start <- IO.filePosition file + IO.writeAll file bytes + after <- IO.filePosition file + IO.resizeFile file size + afterResize <- IO.filePosition file + resized <- IO.fileSize file + IO.seekFile file 0 + prefix <- IO.readSome file 4096 + IO.pure [original start after afterResize resized prefix]) + +append path bytes = IO.io <| IO.scope (owner : do IO.require + file <- IO.openAppender owner path + before <- IO.fileSize file + IO.writeAll file bytes + after <- IO.fileSize file + IO.pure [before after]) + +appendGuards path = IO.io <| IO.scope (owner : IO.require (IO.openAppender owner path) + (file : do IO.bind + seek <- IO.seekFile file 0 + position <- IO.filePosition file + resized <- IO.resizeFile file 0 + read <- IO.readSome file 1 + IO.pure [seek position resized read])) + +badOffset path offset = IO.io <| IO.scope (owner : IO.require (IO.openUpdate owner path) + (file : IO.seekFile file offset)) diff --git a/demos/io/process.tri b/demos/io/process.tri @@ -0,0 +1,38 @@ +!import io as IO +!export pipeline +!export normalExit +!export raw + +-- Child stdout/stderr are drained concurrently while the parent pumps stdin. +-- All three streams use bounded chunks; output is written to files, not collected. +-- Executable and argv are caller supplied; using a shell requires naming one. +copy reader writer = IO.require (IO.copyStream reader writer) IO.pure +completed ticket = IO.require (IO.join ticket) (outcome : case outcome of + ["completed" value] = IO.pure value + _ = IO.fail outcome) +waitCopy [_ ticket] = completed ticket +waitCopy _ = IO.fail "Invalid copy task reply" +pump owner source destination errors spawned = case spawned of + [task ticket input output error] = do IO.require + reader <- IO.openReader owner source + writer <- IO.openWriter owner destination + errorWriter <- IO.openWriter owner errors + stdoutTask <- IO.spawn 0 1 [] (_ : copy output writer) + stderrTask <- IO.spawn 0 1 [] (_ : copy error errorWriter) + IO.bind (copy reader input) (_ : do IO.require + IO.close input + IO.bind (waitCopy stdoutTask) (_ : IO.bind (waitCopy stderrTask) (_ : completed ticket))) + _ = IO.fail "Invalid process reply" +pipeline [executable arguments directory environment grace source destination errors] = IO.io (IO.scope (owner : + IO.require (IO.spawnProcess executable arguments directory environment grace) + (pump owner source destination errors))) +pipeline _ = IO.io (IO.fail "Invalid pipeline arguments") +normalExit [executable arguments] = IO.io (IO.scope (_ : + IO.require (IO.spawnProcess executable arguments ["inherit"] ["inherit"] 20) + (spawned : case spawned of + [_ _ input _ _] = IO.require (IO.close input) (_ : IO.pure 42) + _ = IO.fail "Invalid process reply"))) +normalExit _ = IO.io (IO.fail "Invalid process arguments") +raw [executable arguments directory environment grace] = IO.io + (IO.spawnProcess executable arguments directory environment grace) +raw _ = IO.io (IO.fail "Invalid raw arguments") diff --git a/demos/io/publish.tri b/demos/io/publish.tri @@ -0,0 +1,76 @@ +!import base as B +!import io as IO +!import io_artifacts as Artifacts +!import handlers as H +!export failedAfterFull +!export invalidChunk +!export badDigest +!export nestedProducer +!export forkedProducer +!export publish +!export cancelled +!export earlySuccess +!export ignoresWriteFailure +!export emptyChunk +!export overrideWrite +!export rejectedDestination +!export malformedDestination + +pump reader = H.bind (H.liftIO (IO.readSome reader 4096)) (reply : case reply of + ((t t), (("eof", t), t)) = H.pure (B.ok t t) + ((t t), (("data", bytes), t)) = H.bind (Artifacts.write bytes) (written : case written of + ((t t), (t, t)) = pump reader + _ = H.pure written) + (t, (_, t)) = H.pure reply + _ = H.fail "invalid-response" "reader" "read" "Invalid read reply") + +publish source directory expected = IO.io <| IO.scope (owner : IO.require + (IO.openReader owner source) (reader : + H.runIO (Artifacts.publish directory expected (H.label "file-producer" (pump reader))))) + +cancelled directory = IO.io <| H.runIO (Artifacts.publish directory 3 (do H.bind + Artifacts.write [97 98 99] + H.liftIO (IO.cancelled "producer cancelled"))) +earlySuccess directory = IO.io <| H.runIO (Artifacts.publish directory 3 (H.pure (B.ok t t))) +ignoresWriteFailure directory = IO.io <| H.runIO (Artifacts.publish directory 1 (do H.bind + Artifacts.write [97 98 99] + H.pure (B.ok t t))) +emptyChunk directory = IO.io <| H.runIO (Artifacts.publish directory 0 (do H.bind + Artifacts.write [] + H.pure (B.ok t t))) +acknowledge _ _ = H.pure (B.ok t t) +overrideWrite directory = IO.io <| H.runIO (Artifacts.publish directory 3 + (H.handle "artifact-stream" acknowledge (do H.bind + Artifacts.write [97 98 99] + H.pure (B.ok t t)))) + +failedAfterFull directory = IO.io <| H.runIO (Artifacts.publish directory 3 (do H.bind + Artifacts.write [97 98 99] + H.pure (B.err "Producer failed" t))) +invalidChunk directory = IO.io <| H.runIO (Artifacts.publish directory 1 (do H.bind + Artifacts.write [256] + H.pure (B.ok t t))) +corruptDigest "run" (10, ("digest", (1, ("finish", _)))) = H.pure (B.ok "../escape" t) +corruptDigest operation argument = H.forward "host.io" operation argument +observe computation = IO.io <| IO.require + (IO.spawn 1 1 [] (_ : H.runIO computation)) + (spawned : case spawned of + [task ticket] = IO.require (IO.join ticket) IO.pure + _ = IO.fail "Invalid spawn reply") +badDigest directory = observe (H.handle "host.io" corruptDigest + (Artifacts.publish directory 3 (do H.bind + Artifacts.write [97 98 99] + H.pure (B.ok t t)))) + +observeBoundary directory producer = observe (H.label "outer" + (Artifacts.publish directory 0 (H.label "inner" producer))) +nestedProducer directory = observeBoundary directory (H.scope (_ : H.pure (B.ok t t))) +forkedProducer directory = observeBoundary directory (H.spawn 0 0 [] (_ : H.pure (B.ok t t))) +rejectedDestination directory = IO.io (H.runIO (Artifacts.publishWith directory 3 + (_ : H.pure (B.err "Destination rejected" t)) (do H.bind + Artifacts.write [97 98 99] + H.pure (B.ok t t)))) +malformedDestination directory = observe (Artifacts.publishWith directory 3 + (_ : H.pure 0) (do H.bind + Artifacts.write [97 98 99] + H.pure (B.ok t t))) diff --git a/demos/io/registry.tri b/demos/io/registry.tri @@ -0,0 +1,41 @@ +!import base as B +!import conversions as C +!import io as IO +!import arboricx_handlers as Registry +!import arboricx_streaming as Server +!import handlers as H +!export main +!export server +!export limited +!export badLogger +!export listen +!export cancelledServer +port ["ipv4" _ value] = value +port _ = 0 +ready listener = do IO.require + address <- IO.localAddress listener + writer <- IO.stdout + IO.require (IO.writeAll writer (B.append (C.showNumber (port address)) [10])) IO.pure +quiet _ = H.pure (B.ok t t) +bad _ = H.fail "test" "logger" "write" "private logger detail" +server root count window = IO.io (Server.serveCount root 1048576 quiet + (IO.ipv4 [127 0 0 1] 0) window count ready) +badLogger root count window = IO.io (Server.serveCount root 1048576 bad + (IO.ipv4 [127 0 0 1] 0) window count ready) +listen root address = IO.io (Server.serve root 1048576 Registry.stdoutLog address 16 ready) +main = listen "./store" (IO.ipv4 [0 0 0 0] 8080) +cancelledServer root count window = IO.io (IO.scope (_ : IO.require + (IO.spawn 1 1 [] (_ : Server.serve root 1048576 quiet (IO.ipv4 [127 0 0 1] 0) window ready)) + (spawned : case spawned of + [task ticket] = do IO.require + input <- IO.stdin + IO.readSome input 1 + IO.cancel task "test shutdown" + outcome <- IO.join ticket + case outcome of + ["cancelled" "test shutdown" []] = IO.pure t + _ = IO.fail "Unexpected server cancellation outcome" + _ = IO.fail "Invalid controlled server spawn"))) + +limited root count window = IO.io (Server.serveCount root 4 quiet + (IO.ipv4 [127 0 0 1] 0) window count ready) diff --git a/demos/io/streams.tri b/demos/io/streams.tri @@ -0,0 +1,31 @@ +!import base as B +!import io as IO +!import handlers as H +!export main +!export handled +!export child +!export diagnostic +!export closeDenied +!export transferDenied +!export broken + +-- Run stored exports with --io --no-result: stdout belongs to the pipeline. +stream = do IO.require + input <- IO.stdin + output <- IO.stdout + IO.require (IO.copyStream input output) (_ : IO.pure t) +main = IO.io stream +handled = IO.io <| H.runIO (H.liftIO stream) +-- Root admission closes on return, but the attached child may first borrow +-- inherited streams afterwards. Parent completion waits for the entire copy. +child = IO.io <| IO.require (IO.spawn 0 0 [] (_ : stream)) (_ : IO.pure t) +diagnostic = IO.io <| IO.require IO.stderr + (output : IO.require (IO.writeAll output [0 255 13 10]) (_ : IO.pure t)) +closeDenied = IO.io <| IO.require IO.stdout IO.close +transferDenied = IO.io <| IO.require IO.stdout + (output : IO.spawn 0 0 [output] (_ : IO.fail "must not start")) +broken = IO.io <| IO.require IO.stdout (output : IO.bind (IO.writeAll output [97]) + (reply : case reply of + (t, (_, t)) = IO.require IO.stderr + (diagnostic : IO.require (IO.writeAll diagnostic [102 97 105 108 101 100]) (_ : IO.pure t)) + _ = IO.fail "Expected broken pipe")) diff --git a/demos/io/tcp.tri b/demos/io/tcp.tri @@ -0,0 +1,74 @@ +!import io as IO +!import base as B +!import native_math as N +!import conversions as C +!import handlers as H +!export server +!export handledServer +!export supervisedHandledServer +!export client +!export namedClient +!export resolve +!export candidates +!export connectResult +!export invalidAddress + +port ["ipv4" _ value] = value +port ["ipv6" _ _ value] = value +port _ = 0 +announce listener = do IO.require + address <- IO.localAddress listener + output <- IO.stdout + IO.writeAll output (B.append (C.showNumber (port address)) [10]) +echo socket = IO.require (IO.copyStream socket socket) (_ : + IO.require (IO.shutdownWrite socket) (_ : IO.pure t)) +worker [socket] = echo socket +worker _ = IO.fail "Invalid connection handoff" +serve 0 _ _ = IO.pure t +serve count owner listener = IO.require (IO.acceptTCP listener owner) (socket : + IO.require (IO.spawn 0 0 [socket] worker) (_ : serve (N.predecessor count) owner listener)) +server address count = IO.io <| IO.scope (owner : do IO.require + listener <- IO.listenTCP owner address 128 + announce listener + IO.bind (serve count owner listener) (_ : IO.pure t)) + +-- Child routing is inherited through H.spawn, not raw IO.spawn. The banner +-- binding lives in source and is deliberately not a native networking service. +requireH action next = H.bind (H.liftIO (IO.require action IO.pure)) next +banner _ _ = H.pure [66 0 255 10] +handledWorker [socket] = H.bind (H.request "banner" "bytes" t) (bytes : + requireH (IO.writeAll socket bytes) (_ : H.liftIO (echo socket))) +handledWorker _ = H.liftIO (IO.fail "Invalid connection handoff") +handledServe _ 0 _ _ = H.pure t +handledServe supervised count owner listener = requireH (IO.acceptTCP listener owner) (socket : + H.bind (H.spawn supervised 0 [socket] handledWorker) (reply : case reply of + ((t t), (_, t)) = handledServe supervised (N.predecessor count) owner listener + _ = H.liftIO (IO.fail "Failed connection spawn"))) +handledServer address count = handledServerWith 0 address count +-- Explicit containment probe: discarded child failures do not stop admission. +-- A production service should choose its own logging/outcome-observation policy. +supervisedHandledServer address count = handledServerWith 1 address count +handledServerWith supervised address count = IO.io <| H.runIO + (H.handle "banner" banner (H.scope (owner : + requireH (IO.listenTCP owner address 128) (listener : + requireH (announce listener) (_ : handledServe supervised count owner listener))))) + +-- Read and write proceed concurrently to avoid duplex backpressure deadlock. +-- The source/destination files must differ, including aliases; output truncates. +client address source destination = IO.io <| IO.scope (owner : + IO.require (IO.connectTCP owner address) (socket : clientStreams owner socket source destination)) +namedClient host family port source destination = IO.io <| IO.scope (owner : + IO.require (IO.resolveTCP host family port 64) (resolved : case resolved of + [addresses _] = IO.require (IO.connectFirstTCP owner addresses) + (socket : clientStreams owner socket source destination) + _ = IO.fail "Invalid resolver reply")) +resolve host family port limit = IO.io (IO.resolveTCP host family port limit) +candidates addresses = IO.io <| IO.scope (owner : IO.connectFirstTCP owner addresses) +clientStreams owner socket source destination = do IO.require + input <- IO.openReader owner source + output <- IO.openWriter owner destination + IO.spawn 0 0 [] (_ : IO.require (IO.copyStream input socket) (_ : + IO.require (IO.shutdownWrite socket) (_ : IO.pure t))) + IO.require (IO.copyStream socket output) (_ : IO.pure t) +connectResult address = IO.io <| IO.scope (owner : IO.connectTCP owner address) +invalidAddress = connectResult ["ipv4" [127 0 0 256] 80] diff --git a/demos/io/text.tri b/demos/io/text.tri @@ -0,0 +1,24 @@ +!import io as IO +!import io_text as Text +!export encode +!export decode +!export source +!export write +!export roundTrip + +encode limit text = IO.io (Text.encodeText limit text) +decode final bytes = IO.io (IO.decodeUTF8 final bytes) +source path limit = IO.io <| IO.scope (owner : do IO.onOk + reader <- IO.openReader owner path + Text.readSource reader limit) +write path text = IO.io <| IO.scope (owner : do IO.onOk + writer <- IO.openWriter owner path + Text.writeText writer text) +roundTrip input output limit = IO.io <| IO.scope (owner : do IO.onOk + reader <- IO.openReader owner input + received <- Text.readSource reader limit + case received of + (_, chars) = do IO.onOk + writer <- IO.openWriter owner output + Text.writeText writer chars + _ = IO.fail "Invalid source result") diff --git a/demos/package-inspector-candidate/main.tri b/demos/package-inspector-candidate/main.tri @@ -0,0 +1,18 @@ +!import foundation as B +!import fileio as IO +!import inspection as Inspect +!export main +-- Candidate offline bindings, not imports from the source workspace. Native +-- has OS process authority; --allow-read/--unsafe-io are not accepted. +-- Read once within an explicit byte budget: hash and report use that same value. +main path = IO.io (IO.bind (IO.readBytes path 1048576) (reply : B.matchResult + (problem _ : IO.abortIO ["Read failed" problem]) + (bytes _ : B.matchResult + (code _ : IO.abortIO ["Invalid Arboricx bundle" (Inspect.describeError code) code]) + (summary _ : IO.bind (IO.sha256Hex bytes 1048576) (digestReply : B.matchResult + (problem _ : IO.abortIO ["Hash failed" problem]) + (digest _ : IO.putStr (B.append "arboricx.inspect.v1\nbundle-sha256\t" + (B.append digest (B.append "\n" (Inspect.renderSummary summary))))) + digestReply)) + (Inspect.inspectBundle bytes)) + reply)) diff --git a/demos/package-inspector-v2/tricu.project b/demos/package-inspector-candidate/tricu.project diff --git a/demos/package-inspector-v2/main.tri b/demos/package-inspector-v2/main.tri @@ -1,18 +0,0 @@ -!import foundation as B -!import fileio as IO -!import inspection as Inspect -!export main --- Candidate offline bindings, not imports from the source workspace. Native v2 --- has OS process authority; --allow-read/--unsafe-io are not accepted. --- Read once within an explicit byte budget: hash and report use that same value. -main path = IO.io (IO.bind (IO.readBytes path 1048576) (reply : B.matchResult - (problem _ : IO.abortIO ["Read failed" problem]) - (bytes _ : B.matchResult - (code _ : IO.abortIO ["Invalid Arboricx bundle" (Inspect.describeError code) code]) - (summary _ : IO.bind (IO.sha256Hex bytes 1048576) (digestReply : B.matchResult - (problem _ : IO.abortIO ["Hash failed" problem]) - (digest _ : IO.putStr (B.append "arboricx.inspect.v1\nbundle-sha256\t" - (B.append digest (B.append "\n" (Inspect.renderSummary summary))))) - digestReply)) - (Inspect.inspectBundle bytes)) - reply)) diff --git a/demos/python-filecheck/README.md b/demos/python-filecheck/README.md @@ -2,7 +2,7 @@ `tricu-filecheck` records and verifies SHA-256 manifests. Python provides the CLI, JSON schema and complete-file manifest publication; a packaged tricu worker -performs file inspection, reads and incremental hashing through native IO-v2. +performs file inspection, reads and incremental hashing through native IO. One Python `FileChecker` instance loads its worker once and reuses its context. ## Build and run @@ -29,7 +29,7 @@ Each invocation prints one JSON report. Exit codes are: - `2`: file IO, budget, manifest, package or embedding failure. Argparse usage errors also exit 2 and print usage to stderr. -File-operation errors include the IO-v2 category, operation, detail and progress. +File-operation errors include the IO category, operation, detail and progress. Verification continues through ordinary per-file failures and distinguishes them from changed contents. Recording publishes only when every requested file succeeds. Existing manifests remain intact: publication uses a fully written, synchronized diff --git a/demos/python-filecheck/worker.tri b/demos/python-filecheck/worker.tri @@ -1,6 +1,6 @@ !import base as B !import native_math as N -!import io_v2 as IO +!import io as IO !export hashFile limitError = B.err ["invalid-bound" "filecheck/hash" "file exceeds byte limit" "none"] t diff --git a/demos/runArboricxBundle.tri b/demos/runArboricxBundle.tri @@ -1,7 +1,7 @@ !import base as B -!import io_v2 as IO -!import io_v2_storage as Storage -!import io_v2_text as Text +!import io as IO +!import io_storage as Storage +!import io_text as Text !import arboricx as Bundle !export main !export run diff --git a/docs/build-and-run.md b/docs/build-and-run.md @@ -86,10 +86,10 @@ to indexed storage, preserving shared branches. Importing native library dependencies is a different operation; see [modules](modules.md.html) and the [artifact checks](tri-tools.md.html#host-artifact-checks) before choosing flat storage. -For IO programs, select `--io`. Current runtimes execute IO v2 with OS process +For IO programs, select `--io`. Current runtimes execute IO with OS process authority and reject v1 permission-grant arguments. Use trusted store directories; path confinement requires external isolation. See the -[structured IO contract](../test/io-v2/README.md) and +[structured IO contract](../test/io/README.md) and [execution options](tri-tools.md.html#io-and-permissions). `WORK` is temporary and contains all objects created by this example. Keep it if @@ -131,7 +131,7 @@ The process listens on `0.0.0.0:8080` inside the container. The example publishe only host loopback; put a protected reverse proxy in front of it for remote access. The bind mount must be writable by the selected UID/GID. Preserve and back up `registry-store` across replacements; do not mount over `/nix/store`. -The default runner uses IO v2 with OS process authority. Each connection owns its +The default runner uses IO with OS process authority. Each connection owns its socket and upload resources; admission waits for the oldest child at a window of 16 connections. A stalled oldest connection can delay later requests, including health checks. Uploads process bounded chunks. Bundle downloads authenticate into @@ -175,7 +175,7 @@ entrypoint directly. Container-engine integration requires a separate check. ## Embed an installed Python application The [file-integrity application](../demos/python-filecheck/README.md) packages a -compiled tricu worker with a Python CLI. It streams files through IO-v2, records +compiled tricu worker with a Python CLI. It streams files through IO, records and verifies JSON manifests, and reuses a loaded worker for sequential API calls. Runtime needs neither compiler data nor source lookup. @@ -207,7 +207,7 @@ result/bin/tricu eval "$WORK/hello.tri" --store "$WORK/application" \ Use the manifest hash printed on the last import line, not a mutable alias or individual export hash. `--tool-store` requires `--toolchain` and otherwise defaults -to the application store. An explicit selection never falls back to package data. The packaged compiler and execution default use IO v2 with OS process authority. +to the application store. An explicit selection never falls back to package data. The packaged compiler and execution default use IO with OS process authority. A retained v1 compiler must run with its matching retained v1 runtime; the current host rejects `--toolchain-io-version 1`. No failure-based protocol fallback is performed. @@ -238,8 +238,8 @@ multiline blocks, imports, loading files, and session operations; see The v1 handler, chunked-upload and body-probe runner outputs are retired from current Nix packages. Their source and executable evidence belong to the frozen rollback toolchains; no current runner silently selects or emulates those hosts. -Use the default IO v2 runner and its acceptance gate above. The explicit -`arboricxStreamingV2Runner` and `arboricxStreamingV2Test` outputs exercise the same +Use the default IO runner and its acceptance gate above. The explicit +`arboricxStreamingRunner` and `arboricxStreamingTest` outputs exercise the same supported composition and gates under their descriptive names. The current tests retain startup, restart, binary/path/package, malformed framing, diff --git a/docs/compiler-rebuilding.md b/docs/compiler-rebuilding.md @@ -64,7 +64,7 @@ backend and entrypoint. The default `--execution-host haskell` uses the native CLI. Select `--execution-host haskell-ffi --haskell-library /nix/store/.../lib/libtricu-haskell.so` for the full [Haskell embedding ABI](../ext/haskell/README.md). Both execute the -same canonical reducer and IO-v2 driver. `--tricu` supplies authenticated input +same canonical reducer and IO driver. `--tricu` supplies authenticated input transport, independent store auditing and output packaging for either interface. `--backend` independently selects the lowering backend. Missing or incompatible libraries and exports fail preparation, without interpreter fallback. @@ -75,7 +75,7 @@ IO version and captured bundle identities before effects. Supervision enforces publication, CPU, RSS/address-space and whole-job bounds. The shared library uses one GHC capability; `--capabilities` controls CLI execution and transport. -The shared-library executor accepts IO v2 with OS process authority. Historical +The shared-library executor accepts IO with OS process authority. Historical v1 jobs require a retained CLI runtime. Use OS isolation for path confinement. Bounded cold/warm, syntax, invalidation and corruption controls use: @@ -112,7 +112,7 @@ source projection while selecting a previously prepared v2 adapter for its outpu ```sh python3 test/bootstrap_generations.py "$FIRST" --bootstrap-kit "$V1_KIT" \ --tricu "$TRICU" --source-root "$FROZEN_SOURCE" \ - --map "$FROZEN_SOURCE/bootstrap/compiler-source-map.io-v2.v1" \ + --map "$FROZEN_SOURCE/bootstrap/compiler-source-map.v1" \ --produced-io-version 2 --produced-adapter "$V2_ADAPTER" --backend stable --fresh ``` @@ -121,14 +121,14 @@ Both current source maps select `compiler_io`, the compiler storage facade, plus its v2 dependencies. Compiler source imports this facade explicitly; general application `io` is independent. The packaged compiler uses v2. Input limits and source-owned storage semantics are described in the -[compiler projection controls](../test/io-v2/README.md#source-storage-and-isolated-compiler-projection). +[compiler projection controls](../test/io/README.md#source-storage-and-isolated-compiler-projection). Reproduction requires the previous produced version and exact produced adapter. Pair verification binds generation one's produced version/adapter to generation two's execution and reproduced output, instead of requiring the *input* adapters of a transition pair to match. It still requires the first rebuilt compiler, identical frozen inputs, complete manifest reproduction and all three identical -compiler bundles. New records also check invocation authority flags. V2 Haskell +compiler bundles. New records also check invocation authority flags. IO Haskell execution has no `--unsafe-io` or read/write grants. The correctness smoke and retention probes select the recorded produced version explicitly. @@ -138,7 +138,7 @@ compiler, writes a versioned kit, stages the full v2 map and builds only rejects conflicting versions, missing transition adapters and unsupported hosts: ```sh -python3 test/io_v2_generation.py "$TRICU" --directory /tmp/new-v2-generation-probe +python3 test/io_generation.py "$TRICU" --directory /tmp/new-v2-generation-probe ``` This probe covers preparation and one module. It also checks mixed native, unit @@ -146,7 +146,7 @@ and reexport maps: exact overlays and independently authenticated raw/overlay digests agree with the packaged compiler, including through the shared library when `--haskell-library` is selected. Full pair acceptance requires source -freezing, complete reproduction, smokes and rolling retention. Accepted native v2 pairs support +freezing, complete reproduction, smokes and rolling retention. Accepted native pairs support [recovery archives](#recovery-archives); seed-only v2 archives remain rejected. The native CLI and shared-library probes compare interfaces of the same Haskell implementation; independently expected semantic and IO controls remain separate. Application `provenance` capture/rebuild uses its own @@ -362,9 +362,9 @@ compiler support or mixing v1 constructors with a v2 IO facade: ```sh python3 test/prepare_compiler_kit.py "$TRICU" "$NEW_PREPARATION" \ --compiler-bundle "$APPLICATION_COMPILER" --compiler-io-version 2 \ - --io-version 2 --map bootstrap/compiler-source-map.io-v2.v1 \ + --io-version 2 --map bootstrap/compiler-source-map.v1 \ --source-root "$SOURCE" --backend stable -python3 test/io_v2_generation.py "$TRICU" \ +python3 test/io_generation.py "$TRICU" \ --kit "$NEW_PREPARATION/kit" --directory "$NEW_SCOPED_CHECK" ``` @@ -569,7 +569,7 @@ than inheriting paths hidden by that mount. Each selected compiler passes independent pair audits, CLI compilation/execution, diagnostics and integrity controls, correctness smokes, and a fresh scoped `native_math` map-builder/adapter job with complete-manifest comparison. Current -IO v2 compilers use the current CLI oracle; retained IO v1 compilers use the +IO compilers use the current CLI oracle; retained IO v1 compilers use the `test/portable_cli.py` oracle in their protected frozen source. Reports record the selected oracle and its hash. Selection precedes execution. Support reproduction must pass in that isolation too. The same checks run after deletion; @@ -620,7 +620,7 @@ transfers record working-source differences rather than claiming those edits hav published fixture coverage. Seed-only transfers still require current compiler fixture sources because they construct tools from that fixture. -V2 recovery requires an independently accepted pair. The selected adapter must be +IO recovery requires an independently accepted pair. The selected adapter must be the second generation's authenticated **produced** adapter, not an earlier transition input adapter. `tricu.bootstrap-recovery.v2` records compiler IO version 2; its application probes explicitly select that version and run with OS process authority, @@ -661,9 +661,9 @@ transfers. ```sh python3 test/bootstrap_generation_cli.py "$PREVIOUS" --backend stable --adapter "$ADAPTER" -python3 test/io_v2_indexed.py "$TRICU" --directory "$PROBE_DIRECTORY/read" -python3 test/io_v2_indexed_write.py "$TRICU" --directory "$PROBE_DIRECTORY/write" -python3 test/io_v2_compiler_store.py "$TRICU" --directory "$PROBE_DIRECTORY/compiler-store" +python3 test/io_indexed.py "$TRICU" --directory "$PROBE_DIRECTORY/read" +python3 test/io_indexed_write.py "$TRICU" --directory "$PROBE_DIRECTORY/write" +python3 test/io_compiler_store.py "$TRICU" --directory "$PROBE_DIRECTORY/compiler-store" ``` Use new writable diagnostic directories. The CLI probe checks cold/warm/change, diff --git a/docs/contracts.md b/docs/contracts.md @@ -301,7 +301,7 @@ digests, canonical character/numeral shapes, oversized suffix laziness, accepted-value/remainder preservation, byte authentication, corrupt winners, publication races and ordinary failures. See [handler validation](validation.md.html#scoped-handler-library-opt-in) and the -[IO storage controls](../test/io-v2/README.md). +[IO storage controls](../test/io/README.md). ## 10. Scoped request handlers @@ -429,15 +429,15 @@ A missing handler uses kind `"unhandled-request"`. An invalid response uses `H.liftIO hostAction` requests the named `"host.io"` / `"run"` bridge. `runPure` rejects it like any other unhandled request. `H.runIO computation` is an alias -for `H.runIOV2`: it returns a host IO action yielding the successful computation +for `H.runIO`: it returns a host IO action yielding the successful computation value. Handler failures become terminal v2 failures. Wrap the action with `IO.io` at an executable entrypoint. Install handlers for other named services. The host enforces resource ownership under OS process authority. -`H.scopeV2 body` and `H.spawnV2 supervised keep handles body` preserve the lexical +`H.scope body` and `H.spawn supervised keep handles body` preserve the lexical handler environment and labels in their callbacks. Each callback starts with an empty continuation stack. Their execution uses the host -[scope/task lifecycle](../test/io-v2/README.md); pure interpretation reports an +[scope/task lifecycle](../test/io/README.md); pure interpretation reports an invalid view for these boundaries. ### Artifact storage @@ -446,11 +446,11 @@ invalid view for these boundaries. `"artifacts"` service. `loadFrom service digest` and `publishTo service bytes` select explicit roles, useful for source/destination stores. -`Artifacts.withFilesystemV2 root limit computation` installs the default service; -`withFilesystemForV2 service root limit computation` installs a named one. The +`Artifacts.withFilesystem root limit computation` installs the default service; +`withFilesystemFor service root limit computation` installs a named one. The byte limit applies to storage operations. Paths and publication use the artifact store's hash/shard layout, staging and authenticated winner checks. -`Artifacts.prepareFilesystemV2 root` prepares and synchronizes the directory +`Artifacts.prepareFilesystem root` prepares and synchronizes the directory layout at application startup and returns an ordinary Result. Run it explicitly before serving requests; path access follows OS process authority. diff --git a/docs/host-abi.md b/docs/host-abi.md @@ -242,10 +242,10 @@ Current hosts execute only the canonical `tricuIO` envelope with version **2**. Unsupported versions reject before action payloads are demanded. Historical version-1 trees can be inspected as data; execution requires a retained runtime. -`io` reexports `io_v2`. The protocol supplies sequencing, scope-owned resources, +`io` reexports `io`. The protocol supplies sequencing, scope-owned resources, child tasks, consumable joins, supervision and cancellation. Named/versioned facilities provide binary streams, codecs and backend-specific OS operations. -Native execution uses OS process authority and explicit resource ownership. The [IO v2 contract](../test/io-v2/README.md) specifies +Native execution uses OS process authority and explicit resource ownership. The [IO contract](../test/io/README.md) specifies wire shapes, ordinary Results, terminal outcomes, cancellation and cleanup. Native Haskell supports filesystem, TCP, process/context and clock facilities. @@ -254,7 +254,7 @@ before choosing a facility. ## Source bytes and publication -`compiler_io` is the source-owned compiler policy over IO v2. Whole-source reads +`compiler_io` is the source-owned compiler policy over IO. Whole-source reads preserve exact raw octets and decode strict Unicode scalars, rejecting malformed UTF-8 and an initial BOM without normalizing text or newlines. Encoding U+FEFF is valid; source-file policy rejects only an initial BOM. Whole-source operations @@ -267,4 +267,4 @@ winner verification. File/directory sync follows host filesystem guarantees; a post-publication failure can leave a valid object visible. Scope cleanup releases live resources while the runtime is executing. Process death requires external recovery, and paths resolve under OS process authority. See [storage](storage.md.html) and the -[IO validation contract](../test/io-v2/README.md). +[IO validation contract](../test/io/README.md). diff --git a/docs/language-features.md b/docs/language-features.md @@ -186,7 +186,7 @@ Evidence: [do AST lowering](../lib/source/ast.tri), - `parser` already supplies reply diagnostics, consumed/committed flags, sequencing, alternatives, labels, repetition, and rollback. A parser reply is not merely an HTTP Result; preserve its full protocol when adapting it. -- `io` reexports `io_v2`: Result-aware sequencing, scoped files, bounded binary +- `io` reexports `io`: Result-aware sequencing, scoped files, bounded binary chunks, child tasks, consumable joins, supervision, cancellation and ownership handoff, plus staged publication and independent incremental SHA-256 contexts. Borrowed `stdin`/`stdout`/`stderr` and source `copyStream` compose bounded binary pipelines @@ -204,35 +204,35 @@ Evidence: [do AST lowering](../lib/source/ast.tri), while decoded graphs still occupy memory proportional to their nodes. Canonical indexed hashing/writing and exact-byte comparison avoid expanded-tree transport; source code still owns publication and whole-file verification policy. - File and directory synchronization compose with `io_v2_objects`, which performs + File and directory synchronization compose with `io_objects`, which performs source-owned sharding, no-replace publication, exact winner verification and synchronization before returning object identities. Store roots must preexist; synchronization is subject to host filesystem guarantees. Stateless bounded - UTF-8 codecs preserve strict scalar/byte semantics; `io_v2_text` composes text + UTF-8 codecs preserve strict scalar/byte semantics; `io_text` composes text writing and whole-source reads that preserve raw bytes and reject an initial BOM. Whole-source conveniences retain their complete values in memory. Its explicit single-consumer line cursor preserves bounded read-ahead and strict incremental UTF-8, with a caller-selected encoded-byte limit per line. LF/EOF framing is explicit; CR and BOM remain generic text content. Thread the returned cursor forward and retire it on failure. Callers serialize composite operations. - `io_v2_storage` composes byte hashing, budgeted whole-file reads, immutable winner + `io_storage` composes byte hashing, budgeted whole-file reads, immutable winner verification and mutable alias replacement. Its source-only compiler projection is selected as `compiler_io` by current compiler store callers; see the isolated compiler procedure in the IO validation guide. - `io_v2_artifacts` + `io_artifacts` composes exact-length artifact publication in source, with verification of existing winners. `IO.capabilities` reports implemented named/versioned facilities and limits; `IO.inspect` classifies one action header without executing it or forcing - payloads/callbacks (using the chosen import alias for `IO`). These use OS process authority. `handlers.scopeV2`, `spawnV2` - and `runIOV2` inherit lexical services without sharing continuation frames. + payloads/callbacks (using the chosen import alias for `IO`). These use OS process authority. `handlers.scope`, `spawn` + and `runIO` inherit lexical services without sharing continuation frames. Stateful collection must be installed inside the child explicitly. See the - [implemented contract and validation](../test/io-v2/README.md) for outcome shapes, + [implemented contract and validation](../test/io/README.md) for outcome shapes, cancellation limits, unsupported facilities and backend boundaries. - `handlers` supplies named request/response scopes and pure/host-IO runners. `handler_log` supplies context, sinks and capture; `handler_artifacts` supplies artifact storage; `handler_testing` supplies exact scripts. Service Results remain ordinary computation values. `runPure` wraps completion in an execution - Result; `runIO`/`runIOV2` yield successful values and map handler failures to + Result; `runIO`/`runIO` yield successful values and map handler failures to terminal IO failures. Host scopes own resource cleanup. See [scoped handlers](contracts.md.html#10-scoped-request-handlers). `handler_upload` selects the scoped v2 producer/staged-publication composition; diff --git a/docs/modules.md b/docs/modules.md @@ -552,7 +552,7 @@ The current source interfaces are: Their closed executable exports include the required binary, numeric and parser helpers, so consumers can use the closed exports directly. The current file facade -constructs IO v2: `readBytes path limit` and `sha256Hex bytes limit` take encoded-byte +constructs IO: `readBytes path limit` and `sha256Hex bytes limit` take encoded-byte budgets; native execution uses OS process authority. Each release uses the ordinary one-module package profile above. Historical published v1 releases retain their original interfaces and bytes. diff --git a/docs/storage.md b/docs/storage.md @@ -194,7 +194,7 @@ workflow](modules.md.html#retained-build-inputs-and-reproduction). A portable input pack contains a `build-inputs` descriptor and a standard CAS under `store/objects`. IO v1 descriptors retain the historical header/domain -`arboricx.build-inputs.v1`. IO v2 descriptors use the separate header/domain +`arboricx.build-inputs.v1`. IO descriptors use the separate header/domain `arboricx.build-inputs.v2` and require the exact row `compiler-io\t2` immediately after the compiler manifest row. Header/domain separation binds the execution profile to the pin; missing, duplicate or other version rows are rejected. V1 diff --git a/docs/tri-tools.md b/docs/tri-tools.md @@ -47,7 +47,7 @@ Unsupported or malformed framing terminates the invocation. Manifest/receipt authentication remains required after execution. Retained [application recipes](modules.md.html#retained-build-inputs-and-reproduction) and [generation records](compiler-rebuilding.md.html#explicit-io-framing-and-transitions) -carry explicit v2 metadata. V2 application replay additionally requires +carry explicit v2 metadata. IO application replay additionally requires `--trust-io-version 2`; historical profiles remain explicitly v1. @@ -159,7 +159,7 @@ result/bin/tricu arboricx inspect --file "$WORK/server-inspection.arboricx" \ The `main` outline names every currently visible occurrence: ```text -main: tricuIO v2 +main: tricuIO startupAction: Scope startupBody: scope callback (not applied) ``` @@ -173,7 +173,7 @@ observationally equivalent expressions can have different trees. `createView` separately materializes the directory request: ```text -createView: tricuIO v2 +createView: tricuIO createRootDirectory: Facility directory@1/create storeRoot: arguments (not validated; backend availability not inferred) ``` @@ -217,11 +217,11 @@ reference to a previously described action occurrence. Summaries are display text whose wording may change; retain the artifact for exact payload data. Status is `decoded`, `opaque`, `invalid`, `unknown`, `truncated`, or `reference`. `opaque` marks a value whose internals remain unexamined. -Callability and type validity require separate checks. V2 named facility headers show group, +Callability and type validity require separate checks. IO named facility headers show group, revision and operation, but arguments and scope/task callbacks remain opaque. A decoded request is not evidence that a particular backend supports it; native -programs can query `io_v2.capabilities` separately. See the -[v2 protocol and inspection contract](../test/io-v2/README.md). +programs can query `io.capabilities` separately. See the +[v2 protocol and inspection contract](../test/io/README.md). | Option | Default | Meaning | | --- | --- | --- | @@ -251,13 +251,13 @@ ignores `--store`. ## IO and permissions -Select `--io` for structured IO v2 execution. Compilation and application IO use +Select `--io` for structured IO execution. Compilation and application IO use OS process authority. Current hosts reject `--allow-read`, `--allow-write`, their all-path variants and `--unsafe-io` before application execution. There is no runtime path-grant sandbox; use OS isolation when running untrusted programs. Historical IO v1 bundles require a retained v1 runtime. Current runtimes do not -retry another protocol after failure. See [structured IO](../test/io-v2/README.md) +retry another protocol after failure. See [structured IO](../test/io/README.md) for resource ownership, task settlement and supported facilities. ## Host artifact checks @@ -349,7 +349,7 @@ source locations removed, run: ```sh python3 test/formatter_equivalence.py result/bin/tricu \ --formatter-directory "$FORMAT_DIRECTORY" --directory "$WORK/format-equivalence" \ - lib/io-v2.tri lib/io-v2/storage.tri lib/source/io-v2.tri + lib/io.tri lib/io/storage.tri lib/source/io.tri ``` The check writes candidates and evidence to its new output directory and leaves @@ -358,6 +358,12 @@ Use `--baseline DIRECTORY` to compare named files against saved pre-formatting sources, including manually authored grouping changes. Historical invalid corpus members must remain rejected; successful AST comparisons count valid inputs only. +All current `lib/**/*.tri` sources use equation alignment. Check the full library +with `nix build .#libraryFormatting --no-link`; frozen reference snapshots and +malformed test fixtures have their own byte identities. Formatting changes are +validated against the frozen parser with idempotence and complete AST comparison, +ignoring only source locations. Grouping/style rewrites require separate review. + Default output is UTF-8 source on stdout. `--check` fails when formatting would change bytes. `--write` replaces the file atomically, preserving permissions and refusing symbolic links. Select one of those modes at a time. `--seconds` bounds diff --git a/docs/validation.md b/docs/validation.md @@ -134,7 +134,7 @@ nix build .# PYTHONDONTWRITEBYTECODE=1 python3 test/io_dependencies.py result/bin/tricu ``` -This opt-in probe compiles current `io_v2`, `base`, and `native_math` sources with +This opt-in probe compiles current `io`, `base`, and `native_math` sources with only those modules present in a temporary workspace. It checks IO constructor versioned envelope/facility shapes and rejects an explicit import of the absent `patterns` module. Published compiler/support fixtures remain unchanged. The fast host @@ -542,7 +542,7 @@ python3 test/native_library_migration.py "$TRICU" "$NEW_PREPARATION" \ Select every required library explicitly (all 49 for full current-support retention). Provider modules retain their **complete** interfaces, imported indexed; actual source compilation produces new receipts. `selection.json` records compiler/provider -bytes, source/map selection, backend and IO version. V2 compiler bundles require +bytes, source/map selection, backend and IO version. IO compiler bundles require `--compiler-io-version 2`; generation selection instead uses its produced version. Resume refuses changed selections. This preparation is not generation acceptance. Only subsequent exact artifact/receipt comparisons can establish reproduction of @@ -573,15 +573,16 @@ records this relocation without changing the immutable origin membership. Use that option when publishing the current 39-module support set; ordinary candidate replacement preserves cohort membership. -The v4 support fixture explicitly adds ten IO-v2/handler providers so migrated -demos remain dependency-closed. Use `--relocate-patterns --add-io-v2-support` with -all49 genuinely rebuilt candidates. Python publication and the independent Haskell -verifier require the exact added aliases/paths, declarations and rebuild receipts; -ordinary v2/v3 membership rules are unchanged. No implicit cohort expansion is -permitted. `--compiler-fixture PATH` can bind a freshly packed compiler candidate -before installing the coordinated compiler/support fixtures; omitted, it uses the -published compiler fixture. Retention reproduction selects the same expansion -with `support.add_io_v2_support: true`. +The current support fixture uses schema `tricu.support-library-fixture.v6`. +The compiler owns canonical `io`, `io_storage`, `io_text`, and `compiler_io`. +Support adds seven explicitly declared registry/handler/artifact/HTTP providers +and the relocated `patterns` module. Publish genuinely rebuilt candidates with +`--relocate-patterns --add-io-support --compiler-io-cohort`; ordinary membership +replacement remains closed. Both the publisher and independent Haskell verifier +check exact provider names, paths, ownership and rebuild receipts. Historical +schema validators retain their original closed membership contracts. +`--compiler-fixture PATH` selects a genuinely packed compiler candidate; retention +reproduction records `support.add_io_support` and `support.compiler_io_cohort`. Current indexed native-manifest reproduction uses indexed import and exact manifest comparison. Reproducing retained flat manifests expands trees: its @@ -635,8 +636,8 @@ bash test/source_map_end_to_end.sh result/bin/tricu "$GENERATION" \ Check each probe's `--help` for its other required prepared artifacts. Equation coverage has independent AST/diagnostic controls; module/map probes use native -artifacts. `io_v2_indexed.py`, `io_v2_indexed_write.py`, and -`io_v2_compiler_store.py` cover authenticated transport, canonical publication and +artifacts. `io_indexed.py`, `io_indexed_write.py`, and +`io_compiler_store.py` cover authenticated transport, canonical publication and compiler storage. The old `verified_tree_loading.py` and `native_indexed_store.py` CLIs reject before writes; the latter retains its independent wire-oracle helpers. @@ -1048,7 +1049,7 @@ python3 test/native_source_utf8.py "$TRICU" \ --compiler bootstrap/build-stable.arboricx --directory "$PROBE_DIRECTORY" ``` -Use a Nix-built current Haskell host and a new output directory. This IO v2 +Use a Nix-built current Haskell host and a new output directory. This IO probe compiles complete workspace modules for read/stage, mapping and lexing through the explicitly selected portable compiler. It neither splices source into retained support nor supplies retired permission grants. The published frontend is checked separately by @@ -1205,7 +1206,7 @@ MiB GHC heap ceiling and a 30-second deadline. For a retained-built v2 candidate application compiler, test actual offline replay: ```sh -python3 test/io_v2_provenance.py result/bin/tricu \ +python3 test/io_provenance.py result/bin/tricu \ --compiler-bundle "$V2_CANDIDATE" --directory /tmp/new-v2-provenance ``` @@ -1374,10 +1375,10 @@ compiler and retains inputs' digests, per-invocation logs, and `report.json`. Checks cover lexical handler routing, scope/label restoration, forwarding, contract transformation and malformed replies, ordinary versus interpreter failures, nested log capture/context ordering, scripts, artifact-load/publication -contracts, and real OS permission-denial checks on scoped IO v2 operations. Pure interpretation +contracts, and real OS permission-denial checks on scoped IO operations. Pure interpretation has a negative control proving it cannot execute the host IO bridge. The test-only observer in `test/handlers/observe.tri` retains source diagnostics as data for -independent assertions. Production execution uses terminal v2 outcomes. The test +independent assertions. Production execution uses terminal outcomes. The test observer threads state explicitly and rejects implicit child-state inheritance. The optional benchmark compares complete successful artifact-GET responses and @@ -1442,9 +1443,9 @@ concurrent-server throughput is measured. ### Handler registry composition -Use `nix build .#arboricxServerTest --no-link` for the actual IO v2 default +Use `nix build .#arboricxServerTest --no-link` for the actual IO default runner, including the test-only failed logging sink and common protocol/body -checks. `arboricxStreamingV2Test` exercises the explicit v2 runner through the +checks. `arboricxStreamingTest` exercises the explicit v2 runner through the same gates. The v1 handler/chunked/body-probe Nix outputs are retired; earlier measurements and executable rollback remain historical evidence, not current runtime compatibility. See [the registry runner](build-and-run.md.html#deploy-the-arboricx-registry-linux). diff --git a/ext/demo-site/README.md b/ext/demo-site/README.md @@ -471,7 +471,7 @@ oversized expansion without constructing the exponentially large string. This complete-tree demand differs deliberately from the retired shallow JSON preview. Inspection never interprets an IO-shaped result as execution authority. The current -Conversation demo uses the structured IO v2 terminal profile described below. +Conversation demo uses the structured IO terminal profile described below. Current adapters execute only IO protocol 2. Historical v1 bundles remain inspectable data; they require retained runtimes for execution. No JavaScript reference interpreter supplies IO compatibility. @@ -661,7 +661,7 @@ the exact pinned package/export. Clicking a definition name starts inspection; o The first source edit queues compilation and loads its compiler on demand. Explicitly selecting the compiler package simply loads that library. Work continues while using Settings or scrolling through the page. Source remains editable during compilation; active evaluation/IO locks editing -until completion or Stop. V2 Stop drains its structured session; page departure, +until completion or Stop. IO Stop drains its structured session; page departure, hard Stop of pure work, or replacement terminates the owning Worker. Compilation and program execution have no configurable node, evaluator-frame, @@ -695,7 +695,7 @@ and ordinary-package roles must satisfy the smaller download cap. inputs are bounded decimal strings; decoded naturals are capped at 4,096 bits. - Result previews: up to 64 distinct constructors; internal graph-preview lists: 32 entries with 8-node previews. Decoded text/natural lists: 4,096 entries. Strings/bytes have 4,096-element limits. -- IO v2: 65,536 interpreted actions; 1–4,096-byte chunks; 256 input-line scalars +- IO structured: 65,536 interpreted actions; 1–4,096-byte chunks; 256 input-line scalars plus LF; 16,384 attempted output bytes shared across stdout/stderr. These are protocol bounds, not pure-evaluation fuel, deadlines or heap quotas. - Main-page deadlines: 60 seconds for package loading/download and 120 seconds @@ -1071,7 +1071,7 @@ Linux WebKit harness against a built site. All sixteen current catalogue sources in `demos/` are editable native modules. The six Basic Examples teach one concept at a time; Language reference supplies -an editable syntax sampler. Conversation imports the published IO v2 and text +an editable syntax sampler. Conversation imports the published IO and text modules, checks read/write Results, and distinguishes an empty line from EOF. Send appends LF; End input returns an empty name with an end-of-input message. Structured Stop retires the prompt and permits rerunning without recompilation. @@ -1516,7 +1516,7 @@ reject without retry. `probeIO` recognizes version 2 without inspecting actions or arguments. Historical v1 trees may be loaded and inspected, not executed. The reference JS graph supports pure evaluation only. -V2 uses the shared structured task/resource interpreter, not the old single-read +IO uses the shared structured task/resource interpreter, not the old single-read loop. `IO.capabilities` reports the backend (`haskell-wasm`), browser-terminal authority, core operations, implemented named revision-1 facilities (`runtime`, `standard`, `stream`, `resource`, `utf8`) and limits. Resource close implements borrowed-standard-handle rejection, not owned @@ -1524,7 +1524,7 @@ file handles. File, network, process and clock facilities are unsupported and leave their arguments opaque. `IO.inspect` classifies one header without forcing its payload, continuations or unknown facility arguments. -Public embedding callers supply `IOHost.v2` with identity-bound open/event/close +Public embedding callers supply `IOHost.structured` with identity-bound open/event/close transport callbacks. These callbacks enqueue cloneable commands and must not throw or synchronously reenter a turn. A legacy text-only host rejects v2 before callbacks; it is never silently adapted to lossy byte output or uncancellable @@ -1555,31 +1555,31 @@ in batches of at most128; completely blocked terminal waits do not spin-poll. There is no hard pure-reduction deadline or prompt-cancellation guarantee. In v2, a divergent native observation can prevent reaching a cancellation boundary; reload/navigation/fatal-runtime teardown is emergency disposal, **not** evidence -of cleanup. Pure evaluation retains hard Worker Stop. V2 trace hooks run only +of cleanup. Pure evaluation retains hard Worker Stop. IO trace hooks run only for scheduler stepping and never inspect arbitrary payloads. Current source and -application IO use v2. The browser compiler remains the independently pinned +application IO use structured. The browser compiler remains the independently pinned **pure** module compiler, not the native filesystem compiler. `TEST_IO_VERSION=1` checks inert current recognition and retired/unknown-version rejection, missing-structured-host rejection before callbacks, and separately pinned pure compilation. For public v2, -compile `test/io-v2/browser.tri` as below and select `TEST_IO_V2_PUBLIC=1` in the +compile `test/io/browser.tri` as below and select `TEST_IO_PUBLIC=1` in the same Chromium harness on the Haskell runtime. It exercises compiled direct/handled programs, scoped reader cancellation, binary/line/EOF output, every public codec, >64-bit naturals, expression execution, capability/inspection queries, concurrent operation rejection, codec-error recovery and real-page structured Stop/reuse. -### Inert IO v2 wire controls +### Inert IO wire controls -`io-v2-protocol.ts` classifies one core/request header without executing it or +`io-protocol.ts` classifies one core/request header without executing it or resolving a facility. Its handles are borrowed; callers retain the action while using them. Canonical numbers, bounded ASCII names and revision bounds follow -`IOV2.Wire`, shared by native Haskell and its browser embedding. This is not a capability declaration or a v2 execution entrypoint. +`IO.Wire`, shared by native Haskell and its browser embedding. This is not a capability declaration or a v2 execution entrypoint. ```sh nix build .#arboricxSite --no-link SITE=$(nix path-info .#arboricxSite) -TEST_IO_V2_PROTOCOL=1 TEST_BACKEND=haskell-wasm ARBORICX_SITE_ROOT="$SITE" \ +TEST_IO_PROTOCOL=1 TEST_BACKEND=haskell-wasm ARBORICX_SITE_ROOT="$SITE" \ nix shell --inputs-from . nixpkgs#nodejs nixpkgs#chromium -c \ node ext/demo-site/test/backend-browser.mjs "$SITE/runtime/haskell" chromium ``` @@ -1592,14 +1592,14 @@ reject retired versions and hosts without structured transport before callbacks. ### Browser v2 lifecycle components -`io-v2-tasks.ts` owns lexical/spawned scope states, supervision, consumable joins, +`io-tasks.ts` owns lexical/spawned scope states, supervision, consumable joins, cycle checks, bounded secondary diagnostics and pending host-call ownership. Normal return closes admission and joins children; failure/cancellation requests subtree stop. Neither ordinary Results nor arbitrary payloads are inspected. `payloads()` lists lifecycle-held evaluator roots, not body/continuation roots. Consumed/discarded outcomes and closed scopes are removed rather than archived. -`io-v2-host-work.ts` starts work without blocking sibling actions and separates +`io-host-work.ts` starts work without blocking sibling actions and separates abort delivery from physical completion acknowledgement. Cancelled and stale replies cannot revive bodies. Cleanup is a separate, non-cancellable batch per scope, using that scope's reserved identity so body-call identity exhaustion @@ -1617,18 +1617,18 @@ provider controls run with: ```sh nix shell --inputs-from . nixpkgs#nodejs -c node --test \ - ext/demo-site/test/io-v2-tasks.test.mjs \ - ext/demo-site/test/io-v2-host-work.test.mjs + ext/demo-site/test/io-tasks.test.mjs \ + ext/demo-site/test/io-host-work.test.mjs ``` -After building the site, use `TEST_IO_V2_LIFECYCLE=1` in the Chromium command +After building the site, use `TEST_IO_LIFECYCLE=1` in the Chromium command above for a real DOM read with delayed cleanup acknowledgement and concurrent pure work on each selected WASM runtime. This is a lifecycle/provider integration probe, not execution of a v2 language program. Repeated-task tests establish logical retirement/root removal, not measured JS heap or throughput bounds. -`io-v2-resources.ts` supplies the registry through `lifecycle()`, which binds task +`io-resources.ts` supplies the registry through `lifecycle()`, which binds task release/settlement to resource guards. Use that factory rather than constructing an unguarded task machine alongside a registry. Acquisitions are admitted before host effects; a failed publication leaves its admission live until the caller @@ -1649,14 +1649,14 @@ closing those external backends. Release errors remain failures, never an implicit retry or a claim that an uncertain physical release succeeded. Secondary diagnostics retain at most16 values; omitted errors are counted without calling the diagnostic builder. A diagnostic-builder failure uses a prebuilt fallback -and does not skip remaining releases. Add `io-v2-resources.test.mjs` to the focused +and does not skip remaining releases. Add `io-resources.test.mjs` to the focused Node command above. The Chromium lifecycle probe also checks handoff alias invalidation, a real DOM reader lease, and physical resource removal before join. These are provider/registry controls, not public v2 language execution support. ### Private v2 tree interpreter validation -`io-v2-core.ts` connects the guarded lifecycle to tree-calculus actions and bind +`io-core.ts` connects the guarded lifecycle to tree-calculus actions and bind continuations. Its `advance()` performs a progress turn without awaiting a host operation. The embedding must yield between batches, retain `roots()` across turns, and keep temporary evaluator handles alive for the whole turn. Providers @@ -1668,7 +1668,7 @@ Evaluator/embedding faults are distinct from protocol faults and ordinary Result `hasFault` also records rejection with `undefined`. Cleanup drains before the embedding rethrows the saved fault. No hard pure-reduction deadline is implied. -The private `io-v2-core` worker request executes bare computations, not public IO +The private `io-core` worker request executes bare computations, not public IO envelopes. Haskell's `BrowserTrees.hs` keeps lazy trees in a worker-private handle table with non-reused checked identities and between-turn root sweeps. Evaluator handles stay inside that session. The JS reference evaluator supplies @@ -1678,10 +1678,10 @@ advertisement. Public sessions compose this core with the terminal provider. ```sh # Include the existing lifecycle, resource and non-demand controls. -nix shell --inputs-from . nixpkgs#nodejs -c node --test ext/demo-site/test/io-v2-*.test.mjs +nix shell --inputs-from . nixpkgs#nodejs -c node --test ext/demo-site/test/io-*.test.mjs nix build .#arboricxSite --no-link SITE=$(nix path-info .#arboricxSite) -TEST_IO_V2_CORE=1 TEST_BACKEND=haskell-wasm ARBORICX_SITE_ROOT="$SITE" \ +TEST_IO_CORE=1 TEST_BACKEND=haskell-wasm ARBORICX_SITE_ROOT="$SITE" \ nix shell --inputs-from . nixpkgs#nodejs nixpkgs#chromium -c \ node ext/demo-site/test/backend-browser.mjs "$SITE/runtime/haskell" chromium ``` @@ -1694,11 +1694,11 @@ own integration controls before production dispatch changes. ### Private resumable stream sessions -`io-v2-session.ts` couples the interpreter to `io-v2-events.ts` using cloneable +`io-session.ts` couples the interpreter to `io-events.ts` using cloneable run IDs, operation IDs and bounded byte data only. A turn advances at most128 progress steps and never waits for a terminal operation. Multiple pending operations remain visible; replies, polls and structured stop are separate turns. -The Haskell worker exposes private `io-v2-session-start`/`io-v2-session-turn` +The Haskell worker exposes private `io-session-start`/`io-session-turn` requests, keeping the tree table alive across turns and rejecting unrelated work until settlement. Public RuntimeSession uses the same session with opaque outcomes and selected full codecs. @@ -1722,7 +1722,7 @@ validation state. Structured stop drains leases and scopes; destroying a worker is still emergency disposal, not evidence of structured cleanup. No hard deadline for pure reduction or an uncooperative provider is promised. -Use `TEST_IO_V2_SESSION=1` with the Chromium command above for both backends. The +Use `TEST_IO_SESSION=1` with the Chromium command above for both backends. The probe executes a native computation tree that starts a read, performs a sibling write, and cancels the reader; a real DOM provider must finish cleanup before the run completes. It also rejects wrong-run stop/replies and checks empty lifecycle @@ -1743,18 +1743,18 @@ replacement or normalization. Source `readSource` keeps its separate initial-BOM policy. Excess scalar/byte list cells are rejected before their heads or tails are demanded. Result encoding remains deferred to a live interpreter turn. -`test/io-v2/browser.tri` exercises actual `io_v2`, `io_v2_text` and handler libraries +`test/io/browser.tri` exercises actual `io`, `io_text` and handler libraries through the private native sessions. Compile it with a selected Nix-built host, not a JS compiler or substituted evaluator: ```sh WORK=$(mktemp -d) TRICU=/path/to/selected-nix-built-tricu -"$TRICU" arboricx compile --file test/io-v2/browser.tri \ +"$TRICU" arboricx compile --file test/io/browser.tri \ --store "$WORK/store" --output "$WORK/browser.arboricx" --compile-timeout 240 nix build .#arboricxSite --no-link SITE=$(nix path-info .#arboricxSite) -IO_V2_SOURCE_BUNDLE="$WORK/browser.arboricx" TEST_IO_V2_SOURCE=1 \ +IO_SOURCE_BUNDLE="$WORK/browser.arboricx" TEST_IO_SOURCE=1 \ TEST_BACKEND=haskell-wasm ARBORICX_SITE_ROOT="$SITE" \ nix shell --inputs-from . nixpkgs#nodejs nixpkgs#chromium -c \ node ext/demo-site/test/backend-browser.mjs "$SITE/runtime/haskell" chromium @@ -1772,7 +1772,7 @@ byte-event interface, not the text-only user-interface widget. ### Byte-preserving output presentation model -`io-v2-output.ts` is the presentation-only buffer for the terminal adapter; +`io-output.ts` is the presentation-only buffer for the terminal adapter; it is not public v2 dispatch and does not acknowledge host operations. It retains at most16384 accepted bytes shared across stdout/stderr. The session separately limits attempted output. Append validation is atomic; inputs and snapshots do @@ -1787,32 +1787,32 @@ without creating an IO failure. Actual byte tokens must be distinguishable from literal escape-looking text when rendered. Do not interpret terminal control sequences or insert program text as HTML. The public widget uses these tokens without changing IO acknowledgement semantics. -`io-v2-output.test.mjs` checks exact byte reconstruction, hex, malformed sequences, +`io-output.test.mjs` checks exact byte reconstruction, hex, malformed sequences, chunk-split invariance, stream separation, copied ownership and shared bounds. ### Private terminal adapter validation -`io-v2-terminal.ts` consumes shared session byte events. Input is explicitly a +`io-terminal.ts` consumes shared session byte events. Input is explicitly a line widget, not binary stdin: at most256 Unicode scalars, no embedded CR/LF or unpaired surrogates, followed by exactly one LF. Empty Send produces LF; the separate EOF control produces empty EOF. Encoded bytes are retained across bounded reads (at most1025 bytes per submitted line). Invalid/stale input does not consume a wait. Byte write acceptance is independent of display UTF-8 decoding. -`io-v2-terminal-view.ts` renders text through text nodes, marks actual escaped +`io-terminal-view.ts` renders text through text nodes, marks actual escaped bytes distinctly from literal escape-looking text, and provides exact per-stream hex. It does not interpret HTML or terminal control sequences. Each input wait owns fresh DOM controls/listeners; cancellation clears/removes them before its acknowledgement. Stop enqueues structured cancellation; it does not destroy the worker. Finishing retires listeners only after pending input has settled. -For the compiled-source probe above, add `TEST_IO_V2_TERMINAL=1` while retaining -`TEST_IO_V2_SOURCE=1`. Both native evaluators run the same source bundle through +For the compiled-source probe above, add `TEST_IO_TERMINAL=1` while retaining +`TEST_IO_SOURCE=1`. Both native evaluators run the same source bundle through real DOM controls: text/handler output, line/EOF, scoped cancellation, malformed codec Results, binary output and external structured Stop. Additional DOM controls check old prompts cannot consume new waits, empty lines differ from EOF, invalid lines remain retryable, markup is inert, and repeated Stop is ignored. These probes also remain available independently of public execution. Use -`TEST_IO_V2_PUBLIC=1` instead of `TEST_IO_V2_SOURCE=1` for the public adapter and +`TEST_IO_PUBLIC=1` instead of `TEST_IO_SOURCE=1` for the public adapter and real-page acceptance. No test-only bare-computation route is used by the page. diff --git a/ext/demo-site/dist/app.js b/ext/demo-site/dist/app.js @@ -1,4 +1,4 @@ -import { terminalView } from './io-v2-terminal-view.js'; +import { terminalView } from './io-terminal-view.js'; import { SourceEditor } from './source-editor.js'; import { definitionInspector } from './definition-inspector.js'; import { exampleGroups, exampleGroup, libraryLabel } from './program-catalogue.js'; @@ -34,7 +34,7 @@ let entries = [], releases = [], selected; let selection, versionPackage; let preferredExport, currentName = '', publishedNames = []; let worker, pending; -let ioVersion, v2Run, v2Widget, stoppingV2 = false; +let ioVersion, ioRun, ioWidget, stoppingIO = false; let operation, idleTimer; let compilerTimer; const deadline = new ActiveClock(() => finish(operationTimeout(operation ?? 'loading'), true)); @@ -83,7 +83,7 @@ const definitions = definitionInspector(sourceEditor, (id, name, output) => { deadline.start(); diagnostic(); ioVersion = 0; - stoppingV2 = false; + stoppingIO = false; el('status').textContent = `Running IO · ${name}`; worker.postMessage({ type: 'run-io', name, output, step }); }, @@ -167,11 +167,11 @@ el('stored-download').onclick = () => { worker.postMessage({ type: 'export', export: el('stored-definition').value, format: el('stored-format').value }); }; function dispose() { - v2Widget?.abandon(); - v2Widget = undefined; - v2Run = undefined; + ioWidget?.abandon(); + ioWidget = undefined; + ioRun = undefined; ioVersion = undefined; - stoppingV2 = false; + stoppingIO = false; cancelIdleCompile(); stopCompilerActivity(); deadline.pause(); @@ -184,7 +184,7 @@ function dispose() { pending = undefined; controls(false); } -function idle() { ioVersion = undefined; stoppingV2 = false; stopCompilerActivity(); deadline.pause(); operation = undefined; pending = undefined; controls(false); } +function idle() { ioVersion = undefined; stoppingIO = false; stopCompilerActivity(); deadline.pause(); operation = undefined; pending = undefined; controls(false); } function finish(message, error = false, cancelled = false) { const previous = operation; if (error) @@ -206,8 +206,8 @@ function finish(message, error = false, cancelled = false) { } stopButton.onclick = () => { if (operation === 'running' && ioVersion !== undefined) { - if (!stoppingV2) { - stoppingV2 = true; + if (!stoppingIO) { + stoppingIO = true; worker?.postMessage({ type: 'io-stop' }); } stopButton.disabled = true; @@ -260,28 +260,28 @@ function receive(data) { case 'io-version': ioVersion = data.version; break; - case 'v2-open': { - if (v2Widget) + case 'io-open': { + if (ioWidget) throw new Error('Overlapping terminal runs'); const current = worker; - v2Run = data.run; - v2Widget = terminalView(el('terminal-v2'), data.run, reply => { if (worker === current && v2Run === reply.run) - current?.postMessage({ type: 'v2-command', command: { run: reply.run, kind: 'reply', reply } }); }, () => stopButton.click()); + ioRun = data.run; + ioWidget = terminalView(el('terminal-io'), data.run, reply => { if (worker === current && ioRun === reply.run) + current?.postMessage({ type: 'io-command', command: { run: reply.run, kind: 'reply', reply } }); }, () => stopButton.click()); break; } - case 'v2-event': - if (data.event.run === v2Run) - v2Widget?.handle(data.event); + case 'io-event': + if (data.event.run === ioRun) + ioWidget?.handle(data.event); break; - case 'v2-close': - if (data.run === v2Run) { + case 'io-close': + if (data.run === ioRun) { ioVersion = undefined; if (data.settled) - v2Widget?.finish(); + ioWidget?.finish(); else - v2Widget?.abandon(); - v2Widget = undefined; - v2Run = undefined; + ioWidget?.abandon(); + ioWidget = undefined; + ioRun = undefined; el('status').textContent = data.settled ? 'IO settled; interpreting returned value' : 'Runtime lost'; } break; @@ -289,7 +289,7 @@ function receive(data) { if (data.state === 'running') { pending = undefined; deadline.start(); - if (operation === 'running' && !stoppingV2) + if (operation === 'running' && !stoppingIO) el('status').textContent = `Running IO · ${currentName}`; } else { @@ -754,7 +754,7 @@ for (const button of document.querySelectorAll('[data-compiler-example]')) button.onclick = () => { const math = packageReleases(releases, 'tricu-lib-native-math')[0]; const choice = button.dataset['compilerExample']; - const io = packageReleases(releases, 'tricu-lib-io')[0], text = packageReleases(releases, 'tricu-lib-io-v2-text')[0]; + const io = packageReleases(releases, 'tricu-lib-io')[0], text = packageReleases(releases, 'tricu-lib-io-text')[0]; const source = choice === 'input' && io && text ? `!import ${io.manifest} as IO !import ${text.manifest} as Text !export io diff --git a/ext/demo-site/dist/definition-inspector.js b/ext/demo-site/dist/definition-inspector.js @@ -45,8 +45,8 @@ export function definitionInspector(input, request, io) { ioPanel.className = 'inline-io'; ioPanel.hidden = true; ioPanel.innerHTML = `<div class="inline-io-actions"><button type="button" id="run-io">Run IO</button><button type="button" id="stop-io" disabled>Stop</button></div> - <p class="hint">Recognized tricuIO v2 envelope. Only Run IO executes actions.</p> - <div id="terminal-v2"></div> + <p class="hint">Recognized tricuIO envelope. Only Run IO executes actions.</p> + <div id="terminal-io"></div> <details><summary>IO details</summary><label><input id="step-mode" type="checkbox"> Pause before each scheduler turn</label><button id="next" type="button" disabled>Next scheduler turn</button> <label for="io-return-format">Returned value (next run)</label><select id="io-return-format"><option value="tricu">tricu source</option><option value="string">Text</option><option value="number">Natural number</option><option value="values">List · text / numbers</option><option value="bool">Boolean</option><option value="bytes">Bytes</option><option value="triangle">△ source</option><option value="ternary">Ternary</option><option value="dag">DAG</option></select><ol id="trace"></ol></details>`; card.append(ioPanel); @@ -95,7 +95,7 @@ export function definitionInspector(input, request, io) { return; ioRunning = true; clear(); - get('terminal-v2').replaceChildren(); + get('terminal-io').replaceChildren(); trace.replaceChildren(); status.textContent = 'Running IO…'; io.run(selected, outputType(returnFormat.value), step.checked); @@ -233,7 +233,7 @@ export function definitionInspector(input, request, io) { ioDetected = false; next.disabled = true; ioPanel.hidden = true; - get('terminal-v2').replaceChildren(); + get('terminal-io').replaceChildren(); trace.replaceChildren(); clear(); format.value = 'auto'; diff --git a/ext/demo-site/dist/execution.js b/ext/demo-site/dist/execution.js @@ -1,5 +1,5 @@ // Execution has no application node/work/frame ceilings. Browser memory remains -// finite. Pure/v1 Stop terminates the Worker; v2 Stop drains its structured +// finite. Pure evaluation stops by terminating the Worker; IO Stop drains its structured // session at scheduler boundaries. GC thresholds are not budgets. export const BROWSER_BUDGET = Object.freeze({ nodes: Infinity, fuel: Infinity, stack: Infinity }); export const PACKAGE_LOAD_MS = 60_000; diff --git a/ext/demo-site/dist/haskell-session.js b/ext/demo-site/dist/haskell-session.js @@ -1,4 +1,4 @@ -import { drive } from './io-v2-driver.js'; +import { drive } from './io-driver.js'; import { moduleSnapshot } from './module-bundle.js'; import { isTreeResult, treeResult } from './tree-result.js'; import { expressionUnit, expressionDiagnostic } from './expression.js'; @@ -9,7 +9,7 @@ import { fetchBytes } from './bundle.js'; export class HaskellSession { client; linearMemoryBytes = 0; - v2Active = false; + structuredActive = false; constructor(client) { this.client = client; } @@ -36,8 +36,8 @@ export class HaskellSession { names = []; get disposed() { return this.client.disposed; } async request(message) { - if (this.v2Active && !['io-v2-session-turn', 'io-v2-public-view', 'io-v2-public-clear'].includes(message.type)) - throw new Error('IO v2 session is pending'); + if (this.structuredActive && !['io-session-turn', 'io-public-view', 'io-public-clear'].includes(message.type)) + throw new Error('IO session is pending'); const reply = await this.client.request(message, null); this.linearMemoryBytes = reply.linearBytes; return reply; @@ -45,8 +45,8 @@ export class HaskellSession { async json(message) { return JSON.parse((await this.request(message)).text); } async load(bytes, hash) { this.names = await this.json({ type: 'load', bytes: new Uint8Array(bytes).buffer, hash }); } async compile(source, profileName, localModules = []) { - if (this.v2Active) - throw new Error('IO v2 session is pending'); + if (this.structuredActive) + throw new Error('IO session is pending'); this.names = []; const reply = await this.request({ type: 'compile', source, localModules, base: new URL('../', import.meta.url).href, ...(profileName === undefined ? {} : { profileName }) }); @@ -109,24 +109,24 @@ export class HaskellSession { return displayed(request); let run; try { - const event = await this.json({ ...request, selectIO: true, v2Step: host.v2?.stepping === true }); + const event = await this.json({ ...request, selectIO: true, ioStep: host.structured?.stepping === true }); if (event.kind !== 'v2') throw new Error('Unsupported tricuIO version'); - this.v2Active = true; + this.structuredActive = true; run = event.run; - if (!host.v2) + if (!host.structured) throw new Error('IO version 2 requires a structured terminal host'); host.selected?.(2); return await drive({ run, signal: this.client.signal, discard: () => this.dispose(), - turn: command => this.json({ type: 'io-v2-session-turn', ...command }), - view: () => displayed({ type: 'io-v2-public-view', run, output }), }, host); + turn: command => this.json({ type: 'io-session-turn', ...command }), + view: () => displayed({ type: 'io-public-view', run, output }), }, host); } finally { // Release result/continuations even when decoding or a host hook fails. // A poisoned instance cannot be recovered by cancelling the interaction. if (run !== undefined && !this.disposed) - await this.request({ type: 'io-v2-public-clear', run }); - this.v2Active = false; + await this.request({ type: 'io-public-clear', run }); + this.structuredActive = false; } } stats() { return { backend: 'haskell-wasm', linearMemoryBytes: this.linearMemoryBytes }; } diff --git a/ext/demo-site/dist/io-core.js b/ext/demo-site/dist/io-core.js @@ -0,0 +1,335 @@ +// Private resumable tree interpreter. No public session dispatch or capabilities. +// Adapters must retain roots() between turns and keep temporary evaluator handles +// alive for a whole turn. Provider promises acknowledge physical completion; +// providers return deferred Result encoders, never task outcomes. Encoding runs +// in an interpreter turn, not in a promise/GC gap; cancelled encoders never run. +import { canonicalNat, decodeAction, ProtocolError } from './io-protocol.js'; +import { lifecycle } from './io-resources.js'; +import { HostWork } from './io-host-work.js'; +const operationErrors = new Set(['stale-task', 'join-cycle', 'out-of-scope', 'stale-handle', 'not-owner', + 'borrowed-resource', 'internal-resource', 'closed-handle', 'busy', 'duplicate-handle', 'closed-scope', 'invalid-argument']); +export class OperationError extends Error { +} +function logical(operation) { + try { + return operation(); + } + catch (error) { + if (error instanceof Error && operationErrors.has(error.message)) + throw new OperationError(error.message); + throw error; + } +} +export class Wire { + tree; + identity; + constructor(tree, identity) { + this.tree = tree; + this.identity = identity; + } + pair(value) { + const node = this.tree.observe(value).node; + if (node.kind !== 'fork') + throw new OperationError('invalid-argument'); + return [node.left, node.right]; + } + list(values) { let out = this.tree.leaf; for (let i = values.length - 1; i >= 0; i--) + out = this.tree.fork(values[i], out); return out; } + result(ok, value) { return this.tree.fork(ok ? this.tree.stem(this.tree.leaf) : this.tree.leaf, this.tree.fork(value, this.tree.leaf)); } + problem(category, operation, detail = '', progress = 'none') { + return this.list([category, operation, detail, progress].map(s => this.tree.text(s))); + } + marker(name, key) { return this.list([this.tree.text(name), this.tree.natural(this.identity), this.tree.natural(BigInt(key))]); } + token(value, name) { + const [tag, rest] = this.pair(value), [run, tail] = this.pair(rest), [key, end] = this.pair(tail); + if (this.tree.observe(end).node.kind !== 'leaf') + throw new OperationError('invalid-argument'); + let cursor = tag; + for (const char of name) { + const [head, next] = this.pair(cursor); + if (canonicalNat(this.tree, head, 7) !== BigInt(char.charCodeAt(0))) + throw new OperationError('invalid-argument'); + cursor = next; + } + if (this.tree.observe(cursor).node.kind !== 'leaf') + throw new OperationError('invalid-argument'); + const r = canonicalNat(this.tree, run, 64), k = canonicalNat(this.tree, key, 64); + if (r === undefined || k === undefined) + throw new OperationError('invalid-argument'); + // Unrepresentable keys cannot alias a live JS identity. Keep the ordinary + // stale-handle/task path rather than rounding into a different live handle. + return { run: r, key: k > BigInt(Number.MAX_SAFE_INTEGER) ? -1 : Number(k) }; + } + outcome(value) { + const name = this.tree.text(value.kind); + if (value.kind === 'completed') + return this.list([name, value.value]); + const errors = [...value.cleanup?.items ?? []], omitted = value.cleanup?.omitted ?? 0; + if (omitted) + errors.push(this.problem('cleanup-omitted', 'close', String(omitted))); + return this.list([name, value.value, this.list(errors)]); + } +} +export class Core { + tree; + provider; + actionLimit; + wire; + lifecycle; + work; + bodies = new Map(); + constants; + pendingArguments = new Map(); + cursor = -1; + actions = 0; + result = null; + // Embedding/evaluator failure is retained separately and never stringified or + // mistaken for an ordinary Result. Drain cleanup before rethrowing at boundary. + fault = undefined; + hasFault = false; + constructor(tree, identity, action, standard, provider, actionLimit = 65536) { + this.tree = tree; + this.provider = provider; + this.actionLimit = actionLimit; + if (!Number.isSafeInteger(actionLimit) || actionLimit < 1) + throw new Error('invalid action limit'); + this.wire = new Wire(tree, identity); + this.constants = { + stopped: this.wire.problem('cancelled', 'scope', 'owner stopped'), + cycle: this.wire.problem('join-cycle', 'scope'), fault: this.wire.problem('runtime-error', 'core'), + cleanup: this.wire.problem('cleanup-failed', 'resource/release', '', 'unknown'), budget: this.wire.problem('limit', 'core', 'IO action limit exceeded') + }; + this.lifecycle = lifecycle(identity, this.constants.stopped, this.constants.cycle, standard); + this.work = new HostWork(this.lifecycle.tasks); + this.bodies.set(0, this.body(action)); + } + body(action) { return { action, frames: [], mode: 'runnable' }; } + stop(reason) { if (!this.result) { + this.lifecycle.tasks.stop(0, { kind: 'cancelled', value: reason }); + this.work.cancelStopped(); + } } + done(scope, outcome) { + this.lifecycle.tasks.bodyDone(scope, outcome); + const body = this.bodies.get(scope); + body.mode = 'done'; + body.action = this.tree.leaf; + body.frames = []; + } + value(scope, value) { + const body = this.bodies.get(scope); + if (body.frames.length) { + body.action = this.tree.app(body.frames.pop(), value); + body.mode = 'runnable'; + } + else + this.done(scope, { kind: 'completed', value }); + } + operation(scope, name, action) { + try { + action(); + } + catch (error) { + if (!(error instanceof OperationError)) + throw error; + this.value(scope, this.wire.result(false, this.wire.problem(error.message, name))); + } + } + step(scope) { + const { tasks, resources } = this.lifecycle, body = this.bodies.get(scope), state = tasks.state(scope); + if (body.mode === 'done' || body.mode === 'lexical') + return false; + if (state.reason) { + this.done(scope, state.reason); + return true; + } + if (body.mode === 'host') + return false; + if (body.mode === 'join') { + const outcome = tasks.pollJoin(scope); + if (!outcome) + return false; + this.value(scope, this.wire.result(true, this.wire.outcome(outcome))); + return true; + } + if (++this.actions > this.actionLimit) { + tasks.stop(0, { kind: 'failed', value: this.constants.budget }); + return true; + } + let action; + try { + action = decodeAction(this.tree, body.action); + } + catch (error) { + if (!(error instanceof ProtocolError)) + throw error; + this.done(scope, { kind: 'failed', value: this.wire.problem('protocol-error', 'core/decode', error.message) }); + return true; + } + if (action.kind === 'request') { + // Keep action/arguments rooted until provider acknowledgement, including + // cancellation. The abandoned continuation is cleared by done(). + body.mode = 'host'; + const request = action; + this.pendingArguments.set(scope, request.arguments); + this.work.start(scope, async (signal) => ({ kind: 'body', encode: await this.provider.request(this, scope, request, signal) })); + return true; + } + const p = action.payload; + switch (action.code) { + case 0: + this.value(scope, p); + break; + case 1: { + const node = this.tree.observe(p).node; + if (node.kind !== 'fork') + this.done(scope, { kind: 'failed', value: this.wire.problem('protocol-error', 'core/decode', 'invalid IO computation') }); + else { + body.frames.push(node.right); + body.action = node.left; + } + break; + } + case 2: + this.done(scope, { kind: 'failed', value: p }); + break; + case 9: + this.done(scope, { kind: 'cancelled', value: p }); + break; + case 3: { + const child = tasks.nested(scope); + this.bodies.set(child, this.body(this.tree.leaf)); + body.mode = 'lexical'; + body.action = this.tree.leaf; + this.bodies.get(child).action = this.tree.app(p, this.wire.marker('io.v2.scope', child)); + break; + } + case 4: + this.value(scope, this.wire.marker('io.v2.scope', scope)); + break; + case 5: + this.operation(scope, 'core/spawn', () => { + const [sup, a] = this.wire.pair(p), [keep, b] = this.wire.pair(a), [handles, callback] = this.wire.pair(b); + const supervised = canonicalNat(this.tree, sup, 1), retained = canonicalNat(this.tree, keep, 1); + if (supervised === undefined || retained === undefined) + throw new OperationError('invalid-argument'); + const tokens = []; + let rest = handles; + while (this.tree.observe(rest).node.kind !== 'leaf') { + if (tokens.length === 256) + throw new OperationError('invalid-argument'); + const [head, tail] = this.wire.pair(rest); + tokens.push(this.wire.token(head, 'io.v2.resource')); + rest = tail; + } + const moved = logical(() => resources.spawn(scope, supervised === 1n, retained === 1n, tokens)), child = moved.task.key; + this.bodies.set(child, this.body(this.tree.leaf)); + this.bodies.get(child).action = this.tree.app(callback, this.wire.list(moved.handles.map(t => this.wire.marker('io.v2.resource', t.key)))); + this.value(scope, this.wire.result(true, this.wire.list([this.wire.marker('io.v2.task', child), retained === 1n ? this.wire.marker('io.v2.join', child) : this.tree.leaf]))); + }); + break; + case 6: + case 7: + this.operation(scope, action.code === 6 ? 'core/join' : 'core/discard', () => { + const token = this.wire.token(p, 'io.v2.join'); + if (action.code === 7) { + logical(() => tasks.discard(scope, token)); + this.value(scope, this.wire.result(true, this.tree.leaf)); + } + else { + logical(() => tasks.claimJoin(scope, token)); + body.mode = 'join'; + body.action = this.tree.leaf; + } + }); + break; + case 8: + this.operation(scope, 'core/cancel', () => { + const [handle, reason] = this.wire.pair(p), token = this.wire.token(handle, 'io.v2.task'); + logical(() => tasks.cancel(scope, token, reason)); + this.value(scope, this.wire.result(true, this.tree.leaf)); + }); + break; + } + return true; + } + settled(scope, errors) { + const { tasks } = this.lifecycle, state = tasks.state(scope), outcome = tasks.settle(scope, errors); + this.bodies.delete(scope); + if (scope === 0) + this.result = outcome; + else if (state.kind === 'lexical') { + const parent = state.parent, reason = tasks.state(parent).reason; + this.bodies.get(parent).mode = 'runnable'; + if (reason) + this.done(parent, reason); + else if (outcome.kind === 'completed') + this.value(parent, outcome.value); + else + this.done(parent, outcome); + } + } + // One progress turn, never waits for a provider. False means wait for host + // acknowledgement. The embedding must yield to its event loop between batches. + advance() { + if (this.result) + return false; + try { + this.work.cancelStopped(); + for (const scope of this.pendingArguments.keys()) + if (this.lifecycle.tasks.state(scope).hostCalls === 0) + this.pendingArguments.delete(scope); + const reply = this.work.take(); + if (reply) { + if (!reply.outcome.ok) + throw reply.outcome.error; + const value = reply.outcome.value; + if (value.kind === 'release') + this.settled(reply.token.scope, value.errors); + else + this.value(reply.token.scope, value.encode()); + return true; + } + const { tasks, resources } = this.lifecycle; + for (const scope of tasks.scopeIds()) + if (tasks.state(scope).phase === 'joining' && tasks.beginRelease(scope)) { + this.work.release(scope, async () => ({ kind: 'release', errors: await resources.releaseScope(scope, resource => this.provider.release(resource), () => this.constants.cleanup, this.constants.cleanup) })); + return true; + } + const ids = tasks.scopeIds(), ordered = [...ids.filter(id => id > this.cursor), ...ids.filter(id => id <= this.cursor)]; + for (const scope of ordered) + if (this.step(scope)) { + this.cursor = scope; + return true; + } + return false; + } + catch (error) { + if (!this.hasFault) + this.fault = error; + this.hasFault = true; + this.lifecycle.tasks.stop(0, { kind: 'failed', value: this.constants.fault }); + this.work.cancelStopped(); + return true; + } + } + wait() { return this.work.wait(); } + *roots() { + yield this.tree.leaf; + yield* Object.values(this.constants); + yield* this.lifecycle.tasks.payloads(); + yield* this.pendingArguments.values(); + yield* this.provider.roots?.() ?? []; + for (const body of this.bodies.values()) { + yield body.action; + yield* body.frames; + } + for (const value of this.work.values()) { + if (value.kind === 'release') + yield* value.errors.items; + } + if (this.result) { + yield this.result.value; + yield* this.result.cleanup?.items ?? []; + } + } +} diff --git a/ext/demo-site/dist/io-driver.js b/ext/demo-site/dist/io-driver.js @@ -0,0 +1,102 @@ +// Unlike repeated Promise.race against a lifetime-long promise, listeners are +// removed on EVERY turn/step settlement, not retained until runtime disposal. +function interrupt(promise, signal, aborted) { + if (!signal) + return promise; + let onAbort = () => { }; + return new Promise((resolve, reject) => { + onAbort = () => { try { + resolve(aborted()); + } + catch (error) { + reject(error); + } }; + if (signal.aborted) + onAbort(); + else + signal.addEventListener('abort', onAbort, { once: true }); + void promise.then(resolve, reject); + }).finally(() => signal.removeEventListener('abort', onAbort)); +} +// The queue stays alive while a read or step prompt is waiting. Stop is sticky, +// independent of the resume queue, and never implemented by worker destruction. +export async function drive(driver, host) { + const transport = host.structured; + if (!transport) + throw new Error('IO version 2 requires a structured terminal host'); + let settled = false; + const alive = (promise) => interrupt(promise, driver.signal, () => { throw driver.signal.reason; }); + let active = true, stopping = false, stopSent = false, wake = () => { }, step = 0; + const replies = []; + let boundaryFault = false, fault; + const stopped = new AbortController(); + const enqueue = (command) => { + if (!active || !command || command.run !== driver.run) + return; + if (command.kind === 'stop') { + stopping = true; + stopped.abort(); + } + else if (command.kind === 'reply') + replies.push(command); + wake(); + }; + const close = () => { + if (!active) + return; + active = false; + stopped.abort(); + wake(); + wake = () => { }; + replies.length = 0; + // A failed transport/runtime cannot be reused with admitted work abandoned. + if (!settled) + driver.discard?.(); + transport.close(driver.run, settled); + }; + try { + transport.open(driver.run, enqueue); + for (;;) { + if (!stopping && transport.stepping === true) { + try { + await alive(interrupt(host.before({ step: ++step, name: 'IO scheduler turn', detail: 'one scheduler advance; all tasks paused', depth: 0 }), stopped.signal, () => { })); + } + catch (error) { + if (driver.signal?.aborted) + throw error; + boundaryFault = true; + fault = error; + enqueue({ run: driver.run, kind: 'stop' }); + } + } + const command = stopping && !stopSent ? (stopSent = true, { run: driver.run, kind: 'stop' }) : + replies.shift() ?? { run: driver.run, kind: 'poll' }; + const report = await alive(driver.turn(command)); + for (const event of report.events) + transport.event(event); + if (report.done) { + if (!Object.values(report.counts).every(group => Object.values(group).every(value => value === 0))) + throw new Error('IO settlement retains work'); + settled = true; + close(); + if (boundaryFault) + throw fault; + if (report.outcome?.fault) + throw new Error('IO interpreter fault'); + if (report.outcome?.kind !== 'completed') + throw new Error(`IO ${report.outcome?.kind ?? 'failed'}`); + return await alive(driver.view()); // Full codec, only after physical settlement. + } + // Yield even during pure scheduler progress so the outer worker can receive + // stop/replies. This is not a hard deadline for native tree reduction. + if (report.waiting && !replies.length && (!stopping || stopSent)) + await alive(new Promise(resolve => { wake = resolve; })); + else + await alive(new Promise(resolve => { const timer = setTimeout(resolve, 0); wake = () => { clearTimeout(timer); resolve(); }; })); + wake = () => { }; + } + } + finally { + close(); + } +} diff --git a/ext/demo-site/dist/io-v2-events.js b/ext/demo-site/dist/io-events.js diff --git a/ext/demo-site/dist/io-host-work.js b/ext/demo-site/dist/io-host-work.js @@ -0,0 +1,97 @@ +import { hostKey } from './io-tasks.js'; +// Adapter-side asynchronous work. Starting one callback never waits for it, so +// sibling actions/cancellation can proceed. The provider must settle its promise +// AFTER cancellation cleanup; abort delivery alone is never an acknowledgement. +export class HostWork { + tasks; + active = new Map(); + replies = new Map(); + notification = null; + constructor(tasks) { + this.tasks = tasks; + } + start(scope, operation) { + return this.register(this.tasks.beginHost(scope), operation); + } + // Cleanup is not cancelled by a new stop request. Its result (including a + // failure) must still reach the driver to preserve primary/secondary outcomes. + release(scope, operation) { + return this.register(this.tasks.beginReleaseHost(scope), operation); + } + register(token, operation) { + const controller = new AbortController(); + this.active.set(hostKey(token), { token, controller }); + const complete = (outcome) => { + // Settle first; a cancelled reply is discarded without observing its value. + const disposition = this.tasks.endHost(token); + this.active.delete(hostKey(token)); + if (disposition === 'reply') + this.replies.set(hostKey(token), { token, outcome }); + const notice = this.notification; + this.notification = null; + notice?.resolve(); + }; + try { + Promise.resolve(operation(controller.signal)).then(value => complete({ ok: true, value }), error => complete({ ok: false, error })); + } + catch (error) { + complete({ ok: false, error }); + } + return token; + } + usable(token) { + if (!this.tasks.within(token.scope, token.scope)) + return false; + const state = this.tasks.state(token.scope); + return token.kind === 'release' ? state.phase === 'releasing' : state.phase === 'open' && state.reason === null; + } + // Resource close may interrupt one operation without cancelling its task. + // The resulting provider reply still reaches that body as an ordinary error. + // Never abort a cleanup batch or acknowledge work merely by sending this signal. + cancelBody(token) { + const work = this.active.get(hostKey(token)); + if (token.kind !== 'body' || !work || work.token.run !== token.run || + work.token.scope !== token.scope || work.controller.signal.aborted) + return false; + work.controller.abort(); + return true; + } + cancelStopped() { + for (const token of this.tasks.takeCancellations()) { + const work = this.active.get(hostKey(token)); + if (!work || work.token.scope !== token.scope) + throw new Error('host work ownership mismatch'); + work.controller.abort(); + } + // Completion may have won the race with cancellation. Drop undelivered data + // too; a queued reply cannot revive a stopped/settled body. + for (const [key, reply] of this.replies) + if (!this.usable(reply.token)) + this.replies.delete(key); + } + take() { + for (const [key, reply] of this.replies) { + this.replies.delete(key); + if (this.usable(reply.token)) + return reply; + } + return null; + } + wait() { + if (this.replies.size || !this.active.size) + return Promise.resolve(); + if (!this.notification) { + let resolve; + const promise = new Promise(done => { resolve = done; }); + this.notification = { promise, resolve }; + } + return this.notification.promise; + } + get counts() { return { pending: this.active.size, replies: this.replies.size }; } + // Reply data roots are the embedding's responsibility until taken/discarded. + *values() { + for (const reply of this.replies.values()) + if (this.usable(reply.token) && reply.outcome.ok) + yield reply.outcome.value; + } +} diff --git a/ext/demo-site/dist/io-output.js b/ext/demo-site/dist/io-output.js @@ -0,0 +1,69 @@ +// Presentation only. Byte acceptance and strict source UTF-8 Results are separate. +// No evaluator handles, host callbacks, IO acknowledgements or terminal escapes. +import { decodeUTF8, UTF8Error } from './io-utf8.js'; +function view(bytes, final) { + const tokens = []; + for (let offset = 0; offset < bytes.length;) { + const lead = bytes[offset]; + const width = lead <= 0x7f ? 1 : lead >= 0xc2 && lead <= 0xdf ? 2 : + lead >= 0xe0 && lead <= 0xef ? 3 : lead >= 0xf0 && lead <= 0xf4 ? 4 : 1; + const candidate = bytes.slice(offset, offset + width); + try { + const decoded = decodeUTF8(candidate, false); + if (decoded.carry.length) { + tokens.push({ kind: 'bytes', offset, bytes: candidate, reason: final ? 'incomplete' : 'pending' }); + break; + } + const previous = tokens.at(-1); + if (previous?.kind === 'text') + previous.text += decoded.text; + else + tokens.push({ kind: 'text', offset, text: decoded.text }); + offset += width; + } + catch (error) { + if (!(error instanceof UTF8Error)) + throw error; + // Escape one offending byte; reconsider following bytes independently. + // Thus invalid prefixes cannot swallow subsequent valid text. + tokens.push({ kind: 'bytes', offset, bytes: [lead], reason: 'invalid' }); + offset++; + } + } + return { bytes, hex: bytes.map(b => b.toString(16).padStart(2, '0')).join(' '), tokens }; +} +// Owned by one UI run. The session separately enforces attempted-output budgets; +// this buffer bounds retained accepted bytes, without refunds or history. +export class OutputBuffer { + limit; + streams = { output: [], error: [] }; + size = 0; + ended = false; + constructor(limit = 16384) { + this.limit = limit; + if (!Number.isSafeInteger(limit) || limit < 0 || limit > 16384) + throw new Error('invalid output limit'); + } + append(stream, bytes) { + if (this.ended) + throw new Error('output presentation ended'); + if (stream !== 'output' && stream !== 'error') + throw new Error('invalid output stream'); + if (!Array.isArray(bytes) || bytes.length === 0 || bytes.length > 4096) + throw new Error('invalid output bytes'); + for (const byte of bytes) + if (!Number.isInteger(byte) || byte < 0 || byte > 255) + throw new Error('invalid output bytes'); + if (bytes.length > this.limit - this.size) + throw new Error('output presentation limit'); + this.streams[stream].push(...bytes); + this.size += bytes.length; + } + // End-of-presentation, including after structured cancellation, not closing a + // borrowed stream. Incomplete suffixes become visible bytes, never IO failures. + finish() { this.ended = true; } + snapshot() { + return { done: this.ended, size: this.size, + output: view([...this.streams.output], this.ended), error: view([...this.streams.error], this.ended) }; + } +} diff --git a/ext/demo-site/dist/io-private.js b/ext/demo-site/dist/io-private.js @@ -0,0 +1,49 @@ +// Private native-adapter validation runner, not RuntimeSession IO dispatch. +// Bare computations only; no facility implementation/capability advertisement. +import { Core } from './io-core.js'; +import { canonicalNat } from './io-protocol.js'; +export function constructors(base) { + const natural = (value) => { + const bits = []; + if (value < 0n) + throw new Error('negative natural'); + for (; value; value >>= 1n) + bits.push(value & 1n ? base.stem(base.leaf) : base.leaf); + let out = base.leaf; + for (let i = bits.length - 1; i >= 0; i--) + out = base.fork(bits[i], out); + return out; + }; + return { ...base, natural, text(value) { + let out = base.leaf; + const chars = Array.from(value); + for (let i = chars.length - 1; i >= 0; i--) + out = base.fork(natural(BigInt(chars[i].codePointAt(0))), out); + return out; + } }; +} +export async function runPrivate(tree, action, identity, sweep) { + const engine = new Core(tree, identity, action, [null, null, null], { + request: (core, _scope, request) => () => core.wire.result(false, core.wire.problem('unsupported', `${request.group}/${request.operation}`)), + release: () => { throw new Error('unexpected owned backend in private core probe'); } + }); + sweep(engine.roots()); + for (let turns = 0; !engine.result; turns++) { + const progress = engine.advance(); + sweep(engine.roots()); + if (!progress) { + if (!engine.work.counts.pending) + throw new Error('private interpreter stalled'); + await engine.wait(); + } + if (turns % 32 === 0) + await new Promise(resolve => setTimeout(resolve, 0)); + } + if (engine.hasFault) + throw engine.fault; + const out = engine.result; + // Terminal diagnostics stay opaque, including when the test requests a number. + const value = out.kind === 'completed' ? canonicalNat(tree, out.value, 64)?.toString() ?? null : null; + return { kind: out.kind, value, cleanup: out.cleanup?.items.length ?? 0, + tasks: engine.lifecycle.tasks.counts, resources: engine.lifecycle.resources.counts, host: engine.work.counts }; +} diff --git a/ext/demo-site/dist/io-profile.js b/ext/demo-site/dist/io-profile.js @@ -0,0 +1,47 @@ +// This table is the implemented browser provider, not the native catalogue. +import { canonicalNat, decodeAction, ProtocolError } from './io-protocol.js'; +export const coreNames = ['pure', 'bind', 'fail', 'scope', 'current-scope', 'spawn', 'join', 'discard', 'cancel', 'cancelled']; +const groups = Object.freeze({ + runtime: ['capabilities', 'inspect'], standard: ['input', 'output', 'error'], + stream: ['read', 'write'], resource: ['close'], utf8: ['encode', 'decode'], +}); +export function available(group, revision, operation) { + return revision === 1 && Object.hasOwn(groups, group) && groups[group].includes(operation); +} +export function capabilities(wire, backend) { + const { tree } = wire, text = (s) => tree.text(s), number = (n) => tree.natural(BigInt(n)); + const strings = (xs) => wire.list(xs.map(text)), row = (name, value) => wire.list([text(name), value]); + return wire.list([ + row('schema', number(1)), row('io-version', number(2)), row('backend', text(backend)), row('authority', text('browser-terminal')), + row('core', strings(coreNames)), row('groups', wire.list(Object.entries(groups).map(([name, ops]) => wire.list([text(name), number(1), strings(ops)])))), + row('limits', wire.list(Object.entries({ 'io-actions': 65536, 'chunk-bytes': 4096, 'transfer-handles': 256, 'utf8-encode-scalars': 1024, 'input-line-scalars': 256, 'output-attempted-bytes': 16384 }).map(([key, value]) => row(key, number(value))))), + row('restrictions', strings(['action-boundary-cancellation', 'no-hard-deadlines', 'borrowed-standard-streams', 'standard-stream-leases-per-run', 'utf8-strict-scalars', 'text-line-input', 'byte-preserving-output', 'no-filesystem', 'no-network', 'no-processes'])), + ]); +} +export function inspect(wire, value) { + const action = decodeAction(wire.tree, value), t = wire.tree; + return action.kind === 'core' ? wire.list([t.text('core'), t.text(coreNames[action.code])]) : + wire.list([t.text('facility'), t.text(action.group), t.natural(BigInt(action.revision)), t.text(action.operation), t.text(available(action.group, action.revision, action.operation) ? 'available' : 'unsupported')]); +} +// Authenticate framing before selecting an interpreter. No action is observed. +export function envelope(tree, value) { + const root = tree.observe(value).node; + if (root.kind !== 'fork') + throw new ProtocolError('invalid IO envelope'); + let rest = root.left; + for (const char of 'tricuIO') { + const node = tree.observe(rest).node; + if (node.kind !== 'fork' || canonicalNat(tree, node.left, 7) !== BigInt(char.charCodeAt(0))) + throw new ProtocolError('Missing tricuIO sentinel'); + rest = node.right; + } + if (tree.observe(rest).node.kind !== 'leaf') + throw new ProtocolError('Missing tricuIO sentinel'); + const body = tree.observe(root.right).node; + if (body.kind !== 'fork') + throw new ProtocolError('invalid IO envelope'); + const version = canonicalNat(tree, body.left, 2); + if (version !== 2n) + throw new ProtocolError('Unsupported tricuIO version'); + return { version: 2, action: body.right }; +} diff --git a/ext/demo-site/dist/io-v2-protocol.js b/ext/demo-site/dist/io-protocol.js diff --git a/ext/demo-site/dist/io-resources.js b/ext/demo-site/dist/io-resources.js @@ -0,0 +1,225 @@ +import { Tasks } from './io-tasks.js'; +// State only. Construct via lifecycle() to bind task settlement to registry guards. +// Single owning event loop; host effects occur AFTER admission/lease/close claims. +class Resources { + tasks; + limit; + next = 4; + resources = new Map(); + admissions = new Map(); + leases = new Map(); + constructor(tasks, standard, limit) { + this.tasks = tasks; + this.limit = limit; + if (!Number.isSafeInteger(limit) || limit < 3) + throw new Error('invalid resource identity limit'); + standard.forEach((backend, index) => this.resources.set(index + 1, { + owner: 0, kind: index === 0 ? 'reader' : 'writer', backend, flags: Object.freeze({ borrowed: true }), + busy: 0, closing: false, releasing: false + })); + } + get counts() { return { resources: this.resources.size, admissions: this.admissions.size, leases: this.leases.size }; } + token(key) { return Object.freeze({ run: this.tasks.identity, key }); } + key(token) { + if (token.run !== this.tasks.identity) + throw new Error('stale-handle'); + return token.key; + } + room(count) { + if (count > this.limit - this.next + 1) + throw new Error('resource-identity-exhausted'); + } + open(scope) { + const state = this.tasks.state(scope); + if (state.phase !== 'open' || state.reason) + throw new Error('closed-scope'); + } + entry(current, token) { + const resource = this.resources.get(this.key(token)); + if (!resource) + throw new Error('stale-handle'); + if (!this.tasks.within(current, resource.owner)) + throw new Error('out-of-scope'); + return resource; + } + lookup(current, token) { return { ...this.entry(current, token) }; } + beginAcquire(current, owner = current) { + this.tasks.checkpoint(current); + this.open(owner); + if (!this.tasks.within(current, owner)) + throw new Error('out-of-scope'); + this.room(1); + const token = this.token(this.next++); + this.admissions.set(token.key, owner); + return token; + } + abortAcquire(token) { + if (!this.admissions.delete(this.key(token))) + throw new Error('invalid-admission'); + } + // An admitted acquisition can finish after owner stop/admission closure. + // Publication failure leaves the admission live: release raw backend, then abort. + publish(admission, kind, backend, flags = {}) { + const key = this.key(admission), owner = this.admissions.get(key); + if (owner === undefined) + throw new Error('invalid-admission'); + if (!kind || Object.values(flags).some(value => typeof value !== 'boolean')) + throw new Error('invalid-argument'); + this.room(1); + const savedFlags = Object.freeze({ ...flags }), token = this.token(this.next); + this.resources.set(token.key, { owner, kind, backend, flags: savedFlags, busy: 0, closing: false, releasing: false }); + this.next++; + this.admissions.delete(key); + return token; + } + // All recoverable validation and preparation precede task admission. No host + // callback/await separates admission from rekeying. JS OOM is fatal, not rollback. + spawn(owner, supervised, keep, tokens) { + this.tasks.checkpoint(owner); + this.open(owner); + if (typeof supervised !== 'boolean' || typeof keep !== 'boolean') + throw new Error('invalid-argument'); + if (tokens.length > 256) + throw new Error('transfer-limit'); + const seen = new Set(); + const entries = tokens.map(token => { + const resource = this.entry(owner, token); + if (seen.has(token.key)) + throw new Error('duplicate-handle'); + seen.add(token.key); + if (resource.owner !== owner) + throw new Error('not-owner'); + if (resource.flags.borrowed) + throw new Error('borrowed-resource'); + if (resource.flags.internal) + throw new Error('internal-resource'); + if (resource.closing) + throw new Error('closed-handle'); + if (resource.busy) + throw new Error('busy'); + return { old: token.key, resource }; + }).sort((a, b) => a.old - b.old); + this.room(tokens.length); + const fresh = new Map(entries.map((entry, i) => [entry.old, this.token(this.next + i)])); + const handles = tokens.map(token => fresh.get(token.key)); + const task = this.tasks.spawn(owner, supervised, keep); + for (const entry of entries) { + this.resources.delete(entry.old); + entry.resource.owner = task.key; + this.resources.set(fresh.get(entry.old).key, entry.resource); + } + this.next += tokens.length; + return { task, handles }; + } + beginLease(current, token, kind, access, internal = false) { + this.tasks.checkpoint(current); + const resource = this.entry(current, token); + if (resource.kind !== kind) + throw new Error('wrong-kind'); + if (resource.closing) + throw new Error('closed-handle'); + if (resource.flags.internal && !internal) + throw new Error('internal-resource'); + if (!['exclusive', 'read', 'write'].includes(access)) + throw new Error('invalid-argument'); + const mask = !resource.flags.duplex || access === 'exclusive' ? 3 : access === 'read' ? 1 : 2; + if (resource.busy & mask) + throw new Error('busy'); + this.room(1); + const lease = this.token(this.next++); + this.leases.set(lease.key, { scope: current, resource: token.key, mask }); + resource.busy |= mask; + return { token: lease, backend: resource.backend }; + } + endLease(token) { + const key = this.key(token), lease = this.leases.get(key); + if (!lease) + throw new Error('invalid-lease'); + this.resources.get(lease.resource).busy &= ~lease.mask; + this.leases.delete(key); + } + claimClose(current, token, settling = false) { + const resource = this.entry(current, token); + if (settling) { + if (resource.owner !== current || this.tasks.state(current).phase !== 'releasing') + throw new Error('invalid-close'); + } + else { + this.tasks.checkpoint(current); + if (resource.flags.internal) + throw new Error('internal-resource'); + if (resource.flags.borrowed) + throw new Error('borrowed-resource'); + } + resource.closing = true; + if (resource.busy || resource.releasing) + return null; + resource.releasing = true; + return { ...resource }; + } + // Once, including failed release. Never retry a physically ambiguous close. + finishClose(token) { + const key = this.key(token), resource = this.resources.get(key); + if (!resource) + throw new Error('stale-handle'); + if (!resource.releasing) + throw new Error('invalid-close'); + this.resources.delete(key); + } + newestOwned(scope) { + let newest = -1; + for (const [key, resource] of this.resources) + if (resource.owner === scope && key > newest) + newest = key; + return newest < 0 ? null : this.token(newest); + } + readyForRelease(scope) { + return ![...this.admissions.values()].includes(scope) && ![...this.leases.values()].some(lease => lease.scope === scope); + } + finishScope(scope) { + if (!this.readyForRelease(scope) || this.newestOwned(scope)) + throw new Error('busy'); + } + // Use inside HostWork.release, after Tasks.beginRelease. Each provider is + // attempted once, sequentially in reverse publication order; borrowed backends + // stay open. Diagnostics construction failure must not skip remaining releases. + async releaseScope(scope, release, problem, fallback) { + if (this.tasks.state(scope).phase !== 'releasing') + throw new Error('invalid-transition'); + const items = []; + let omitted = 0; + for (let token = this.newestOwned(scope); token; token = this.newestOwned(scope)) { + const resource = this.claimClose(scope, token, true); + if (!resource) + throw new Error('busy'); + try { + if (!resource.flags.borrowed) + await release(resource); + } + catch (error) { + if (items.length === 16) + omitted++; + else { + let value = fallback; + try { + value = problem(error); + } + catch { /* retain prebuilt fallback */ } + items.push(value); + } + } + finally { + this.finishClose(token); + } + } + return { items, omitted }; + } +} +export function lifecycle(identity, ownerStopped, cycle, standard, limits = {}) { + let resources; + const tasks = new Tasks(identity, ownerStopped, cycle, limits.taskIdentities ?? Number.MAX_SAFE_INTEGER, { + readyForRelease: scope => resources.readyForRelease(scope), finishScope: scope => resources.finishScope(scope) + }); + resources = new Resources(tasks, standard, limits.resourceIdentities ?? Number.MAX_SAFE_INTEGER); + return { tasks, resources }; +} diff --git a/ext/demo-site/dist/io-session.js b/ext/demo-site/dist/io-session.js @@ -0,0 +1,216 @@ +// Cloneable event session shared by private probes and public native adapters. +import { Core, OperationError } from './io-core.js'; +import { available, capabilities, inspect } from './io-profile.js'; +import { ProtocolError, decodeAction, canonicalNat } from './io-protocol.js'; +import { encodeUTF8, decodeUTF8, UTF8Error } from './io-utf8.js'; +import { Events } from './io-events.js'; +const faults = new Set(['stale-handle', 'out-of-scope', 'closed-handle', 'wrong-kind', 'internal-resource', 'borrowed-resource', 'busy']); +function logical(f) { + try { + return f(); + } + catch (error) { + if (error instanceof Error && faults.has(error.message)) + throw new OperationError(error.message); + throw error; + } +} +function provider(events, backend) { + let outputBudget = 16384; // Attempted bytes across both slots; no replay/refund on uncertainty. + return { + release: () => { throw new Error('unexpected owned terminal resource'); }, + async request(engine, scope, request, signal) { + const { tree, wire } = engine, registry = engine.lifecycle.resources, op = `${request.group}/${request.operation}`; + const bad = (category, progress = 'none') => () => wire.result(false, wire.problem(category, op, '', progress)); + // Unsupported names/revisions leave arguments completely opaque. + if (!available(request.group, request.revision, request.operation)) + return bad('unsupported'); + try { + if (request.group === 'runtime') { + if (request.operation === 'capabilities') { + if (tree.observe(request.arguments).node.kind !== 'leaf') + return bad('invalid-argument'); + return () => wire.result(true, capabilities(wire, backend)); + } + // Classify in a live turn; defer construction so no newly allocated + // handles can be swept between promise resolution and consumption. + try { + decodeAction(tree, request.arguments); + } + catch (error) { + if (error instanceof ProtocolError) + return bad('invalid-argument'); + throw error; + } + return () => wire.result(true, inspect(wire, request.arguments)); + } + if (request.group === 'utf8') { + const readList = (value, limit, bits, scalar) => { + const values = []; + let rest = value; + for (;;) { + const node = tree.observe(rest).node; + if (node.kind === 'leaf') + return values; + // Reject the excess spine cell before observing its head or tail. + if (values.length === limit || node.kind !== 'fork') + throw new OperationError('invalid-argument'); + const number = canonicalNat(tree, node.left, bits); + if (number === undefined || scalar && (number > 0x10ffffn || number >= 0xd800n && number <= 0xdfffn)) + throw new OperationError('invalid-argument'); + values.push(Number(number)); + rest = node.right; + } + }; + if (request.operation === 'encode') { + const scalars = readList(request.arguments, 1024, 21, true); + const bytes = encodeUTF8(String.fromCodePoint(...scalars)); + return () => wire.result(true, wire.list(bytes.map(byte => tree.natural(BigInt(byte))))); + } + const [final, rest] = wire.pair(request.arguments), [bytes, end] = wire.pair(rest); + if (tree.observe(end).node.kind !== 'leaf') + throw new OperationError('invalid-argument'); + const last = canonicalNat(tree, final, 1); + if (last === undefined) + throw new OperationError('invalid-argument'); + const input = readList(bytes, 4096, 8, false); + try { + const decoded = decodeUTF8(input, last === 1n); + return () => wire.result(true, wire.list([tree.text(decoded.text), wire.list(decoded.carry.map(byte => tree.natural(BigInt(byte))))])); + } + catch (error) { + if (!(error instanceof UTF8Error)) + throw error; + return () => wire.result(false, wire.problem('invalid-data', op, error.message)); + } + } + if (request.group === 'standard') { + if (tree.observe(request.arguments).node.kind !== 'leaf') + throw new OperationError('invalid-argument'); + const key = ['input', 'output', 'error'].indexOf(request.operation) + 1; + return () => wire.result(true, wire.marker('io.v2.resource', key)); + } + if (request.group === 'resource') { + const token = wire.token(request.arguments, 'io.v2.resource'); + logical(() => registry.claimClose(scope, token)); + throw new Error('unexpected owned terminal close'); + } + const [handle, argument] = wire.pair(request.arguments), token = wire.token(handle, 'io.v2.resource'); + const read = request.operation === 'read'; + const lease = logical(() => registry.beginLease(scope, token, read ? 'reader' : 'writer', read ? 'read' : 'write')); + try { + const slot = lease.backend; + if (slot.poisoned) + return bad('invalid-state', 'unknown'); + let reply; + if (read) { + const limit = canonicalNat(tree, argument, 16); + if (limit === undefined) + throw new OperationError('invalid-argument'); + if (limit < 1n || limit > 4096n) + return bad('invalid-bound'); + if (slot.eof) + return () => wire.result(true, wire.list([tree.text('eof')])); + reply = await events.read(Number(limit), signal); + if (reply.kind === 'read') { + const received = reply; + slot.eof = received.eof; + return () => wire.result(true, received.eof ? wire.list([tree.text('eof')]) : + tree.fork(tree.text('data'), wire.list(received.bytes.map(byte => tree.natural(BigInt(byte)))))); + } + } + else { + const bytes = []; + let rest = argument; + while (tree.observe(rest).node.kind !== 'leaf') { + if (bytes.length === 4096) + throw new OperationError('invalid-argument'); + const [head, tail] = wire.pair(rest), byte = canonicalNat(tree, head, 8); + if (byte === undefined) + throw new OperationError('invalid-argument'); + bytes.push(Number(byte)); + rest = tail; + } + if (!bytes.length) + return bad('invalid-bound'); + if (bytes.length > outputBudget) + return bad('invalid-bound'); + outputBudget -= bytes.length; + reply = await events.write(slot.name === 'error' ? 'error' : 'output', bytes, signal); + if (reply.kind === 'write') { + const count = reply.count; + return () => wire.result(true, tree.natural(BigInt(count))); + } + } + if (!read) + slot.poisoned = true; + return bad(reply.kind === 'cancelled' ? 'cancelled' : 'io-error', 'unknown'); + } + finally { + registry.endLease(lease.token); + } + } + catch (error) { + if (!(error instanceof OperationError)) + throw error; + return bad(error.message); + } + } + }; +} +export class Session { + tree; + run; + sweep; + options; + events; + core; + busy = false; + finished = false; + constructor(tree, action, run, sweep, options = {}) { + this.tree = tree; + this.run = run; + this.sweep = sweep; + this.options = options; + if (options.steps !== undefined && (!Number.isInteger(options.steps) || options.steps < 1 || options.steps > 128)) + throw new Error('invalid session step bound'); + this.events = new Events(run); + this.core = new Core(tree, BigInt(run), action, ['input', 'output', 'error'].map(name => ({ name, poisoned: false, eof: false })), provider(this.events, options.backend ?? 'browser')); + sweep(this.core.roots()); + } + async turn(command) { + if (this.busy) + throw new Error('session turn already active'); + if (this.finished || command.run !== this.run) + throw new Error('stale session run'); + if (!['poll', 'stop', 'reply'].includes(command.kind)) + throw new Error('invalid session command'); + this.busy = true; + try { + const accepted = command.kind === 'reply' ? this.events.reply(command.reply) : null; + if (command.kind === 'stop') + this.core.stop(this.tree.leaf); + // Never await a terminal operation. Bounded progress batches leave the + // transport available for multiple replies, polls and structured stop. + let progress = false; + for (let turns = 0; turns < (this.options.steps ?? 128) && !this.core.result; turns++) { + progress = this.core.advance(); + this.sweep(this.core.roots()); + await Promise.resolve(); + if (!progress) + break; + } + const outcome = this.core.result; + if (outcome) + this.finished = true; + return { run: this.run, accepted, waiting: !progress && this.events.counts.pending > 0 && this.core.work.counts.pending === this.events.counts.pending && this.core.work.counts.replies === 0, done: !!outcome, events: this.events.take(), + outcome: outcome ? { kind: outcome.kind, value: outcome.kind === 'completed' && this.options.summarize !== false ? canonicalNat(this.tree, outcome.value, 64)?.toString() ?? null : null, + cleanup: outcome.cleanup?.items.length ?? 0, fault: this.core.hasFault } : null, + counts: { tasks: this.core.lifecycle.tasks.counts, resources: this.core.lifecycle.resources.counts, + work: this.core.work.counts, events: this.events.counts } }; + } + finally { + this.busy = false; + } + } +} diff --git a/ext/demo-site/dist/io-v2-tasks.js b/ext/demo-site/dist/io-tasks.js diff --git a/ext/demo-site/dist/io-terminal-view.js b/ext/demo-site/dist/io-terminal-view.js @@ -0,0 +1,125 @@ +import { Terminal } from './io-terminal.js'; +// One identity-bound UI per run. Callbacks enqueue cloneable replies/stop requests; +// they must not synchronously re-enter this widget or throw. +export function terminalView(parent, run, reply, stop) { + const terminal = new Terminal(run), doc = parent.ownerDocument; + const root = doc.createElement('section'), status = doc.createElement('p'); + const outputs = ['output', 'error'].map(name => { + const section = doc.createElement('section'), title = doc.createElement('h4'); + title.textContent = name === 'output' ? 'Standard output' : 'Standard error'; + const text = doc.createElement('pre'), details = doc.createElement('details'), summary = doc.createElement('summary'), hex = doc.createElement('pre'); + summary.textContent = 'Exact bytes (hexadecimal)'; + details.append(summary, hex); + section.append(title, text, details); + root.append(section); + text.dataset.stream = name; + hex.dataset.hex = name; + return { text, hex }; + }); + let form = doc.createElement('form'), clearInput = () => { }, waitingId; + const stopping = doc.createElement('button'); + stopping.textContent = 'Stop (cancel and wait for cleanup)'; + stopping.type = 'button'; + root.append(form, stopping, status); + parent.append(root); + let stopped = false, finished = false; + const onStop = () => { if (stopped || finished) + return; stopped = true; stopping.disabled = true; status.textContent = 'Cancelling; waiting for cleanup…'; stop(); }; + stopping.addEventListener('click', onStop); + function inputView() { + const wait = terminal.waiting; + if (wait?.id === waitingId) { + form.hidden = !wait; + return; + } + clearInput(); + const fresh = doc.createElement('form'); + form.replaceWith(fresh); + form = fresh; + form.hidden = !wait; + waitingId = wait?.id; + if (!wait) + return; + const label = doc.createElement('label'), input = doc.createElement('input'), send = doc.createElement('button'), eof = doc.createElement('button'); + label.textContent = 'One line, at most 256 Unicode scalars (Send appends LF)'; + input.maxLength = 512; + label.append(input); + send.textContent = 'Send'; + send.type = 'submit'; + eof.textContent = 'End input (EOF)'; + eof.type = 'button'; + form.append(label, send, eof); + function submit(end) { + const result = end ? terminal.endInput(wait.run, wait.id) : terminal.line(wait.run, wait.id, input.value); + if (!result) { + status.textContent = 'Enter at most 256 Unicode scalars without CR or LF.'; + return; + } + render(); + reply(result); + } + const onSubmit = (event) => { event.preventDefault(); submit(false); }, onEOF = () => submit(true); + form.addEventListener('submit', onSubmit); + eof.addEventListener('click', onEOF); + const owned = form; + clearInput = () => { owned.removeEventListener('submit', onSubmit); eof.removeEventListener('click', onEOF); input.value = ''; }; + } + function render() { + const snapshot = terminal.output.snapshot(); + [snapshot.output, snapshot.error].forEach((view, index) => { + const target = outputs[index]; + target.text.replaceChildren(); + target.hex.textContent = view.hex; + for (const token of view.tokens) { + if (token.kind === 'text') + target.text.append(doc.createTextNode(token.text)); + else { + const span = doc.createElement('mark'); + span.dataset.byteReason = token.reason; + span.title = `${token.reason} UTF-8 bytes at stream offset ${token.offset}; see exact hex view`; + span.textContent = token.bytes.map(b => `\\x${b.toString(16).padStart(2, '0')}`).join(''); + target.text.append(span); + } + } + }); + inputView(); + if (!stopped) + status.textContent = terminal.waiting ? 'Waiting for a line or explicit EOF.' : 'Running…'; + } + render(); + return { + root, + handle(event) { + if (finished) + throw new Error('terminal view retired'); + const result = terminal.handle(event); + // Physically hide/clear an abandoned input BEFORE acknowledging cancel. + render(); + if (result) + reply(result); + }, + finish() { + terminal.finish(); + finished = true; + render(); + clearInput(); + stopping.removeEventListener('click', onStop); + form.remove(); + stopping.disabled = true; + status.textContent = 'Settled.'; + }, + // Fatal runtime loss/navigation is NOT a physical cleanup acknowledgement. + // Retire DOM callbacks without fabricating a settled interpreter outcome. + abandon() { + if (finished) + return; + finished = true; + clearInput(); + form.remove(); + stopping.removeEventListener('click', onStop); + stopping.disabled = true; + status.textContent = 'Runtime lost; cleanup was not acknowledged.'; + }, + snapshot: () => terminal.output.snapshot() + }; +} diff --git a/ext/demo-site/dist/io-terminal.js b/ext/demo-site/dist/io-terminal.js @@ -0,0 +1,77 @@ +import { OutputBuffer } from './io-output.js'; +export class Terminal { + run; + output = new OutputBuffer(); + nextAfter = 0; + reader; + buffered = []; + eof = false; + ended = false; + constructor(run) { + this.run = run; + if (!/^[1-9][0-9]*$/.test(run) || run.length > 20 || BigInt(run) > 0xffffffffffffffffn) + throw new Error('invalid terminal run'); + } + get waiting() { + return this.reader ? { run: this.run, id: this.reader.id } : undefined; + } + read() { + const reader = this.reader; + if (!reader || !this.buffered.length && !this.eof) + return; + const bytes = this.buffered.splice(0, reader.limit); + this.reader = undefined; + return { run: this.run, id: reader.id, kind: 'read', bytes, eof: bytes.length === 0 }; + } + handle(event) { + if (this.ended || event.run !== this.run || !Number.isSafeInteger(event.id) || event.id < 1) + throw new Error('invalid terminal event'); + if (event.kind === 'cancel') { + if (event.id > this.nextAfter || !['read', 'write'].includes(event.operation)) + throw new Error('invalid terminal cancellation'); + // Completed synchronous writes/reads already have a normal acknowledgement + // queued. A racing abort does not erase it or manufacture a second reply. + if (this.reader?.id !== event.id) + return; + if (event.operation !== 'read') + throw new Error('invalid terminal cancellation'); + this.reader = undefined; + return { run: this.run, id: event.id, kind: 'cancelled' }; + } + if (event.id <= this.nextAfter) + throw new Error('stale terminal event'); + if (event.kind === 'read') { + if (this.reader || !Number.isInteger(event.limit) || event.limit < 1 || event.limit > 4096) + throw new Error('invalid terminal read'); + this.reader = { ...event }; + } + else if (event.kind === 'write') + this.output.append(event.stream, event.bytes); + else + throw new Error('invalid terminal operation'); + this.nextAfter = event.id; + return event.kind === 'read' ? this.read() : { run: this.run, id: event.id, kind: 'write', count: event.bytes.length }; + } + // Explicit one-line UI: no CR/LF in the submitted text, append ONE LF; an + // empty line is data, never EOF. Scalars, not UTF-16 code units, are bounded. + line(run, id, value) { + if (run !== this.run || this.reader?.id !== id || typeof value !== 'string' || value.length > 512 || + /[\r\n\uD800-\uDFFF]/u.test(value) || Array.from(value).length > 256) + return; + this.buffered = [...new TextEncoder().encode(value), 10]; + return this.read(); + } + endInput(run, id) { + if (run !== this.run || this.reader?.id !== id) + return; + this.eof = true; + return this.read(); + } + finish() { + if (this.reader) + throw new Error('terminal input still pending'); + this.ended = true; + this.buffered = []; + this.output.finish(); + } +} diff --git a/ext/demo-site/dist/io-v2-utf8.js b/ext/demo-site/dist/io-utf8.js diff --git a/ext/demo-site/dist/io-v2-core.js b/ext/demo-site/dist/io-v2-core.js @@ -1,335 +0,0 @@ -// Private resumable tree interpreter. No public session dispatch or capabilities. -// Adapters must retain roots() between turns and keep temporary evaluator handles -// alive for a whole turn. Provider promises acknowledge physical completion; -// providers return deferred Result encoders, never task outcomes. Encoding runs -// in an interpreter turn, not in a promise/GC gap; cancelled encoders never run. -import { canonicalNat, decodeAction, ProtocolError } from './io-v2-protocol.js'; -import { lifecycle } from './io-v2-resources.js'; -import { HostWork } from './io-v2-host-work.js'; -const operationErrors = new Set(['stale-task', 'join-cycle', 'out-of-scope', 'stale-handle', 'not-owner', - 'borrowed-resource', 'internal-resource', 'closed-handle', 'busy', 'duplicate-handle', 'closed-scope', 'invalid-argument']); -export class OperationError extends Error { -} -function logical(operation) { - try { - return operation(); - } - catch (error) { - if (error instanceof Error && operationErrors.has(error.message)) - throw new OperationError(error.message); - throw error; - } -} -export class Wire { - tree; - identity; - constructor(tree, identity) { - this.tree = tree; - this.identity = identity; - } - pair(value) { - const node = this.tree.observe(value).node; - if (node.kind !== 'fork') - throw new OperationError('invalid-argument'); - return [node.left, node.right]; - } - list(values) { let out = this.tree.leaf; for (let i = values.length - 1; i >= 0; i--) - out = this.tree.fork(values[i], out); return out; } - result(ok, value) { return this.tree.fork(ok ? this.tree.stem(this.tree.leaf) : this.tree.leaf, this.tree.fork(value, this.tree.leaf)); } - problem(category, operation, detail = '', progress = 'none') { - return this.list([category, operation, detail, progress].map(s => this.tree.text(s))); - } - marker(name, key) { return this.list([this.tree.text(name), this.tree.natural(this.identity), this.tree.natural(BigInt(key))]); } - token(value, name) { - const [tag, rest] = this.pair(value), [run, tail] = this.pair(rest), [key, end] = this.pair(tail); - if (this.tree.observe(end).node.kind !== 'leaf') - throw new OperationError('invalid-argument'); - let cursor = tag; - for (const char of name) { - const [head, next] = this.pair(cursor); - if (canonicalNat(this.tree, head, 7) !== BigInt(char.charCodeAt(0))) - throw new OperationError('invalid-argument'); - cursor = next; - } - if (this.tree.observe(cursor).node.kind !== 'leaf') - throw new OperationError('invalid-argument'); - const r = canonicalNat(this.tree, run, 64), k = canonicalNat(this.tree, key, 64); - if (r === undefined || k === undefined) - throw new OperationError('invalid-argument'); - // Unrepresentable keys cannot alias a live JS identity. Keep the ordinary - // stale-handle/task path rather than rounding into a different live handle. - return { run: r, key: k > BigInt(Number.MAX_SAFE_INTEGER) ? -1 : Number(k) }; - } - outcome(value) { - const name = this.tree.text(value.kind); - if (value.kind === 'completed') - return this.list([name, value.value]); - const errors = [...value.cleanup?.items ?? []], omitted = value.cleanup?.omitted ?? 0; - if (omitted) - errors.push(this.problem('cleanup-omitted', 'close', String(omitted))); - return this.list([name, value.value, this.list(errors)]); - } -} -export class Core { - tree; - provider; - actionLimit; - wire; - lifecycle; - work; - bodies = new Map(); - constants; - pendingArguments = new Map(); - cursor = -1; - actions = 0; - result = null; - // Embedding/evaluator failure is retained separately and never stringified or - // mistaken for an ordinary Result. Drain cleanup before rethrowing at boundary. - fault = undefined; - hasFault = false; - constructor(tree, identity, action, standard, provider, actionLimit = 65536) { - this.tree = tree; - this.provider = provider; - this.actionLimit = actionLimit; - if (!Number.isSafeInteger(actionLimit) || actionLimit < 1) - throw new Error('invalid action limit'); - this.wire = new Wire(tree, identity); - this.constants = { - stopped: this.wire.problem('cancelled', 'scope', 'owner stopped'), - cycle: this.wire.problem('join-cycle', 'scope'), fault: this.wire.problem('runtime-error', 'core'), - cleanup: this.wire.problem('cleanup-failed', 'resource/release', '', 'unknown'), budget: this.wire.problem('limit', 'core', 'IO action limit exceeded') - }; - this.lifecycle = lifecycle(identity, this.constants.stopped, this.constants.cycle, standard); - this.work = new HostWork(this.lifecycle.tasks); - this.bodies.set(0, this.body(action)); - } - body(action) { return { action, frames: [], mode: 'runnable' }; } - stop(reason) { if (!this.result) { - this.lifecycle.tasks.stop(0, { kind: 'cancelled', value: reason }); - this.work.cancelStopped(); - } } - done(scope, outcome) { - this.lifecycle.tasks.bodyDone(scope, outcome); - const body = this.bodies.get(scope); - body.mode = 'done'; - body.action = this.tree.leaf; - body.frames = []; - } - value(scope, value) { - const body = this.bodies.get(scope); - if (body.frames.length) { - body.action = this.tree.app(body.frames.pop(), value); - body.mode = 'runnable'; - } - else - this.done(scope, { kind: 'completed', value }); - } - operation(scope, name, action) { - try { - action(); - } - catch (error) { - if (!(error instanceof OperationError)) - throw error; - this.value(scope, this.wire.result(false, this.wire.problem(error.message, name))); - } - } - step(scope) { - const { tasks, resources } = this.lifecycle, body = this.bodies.get(scope), state = tasks.state(scope); - if (body.mode === 'done' || body.mode === 'lexical') - return false; - if (state.reason) { - this.done(scope, state.reason); - return true; - } - if (body.mode === 'host') - return false; - if (body.mode === 'join') { - const outcome = tasks.pollJoin(scope); - if (!outcome) - return false; - this.value(scope, this.wire.result(true, this.wire.outcome(outcome))); - return true; - } - if (++this.actions > this.actionLimit) { - tasks.stop(0, { kind: 'failed', value: this.constants.budget }); - return true; - } - let action; - try { - action = decodeAction(this.tree, body.action); - } - catch (error) { - if (!(error instanceof ProtocolError)) - throw error; - this.done(scope, { kind: 'failed', value: this.wire.problem('protocol-error', 'core/decode', error.message) }); - return true; - } - if (action.kind === 'request') { - // Keep action/arguments rooted until provider acknowledgement, including - // cancellation. The abandoned continuation is cleared by done(). - body.mode = 'host'; - const request = action; - this.pendingArguments.set(scope, request.arguments); - this.work.start(scope, async (signal) => ({ kind: 'body', encode: await this.provider.request(this, scope, request, signal) })); - return true; - } - const p = action.payload; - switch (action.code) { - case 0: - this.value(scope, p); - break; - case 1: { - const node = this.tree.observe(p).node; - if (node.kind !== 'fork') - this.done(scope, { kind: 'failed', value: this.wire.problem('protocol-error', 'core/decode', 'invalid v2 computation') }); - else { - body.frames.push(node.right); - body.action = node.left; - } - break; - } - case 2: - this.done(scope, { kind: 'failed', value: p }); - break; - case 9: - this.done(scope, { kind: 'cancelled', value: p }); - break; - case 3: { - const child = tasks.nested(scope); - this.bodies.set(child, this.body(this.tree.leaf)); - body.mode = 'lexical'; - body.action = this.tree.leaf; - this.bodies.get(child).action = this.tree.app(p, this.wire.marker('io.v2.scope', child)); - break; - } - case 4: - this.value(scope, this.wire.marker('io.v2.scope', scope)); - break; - case 5: - this.operation(scope, 'core/spawn', () => { - const [sup, a] = this.wire.pair(p), [keep, b] = this.wire.pair(a), [handles, callback] = this.wire.pair(b); - const supervised = canonicalNat(this.tree, sup, 1), retained = canonicalNat(this.tree, keep, 1); - if (supervised === undefined || retained === undefined) - throw new OperationError('invalid-argument'); - const tokens = []; - let rest = handles; - while (this.tree.observe(rest).node.kind !== 'leaf') { - if (tokens.length === 256) - throw new OperationError('invalid-argument'); - const [head, tail] = this.wire.pair(rest); - tokens.push(this.wire.token(head, 'io.v2.resource')); - rest = tail; - } - const moved = logical(() => resources.spawn(scope, supervised === 1n, retained === 1n, tokens)), child = moved.task.key; - this.bodies.set(child, this.body(this.tree.leaf)); - this.bodies.get(child).action = this.tree.app(callback, this.wire.list(moved.handles.map(t => this.wire.marker('io.v2.resource', t.key)))); - this.value(scope, this.wire.result(true, this.wire.list([this.wire.marker('io.v2.task', child), retained === 1n ? this.wire.marker('io.v2.join', child) : this.tree.leaf]))); - }); - break; - case 6: - case 7: - this.operation(scope, action.code === 6 ? 'core/join' : 'core/discard', () => { - const token = this.wire.token(p, 'io.v2.join'); - if (action.code === 7) { - logical(() => tasks.discard(scope, token)); - this.value(scope, this.wire.result(true, this.tree.leaf)); - } - else { - logical(() => tasks.claimJoin(scope, token)); - body.mode = 'join'; - body.action = this.tree.leaf; - } - }); - break; - case 8: - this.operation(scope, 'core/cancel', () => { - const [handle, reason] = this.wire.pair(p), token = this.wire.token(handle, 'io.v2.task'); - logical(() => tasks.cancel(scope, token, reason)); - this.value(scope, this.wire.result(true, this.tree.leaf)); - }); - break; - } - return true; - } - settled(scope, errors) { - const { tasks } = this.lifecycle, state = tasks.state(scope), outcome = tasks.settle(scope, errors); - this.bodies.delete(scope); - if (scope === 0) - this.result = outcome; - else if (state.kind === 'lexical') { - const parent = state.parent, reason = tasks.state(parent).reason; - this.bodies.get(parent).mode = 'runnable'; - if (reason) - this.done(parent, reason); - else if (outcome.kind === 'completed') - this.value(parent, outcome.value); - else - this.done(parent, outcome); - } - } - // One progress turn, never waits for a provider. False means wait for host - // acknowledgement. The embedding must yield to its event loop between batches. - advance() { - if (this.result) - return false; - try { - this.work.cancelStopped(); - for (const scope of this.pendingArguments.keys()) - if (this.lifecycle.tasks.state(scope).hostCalls === 0) - this.pendingArguments.delete(scope); - const reply = this.work.take(); - if (reply) { - if (!reply.outcome.ok) - throw reply.outcome.error; - const value = reply.outcome.value; - if (value.kind === 'release') - this.settled(reply.token.scope, value.errors); - else - this.value(reply.token.scope, value.encode()); - return true; - } - const { tasks, resources } = this.lifecycle; - for (const scope of tasks.scopeIds()) - if (tasks.state(scope).phase === 'joining' && tasks.beginRelease(scope)) { - this.work.release(scope, async () => ({ kind: 'release', errors: await resources.releaseScope(scope, resource => this.provider.release(resource), () => this.constants.cleanup, this.constants.cleanup) })); - return true; - } - const ids = tasks.scopeIds(), ordered = [...ids.filter(id => id > this.cursor), ...ids.filter(id => id <= this.cursor)]; - for (const scope of ordered) - if (this.step(scope)) { - this.cursor = scope; - return true; - } - return false; - } - catch (error) { - if (!this.hasFault) - this.fault = error; - this.hasFault = true; - this.lifecycle.tasks.stop(0, { kind: 'failed', value: this.constants.fault }); - this.work.cancelStopped(); - return true; - } - } - wait() { return this.work.wait(); } - *roots() { - yield this.tree.leaf; - yield* Object.values(this.constants); - yield* this.lifecycle.tasks.payloads(); - yield* this.pendingArguments.values(); - yield* this.provider.roots?.() ?? []; - for (const body of this.bodies.values()) { - yield body.action; - yield* body.frames; - } - for (const value of this.work.values()) { - if (value.kind === 'release') - yield* value.errors.items; - } - if (this.result) { - yield this.result.value; - yield* this.result.cleanup?.items ?? []; - } - } -} diff --git a/ext/demo-site/dist/io-v2-driver.js b/ext/demo-site/dist/io-v2-driver.js @@ -1,102 +0,0 @@ -// Unlike repeated Promise.race against a lifetime-long promise, listeners are -// removed on EVERY turn/step settlement, not retained until runtime disposal. -function interrupt(promise, signal, aborted) { - if (!signal) - return promise; - let onAbort = () => { }; - return new Promise((resolve, reject) => { - onAbort = () => { try { - resolve(aborted()); - } - catch (error) { - reject(error); - } }; - if (signal.aborted) - onAbort(); - else - signal.addEventListener('abort', onAbort, { once: true }); - void promise.then(resolve, reject); - }).finally(() => signal.removeEventListener('abort', onAbort)); -} -// The queue stays alive while a read or step prompt is waiting. Stop is sticky, -// independent of the resume queue, and never implemented by worker destruction. -export async function drive(driver, host) { - const transport = host.v2; - if (!transport) - throw new Error('IO version 2 requires a structured terminal host'); - let settled = false; - const alive = (promise) => interrupt(promise, driver.signal, () => { throw driver.signal.reason; }); - let active = true, stopping = false, stopSent = false, wake = () => { }, step = 0; - const replies = []; - let boundaryFault = false, fault; - const stopped = new AbortController(); - const enqueue = (command) => { - if (!active || !command || command.run !== driver.run) - return; - if (command.kind === 'stop') { - stopping = true; - stopped.abort(); - } - else if (command.kind === 'reply') - replies.push(command); - wake(); - }; - const close = () => { - if (!active) - return; - active = false; - stopped.abort(); - wake(); - wake = () => { }; - replies.length = 0; - // A failed transport/runtime cannot be reused with admitted work abandoned. - if (!settled) - driver.discard?.(); - transport.close(driver.run, settled); - }; - try { - transport.open(driver.run, enqueue); - for (;;) { - if (!stopping && transport.stepping === true) { - try { - await alive(interrupt(host.before({ step: ++step, name: 'v2 scheduler turn', detail: 'one scheduler advance; all tasks paused', depth: 0 }), stopped.signal, () => { })); - } - catch (error) { - if (driver.signal?.aborted) - throw error; - boundaryFault = true; - fault = error; - enqueue({ run: driver.run, kind: 'stop' }); - } - } - const command = stopping && !stopSent ? (stopSent = true, { run: driver.run, kind: 'stop' }) : - replies.shift() ?? { run: driver.run, kind: 'poll' }; - const report = await alive(driver.turn(command)); - for (const event of report.events) - transport.event(event); - if (report.done) { - if (!Object.values(report.counts).every(group => Object.values(group).every(value => value === 0))) - throw new Error('IO v2 settlement retains work'); - settled = true; - close(); - if (boundaryFault) - throw fault; - if (report.outcome?.fault) - throw new Error('IO v2 interpreter fault'); - if (report.outcome?.kind !== 'completed') - throw new Error(`IO v2 ${report.outcome?.kind ?? 'failed'}`); - return await alive(driver.view()); // Full codec, only after physical settlement. - } - // Yield even during pure scheduler progress so the outer worker can receive - // stop/replies. This is not a hard deadline for native tree reduction. - if (report.waiting && !replies.length && (!stopping || stopSent)) - await alive(new Promise(resolve => { wake = resolve; })); - else - await alive(new Promise(resolve => { const timer = setTimeout(resolve, 0); wake = () => { clearTimeout(timer); resolve(); }; })); - wake = () => { }; - } - } - finally { - close(); - } -} diff --git a/ext/demo-site/dist/io-v2-host-work.js b/ext/demo-site/dist/io-v2-host-work.js @@ -1,97 +0,0 @@ -import { hostKey } from './io-v2-tasks.js'; -// Adapter-side asynchronous work. Starting one callback never waits for it, so -// sibling actions/cancellation can proceed. The provider must settle its promise -// AFTER cancellation cleanup; abort delivery alone is never an acknowledgement. -export class HostWork { - tasks; - active = new Map(); - replies = new Map(); - notification = null; - constructor(tasks) { - this.tasks = tasks; - } - start(scope, operation) { - return this.register(this.tasks.beginHost(scope), operation); - } - // Cleanup is not cancelled by a new stop request. Its result (including a - // failure) must still reach the driver to preserve primary/secondary outcomes. - release(scope, operation) { - return this.register(this.tasks.beginReleaseHost(scope), operation); - } - register(token, operation) { - const controller = new AbortController(); - this.active.set(hostKey(token), { token, controller }); - const complete = (outcome) => { - // Settle first; a cancelled reply is discarded without observing its value. - const disposition = this.tasks.endHost(token); - this.active.delete(hostKey(token)); - if (disposition === 'reply') - this.replies.set(hostKey(token), { token, outcome }); - const notice = this.notification; - this.notification = null; - notice?.resolve(); - }; - try { - Promise.resolve(operation(controller.signal)).then(value => complete({ ok: true, value }), error => complete({ ok: false, error })); - } - catch (error) { - complete({ ok: false, error }); - } - return token; - } - usable(token) { - if (!this.tasks.within(token.scope, token.scope)) - return false; - const state = this.tasks.state(token.scope); - return token.kind === 'release' ? state.phase === 'releasing' : state.phase === 'open' && state.reason === null; - } - // Resource close may interrupt one operation without cancelling its task. - // The resulting provider reply still reaches that body as an ordinary error. - // Never abort a cleanup batch or acknowledge work merely by sending this signal. - cancelBody(token) { - const work = this.active.get(hostKey(token)); - if (token.kind !== 'body' || !work || work.token.run !== token.run || - work.token.scope !== token.scope || work.controller.signal.aborted) - return false; - work.controller.abort(); - return true; - } - cancelStopped() { - for (const token of this.tasks.takeCancellations()) { - const work = this.active.get(hostKey(token)); - if (!work || work.token.scope !== token.scope) - throw new Error('host work ownership mismatch'); - work.controller.abort(); - } - // Completion may have won the race with cancellation. Drop undelivered data - // too; a queued reply cannot revive a stopped/settled body. - for (const [key, reply] of this.replies) - if (!this.usable(reply.token)) - this.replies.delete(key); - } - take() { - for (const [key, reply] of this.replies) { - this.replies.delete(key); - if (this.usable(reply.token)) - return reply; - } - return null; - } - wait() { - if (this.replies.size || !this.active.size) - return Promise.resolve(); - if (!this.notification) { - let resolve; - const promise = new Promise(done => { resolve = done; }); - this.notification = { promise, resolve }; - } - return this.notification.promise; - } - get counts() { return { pending: this.active.size, replies: this.replies.size }; } - // Reply data roots are the embedding's responsibility until taken/discarded. - *values() { - for (const reply of this.replies.values()) - if (this.usable(reply.token) && reply.outcome.ok) - yield reply.outcome.value; - } -} diff --git a/ext/demo-site/dist/io-v2-output.js b/ext/demo-site/dist/io-v2-output.js @@ -1,69 +0,0 @@ -// Presentation only. Byte acceptance and strict source UTF-8 Results are separate. -// No evaluator handles, host callbacks, IO acknowledgements or terminal escapes. -import { decodeUTF8, UTF8Error } from './io-v2-utf8.js'; -function view(bytes, final) { - const tokens = []; - for (let offset = 0; offset < bytes.length;) { - const lead = bytes[offset]; - const width = lead <= 0x7f ? 1 : lead >= 0xc2 && lead <= 0xdf ? 2 : - lead >= 0xe0 && lead <= 0xef ? 3 : lead >= 0xf0 && lead <= 0xf4 ? 4 : 1; - const candidate = bytes.slice(offset, offset + width); - try { - const decoded = decodeUTF8(candidate, false); - if (decoded.carry.length) { - tokens.push({ kind: 'bytes', offset, bytes: candidate, reason: final ? 'incomplete' : 'pending' }); - break; - } - const previous = tokens.at(-1); - if (previous?.kind === 'text') - previous.text += decoded.text; - else - tokens.push({ kind: 'text', offset, text: decoded.text }); - offset += width; - } - catch (error) { - if (!(error instanceof UTF8Error)) - throw error; - // Escape one offending byte; reconsider following bytes independently. - // Thus invalid prefixes cannot swallow subsequent valid text. - tokens.push({ kind: 'bytes', offset, bytes: [lead], reason: 'invalid' }); - offset++; - } - } - return { bytes, hex: bytes.map(b => b.toString(16).padStart(2, '0')).join(' '), tokens }; -} -// Owned by one UI run. The session separately enforces attempted-output budgets; -// this buffer bounds retained accepted bytes, without refunds or history. -export class OutputBuffer { - limit; - streams = { output: [], error: [] }; - size = 0; - ended = false; - constructor(limit = 16384) { - this.limit = limit; - if (!Number.isSafeInteger(limit) || limit < 0 || limit > 16384) - throw new Error('invalid output limit'); - } - append(stream, bytes) { - if (this.ended) - throw new Error('output presentation ended'); - if (stream !== 'output' && stream !== 'error') - throw new Error('invalid output stream'); - if (!Array.isArray(bytes) || bytes.length === 0 || bytes.length > 4096) - throw new Error('invalid output bytes'); - for (const byte of bytes) - if (!Number.isInteger(byte) || byte < 0 || byte > 255) - throw new Error('invalid output bytes'); - if (bytes.length > this.limit - this.size) - throw new Error('output presentation limit'); - this.streams[stream].push(...bytes); - this.size += bytes.length; - } - // End-of-presentation, including after structured cancellation, not closing a - // borrowed stream. Incomplete suffixes become visible bytes, never IO failures. - finish() { this.ended = true; } - snapshot() { - return { done: this.ended, size: this.size, - output: view([...this.streams.output], this.ended), error: view([...this.streams.error], this.ended) }; - } -} diff --git a/ext/demo-site/dist/io-v2-private.js b/ext/demo-site/dist/io-v2-private.js @@ -1,49 +0,0 @@ -// Private native-adapter validation runner, not RuntimeSession IO dispatch. -// Bare computations only; no facility implementation/capability advertisement. -import { Core } from './io-v2-core.js'; -import { canonicalNat } from './io-v2-protocol.js'; -export function constructors(base) { - const natural = (value) => { - const bits = []; - if (value < 0n) - throw new Error('negative natural'); - for (; value; value >>= 1n) - bits.push(value & 1n ? base.stem(base.leaf) : base.leaf); - let out = base.leaf; - for (let i = bits.length - 1; i >= 0; i--) - out = base.fork(bits[i], out); - return out; - }; - return { ...base, natural, text(value) { - let out = base.leaf; - const chars = Array.from(value); - for (let i = chars.length - 1; i >= 0; i--) - out = base.fork(natural(BigInt(chars[i].codePointAt(0))), out); - return out; - } }; -} -export async function runPrivate(tree, action, identity, sweep) { - const engine = new Core(tree, identity, action, [null, null, null], { - request: (core, _scope, request) => () => core.wire.result(false, core.wire.problem('unsupported', `${request.group}/${request.operation}`)), - release: () => { throw new Error('unexpected owned backend in private core probe'); } - }); - sweep(engine.roots()); - for (let turns = 0; !engine.result; turns++) { - const progress = engine.advance(); - sweep(engine.roots()); - if (!progress) { - if (!engine.work.counts.pending) - throw new Error('private interpreter stalled'); - await engine.wait(); - } - if (turns % 32 === 0) - await new Promise(resolve => setTimeout(resolve, 0)); - } - if (engine.hasFault) - throw engine.fault; - const out = engine.result; - // Terminal diagnostics stay opaque, including when the test requests a number. - const value = out.kind === 'completed' ? canonicalNat(tree, out.value, 64)?.toString() ?? null : null; - return { kind: out.kind, value, cleanup: out.cleanup?.items.length ?? 0, - tasks: engine.lifecycle.tasks.counts, resources: engine.lifecycle.resources.counts, host: engine.work.counts }; -} diff --git a/ext/demo-site/dist/io-v2-profile.js b/ext/demo-site/dist/io-v2-profile.js @@ -1,47 +0,0 @@ -// This table is the implemented browser provider, not the native catalogue. -import { canonicalNat, decodeAction, ProtocolError } from './io-v2-protocol.js'; -export const coreNames = ['pure', 'bind', 'fail', 'scope', 'current-scope', 'spawn', 'join', 'discard', 'cancel', 'cancelled']; -const groups = Object.freeze({ - runtime: ['capabilities', 'inspect'], standard: ['input', 'output', 'error'], - stream: ['read', 'write'], resource: ['close'], utf8: ['encode', 'decode'], -}); -export function available(group, revision, operation) { - return revision === 1 && Object.hasOwn(groups, group) && groups[group].includes(operation); -} -export function capabilities(wire, backend) { - const { tree } = wire, text = (s) => tree.text(s), number = (n) => tree.natural(BigInt(n)); - const strings = (xs) => wire.list(xs.map(text)), row = (name, value) => wire.list([text(name), value]); - return wire.list([ - row('schema', number(1)), row('io-version', number(2)), row('backend', text(backend)), row('authority', text('browser-terminal')), - row('core', strings(coreNames)), row('groups', wire.list(Object.entries(groups).map(([name, ops]) => wire.list([text(name), number(1), strings(ops)])))), - row('limits', wire.list(Object.entries({ 'io-actions': 65536, 'chunk-bytes': 4096, 'transfer-handles': 256, 'utf8-encode-scalars': 1024, 'input-line-scalars': 256, 'output-attempted-bytes': 16384 }).map(([key, value]) => row(key, number(value))))), - row('restrictions', strings(['action-boundary-cancellation', 'no-hard-deadlines', 'borrowed-standard-streams', 'standard-stream-leases-per-run', 'utf8-strict-scalars', 'text-line-input', 'byte-preserving-output', 'no-filesystem', 'no-network', 'no-processes'])), - ]); -} -export function inspect(wire, value) { - const action = decodeAction(wire.tree, value), t = wire.tree; - return action.kind === 'core' ? wire.list([t.text('core'), t.text(coreNames[action.code])]) : - wire.list([t.text('facility'), t.text(action.group), t.natural(BigInt(action.revision)), t.text(action.operation), t.text(available(action.group, action.revision, action.operation) ? 'available' : 'unsupported')]); -} -// Authenticate framing before selecting an interpreter. No action is observed. -export function envelope(tree, value) { - const root = tree.observe(value).node; - if (root.kind !== 'fork') - throw new ProtocolError('invalid IO envelope'); - let rest = root.left; - for (const char of 'tricuIO') { - const node = tree.observe(rest).node; - if (node.kind !== 'fork' || canonicalNat(tree, node.left, 7) !== BigInt(char.charCodeAt(0))) - throw new ProtocolError('Missing tricuIO sentinel'); - rest = node.right; - } - if (tree.observe(rest).node.kind !== 'leaf') - throw new ProtocolError('Missing tricuIO sentinel'); - const body = tree.observe(root.right).node; - if (body.kind !== 'fork') - throw new ProtocolError('invalid IO envelope'); - const version = canonicalNat(tree, body.left, 2); - if (version !== 2n) - throw new ProtocolError('Unsupported tricuIO version'); - return { version: 2, action: body.right }; -} diff --git a/ext/demo-site/dist/io-v2-resources.js b/ext/demo-site/dist/io-v2-resources.js @@ -1,225 +0,0 @@ -import { Tasks } from './io-v2-tasks.js'; -// State only. Construct via lifecycle() to bind task settlement to registry guards. -// Single owning event loop; host effects occur AFTER admission/lease/close claims. -class Resources { - tasks; - limit; - next = 4; - resources = new Map(); - admissions = new Map(); - leases = new Map(); - constructor(tasks, standard, limit) { - this.tasks = tasks; - this.limit = limit; - if (!Number.isSafeInteger(limit) || limit < 3) - throw new Error('invalid resource identity limit'); - standard.forEach((backend, index) => this.resources.set(index + 1, { - owner: 0, kind: index === 0 ? 'reader' : 'writer', backend, flags: Object.freeze({ borrowed: true }), - busy: 0, closing: false, releasing: false - })); - } - get counts() { return { resources: this.resources.size, admissions: this.admissions.size, leases: this.leases.size }; } - token(key) { return Object.freeze({ run: this.tasks.identity, key }); } - key(token) { - if (token.run !== this.tasks.identity) - throw new Error('stale-handle'); - return token.key; - } - room(count) { - if (count > this.limit - this.next + 1) - throw new Error('resource-identity-exhausted'); - } - open(scope) { - const state = this.tasks.state(scope); - if (state.phase !== 'open' || state.reason) - throw new Error('closed-scope'); - } - entry(current, token) { - const resource = this.resources.get(this.key(token)); - if (!resource) - throw new Error('stale-handle'); - if (!this.tasks.within(current, resource.owner)) - throw new Error('out-of-scope'); - return resource; - } - lookup(current, token) { return { ...this.entry(current, token) }; } - beginAcquire(current, owner = current) { - this.tasks.checkpoint(current); - this.open(owner); - if (!this.tasks.within(current, owner)) - throw new Error('out-of-scope'); - this.room(1); - const token = this.token(this.next++); - this.admissions.set(token.key, owner); - return token; - } - abortAcquire(token) { - if (!this.admissions.delete(this.key(token))) - throw new Error('invalid-admission'); - } - // An admitted acquisition can finish after owner stop/admission closure. - // Publication failure leaves the admission live: release raw backend, then abort. - publish(admission, kind, backend, flags = {}) { - const key = this.key(admission), owner = this.admissions.get(key); - if (owner === undefined) - throw new Error('invalid-admission'); - if (!kind || Object.values(flags).some(value => typeof value !== 'boolean')) - throw new Error('invalid-argument'); - this.room(1); - const savedFlags = Object.freeze({ ...flags }), token = this.token(this.next); - this.resources.set(token.key, { owner, kind, backend, flags: savedFlags, busy: 0, closing: false, releasing: false }); - this.next++; - this.admissions.delete(key); - return token; - } - // All recoverable validation and preparation precede task admission. No host - // callback/await separates admission from rekeying. JS OOM is fatal, not rollback. - spawn(owner, supervised, keep, tokens) { - this.tasks.checkpoint(owner); - this.open(owner); - if (typeof supervised !== 'boolean' || typeof keep !== 'boolean') - throw new Error('invalid-argument'); - if (tokens.length > 256) - throw new Error('transfer-limit'); - const seen = new Set(); - const entries = tokens.map(token => { - const resource = this.entry(owner, token); - if (seen.has(token.key)) - throw new Error('duplicate-handle'); - seen.add(token.key); - if (resource.owner !== owner) - throw new Error('not-owner'); - if (resource.flags.borrowed) - throw new Error('borrowed-resource'); - if (resource.flags.internal) - throw new Error('internal-resource'); - if (resource.closing) - throw new Error('closed-handle'); - if (resource.busy) - throw new Error('busy'); - return { old: token.key, resource }; - }).sort((a, b) => a.old - b.old); - this.room(tokens.length); - const fresh = new Map(entries.map((entry, i) => [entry.old, this.token(this.next + i)])); - const handles = tokens.map(token => fresh.get(token.key)); - const task = this.tasks.spawn(owner, supervised, keep); - for (const entry of entries) { - this.resources.delete(entry.old); - entry.resource.owner = task.key; - this.resources.set(fresh.get(entry.old).key, entry.resource); - } - this.next += tokens.length; - return { task, handles }; - } - beginLease(current, token, kind, access, internal = false) { - this.tasks.checkpoint(current); - const resource = this.entry(current, token); - if (resource.kind !== kind) - throw new Error('wrong-kind'); - if (resource.closing) - throw new Error('closed-handle'); - if (resource.flags.internal && !internal) - throw new Error('internal-resource'); - if (!['exclusive', 'read', 'write'].includes(access)) - throw new Error('invalid-argument'); - const mask = !resource.flags.duplex || access === 'exclusive' ? 3 : access === 'read' ? 1 : 2; - if (resource.busy & mask) - throw new Error('busy'); - this.room(1); - const lease = this.token(this.next++); - this.leases.set(lease.key, { scope: current, resource: token.key, mask }); - resource.busy |= mask; - return { token: lease, backend: resource.backend }; - } - endLease(token) { - const key = this.key(token), lease = this.leases.get(key); - if (!lease) - throw new Error('invalid-lease'); - this.resources.get(lease.resource).busy &= ~lease.mask; - this.leases.delete(key); - } - claimClose(current, token, settling = false) { - const resource = this.entry(current, token); - if (settling) { - if (resource.owner !== current || this.tasks.state(current).phase !== 'releasing') - throw new Error('invalid-close'); - } - else { - this.tasks.checkpoint(current); - if (resource.flags.internal) - throw new Error('internal-resource'); - if (resource.flags.borrowed) - throw new Error('borrowed-resource'); - } - resource.closing = true; - if (resource.busy || resource.releasing) - return null; - resource.releasing = true; - return { ...resource }; - } - // Once, including failed release. Never retry a physically ambiguous close. - finishClose(token) { - const key = this.key(token), resource = this.resources.get(key); - if (!resource) - throw new Error('stale-handle'); - if (!resource.releasing) - throw new Error('invalid-close'); - this.resources.delete(key); - } - newestOwned(scope) { - let newest = -1; - for (const [key, resource] of this.resources) - if (resource.owner === scope && key > newest) - newest = key; - return newest < 0 ? null : this.token(newest); - } - readyForRelease(scope) { - return ![...this.admissions.values()].includes(scope) && ![...this.leases.values()].some(lease => lease.scope === scope); - } - finishScope(scope) { - if (!this.readyForRelease(scope) || this.newestOwned(scope)) - throw new Error('busy'); - } - // Use inside HostWork.release, after Tasks.beginRelease. Each provider is - // attempted once, sequentially in reverse publication order; borrowed backends - // stay open. Diagnostics construction failure must not skip remaining releases. - async releaseScope(scope, release, problem, fallback) { - if (this.tasks.state(scope).phase !== 'releasing') - throw new Error('invalid-transition'); - const items = []; - let omitted = 0; - for (let token = this.newestOwned(scope); token; token = this.newestOwned(scope)) { - const resource = this.claimClose(scope, token, true); - if (!resource) - throw new Error('busy'); - try { - if (!resource.flags.borrowed) - await release(resource); - } - catch (error) { - if (items.length === 16) - omitted++; - else { - let value = fallback; - try { - value = problem(error); - } - catch { /* retain prebuilt fallback */ } - items.push(value); - } - } - finally { - this.finishClose(token); - } - } - return { items, omitted }; - } -} -export function lifecycle(identity, ownerStopped, cycle, standard, limits = {}) { - let resources; - const tasks = new Tasks(identity, ownerStopped, cycle, limits.taskIdentities ?? Number.MAX_SAFE_INTEGER, { - readyForRelease: scope => resources.readyForRelease(scope), finishScope: scope => resources.finishScope(scope) - }); - resources = new Resources(tasks, standard, limits.resourceIdentities ?? Number.MAX_SAFE_INTEGER); - return { tasks, resources }; -} diff --git a/ext/demo-site/dist/io-v2-session.js b/ext/demo-site/dist/io-v2-session.js @@ -1,216 +0,0 @@ -// Cloneable event session shared by private probes and public native adapters. -import { Core, OperationError } from './io-v2-core.js'; -import { available, capabilities, inspect } from './io-v2-profile.js'; -import { ProtocolError, decodeAction, canonicalNat } from './io-v2-protocol.js'; -import { encodeUTF8, decodeUTF8, UTF8Error } from './io-v2-utf8.js'; -import { Events } from './io-v2-events.js'; -const faults = new Set(['stale-handle', 'out-of-scope', 'closed-handle', 'wrong-kind', 'internal-resource', 'borrowed-resource', 'busy']); -function logical(f) { - try { - return f(); - } - catch (error) { - if (error instanceof Error && faults.has(error.message)) - throw new OperationError(error.message); - throw error; - } -} -function provider(events, backend) { - let outputBudget = 16384; // Attempted bytes across both slots; no replay/refund on uncertainty. - return { - release: () => { throw new Error('unexpected owned terminal resource'); }, - async request(engine, scope, request, signal) { - const { tree, wire } = engine, registry = engine.lifecycle.resources, op = `${request.group}/${request.operation}`; - const bad = (category, progress = 'none') => () => wire.result(false, wire.problem(category, op, '', progress)); - // Unsupported names/revisions leave arguments completely opaque. - if (!available(request.group, request.revision, request.operation)) - return bad('unsupported'); - try { - if (request.group === 'runtime') { - if (request.operation === 'capabilities') { - if (tree.observe(request.arguments).node.kind !== 'leaf') - return bad('invalid-argument'); - return () => wire.result(true, capabilities(wire, backend)); - } - // Classify in a live turn; defer construction so no newly allocated - // handles can be swept between promise resolution and consumption. - try { - decodeAction(tree, request.arguments); - } - catch (error) { - if (error instanceof ProtocolError) - return bad('invalid-argument'); - throw error; - } - return () => wire.result(true, inspect(wire, request.arguments)); - } - if (request.group === 'utf8') { - const readList = (value, limit, bits, scalar) => { - const values = []; - let rest = value; - for (;;) { - const node = tree.observe(rest).node; - if (node.kind === 'leaf') - return values; - // Reject the excess spine cell before observing its head or tail. - if (values.length === limit || node.kind !== 'fork') - throw new OperationError('invalid-argument'); - const number = canonicalNat(tree, node.left, bits); - if (number === undefined || scalar && (number > 0x10ffffn || number >= 0xd800n && number <= 0xdfffn)) - throw new OperationError('invalid-argument'); - values.push(Number(number)); - rest = node.right; - } - }; - if (request.operation === 'encode') { - const scalars = readList(request.arguments, 1024, 21, true); - const bytes = encodeUTF8(String.fromCodePoint(...scalars)); - return () => wire.result(true, wire.list(bytes.map(byte => tree.natural(BigInt(byte))))); - } - const [final, rest] = wire.pair(request.arguments), [bytes, end] = wire.pair(rest); - if (tree.observe(end).node.kind !== 'leaf') - throw new OperationError('invalid-argument'); - const last = canonicalNat(tree, final, 1); - if (last === undefined) - throw new OperationError('invalid-argument'); - const input = readList(bytes, 4096, 8, false); - try { - const decoded = decodeUTF8(input, last === 1n); - return () => wire.result(true, wire.list([tree.text(decoded.text), wire.list(decoded.carry.map(byte => tree.natural(BigInt(byte))))])); - } - catch (error) { - if (!(error instanceof UTF8Error)) - throw error; - return () => wire.result(false, wire.problem('invalid-data', op, error.message)); - } - } - if (request.group === 'standard') { - if (tree.observe(request.arguments).node.kind !== 'leaf') - throw new OperationError('invalid-argument'); - const key = ['input', 'output', 'error'].indexOf(request.operation) + 1; - return () => wire.result(true, wire.marker('io.v2.resource', key)); - } - if (request.group === 'resource') { - const token = wire.token(request.arguments, 'io.v2.resource'); - logical(() => registry.claimClose(scope, token)); - throw new Error('unexpected owned terminal close'); - } - const [handle, argument] = wire.pair(request.arguments), token = wire.token(handle, 'io.v2.resource'); - const read = request.operation === 'read'; - const lease = logical(() => registry.beginLease(scope, token, read ? 'reader' : 'writer', read ? 'read' : 'write')); - try { - const slot = lease.backend; - if (slot.poisoned) - return bad('invalid-state', 'unknown'); - let reply; - if (read) { - const limit = canonicalNat(tree, argument, 16); - if (limit === undefined) - throw new OperationError('invalid-argument'); - if (limit < 1n || limit > 4096n) - return bad('invalid-bound'); - if (slot.eof) - return () => wire.result(true, wire.list([tree.text('eof')])); - reply = await events.read(Number(limit), signal); - if (reply.kind === 'read') { - const received = reply; - slot.eof = received.eof; - return () => wire.result(true, received.eof ? wire.list([tree.text('eof')]) : - tree.fork(tree.text('data'), wire.list(received.bytes.map(byte => tree.natural(BigInt(byte)))))); - } - } - else { - const bytes = []; - let rest = argument; - while (tree.observe(rest).node.kind !== 'leaf') { - if (bytes.length === 4096) - throw new OperationError('invalid-argument'); - const [head, tail] = wire.pair(rest), byte = canonicalNat(tree, head, 8); - if (byte === undefined) - throw new OperationError('invalid-argument'); - bytes.push(Number(byte)); - rest = tail; - } - if (!bytes.length) - return bad('invalid-bound'); - if (bytes.length > outputBudget) - return bad('invalid-bound'); - outputBudget -= bytes.length; - reply = await events.write(slot.name === 'error' ? 'error' : 'output', bytes, signal); - if (reply.kind === 'write') { - const count = reply.count; - return () => wire.result(true, tree.natural(BigInt(count))); - } - } - if (!read) - slot.poisoned = true; - return bad(reply.kind === 'cancelled' ? 'cancelled' : 'io-error', 'unknown'); - } - finally { - registry.endLease(lease.token); - } - } - catch (error) { - if (!(error instanceof OperationError)) - throw error; - return bad(error.message); - } - } - }; -} -export class Session { - tree; - run; - sweep; - options; - events; - core; - busy = false; - finished = false; - constructor(tree, action, run, sweep, options = {}) { - this.tree = tree; - this.run = run; - this.sweep = sweep; - this.options = options; - if (options.steps !== undefined && (!Number.isInteger(options.steps) || options.steps < 1 || options.steps > 128)) - throw new Error('invalid session step bound'); - this.events = new Events(run); - this.core = new Core(tree, BigInt(run), action, ['input', 'output', 'error'].map(name => ({ name, poisoned: false, eof: false })), provider(this.events, options.backend ?? 'browser')); - sweep(this.core.roots()); - } - async turn(command) { - if (this.busy) - throw new Error('session turn already active'); - if (this.finished || command.run !== this.run) - throw new Error('stale session run'); - if (!['poll', 'stop', 'reply'].includes(command.kind)) - throw new Error('invalid session command'); - this.busy = true; - try { - const accepted = command.kind === 'reply' ? this.events.reply(command.reply) : null; - if (command.kind === 'stop') - this.core.stop(this.tree.leaf); - // Never await a terminal operation. Bounded progress batches leave the - // transport available for multiple replies, polls and structured stop. - let progress = false; - for (let turns = 0; turns < (this.options.steps ?? 128) && !this.core.result; turns++) { - progress = this.core.advance(); - this.sweep(this.core.roots()); - await Promise.resolve(); - if (!progress) - break; - } - const outcome = this.core.result; - if (outcome) - this.finished = true; - return { run: this.run, accepted, waiting: !progress && this.events.counts.pending > 0 && this.core.work.counts.pending === this.events.counts.pending && this.core.work.counts.replies === 0, done: !!outcome, events: this.events.take(), - outcome: outcome ? { kind: outcome.kind, value: outcome.kind === 'completed' && this.options.summarize !== false ? canonicalNat(this.tree, outcome.value, 64)?.toString() ?? null : null, - cleanup: outcome.cleanup?.items.length ?? 0, fault: this.core.hasFault } : null, - counts: { tasks: this.core.lifecycle.tasks.counts, resources: this.core.lifecycle.resources.counts, - work: this.core.work.counts, events: this.events.counts } }; - } - finally { - this.busy = false; - } - } -} diff --git a/ext/demo-site/dist/io-v2-terminal-view.js b/ext/demo-site/dist/io-v2-terminal-view.js @@ -1,125 +0,0 @@ -import { Terminal } from './io-v2-terminal.js'; -// One identity-bound UI per run. Callbacks enqueue cloneable replies/stop requests; -// they must not synchronously re-enter this widget or throw. -export function terminalView(parent, run, reply, stop) { - const terminal = new Terminal(run), doc = parent.ownerDocument; - const root = doc.createElement('section'), status = doc.createElement('p'); - const outputs = ['output', 'error'].map(name => { - const section = doc.createElement('section'), title = doc.createElement('h4'); - title.textContent = name === 'output' ? 'Standard output' : 'Standard error'; - const text = doc.createElement('pre'), details = doc.createElement('details'), summary = doc.createElement('summary'), hex = doc.createElement('pre'); - summary.textContent = 'Exact bytes (hexadecimal)'; - details.append(summary, hex); - section.append(title, text, details); - root.append(section); - text.dataset.stream = name; - hex.dataset.hex = name; - return { text, hex }; - }); - let form = doc.createElement('form'), clearInput = () => { }, waitingId; - const stopping = doc.createElement('button'); - stopping.textContent = 'Stop (cancel and wait for cleanup)'; - stopping.type = 'button'; - root.append(form, stopping, status); - parent.append(root); - let stopped = false, finished = false; - const onStop = () => { if (stopped || finished) - return; stopped = true; stopping.disabled = true; status.textContent = 'Cancelling; waiting for cleanup…'; stop(); }; - stopping.addEventListener('click', onStop); - function inputView() { - const wait = terminal.waiting; - if (wait?.id === waitingId) { - form.hidden = !wait; - return; - } - clearInput(); - const fresh = doc.createElement('form'); - form.replaceWith(fresh); - form = fresh; - form.hidden = !wait; - waitingId = wait?.id; - if (!wait) - return; - const label = doc.createElement('label'), input = doc.createElement('input'), send = doc.createElement('button'), eof = doc.createElement('button'); - label.textContent = 'One line, at most 256 Unicode scalars (Send appends LF)'; - input.maxLength = 512; - label.append(input); - send.textContent = 'Send'; - send.type = 'submit'; - eof.textContent = 'End input (EOF)'; - eof.type = 'button'; - form.append(label, send, eof); - function submit(end) { - const result = end ? terminal.endInput(wait.run, wait.id) : terminal.line(wait.run, wait.id, input.value); - if (!result) { - status.textContent = 'Enter at most 256 Unicode scalars without CR or LF.'; - return; - } - render(); - reply(result); - } - const onSubmit = (event) => { event.preventDefault(); submit(false); }, onEOF = () => submit(true); - form.addEventListener('submit', onSubmit); - eof.addEventListener('click', onEOF); - const owned = form; - clearInput = () => { owned.removeEventListener('submit', onSubmit); eof.removeEventListener('click', onEOF); input.value = ''; }; - } - function render() { - const snapshot = terminal.output.snapshot(); - [snapshot.output, snapshot.error].forEach((view, index) => { - const target = outputs[index]; - target.text.replaceChildren(); - target.hex.textContent = view.hex; - for (const token of view.tokens) { - if (token.kind === 'text') - target.text.append(doc.createTextNode(token.text)); - else { - const span = doc.createElement('mark'); - span.dataset.byteReason = token.reason; - span.title = `${token.reason} UTF-8 bytes at stream offset ${token.offset}; see exact hex view`; - span.textContent = token.bytes.map(b => `\\x${b.toString(16).padStart(2, '0')}`).join(''); - target.text.append(span); - } - } - }); - inputView(); - if (!stopped) - status.textContent = terminal.waiting ? 'Waiting for a line or explicit EOF.' : 'Running…'; - } - render(); - return { - root, - handle(event) { - if (finished) - throw new Error('terminal view retired'); - const result = terminal.handle(event); - // Physically hide/clear an abandoned input BEFORE acknowledging cancel. - render(); - if (result) - reply(result); - }, - finish() { - terminal.finish(); - finished = true; - render(); - clearInput(); - stopping.removeEventListener('click', onStop); - form.remove(); - stopping.disabled = true; - status.textContent = 'Settled.'; - }, - // Fatal runtime loss/navigation is NOT a physical cleanup acknowledgement. - // Retire DOM callbacks without fabricating a settled interpreter outcome. - abandon() { - if (finished) - return; - finished = true; - clearInput(); - form.remove(); - stopping.removeEventListener('click', onStop); - stopping.disabled = true; - status.textContent = 'Runtime lost; cleanup was not acknowledged.'; - }, - snapshot: () => terminal.output.snapshot() - }; -} diff --git a/ext/demo-site/dist/io-v2-terminal.js b/ext/demo-site/dist/io-v2-terminal.js @@ -1,77 +0,0 @@ -import { OutputBuffer } from './io-v2-output.js'; -export class Terminal { - run; - output = new OutputBuffer(); - nextAfter = 0; - reader; - buffered = []; - eof = false; - ended = false; - constructor(run) { - this.run = run; - if (!/^[1-9][0-9]*$/.test(run) || run.length > 20 || BigInt(run) > 0xffffffffffffffffn) - throw new Error('invalid terminal run'); - } - get waiting() { - return this.reader ? { run: this.run, id: this.reader.id } : undefined; - } - read() { - const reader = this.reader; - if (!reader || !this.buffered.length && !this.eof) - return; - const bytes = this.buffered.splice(0, reader.limit); - this.reader = undefined; - return { run: this.run, id: reader.id, kind: 'read', bytes, eof: bytes.length === 0 }; - } - handle(event) { - if (this.ended || event.run !== this.run || !Number.isSafeInteger(event.id) || event.id < 1) - throw new Error('invalid terminal event'); - if (event.kind === 'cancel') { - if (event.id > this.nextAfter || !['read', 'write'].includes(event.operation)) - throw new Error('invalid terminal cancellation'); - // Completed synchronous writes/reads already have a normal acknowledgement - // queued. A racing abort does not erase it or manufacture a second reply. - if (this.reader?.id !== event.id) - return; - if (event.operation !== 'read') - throw new Error('invalid terminal cancellation'); - this.reader = undefined; - return { run: this.run, id: event.id, kind: 'cancelled' }; - } - if (event.id <= this.nextAfter) - throw new Error('stale terminal event'); - if (event.kind === 'read') { - if (this.reader || !Number.isInteger(event.limit) || event.limit < 1 || event.limit > 4096) - throw new Error('invalid terminal read'); - this.reader = { ...event }; - } - else if (event.kind === 'write') - this.output.append(event.stream, event.bytes); - else - throw new Error('invalid terminal operation'); - this.nextAfter = event.id; - return event.kind === 'read' ? this.read() : { run: this.run, id: event.id, kind: 'write', count: event.bytes.length }; - } - // Explicit one-line UI: no CR/LF in the submitted text, append ONE LF; an - // empty line is data, never EOF. Scalars, not UTF-16 code units, are bounded. - line(run, id, value) { - if (run !== this.run || this.reader?.id !== id || typeof value !== 'string' || value.length > 512 || - /[\r\n\uD800-\uDFFF]/u.test(value) || Array.from(value).length > 256) - return; - this.buffered = [...new TextEncoder().encode(value), 10]; - return this.read(); - } - endInput(run, id) { - if (run !== this.run || this.reader?.id !== id) - return; - this.eof = true; - return this.read(); - } - finish() { - if (this.reader) - throw new Error('terminal input still pending'); - this.ended = true; - this.buffered = []; - this.output.finish(); - } -} diff --git a/ext/demo-site/dist/io.js b/ext/demo-site/dist/io.js @@ -1,3 +1,3 @@ // A scheduler-step acknowledgement carries no terminal text. Malformed replies -// leave the waiting token live; terminal input uses run-bound v2 byte replies. +// leave the waiting token live; terminal input uses run-bound byte replies. export function validStepResume(value) { return value === undefined; } diff --git a/ext/demo-site/dist/wasm-worker.js b/ext/demo-site/dist/wasm-worker.js @@ -23,19 +23,19 @@ function requestStep() { const id = ++requestId; return new Promise((resolve, reject) => { pending = { id, resolve, reject }; send({ type: 'state', state: 'step', id }); }); } -let v2Command, v2Run, stopRequested = false; +let ioCommand, ioRun, stopRequested = false; function host(stepping) { stopRequested = false; return { selected(version) { send({ type: 'io-version', version }); }, async before(event) { send({ type: 'trace', phase: 'before', event }); if (stepping) await requestStep(); running(); }, - v2: { + structured: { stepping, - open(run, command) { v2Run = run; v2Command = command; send({ type: 'v2-open', run }); if (stopRequested) + open(run, command) { ioRun = run; ioCommand = command; send({ type: 'io-open', run }); if (stopRequested) command({ run, kind: 'stop' }); }, - event(event) { send({ type: 'v2-event', event }); }, - close(run, settled) { v2Command = undefined; v2Run = undefined; pending = undefined; send({ type: 'v2-close', run, settled }); }, + event(event) { send({ type: 'io-event', event }); }, + close(run, settled) { ioCommand = undefined; ioRun = undefined; pending = undefined; send({ type: 'io-close', run, settled }); }, }, }; } @@ -47,8 +47,8 @@ scope.onmessage = ({ data }) => { if (!busy) return; stopRequested = true; - if (v2Run) - v2Command?.({ run: v2Run, kind: 'stop' }); + if (ioRun) + ioCommand?.({ run: ioRun, kind: 'stop' }); if (pending) { const waiter = pending; pending = undefined; @@ -56,10 +56,10 @@ scope.onmessage = ({ data }) => { } return; } - if (m['type'] === 'v2-command') { + if (m['type'] === 'io-command') { const command = m['command']; if (command && typeof command === 'object') - v2Command?.(command); + ioCommand?.(command); return; } if (m['type'] === 'resume') { diff --git a/ext/demo-site/src/app.ts b/ext/demo-site/src/app.ts @@ -1,4 +1,4 @@ -import { terminalView } from './io-v2-terminal-view.js'; +import { terminalView } from './io-terminal-view.js'; import { SourceEditor } from './source-editor.js'; import { definitionInspector } from './definition-inspector.js'; import { exampleGroups, exampleGroup, libraryLabel } from './program-catalogue.js'; @@ -33,7 +33,7 @@ let entries: CatalogueEntry[] = [], releases: Release[] = [], selected: Catalogu let selection: { hash: BundleHash; localId?: string } | undefined, versionPackage: string | undefined; let preferredExport: string | undefined, currentName = '', publishedNames: string[] = []; let worker: Worker | undefined, pending: { id: RequestId } | undefined; -let ioVersion: 0 | 2 | undefined, v2Run: string | undefined, v2Widget: ReturnType<typeof terminalView> | undefined, stoppingV2 = false; +let ioVersion: 0 | 2 | undefined, ioRun: string | undefined, ioWidget: ReturnType<typeof terminalView> | undefined, stoppingIO = false; let operation: Operation | undefined, idleTimer: ReturnType<typeof setTimeout> | undefined; let compilerTimer: ReturnType<typeof setInterval> | undefined; const deadline = new ActiveClock(() => finish(operationTimeout(operation ?? 'loading'), true)); @@ -60,7 +60,7 @@ const definitions = definitionInspector(sourceEditor, (id, name, output) => { run(name, output, step) { if (busy || !worker || draft && (!compiled || sourceEditor.getSource() !== compiledSource || editRevision !== compiledRevision)) return; currentName = name; operation = 'running'; controls(true); deadline.reset(Infinity); deadline.start(); diagnostic(); - ioVersion = 0; stoppingV2 = false; + ioVersion = 0; stoppingIO = false; el('status').textContent = `Running IO · ${name}`; worker.postMessage({ type: 'run-io', name, output, step }); }, @@ -120,11 +120,11 @@ el('stored-download').onclick = () => { worker.postMessage({type:'export',export:el<HTMLSelectElement>('stored-definition').value,format:el<HTMLSelectElement>('stored-format').value}); }; function dispose(): void { - v2Widget?.abandon(); v2Widget = undefined; v2Run = undefined; ioVersion = undefined; stoppingV2 = false; + ioWidget?.abandon(); ioWidget = undefined; ioRun = undefined; ioVersion = undefined; stoppingIO = false; cancelIdleCompile(); stopCompilerActivity(); deadline.pause(); operation = undefined; worker?.terminate(); worker = undefined; compiled = false; compiledSource = undefined; compiledRevision = undefined; pending = undefined; controls(false); } -function idle(): void { ioVersion = undefined; stoppingV2 = false; stopCompilerActivity(); deadline.pause(); operation = undefined; pending = undefined; controls(false); } +function idle(): void { ioVersion = undefined; stoppingIO = false; stopCompilerActivity(); deadline.pause(); operation = undefined; pending = undefined; controls(false); } function finish(message: string, error = false, cancelled = false): void { const previous = operation; if (error) message = operationFailure(operation, message); @@ -137,7 +137,7 @@ function finish(message: string, error = false, cancelled = false): void { } stopButton.onclick = () => { if (operation === 'running' && ioVersion !== undefined) { - if (!stoppingV2) { stoppingV2 = true; worker?.postMessage({ type: 'io-stop' }); } + if (!stoppingIO) { stoppingIO = true; worker?.postMessage({ type: 'io-stop' }); } stopButton.disabled = true; el('status').textContent = 'Cancelling; waiting for cleanup…'; return; } imports.cancel(); @@ -167,20 +167,20 @@ function receive(data: CompilerMessage): void { definitions.event(data); switch (data.type) { case 'io-version': ioVersion = data.version; break; - case 'v2-open': { - if (v2Widget) throw new Error('Overlapping terminal runs'); - const current = worker; v2Run = data.run; - v2Widget = terminalView(el('terminal-v2'), data.run, - reply => { if (worker === current && v2Run === reply.run) current?.postMessage({ type: 'v2-command', command: { run: reply.run, kind: 'reply', reply } }); }, + case 'io-open': { + if (ioWidget) throw new Error('Overlapping terminal runs'); + const current = worker; ioRun = data.run; + ioWidget = terminalView(el('terminal-io'), data.run, + reply => { if (worker === current && ioRun === reply.run) current?.postMessage({ type: 'io-command', command: { run: reply.run, kind: 'reply', reply } }); }, () => stopButton.click()); break; } - case 'v2-event': if (data.event.run === v2Run) v2Widget?.handle(data.event); break; - case 'v2-close': - if (data.run === v2Run) { ioVersion = undefined; if (data.settled) v2Widget?.finish(); else v2Widget?.abandon(); v2Widget = undefined; v2Run = undefined; el('status').textContent = data.settled ? 'IO settled; interpreting returned value' : 'Runtime lost'; } + case 'io-event': if (data.event.run === ioRun) ioWidget?.handle(data.event); break; + case 'io-close': + if (data.run === ioRun) { ioVersion = undefined; if (data.settled) ioWidget?.finish(); else ioWidget?.abandon(); ioWidget = undefined; ioRun = undefined; el('status').textContent = data.settled ? 'IO settled; interpreting returned value' : 'Runtime lost'; } break; case 'state': - if (data.state === 'running') { pending = undefined; deadline.start(); if (operation === 'running' && !stoppingV2) el('status').textContent = `Running IO · ${currentName}`; } + if (data.state === 'running') { pending = undefined; deadline.start(); if (operation === 'running' && !stoppingIO) el('status').textContent = `Running IO · ${currentName}`; } else { deadline.pause(); pending = {id:data.id}; el('status').textContent = 'Paused before action'; } break; case 'done': idle(); el('status').textContent = 'Finished'; el('compiler-stats').textContent = `${compilationStats} · IO · ${runtimeStats(data.stats)}`; break; @@ -409,7 +409,7 @@ const examples={ for(const button of document.querySelectorAll<HTMLButtonElement>('[data-compiler-example]'))button.onclick=()=>{ const math=packageReleases(releases,'tricu-lib-native-math')[0]; const choice=button.dataset['compilerExample']; - const io=packageReleases(releases,'tricu-lib-io')[0],text=packageReleases(releases,'tricu-lib-io-v2-text')[0]; + const io=packageReleases(releases,'tricu-lib-io')[0],text=packageReleases(releases,'tricu-lib-io-text')[0]; const source=choice==='input'&&io&&text?`!import ${io.manifest} as IO !import ${text.manifest} as Text !export io diff --git a/ext/demo-site/src/definition-inspector.ts b/ext/demo-site/src/definition-inspector.ts @@ -30,8 +30,8 @@ export function definitionInspector(input: SourceEditor, request: (id: number, n actions.append(copy, download); card.append(actions, edit); edit.className = 'edit-definition-name'; edit.hidden = true; const ioPanel = document.createElement('section'); ioPanel.className = 'inline-io'; ioPanel.hidden = true; ioPanel.innerHTML = `<div class="inline-io-actions"><button type="button" id="run-io">Run IO</button><button type="button" id="stop-io" disabled>Stop</button></div> - <p class="hint">Recognized tricuIO v2 envelope. Only Run IO executes actions.</p> - <div id="terminal-v2"></div> + <p class="hint">Recognized tricuIO envelope. Only Run IO executes actions.</p> + <div id="terminal-io"></div> <details><summary>IO details</summary><label><input id="step-mode" type="checkbox"> Pause before each scheduler turn</label><button id="next" type="button" disabled>Next scheduler turn</button> <label for="io-return-format">Returned value (next run)</label><select id="io-return-format"><option value="tricu">tricu source</option><option value="string">Text</option><option value="number">Natural number</option><option value="values">List · text / numbers</option><option value="bool">Boolean</option><option value="bytes">Bytes</option><option value="triangle">△ source</option><option value="ternary">Ternary</option><option value="dag">DAG</option></select><ol id="trace"></ol></details>`; card.append(ioPanel); @@ -62,7 +62,7 @@ export function definitionInspector(input: SourceEditor, request: (id: number, n }; run.onclick = () => { if (!selected || !ready || busy || !ioDetected) return; - ioRunning = true; clear(); get('terminal-v2').replaceChildren(); trace.replaceChildren(); + ioRunning = true; clear(); get('terminal-io').replaceChildren(); trace.replaceChildren(); status.textContent = 'Running IO…'; io.run(selected, outputType(returnFormat.value), step.checked); }; stop.onclick = () => io.stop(); next.onclick = () => io.resume(); @@ -136,7 +136,7 @@ export function definitionInspector(input: SourceEditor, request: (id: number, n } function choose(name: string): void { if (selected === name && !card.hidden && !suspended) { hide(); return; } - selected = name; suspended = false; ioDetected = false; next.disabled = true; ioPanel.hidden = true; get('terminal-v2').replaceChildren(); trace.replaceChildren(); clear(); format.value = 'auto'; title.textContent = name; card.hidden = false; last = ''; result.textContent = ''; + selected = name; suspended = false; ioDetected = false; next.disabled = true; ioPanel.hidden = true; get('terminal-io').replaceChildren(); trace.replaceChildren(); clear(); format.value = 'auto'; title.textContent = name; card.hidden = false; last = ''; result.textContent = ''; position(); schedule(); } function hide(): void { diff --git a/ext/demo-site/src/execution.ts b/ext/demo-site/src/execution.ts @@ -1,6 +1,6 @@ import type { Budget, CollectionPolicy } from './graph.js'; // Execution has no application node/work/frame ceilings. Browser memory remains -// finite. Pure/v1 Stop terminates the Worker; v2 Stop drains its structured +// finite. Pure evaluation stops by terminating the Worker; IO Stop drains its structured // session at scheduler boundaries. GC thresholds are not budgets. export const BROWSER_BUDGET: Readonly<Budget> = Object.freeze({ nodes: Infinity, fuel: Infinity, stack: Infinity }); export const PACKAGE_LOAD_MS = 60_000; diff --git a/ext/demo-site/src/haskell-session.ts b/ext/demo-site/src/haskell-session.ts @@ -1,4 +1,4 @@ -import { drive, type Report } from './io-v2-driver.js'; +import { drive, type Report } from './io-driver.js'; import type { ModuleStage } from './module-stage.js'; import { moduleSnapshot } from './module-bundle.js'; import type { LocalModuleInput } from './local-modules.js'; @@ -21,7 +21,7 @@ interface Client { } export class HaskellSession { private linearMemoryBytes = 0; - private v2Active = false; + private structuredActive = false; private constructor(private readonly client: Client) {} static async create(compiler: boolean): Promise<HaskellSession> { const module = await import(new URL('../runtime/haskell/client.mjs', import.meta.url).href) as { CompilerWorker: new () => Client }; @@ -41,7 +41,7 @@ export class HaskellSession { private names: string[] = []; get disposed(): boolean { return this.client.disposed; } private async request(message: object): Promise<Reply> { - if (this.v2Active && !['io-v2-session-turn', 'io-v2-public-view', 'io-v2-public-clear'].includes((message as { type: string }).type)) throw new Error('IO v2 session is pending'); + if (this.structuredActive && !['io-session-turn', 'io-public-view', 'io-public-clear'].includes((message as { type: string }).type)) throw new Error('IO session is pending'); const reply = await this.client.request(message, null); this.linearMemoryBytes = reply.linearBytes; return reply; @@ -49,7 +49,7 @@ export class HaskellSession { private async json<T>(message: object): Promise<T> { return JSON.parse((await this.request(message)).text) as T; } async load(bytes: Uint8Array, hash: BundleHash): Promise<void> { this.names = await this.json<string[]>({ type: 'load', bytes: new Uint8Array(bytes).buffer, hash }); } async compile(source: string, profileName?: string, localModules: LocalModuleInput[] = []): Promise<string[]> { - if (this.v2Active) throw new Error('IO v2 session is pending'); + if (this.structuredActive) throw new Error('IO session is pending'); this.names = []; const reply = await this.request({ type: 'compile', source, localModules, base: new URL('../', import.meta.url).href, ...(profileName === undefined ? {} : { profileName }) }); @@ -105,20 +105,20 @@ export class HaskellSession { if (mode === 'pure') return displayed(request); let run: string | undefined; try { - const event = await this.json<{ kind: string; run: string }>({ ...request, selectIO: true, v2Step: host.v2?.stepping === true }); + const event = await this.json<{ kind: string; run: string }>({ ...request, selectIO: true, ioStep: host.structured?.stepping === true }); if (event.kind !== 'v2') throw new Error('Unsupported tricuIO version'); - this.v2Active = true; run = event.run; - if (!host.v2) throw new Error('IO version 2 requires a structured terminal host'); + this.structuredActive = true; run = event.run; + if (!host.structured) throw new Error('IO version 2 requires a structured terminal host'); host.selected?.(2); return await drive({ run, signal: this.client.signal, discard: () => this.dispose(), - turn: command => this.json<Report>({ type: 'io-v2-session-turn', ...command }), - view: () => displayed({ type: 'io-v2-public-view', run, output }), + turn: command => this.json<Report>({ type: 'io-session-turn', ...command }), + view: () => displayed({ type: 'io-public-view', run, output }), }, host); } finally { // Release result/continuations even when decoding or a host hook fails. // A poisoned instance cannot be recovered by cancelling the interaction. - if (run !== undefined && !this.disposed) await this.request({ type: 'io-v2-public-clear', run }); - this.v2Active = false; + if (run !== undefined && !this.disposed) await this.request({ type: 'io-public-clear', run }); + this.structuredActive = false; } } stats(): RuntimeStats { return { backend: 'haskell-wasm', linearMemoryBytes: this.linearMemoryBytes }; } diff --git a/ext/demo-site/src/io-core.ts b/ext/demo-site/src/io-core.ts @@ -0,0 +1,233 @@ +// Private resumable tree interpreter. No public session dispatch or capabilities. +// Adapters must retain roots() between turns and keep temporary evaluator handles +// alive for a whole turn. Provider promises acknowledge physical completion; +// providers return deferred Result encoders, never task outcomes. Encoding runs +// in an interpreter turn, not in a promise/GC gap; cancelled encoders never run. +import { canonicalNat, decodeAction, ProtocolError, type Reader, type Action } from './io-protocol.js'; +import { lifecycle, type Resource } from './io-resources.js'; +import { HostWork } from './io-host-work.js'; +import type { Token, Outcome, Diagnostics } from './io-tasks.js'; +export interface Trees<H> extends Reader<H> { + readonly leaf: H; fork(a: H, b: H): H; stem(a: H): H; app(f: H, x: H): H; + natural(value: bigint): H; text(value: string): H; +} +type Request<H> = Extract<Action<H>, { kind: 'request' }>; +type Completion<H> = { kind: 'body'; encode: () => H } | { kind: 'release'; errors: Diagnostics<H> }; +type Body<H> = { action: H; frames: H[]; mode: 'runnable' | 'lexical' | 'join' | 'host' | 'done' }; +const operationErrors = new Set(['stale-task', 'join-cycle', 'out-of-scope', 'stale-handle', 'not-owner', + 'borrowed-resource', 'internal-resource', 'closed-handle', 'busy', 'duplicate-handle', 'closed-scope', 'invalid-argument']); +export class OperationError extends Error {} +function logical<T>(operation: () => T): T { + try { return operation(); } + catch (error) { + if (error instanceof Error && operationErrors.has(error.message)) throw new OperationError(error.message); + throw error; + } +} +export class Wire<H> { + constructor(readonly tree: Trees<H>, readonly identity: bigint) {} + pair(value: H): [H, H] { + const node = this.tree.observe(value).node; + if (node.kind !== 'fork') throw new OperationError('invalid-argument'); + return [node.left, node.right]; + } + list(values: readonly H[]): H { let out = this.tree.leaf; for (let i = values.length - 1; i >= 0; i--) out = this.tree.fork(values[i]!, out); return out; } + result(ok: boolean, value: H): H { return this.tree.fork(ok ? this.tree.stem(this.tree.leaf) : this.tree.leaf, this.tree.fork(value, this.tree.leaf)); } + problem(category: string, operation: string, detail = '', progress: 'none' | 'unknown' = 'none'): H { + return this.list([category, operation, detail, progress].map(s => this.tree.text(s))); + } + marker(name: string, key: number): H { return this.list([this.tree.text(name), this.tree.natural(this.identity), this.tree.natural(BigInt(key))]); } + token(value: H, name: string): Token { + const [tag, rest] = this.pair(value), [run, tail] = this.pair(rest), [key, end] = this.pair(tail); + if (this.tree.observe(end).node.kind !== 'leaf') throw new OperationError('invalid-argument'); + let cursor = tag; + for (const char of name) { + const [head, next] = this.pair(cursor); + if (canonicalNat(this.tree, head, 7) !== BigInt(char.charCodeAt(0))) throw new OperationError('invalid-argument'); + cursor = next; + } + if (this.tree.observe(cursor).node.kind !== 'leaf') throw new OperationError('invalid-argument'); + const r = canonicalNat(this.tree, run, 64), k = canonicalNat(this.tree, key, 64); + if (r === undefined || k === undefined) throw new OperationError('invalid-argument'); + // Unrepresentable keys cannot alias a live JS identity. Keep the ordinary + // stale-handle/task path rather than rounding into a different live handle. + return { run: r, key: k > BigInt(Number.MAX_SAFE_INTEGER) ? -1 : Number(k) }; + } + outcome(value: Outcome<H>): H { + const name = this.tree.text(value.kind); + if (value.kind === 'completed') return this.list([name, value.value]); + const errors = [...value.cleanup?.items ?? []], omitted = value.cleanup?.omitted ?? 0; + if (omitted) errors.push(this.problem('cleanup-omitted', 'close', String(omitted))); + return this.list([name, value.value, this.list(errors)]); + } +} +export interface Provider<H, B> { + request(engine: Core<H, B>, scope: number, request: Request<H>, signal: AbortSignal): (() => H) | Promise<() => H>; + release(resource: Resource<B>): void | Promise<void>; + roots?(): Iterable<H>; +} +export class Core<H, B> { + readonly wire: Wire<H>; + readonly lifecycle: ReturnType<typeof lifecycle<H, B>>; + readonly work: HostWork<H, Completion<H>>; + private readonly bodies = new Map<number, Body<H>>(); + private readonly constants: { stopped: H; cycle: H; fault: H; cleanup: H; budget: H }; + private readonly pendingArguments = new Map<number, H>(); + private cursor = -1; + private actions = 0; + result: Outcome<H> | null = null; + // Embedding/evaluator failure is retained separately and never stringified or + // mistaken for an ordinary Result. Drain cleanup before rethrowing at boundary. + fault: unknown = undefined; + hasFault = false; + constructor(readonly tree: Trees<H>, identity: bigint, action: H, standard: readonly [B, B, B], + private readonly provider: Provider<H, B>, private readonly actionLimit = 65536) { + if (!Number.isSafeInteger(actionLimit) || actionLimit < 1) throw new Error('invalid action limit'); + this.wire = new Wire(tree, identity); + this.constants = { + stopped: this.wire.problem('cancelled', 'scope', 'owner stopped'), + cycle: this.wire.problem('join-cycle', 'scope'), fault: this.wire.problem('runtime-error', 'core'), + cleanup: this.wire.problem('cleanup-failed', 'resource/release', '', 'unknown'), budget: this.wire.problem('limit', 'core', 'IO action limit exceeded') }; + this.lifecycle = lifecycle(identity, this.constants.stopped, this.constants.cycle, standard); + this.work = new HostWork(this.lifecycle.tasks); + this.bodies.set(0, this.body(action)); + } + private body(action: H): Body<H> { return { action, frames: [], mode: 'runnable' }; } + stop(reason: H): void { if (!this.result) { this.lifecycle.tasks.stop(0, { kind: 'cancelled', value: reason }); this.work.cancelStopped(); } } + private done(scope: number, outcome: Outcome<H>): void { + this.lifecycle.tasks.bodyDone(scope, outcome); + const body = this.bodies.get(scope)!; body.mode = 'done'; body.action = this.tree.leaf; body.frames = []; + } + private value(scope: number, value: H): void { + const body = this.bodies.get(scope)!; + if (body.frames.length) { body.action = this.tree.app(body.frames.pop()!, value); body.mode = 'runnable'; } + else this.done(scope, { kind: 'completed', value }); + } + private operation(scope: number, name: string, action: () => void): void { + try { action(); } + catch (error) { + if (!(error instanceof OperationError)) throw error; + this.value(scope, this.wire.result(false, this.wire.problem(error.message, name))); + } + } + private step(scope: number): boolean { + const { tasks, resources } = this.lifecycle, body = this.bodies.get(scope)!, state = tasks.state(scope); + if (body.mode === 'done' || body.mode === 'lexical') return false; + if (state.reason) { this.done(scope, state.reason); return true; } + if (body.mode === 'host') return false; + if (body.mode === 'join') { + const outcome = tasks.pollJoin(scope); if (!outcome) return false; + this.value(scope, this.wire.result(true, this.wire.outcome(outcome))); return true; + } + if (++this.actions > this.actionLimit) { tasks.stop(0, { kind: 'failed', value: this.constants.budget }); return true; } + let action: Action<H>; + try { action = decodeAction(this.tree, body.action); } + catch (error) { + if (!(error instanceof ProtocolError)) throw error; + this.done(scope, { kind: 'failed', value: this.wire.problem('protocol-error', 'core/decode', error.message) }); return true; + } + if (action.kind === 'request') { + // Keep action/arguments rooted until provider acknowledgement, including + // cancellation. The abandoned continuation is cleared by done(). + body.mode = 'host'; + const request = action; this.pendingArguments.set(scope, request.arguments); + this.work.start(scope, async signal => ({ kind: 'body', encode: await this.provider.request(this, scope, request, signal) })); + return true; + } + const p = action.payload; + switch (action.code) { + case 0: this.value(scope, p); break; + case 1: { + const node = this.tree.observe(p).node; + if (node.kind !== 'fork') this.done(scope, { kind: 'failed', value: this.wire.problem('protocol-error', 'core/decode', 'invalid IO computation') }); + else { body.frames.push(node.right); body.action = node.left; } + break; + } + case 2: this.done(scope, { kind: 'failed', value: p }); break; + case 9: this.done(scope, { kind: 'cancelled', value: p }); break; + case 3: { + const child = tasks.nested(scope); this.bodies.set(child, this.body(this.tree.leaf)); body.mode = 'lexical'; body.action = this.tree.leaf; + this.bodies.get(child)!.action = this.tree.app(p, this.wire.marker('io.v2.scope', child)); break; + } + case 4: this.value(scope, this.wire.marker('io.v2.scope', scope)); break; + case 5: this.operation(scope, 'core/spawn', () => { + const [sup, a] = this.wire.pair(p), [keep, b] = this.wire.pair(a), [handles, callback] = this.wire.pair(b); + const supervised = canonicalNat(this.tree, sup, 1), retained = canonicalNat(this.tree, keep, 1); + if (supervised === undefined || retained === undefined) throw new OperationError('invalid-argument'); + const tokens: Token[] = []; let rest = handles; + while (this.tree.observe(rest).node.kind !== 'leaf') { + if (tokens.length === 256) throw new OperationError('invalid-argument'); + const [head, tail] = this.wire.pair(rest); tokens.push(this.wire.token(head, 'io.v2.resource')); rest = tail; + } + const moved = logical(() => resources.spawn(scope, supervised === 1n, retained === 1n, tokens)), child = moved.task.key; + this.bodies.set(child, this.body(this.tree.leaf)); + this.bodies.get(child)!.action = this.tree.app(callback, this.wire.list(moved.handles.map(t => this.wire.marker('io.v2.resource', t.key)))); + this.value(scope, this.wire.result(true, this.wire.list([this.wire.marker('io.v2.task', child), retained === 1n ? this.wire.marker('io.v2.join', child) : this.tree.leaf]))); + }); break; + case 6: case 7: this.operation(scope, action.code === 6 ? 'core/join' : 'core/discard', () => { + const token = this.wire.token(p, 'io.v2.join'); + if (action.code === 7) { logical(() => tasks.discard(scope, token)); this.value(scope, this.wire.result(true, this.tree.leaf)); } + else { logical(() => tasks.claimJoin(scope, token)); body.mode = 'join'; body.action = this.tree.leaf; } + }); break; + case 8: this.operation(scope, 'core/cancel', () => { + const [handle, reason] = this.wire.pair(p), token = this.wire.token(handle, 'io.v2.task'); logical(() => tasks.cancel(scope, token, reason)); + this.value(scope, this.wire.result(true, this.tree.leaf)); + }); break; + } + return true; + } + private settled(scope: number, errors: Diagnostics<H>): void { + const { tasks } = this.lifecycle, state = tasks.state(scope), outcome = tasks.settle(scope, errors); + this.bodies.delete(scope); + if (scope === 0) this.result = outcome; + else if (state.kind === 'lexical') { + const parent = state.parent!, reason = tasks.state(parent).reason; + this.bodies.get(parent)!.mode = 'runnable'; + if (reason) this.done(parent, reason); + else if (outcome.kind === 'completed') this.value(parent, outcome.value); + else this.done(parent, outcome); + } + } + // One progress turn, never waits for a provider. False means wait for host + // acknowledgement. The embedding must yield to its event loop between batches. + advance(): boolean { + if (this.result) return false; + try { + this.work.cancelStopped(); + for (const scope of this.pendingArguments.keys()) + if (this.lifecycle.tasks.state(scope).hostCalls === 0) this.pendingArguments.delete(scope); + const reply = this.work.take(); + if (reply) { + if (!reply.outcome.ok) throw reply.outcome.error; + const value = reply.outcome.value; + if (value.kind === 'release') this.settled(reply.token.scope, value.errors); + else this.value(reply.token.scope, value.encode()); + return true; + } + const { tasks, resources } = this.lifecycle; + for (const scope of tasks.scopeIds()) if (tasks.state(scope).phase === 'joining' && tasks.beginRelease(scope)) { + this.work.release(scope, async () => ({ kind: 'release', errors: await resources.releaseScope(scope, + resource => this.provider.release(resource), () => this.constants.cleanup, this.constants.cleanup) })); + return true; + } + const ids = tasks.scopeIds(), ordered = [...ids.filter(id => id > this.cursor), ...ids.filter(id => id <= this.cursor)]; + for (const scope of ordered) if (this.step(scope)) { this.cursor = scope; return true; } + return false; + } catch (error) { + if (!this.hasFault) this.fault = error; + this.hasFault = true; + this.lifecycle.tasks.stop(0, { kind: 'failed', value: this.constants.fault }); + this.work.cancelStopped(); return true; + } + } + wait(): Promise<void> { return this.work.wait(); } + *roots(): Iterable<H> { + yield this.tree.leaf; yield* Object.values(this.constants); yield* this.lifecycle.tasks.payloads(); + yield* this.pendingArguments.values(); yield* this.provider.roots?.() ?? []; + for (const body of this.bodies.values()) { yield body.action; yield* body.frames; } + for (const value of this.work.values()) { + if (value.kind === 'release') yield* value.errors.items; + } + if (this.result) { yield this.result.value; yield* this.result.cleanup?.items ?? []; } + } +} diff --git a/ext/demo-site/src/io-driver.ts b/ext/demo-site/src/io-driver.ts @@ -0,0 +1,82 @@ +import type { IOHost } from './io.js'; +import type { Command, Session } from './io-session.js'; +import type { ValueView } from './codecs.js'; +import type { Event } from './io-events.js'; +export interface StructuredHost { + readonly stepping?: boolean; + // These transport callbacks must not throw or synchronously reenter a turn. + open(run: string, command: (command: Command) => void): void; + event(event: Event): void; + close(run: string, settled: boolean): void; +} +export type Report = Awaited<ReturnType<Session<unknown>['turn']>>; +export interface Driver { + run: string; + // Emergency runtime loss, distinct from structured stop/acknowledgement. + signal?: AbortSignal; + turn(command: Command): Promise<Report>; + view(): Promise<ValueView>; + discard?(): void; +} +// Unlike repeated Promise.race against a lifetime-long promise, listeners are +// removed on EVERY turn/step settlement, not retained until runtime disposal. +function interrupt<T>(promise: Promise<T>, signal: AbortSignal | undefined, aborted: () => T): Promise<T> { + if (!signal) return promise; + let onAbort = () => {}; + return new Promise<T>((resolve, reject) => { + onAbort = () => { try { resolve(aborted()); } catch (error) { reject(error); } }; + if (signal.aborted) onAbort(); else signal.addEventListener('abort', onAbort, { once: true }); + void promise.then(resolve, reject); + }).finally(() => signal.removeEventListener('abort', onAbort)); +} +// The queue stays alive while a read or step prompt is waiting. Stop is sticky, +// independent of the resume queue, and never implemented by worker destruction. +export async function drive(driver: Driver, host: IOHost): Promise<ValueView> { + const transport = host.structured; + if (!transport) throw new Error('IO version 2 requires a structured terminal host'); + let settled = false; + const alive = <T>(promise: Promise<T>): Promise<T> => interrupt(promise, driver.signal, () => { throw driver.signal!.reason; }); + let active = true, stopping = false, stopSent = false, wake = () => {}, step = 0; + const replies: Command[] = []; + let boundaryFault = false, fault: unknown; + const stopped = new AbortController(); + const enqueue = (command: Command) => { + if (!active || !command || command.run !== driver.run) return; + if (command.kind === 'stop') { stopping = true; stopped.abort(); } + else if (command.kind === 'reply') replies.push(command); + wake(); + }; + const close = () => { + if (!active) return; + active = false; stopped.abort(); wake(); wake = () => {}; replies.length = 0; + // A failed transport/runtime cannot be reused with admitted work abandoned. + if (!settled) driver.discard?.(); + transport.close(driver.run, settled); + }; + try { + transport.open(driver.run, enqueue); + for (;;) { + if (!stopping && transport.stepping === true) { + try { await alive(interrupt(host.before({ step: ++step, name: 'IO scheduler turn', detail: 'one scheduler advance; all tasks paused', depth: 0 }), stopped.signal, () => {})); } + catch (error) { if (driver.signal?.aborted) throw error; boundaryFault = true; fault = error; enqueue({ run: driver.run, kind: 'stop' }); } + } + const command: Command = stopping && !stopSent ? (stopSent = true, { run: driver.run, kind: 'stop' }) : + replies.shift() ?? { run: driver.run, kind: 'poll' }; + const report = await alive(driver.turn(command)); + for (const event of report.events) transport.event(event); + if (report.done) { + if (!Object.values(report.counts).every(group => Object.values(group).every(value => value === 0))) throw new Error('IO settlement retains work'); + settled = true; close(); + if (boundaryFault) throw fault; + if (report.outcome?.fault) throw new Error('IO interpreter fault'); + if (report.outcome?.kind !== 'completed') throw new Error(`IO ${report.outcome?.kind ?? 'failed'}`); + return await alive(driver.view()); // Full codec, only after physical settlement. + } + // Yield even during pure scheduler progress so the outer worker can receive + // stop/replies. This is not a hard deadline for native tree reduction. + if (report.waiting && !replies.length && (!stopping || stopSent)) await alive(new Promise<void>(resolve => { wake = resolve; })); + else await alive(new Promise<void>(resolve => { const timer = setTimeout(resolve, 0); wake = () => { clearTimeout(timer); resolve(); }; })); + wake = () => {}; + } + } finally { close(); } +} diff --git a/ext/demo-site/src/io-v2-events.ts b/ext/demo-site/src/io-events.ts diff --git a/ext/demo-site/src/io-host-work.ts b/ext/demo-site/src/io-host-work.ts @@ -0,0 +1,80 @@ +import { Tasks, hostKey, type HostToken } from './io-tasks.js'; +export type Reply<T> = { readonly token: HostToken; readonly outcome: + { readonly ok: true; readonly value: T } | { readonly ok: false; readonly error: unknown } }; +// Adapter-side asynchronous work. Starting one callback never waits for it, so +// sibling actions/cancellation can proceed. The provider must settle its promise +// AFTER cancellation cleanup; abort delivery alone is never an acknowledgement. +export class HostWork<P, T> { + private readonly active = new Map<string, { token: HostToken; controller: AbortController }>(); + private readonly replies = new Map<string, Reply<T>>(); + private notification: { promise: Promise<void>; resolve: () => void } | null = null; + constructor(private readonly tasks: Tasks<P>) {} + start(scope: number, operation: (signal: AbortSignal) => T | Promise<T>): HostToken { + return this.register(this.tasks.beginHost(scope), operation); + } + // Cleanup is not cancelled by a new stop request. Its result (including a + // failure) must still reach the driver to preserve primary/secondary outcomes. + release(scope: number, operation: () => T | Promise<T>): HostToken { + return this.register(this.tasks.beginReleaseHost(scope), operation); + } + private register(token: HostToken, operation: (signal: AbortSignal) => T | Promise<T>): HostToken { + const controller = new AbortController(); + this.active.set(hostKey(token), { token, controller }); + const complete = (outcome: Reply<T>['outcome']) => { + // Settle first; a cancelled reply is discarded without observing its value. + const disposition = this.tasks.endHost(token); + this.active.delete(hostKey(token)); + if (disposition === 'reply') this.replies.set(hostKey(token), { token, outcome }); + const notice = this.notification; this.notification = null; notice?.resolve(); + }; + try { Promise.resolve(operation(controller.signal)).then( + value => complete({ ok: true, value }), error => complete({ ok: false, error })); } + catch (error) { complete({ ok: false, error }); } + return token; + } + private usable(token: HostToken): boolean { + if (!this.tasks.within(token.scope, token.scope)) return false; + const state = this.tasks.state(token.scope); + return token.kind === 'release' ? state.phase === 'releasing' : state.phase === 'open' && state.reason === null; + } + // Resource close may interrupt one operation without cancelling its task. + // The resulting provider reply still reaches that body as an ordinary error. + // Never abort a cleanup batch or acknowledge work merely by sending this signal. + cancelBody(token: HostToken): boolean { + const work = this.active.get(hostKey(token)); + if (token.kind !== 'body' || !work || work.token.run !== token.run || + work.token.scope !== token.scope || work.controller.signal.aborted) return false; + work.controller.abort(); return true; + } + cancelStopped(): void { + for (const token of this.tasks.takeCancellations()) { + const work = this.active.get(hostKey(token)); + if (!work || work.token.scope !== token.scope) throw new Error('host work ownership mismatch'); + work.controller.abort(); + } + // Completion may have won the race with cancellation. Drop undelivered data + // too; a queued reply cannot revive a stopped/settled body. + for (const [key, reply] of this.replies) if (!this.usable(reply.token)) this.replies.delete(key); + } + take(): Reply<T> | null { + for (const [key, reply] of this.replies) { + this.replies.delete(key); + if (this.usable(reply.token)) return reply; + } + return null; + } + wait(): Promise<void> { + if (this.replies.size || !this.active.size) return Promise.resolve(); + if (!this.notification) { + let resolve!: () => void; + const promise = new Promise<void>(done => { resolve = done; }); + this.notification = { promise, resolve }; + } + return this.notification.promise; + } + get counts() { return { pending: this.active.size, replies: this.replies.size }; } + // Reply data roots are the embedding's responsibility until taken/discarded. + *values(): Iterable<T> { + for (const reply of this.replies.values()) if (this.usable(reply.token) && reply.outcome.ok) yield reply.outcome.value; + } +} diff --git a/ext/demo-site/src/io-output.ts b/ext/demo-site/src/io-output.ts @@ -0,0 +1,61 @@ +// Presentation only. Byte acceptance and strict source UTF-8 Results are separate. +// No evaluator handles, host callbacks, IO acknowledgements or terminal escapes. +import { decodeUTF8, UTF8Error } from './io-utf8.js'; +export type OutputStream = 'output' | 'error'; +export type OutputToken = { kind: 'text'; offset: number; text: string } | + { kind: 'bytes'; offset: number; bytes: number[]; reason: 'invalid' | 'incomplete' | 'pending' }; +export interface OutputView { bytes: number[]; hex: string; tokens: OutputToken[] } + +function view(bytes: number[], final: boolean): OutputView { + const tokens: OutputToken[] = []; + for (let offset = 0; offset < bytes.length;) { + const lead = bytes[offset]!; + const width = lead <= 0x7f ? 1 : lead >= 0xc2 && lead <= 0xdf ? 2 : + lead >= 0xe0 && lead <= 0xef ? 3 : lead >= 0xf0 && lead <= 0xf4 ? 4 : 1; + const candidate = bytes.slice(offset, offset + width); + try { + const decoded = decodeUTF8(candidate, false); + if (decoded.carry.length) { + tokens.push({ kind: 'bytes', offset, bytes: candidate, reason: final ? 'incomplete' : 'pending' }); + break; + } + const previous = tokens.at(-1); + if (previous?.kind === 'text') previous.text += decoded.text; + else tokens.push({ kind: 'text', offset, text: decoded.text }); + offset += width; + } catch (error) { + if (!(error instanceof UTF8Error)) throw error; + // Escape one offending byte; reconsider following bytes independently. + // Thus invalid prefixes cannot swallow subsequent valid text. + tokens.push({ kind: 'bytes', offset, bytes: [lead], reason: 'invalid' }); + offset++; + } + } + return { bytes, hex: bytes.map(b => b.toString(16).padStart(2, '0')).join(' '), tokens }; +} + +// Owned by one UI run. The session separately enforces attempted-output budgets; +// this buffer bounds retained accepted bytes, without refunds or history. +export class OutputBuffer { + private readonly streams: Record<OutputStream, number[]> = { output: [], error: [] }; + private size = 0; + private ended = false; + constructor(readonly limit = 16384) { + if (!Number.isSafeInteger(limit) || limit < 0 || limit > 16384) throw new Error('invalid output limit'); + } + append(stream: OutputStream, bytes: readonly number[]): void { + if (this.ended) throw new Error('output presentation ended'); + if (stream !== 'output' && stream !== 'error') throw new Error('invalid output stream'); + if (!Array.isArray(bytes) || bytes.length === 0 || bytes.length > 4096) throw new Error('invalid output bytes'); + for (const byte of bytes) if (!Number.isInteger(byte) || byte < 0 || byte > 255) throw new Error('invalid output bytes'); + if (bytes.length > this.limit - this.size) throw new Error('output presentation limit'); + this.streams[stream].push(...bytes); this.size += bytes.length; + } + // End-of-presentation, including after structured cancellation, not closing a + // borrowed stream. Incomplete suffixes become visible bytes, never IO failures. + finish(): void { this.ended = true; } + snapshot(): { done: boolean; size: number; output: OutputView; error: OutputView } { + return { done: this.ended, size: this.size, + output: view([...this.streams.output], this.ended), error: view([...this.streams.error], this.ended) }; + } +} diff --git a/ext/demo-site/src/io-private.ts b/ext/demo-site/src/io-private.ts @@ -0,0 +1,37 @@ +// Private native-adapter validation runner, not RuntimeSession IO dispatch. +// Bare computations only; no facility implementation/capability advertisement. +import { Core, type Trees } from './io-core.js'; +import { canonicalNat, type Reader } from './io-protocol.js'; +export function constructors<H>(base: Reader<H> & Pick<Trees<H>, 'leaf' | 'fork' | 'stem' | 'app'>): Trees<H> { + const natural = (value: bigint): H => { + const bits: H[] = []; + if (value < 0n) throw new Error('negative natural'); + for (; value; value >>= 1n) bits.push(value & 1n ? base.stem(base.leaf) : base.leaf); + let out = base.leaf; for (let i = bits.length - 1; i >= 0; i--) out = base.fork(bits[i]!, out); return out; + }; + return { ...base, natural, text(value: string): H { + let out = base.leaf; const chars = Array.from(value); + for (let i = chars.length - 1; i >= 0; i--) out = base.fork(natural(BigInt(chars[i]!.codePointAt(0)!)), out); + return out; + } }; +} +export async function runPrivate<H>(tree: Trees<H>, action: H, identity: bigint, sweep: (roots: Iterable<H>) => void) { + const engine = new Core(tree, identity, action, [null, null, null], { + request: (core, _scope, request) => () => core.wire.result(false, core.wire.problem('unsupported', `${request.group}/${request.operation}`)), + release: () => { throw new Error('unexpected owned backend in private core probe'); } }); + sweep(engine.roots()); + for (let turns = 0; !engine.result; turns++) { + const progress = engine.advance(); sweep(engine.roots()); + if (!progress) { + if (!engine.work.counts.pending) throw new Error('private interpreter stalled'); + await engine.wait(); + } + if (turns % 32 === 0) await new Promise<void>(resolve => setTimeout(resolve, 0)); + } + if (engine.hasFault) throw engine.fault; + const out = engine.result; + // Terminal diagnostics stay opaque, including when the test requests a number. + const value = out.kind === 'completed' ? canonicalNat(tree, out.value, 64)?.toString() ?? null : null; + return { kind: out.kind, value, cleanup: out.cleanup?.items.length ?? 0, + tasks: engine.lifecycle.tasks.counts, resources: engine.lifecycle.resources.counts, host: engine.work.counts }; +} diff --git a/ext/demo-site/src/io-profile.ts b/ext/demo-site/src/io-profile.ts @@ -0,0 +1,43 @@ +// This table is the implemented browser provider, not the native catalogue. +import { canonicalNat, decodeAction, ProtocolError, type Reader } from './io-protocol.js'; +import type { Wire } from './io-core.js'; +export const coreNames = ['pure', 'bind', 'fail', 'scope', 'current-scope', 'spawn', 'join', 'discard', 'cancel', 'cancelled'] as const; +const groups: Readonly<Record<string, readonly string[]>> = Object.freeze({ + runtime: ['capabilities', 'inspect'], standard: ['input', 'output', 'error'], + stream: ['read', 'write'], resource: ['close'], utf8: ['encode', 'decode'], +}); +export function available(group: string, revision: number, operation: string): boolean { + return revision === 1 && Object.hasOwn(groups, group) && groups[group]!.includes(operation); +} +export function capabilities<H>(wire: Wire<H>, backend: string): H { + const { tree } = wire, text = (s: string) => tree.text(s), number = (n: number) => tree.natural(BigInt(n)); + const strings = (xs: readonly string[]) => wire.list(xs.map(text)), row = (name: string, value: H) => wire.list([text(name), value]); + return wire.list([ + row('schema', number(1)), row('io-version', number(2)), row('backend', text(backend)), row('authority', text('browser-terminal')), + row('core', strings(coreNames)), row('groups', wire.list(Object.entries(groups).map(([name, ops]) => wire.list([text(name), number(1), strings(ops)])))), + row('limits', wire.list(Object.entries({ 'io-actions': 65536, 'chunk-bytes': 4096, 'transfer-handles': 256, 'utf8-encode-scalars': 1024, 'input-line-scalars': 256, 'output-attempted-bytes': 16384 }).map(([key, value]) => row(key, number(value))))), + row('restrictions', strings(['action-boundary-cancellation', 'no-hard-deadlines', 'borrowed-standard-streams', 'standard-stream-leases-per-run', 'utf8-strict-scalars', 'text-line-input', 'byte-preserving-output', 'no-filesystem', 'no-network', 'no-processes'])), + ]); +} +export function inspect<H>(wire: Wire<H>, value: H): H { + const action = decodeAction(wire.tree, value), t = wire.tree; + return action.kind === 'core' ? wire.list([t.text('core'), t.text(coreNames[action.code]!)]) : + wire.list([t.text('facility'), t.text(action.group), t.natural(BigInt(action.revision)), t.text(action.operation), t.text(available(action.group, action.revision, action.operation) ? 'available' : 'unsupported')]); +} +// Authenticate framing before selecting an interpreter. No action is observed. +export function envelope<H>(tree: Reader<H>, value: H): { version: 2; action: H } { + const root = tree.observe(value).node; + if (root.kind !== 'fork') throw new ProtocolError('invalid IO envelope'); + let rest = root.left; + for (const char of 'tricuIO') { + const node = tree.observe(rest).node; + if (node.kind !== 'fork' || canonicalNat(tree, node.left, 7) !== BigInt(char.charCodeAt(0))) throw new ProtocolError('Missing tricuIO sentinel'); + rest = node.right; + } + if (tree.observe(rest).node.kind !== 'leaf') throw new ProtocolError('Missing tricuIO sentinel'); + const body = tree.observe(root.right).node; + if (body.kind !== 'fork') throw new ProtocolError('invalid IO envelope'); + const version = canonicalNat(tree, body.left, 2); + if (version !== 2n) throw new ProtocolError('Unsupported tricuIO version'); + return { version: 2, action: body.right }; +} diff --git a/ext/demo-site/src/io-v2-protocol.ts b/ext/demo-site/src/io-protocol.ts diff --git a/ext/demo-site/src/io-resources.ts b/ext/demo-site/src/io-resources.ts @@ -0,0 +1,169 @@ +import { Tasks, type Token, type Diagnostics } from './io-tasks.js'; +export interface Flags { readonly borrowed?: boolean; readonly internal?: boolean; readonly duplex?: boolean; } +export interface Resource<B> { + readonly owner: number; readonly kind: string; readonly backend: B; readonly flags: Flags; + readonly busy: number; readonly closing: boolean; readonly releasing: boolean; +} +type Entry<B> = { -readonly [K in keyof Resource<B>]: Resource<B>[K] }; +export type Access = 'exclusive' | 'read' | 'write'; +// State only. Construct via lifecycle() to bind task settlement to registry guards. +// Single owning event loop; host effects occur AFTER admission/lease/close claims. +class Resources<P, B> { + private next = 4; + private readonly resources = new Map<number, Entry<B>>(); + private readonly admissions = new Map<number, number>(); + private readonly leases = new Map<number, { scope: number; resource: number; mask: number }>(); + constructor(private readonly tasks: Tasks<P>, standard: readonly [B, B, B], private readonly limit: number) { + if (!Number.isSafeInteger(limit) || limit < 3) throw new Error('invalid resource identity limit'); + standard.forEach((backend, index) => this.resources.set(index + 1, { + owner: 0, kind: index === 0 ? 'reader' : 'writer', backend, flags: Object.freeze({ borrowed: true }), + busy: 0, closing: false, releasing: false })); + } + get counts() { return { resources: this.resources.size, admissions: this.admissions.size, leases: this.leases.size }; } + private token(key: number): Token { return Object.freeze({ run: this.tasks.identity, key }); } + private key(token: Token): number { + if (token.run !== this.tasks.identity) throw new Error('stale-handle'); + return token.key; + } + private room(count: number): void { + if (count > this.limit - this.next + 1) throw new Error('resource-identity-exhausted'); + } + private open(scope: number): void { + const state = this.tasks.state(scope); + if (state.phase !== 'open' || state.reason) throw new Error('closed-scope'); + } + private entry(current: number, token: Token): Entry<B> { + const resource = this.resources.get(this.key(token)); + if (!resource) throw new Error('stale-handle'); + if (!this.tasks.within(current, resource.owner)) throw new Error('out-of-scope'); + return resource; + } + lookup(current: number, token: Token): Resource<B> { return { ...this.entry(current, token) }; } + beginAcquire(current: number, owner = current): Token { + this.tasks.checkpoint(current); this.open(owner); + if (!this.tasks.within(current, owner)) throw new Error('out-of-scope'); + this.room(1); const token = this.token(this.next++); + this.admissions.set(token.key, owner); return token; + } + abortAcquire(token: Token): void { + if (!this.admissions.delete(this.key(token))) throw new Error('invalid-admission'); + } + // An admitted acquisition can finish after owner stop/admission closure. + // Publication failure leaves the admission live: release raw backend, then abort. + publish(admission: Token, kind: string, backend: B, flags: Flags = {}): Token { + const key = this.key(admission), owner = this.admissions.get(key); + if (owner === undefined) throw new Error('invalid-admission'); + if (!kind || Object.values(flags).some(value => typeof value !== 'boolean')) throw new Error('invalid-argument'); + this.room(1); + const savedFlags = Object.freeze({ ...flags }), token = this.token(this.next); + this.resources.set(token.key, { owner, kind, backend, flags: savedFlags, busy: 0, closing: false, releasing: false }); + this.next++; this.admissions.delete(key); return token; + } + // All recoverable validation and preparation precede task admission. No host + // callback/await separates admission from rekeying. JS OOM is fatal, not rollback. + spawn(owner: number, supervised: boolean, keep: boolean, tokens: readonly Token[]) { + this.tasks.checkpoint(owner); this.open(owner); + if (typeof supervised !== 'boolean' || typeof keep !== 'boolean') throw new Error('invalid-argument'); + if (tokens.length > 256) throw new Error('transfer-limit'); + const seen = new Set<number>(); + const entries = tokens.map(token => { + const resource = this.entry(owner, token); + if (seen.has(token.key)) throw new Error('duplicate-handle'); + seen.add(token.key); + if (resource.owner !== owner) throw new Error('not-owner'); + if (resource.flags.borrowed) throw new Error('borrowed-resource'); + if (resource.flags.internal) throw new Error('internal-resource'); + if (resource.closing) throw new Error('closed-handle'); + if (resource.busy) throw new Error('busy'); + return { old: token.key, resource }; + }).sort((a, b) => a.old - b.old); + this.room(tokens.length); + const fresh = new Map(entries.map((entry, i) => [entry.old, this.token(this.next + i)])); + const handles = tokens.map(token => fresh.get(token.key)!); + const task = this.tasks.spawn(owner, supervised, keep); + for (const entry of entries) { + this.resources.delete(entry.old); entry.resource.owner = task.key; + this.resources.set(fresh.get(entry.old)!.key, entry.resource); + } + this.next += tokens.length; + return { task, handles }; + } + beginLease(current: number, token: Token, kind: string, access: Access, internal = false) { + this.tasks.checkpoint(current); + const resource = this.entry(current, token); + if (resource.kind !== kind) throw new Error('wrong-kind'); + if (resource.closing) throw new Error('closed-handle'); + if (resource.flags.internal && !internal) throw new Error('internal-resource'); + if (!['exclusive', 'read', 'write'].includes(access)) throw new Error('invalid-argument'); + const mask = !resource.flags.duplex || access === 'exclusive' ? 3 : access === 'read' ? 1 : 2; + if (resource.busy & mask) throw new Error('busy'); + this.room(1); const lease = this.token(this.next++); + this.leases.set(lease.key, { scope: current, resource: token.key, mask }); resource.busy |= mask; + return { token: lease, backend: resource.backend }; + } + endLease(token: Token): void { + const key = this.key(token), lease = this.leases.get(key); + if (!lease) throw new Error('invalid-lease'); + this.resources.get(lease.resource)!.busy &= ~lease.mask; this.leases.delete(key); + } + claimClose(current: number, token: Token, settling = false): Resource<B> | null { + const resource = this.entry(current, token); + if (settling) { + if (resource.owner !== current || this.tasks.state(current).phase !== 'releasing') throw new Error('invalid-close'); + } else { + this.tasks.checkpoint(current); + if (resource.flags.internal) throw new Error('internal-resource'); + if (resource.flags.borrowed) throw new Error('borrowed-resource'); + } + resource.closing = true; + if (resource.busy || resource.releasing) return null; + resource.releasing = true; return { ...resource }; + } + // Once, including failed release. Never retry a physically ambiguous close. + finishClose(token: Token): void { + const key = this.key(token), resource = this.resources.get(key); + if (!resource) throw new Error('stale-handle'); + if (!resource.releasing) throw new Error('invalid-close'); + this.resources.delete(key); + } + newestOwned(scope: number): Token | null { + let newest = -1; + for (const [key, resource] of this.resources) if (resource.owner === scope && key > newest) newest = key; + return newest < 0 ? null : this.token(newest); + } + readyForRelease(scope: number): boolean { + return ![...this.admissions.values()].includes(scope) && ![...this.leases.values()].some(lease => lease.scope === scope); + } + finishScope(scope: number): void { + if (!this.readyForRelease(scope) || this.newestOwned(scope)) throw new Error('busy'); + } + // Use inside HostWork.release, after Tasks.beginRelease. Each provider is + // attempted once, sequentially in reverse publication order; borrowed backends + // stay open. Diagnostics construction failure must not skip remaining releases. + async releaseScope(scope: number, release: (resource: Resource<B>) => void | Promise<void>, + problem: (error: unknown) => P, fallback: P): Promise<Diagnostics<P>> { + if (this.tasks.state(scope).phase !== 'releasing') throw new Error('invalid-transition'); + const items: P[] = []; let omitted = 0; + for (let token = this.newestOwned(scope); token; token = this.newestOwned(scope)) { + const resource = this.claimClose(scope, token, true); + if (!resource) throw new Error('busy'); + try { if (!resource.flags.borrowed) await release(resource); } + catch (error) { + if (items.length === 16) omitted++; + else { + let value = fallback; try { value = problem(error); } catch { /* retain prebuilt fallback */ } + items.push(value); + } + } finally { this.finishClose(token); } + } + return { items, omitted }; + } +} +export function lifecycle<P, B>(identity: bigint, ownerStopped: P, cycle: P, standard: readonly [B, B, B], + limits: { taskIdentities?: number; resourceIdentities?: number } = {}) { + let resources!: Resources<P, B>; + const tasks = new Tasks(identity, ownerStopped, cycle, limits.taskIdentities ?? Number.MAX_SAFE_INTEGER, { + readyForRelease: scope => resources.readyForRelease(scope), finishScope: scope => resources.finishScope(scope) }); + resources = new Resources(tasks, standard, limits.resourceIdentities ?? Number.MAX_SAFE_INTEGER); + return { tasks, resources }; +} diff --git a/ext/demo-site/src/io-session.ts b/ext/demo-site/src/io-session.ts @@ -0,0 +1,157 @@ +// Cloneable event session shared by private probes and public native adapters. +import { Core, OperationError, type Trees, type Provider } from './io-core.js'; +import { available, capabilities, inspect } from './io-profile.js'; +import { ProtocolError, decodeAction, canonicalNat } from './io-protocol.js'; +import { encodeUTF8, decodeUTF8, UTF8Error } from './io-utf8.js'; +import { Events, type Completion } from './io-events.js'; +interface Slot { name: 'input' | 'output' | 'error'; poisoned: boolean; eof: boolean; } +const faults = new Set(['stale-handle', 'out-of-scope', 'closed-handle', 'wrong-kind', 'internal-resource', 'borrowed-resource', 'busy']); +function logical<T>(f: () => T): T { + try { return f(); } catch (error) { + if (error instanceof Error && faults.has(error.message)) throw new OperationError(error.message); + throw error; + } +} +function provider<H>(events: Events, backend: string): Provider<H, Slot> { + let outputBudget = 16384; // Attempted bytes across both slots; no replay/refund on uncertainty. + return { + release: () => { throw new Error('unexpected owned terminal resource'); }, + async request(engine, scope, request, signal) { + const { tree, wire } = engine, registry = engine.lifecycle.resources, op = `${request.group}/${request.operation}`; + const bad = (category: string, progress: 'none' | 'unknown' = 'none') => () => wire.result(false, wire.problem(category, op, '', progress)); + // Unsupported names/revisions leave arguments completely opaque. + if (!available(request.group, request.revision, request.operation)) return bad('unsupported'); + try { + if (request.group === 'runtime') { + if (request.operation === 'capabilities') { + if (tree.observe(request.arguments).node.kind !== 'leaf') return bad('invalid-argument'); + return () => wire.result(true, capabilities(wire, backend)); + } + // Classify in a live turn; defer construction so no newly allocated + // handles can be swept between promise resolution and consumption. + try { decodeAction(tree, request.arguments); } + catch (error) { if (error instanceof ProtocolError) return bad('invalid-argument'); throw error; } + return () => wire.result(true, inspect(wire, request.arguments)); + } + if (request.group === 'utf8') { + const readList = (value: H, limit: number, bits: number, scalar: boolean): number[] => { + const values: number[] = []; let rest = value; + for (;;) { + const node = tree.observe(rest).node; + if (node.kind === 'leaf') return values; + // Reject the excess spine cell before observing its head or tail. + if (values.length === limit || node.kind !== 'fork') throw new OperationError('invalid-argument'); + const number = canonicalNat(tree, node.left, bits); + if (number === undefined || scalar && (number > 0x10ffffn || number >= 0xd800n && number <= 0xdfffn)) throw new OperationError('invalid-argument'); + values.push(Number(number)); rest = node.right; + } + }; + if (request.operation === 'encode') { + const scalars = readList(request.arguments, 1024, 21, true); + const bytes = encodeUTF8(String.fromCodePoint(...scalars)); + return () => wire.result(true, wire.list(bytes.map(byte => tree.natural(BigInt(byte))))); + } + const [final, rest] = wire.pair(request.arguments), [bytes, end] = wire.pair(rest); + if (tree.observe(end).node.kind !== 'leaf') throw new OperationError('invalid-argument'); + const last = canonicalNat(tree, final, 1); + if (last === undefined) throw new OperationError('invalid-argument'); + const input = readList(bytes, 4096, 8, false); + try { + const decoded = decodeUTF8(input, last === 1n); + return () => wire.result(true, wire.list([tree.text(decoded.text), wire.list(decoded.carry.map(byte => tree.natural(BigInt(byte))))])); + } catch (error) { + if (!(error instanceof UTF8Error)) throw error; + return () => wire.result(false, wire.problem('invalid-data', op, error.message)); + } + } + if (request.group === 'standard') { + if (tree.observe(request.arguments).node.kind !== 'leaf') throw new OperationError('invalid-argument'); + const key = ['input', 'output', 'error'].indexOf(request.operation) + 1; + return () => wire.result(true, wire.marker('io.v2.resource', key)); + } + if (request.group === 'resource') { + const token = wire.token(request.arguments, 'io.v2.resource'); + logical(() => registry.claimClose(scope, token)); + throw new Error('unexpected owned terminal close'); + } + const [handle, argument] = wire.pair(request.arguments), token = wire.token(handle, 'io.v2.resource'); + const read = request.operation === 'read'; + const lease = logical(() => registry.beginLease(scope, token, read ? 'reader' : 'writer', read ? 'read' : 'write')); + try { + const slot = lease.backend; + if (slot.poisoned) return bad('invalid-state', 'unknown'); + let reply: Completion; + if (read) { + const limit = canonicalNat(tree, argument, 16); + if (limit === undefined) throw new OperationError('invalid-argument'); + if (limit < 1n || limit > 4096n) return bad('invalid-bound'); + if (slot.eof) return () => wire.result(true, wire.list([tree.text('eof')])); + reply = await events.read(Number(limit), signal); + if (reply.kind === 'read') { + const received = reply; slot.eof = received.eof; + return () => wire.result(true, received.eof ? wire.list([tree.text('eof')]) : + tree.fork(tree.text('data'), wire.list(received.bytes.map(byte => tree.natural(BigInt(byte)))))); + } + } else { + const bytes: number[] = []; let rest = argument; + while (tree.observe(rest).node.kind !== 'leaf') { + if (bytes.length === 4096) throw new OperationError('invalid-argument'); + const [head, tail] = wire.pair(rest), byte = canonicalNat(tree, head, 8); + if (byte === undefined) throw new OperationError('invalid-argument'); + bytes.push(Number(byte)); rest = tail; + } + if (!bytes.length) return bad('invalid-bound'); + if (bytes.length > outputBudget) return bad('invalid-bound'); + outputBudget -= bytes.length; + reply = await events.write(slot.name === 'error' ? 'error' : 'output', bytes, signal); + if (reply.kind === 'write') { const count = reply.count; return () => wire.result(true, tree.natural(BigInt(count))); } + } + if (!read) slot.poisoned = true; + return bad(reply.kind === 'cancelled' ? 'cancelled' : 'io-error', 'unknown'); + } finally { registry.endLease(lease.token); } + } catch (error) { + if (!(error instanceof OperationError)) throw error; + return bad(error.message); + } + } + }; +} +export type Command = { run: string; kind: 'poll' | 'stop' } | { run: string; kind: 'reply'; reply: unknown }; +export class Session<H> { + readonly events: Events; + readonly core: Core<H, Slot>; + private busy = false; + private finished = false; + constructor(readonly tree: Trees<H>, action: H, readonly run: string, private readonly sweep: (roots: Iterable<H>) => void, private readonly options: { summarize?: boolean; steps?: number; backend?: string } = {}) { + if (options.steps !== undefined && (!Number.isInteger(options.steps) || options.steps < 1 || options.steps > 128)) throw new Error('invalid session step bound'); + this.events = new Events(run); + this.core = new Core(tree, BigInt(run), action, + ['input', 'output', 'error'].map(name => ({ name, poisoned: false, eof: false })) as [Slot, Slot, Slot], provider(this.events, options.backend ?? 'browser')); + sweep(this.core.roots()); + } + async turn(command: Command) { + if (this.busy) throw new Error('session turn already active'); + if (this.finished || command.run !== this.run) throw new Error('stale session run'); + if (!['poll', 'stop', 'reply'].includes(command.kind)) throw new Error('invalid session command'); + this.busy = true; + try { + const accepted = command.kind === 'reply' ? this.events.reply(command.reply) : null; + if (command.kind === 'stop') this.core.stop(this.tree.leaf); + // Never await a terminal operation. Bounded progress batches leave the + // transport available for multiple replies, polls and structured stop. + let progress = false; + for (let turns = 0; turns < (this.options.steps ?? 128) && !this.core.result; turns++) { + progress = this.core.advance(); this.sweep(this.core.roots()); + await Promise.resolve(); + if (!progress) break; + } + const outcome = this.core.result; + if (outcome) this.finished = true; + return { run: this.run, accepted, waiting: !progress && this.events.counts.pending > 0 && this.core.work.counts.pending === this.events.counts.pending && this.core.work.counts.replies === 0, done: !!outcome, events: this.events.take(), + outcome: outcome ? { kind: outcome.kind, value: outcome.kind === 'completed' && this.options.summarize !== false ? canonicalNat(this.tree, outcome.value, 64)?.toString() ?? null : null, + cleanup: outcome.cleanup?.items.length ?? 0, fault: this.core.hasFault } : null, + counts: { tasks: this.core.lifecycle.tasks.counts, resources: this.core.lifecycle.resources.counts, + work: this.core.work.counts, events: this.events.counts } }; + } finally { this.busy = false; } + } +} diff --git a/ext/demo-site/src/io-v2-tasks.ts b/ext/demo-site/src/io-tasks.ts diff --git a/ext/demo-site/src/io-terminal-view.ts b/ext/demo-site/src/io-terminal-view.ts @@ -0,0 +1,82 @@ +import { Terminal, type Reply } from './io-terminal.js'; +import type { Event } from './io-events.js'; +// One identity-bound UI per run. Callbacks enqueue cloneable replies/stop requests; +// they must not synchronously re-enter this widget or throw. +export function terminalView(parent: HTMLElement, run: string, reply: (value: Reply) => void, stop: () => void) { + const terminal = new Terminal(run), doc = parent.ownerDocument; + const root = doc.createElement('section'), status = doc.createElement('p'); + const outputs = ['output', 'error'].map(name => { + const section = doc.createElement('section'), title = doc.createElement('h4'); title.textContent = name === 'output' ? 'Standard output' : 'Standard error'; + const text = doc.createElement('pre'), details = doc.createElement('details'), summary = doc.createElement('summary'), hex = doc.createElement('pre'); + summary.textContent = 'Exact bytes (hexadecimal)'; details.append(summary, hex); section.append(title, text, details); root.append(section); + text.dataset.stream = name; hex.dataset.hex = name; + return { text, hex }; + }); + let form = doc.createElement('form'), clearInput = () => {}, waitingId: number | undefined; + const stopping = doc.createElement('button'); + stopping.textContent = 'Stop (cancel and wait for cleanup)'; stopping.type = 'button'; + root.append(form, stopping, status); parent.append(root); + let stopped = false, finished = false; + const onStop = () => { if (stopped || finished) return; stopped = true; stopping.disabled = true; status.textContent = 'Cancelling; waiting for cleanup…'; stop(); }; + stopping.addEventListener('click', onStop); + function inputView(): void { + const wait = terminal.waiting; + if (wait?.id === waitingId) { form.hidden = !wait; return; } + clearInput(); + const fresh = doc.createElement('form'); form.replaceWith(fresh); form = fresh; form.hidden = !wait; waitingId = wait?.id; + if (!wait) return; + const label = doc.createElement('label'), input = doc.createElement('input'), send = doc.createElement('button'), eof = doc.createElement('button'); + label.textContent = 'One line, at most 256 Unicode scalars (Send appends LF)'; input.maxLength = 512; label.append(input); + send.textContent = 'Send'; send.type = 'submit'; eof.textContent = 'End input (EOF)'; eof.type = 'button'; + form.append(label, send, eof); + function submit(end: boolean): void { + const result = end ? terminal.endInput(wait!.run, wait!.id) : terminal.line(wait!.run, wait!.id, input.value); + if (!result) { status.textContent = 'Enter at most 256 Unicode scalars without CR or LF.'; return; } + render(); reply(result); + } + const onSubmit = (event: SubmitEvent) => { event.preventDefault(); submit(false); }, onEOF = () => submit(true); + form.addEventListener('submit', onSubmit); eof.addEventListener('click', onEOF); + const owned = form; + clearInput = () => { owned.removeEventListener('submit', onSubmit); eof.removeEventListener('click', onEOF); input.value = ''; }; + } + function render(): void { + const snapshot = terminal.output.snapshot(); + [snapshot.output, snapshot.error].forEach((view, index) => { + const target = outputs[index]!; target.text.replaceChildren(); target.hex.textContent = view.hex; + for (const token of view.tokens) { + if (token.kind === 'text') target.text.append(doc.createTextNode(token.text)); + else { + const span = doc.createElement('mark'); span.dataset.byteReason = token.reason; + span.title = `${token.reason} UTF-8 bytes at stream offset ${token.offset}; see exact hex view`; + span.textContent = token.bytes.map(b => `\\x${b.toString(16).padStart(2, '0')}`).join(''); target.text.append(span); + } + } + }); + inputView(); + if (!stopped) status.textContent = terminal.waiting ? 'Waiting for a line or explicit EOF.' : 'Running…'; + } + render(); + return { + root, + handle(event: Event): void { + if (finished) throw new Error('terminal view retired'); + const result = terminal.handle(event); + // Physically hide/clear an abandoned input BEFORE acknowledging cancel. + render(); if (result) reply(result); + }, + finish(): void { + terminal.finish(); finished = true; render(); + clearInput(); stopping.removeEventListener('click', onStop); + form.remove(); stopping.disabled = true; status.textContent = 'Settled.'; + }, + // Fatal runtime loss/navigation is NOT a physical cleanup acknowledgement. + // Retire DOM callbacks without fabricating a settled interpreter outcome. + abandon(): void { + if (finished) return; + finished = true; clearInput(); form.remove(); + stopping.removeEventListener('click', onStop); stopping.disabled = true; + status.textContent = 'Runtime lost; cleanup was not acknowledged.'; + }, + snapshot: () => terminal.output.snapshot() + }; +} diff --git a/ext/demo-site/src/io-terminal.ts b/ext/demo-site/src/io-terminal.ts @@ -0,0 +1,60 @@ +// Terminal provider; cloneable identities only, independent of evaluators. +import type { Event, Completion } from './io-events.js'; +import { OutputBuffer } from './io-output.js'; +export type Reply = Completion & { run: string; id: number }; +export class Terminal { + readonly output = new OutputBuffer(); + private nextAfter = 0; + private reader: Extract<Event, { kind: 'read' }> | undefined; + private buffered: number[] = []; + private eof = false; + private ended = false; + constructor(readonly run: string) { + if (!/^[1-9][0-9]*$/.test(run) || run.length > 20 || BigInt(run) > 0xffffffffffffffffn) throw new Error('invalid terminal run'); + } + get waiting(): { run: string; id: number } | undefined { + return this.reader ? { run: this.run, id: this.reader.id } : undefined; + } + private read(): Reply | undefined { + const reader = this.reader; + if (!reader || !this.buffered.length && !this.eof) return; + const bytes = this.buffered.splice(0, reader.limit); this.reader = undefined; + return { run: this.run, id: reader.id, kind: 'read', bytes, eof: bytes.length === 0 }; + } + handle(event: Event): Reply | undefined { + if (this.ended || event.run !== this.run || !Number.isSafeInteger(event.id) || event.id < 1) throw new Error('invalid terminal event'); + if (event.kind === 'cancel') { + if (event.id > this.nextAfter || !['read', 'write'].includes(event.operation)) throw new Error('invalid terminal cancellation'); + // Completed synchronous writes/reads already have a normal acknowledgement + // queued. A racing abort does not erase it or manufacture a second reply. + if (this.reader?.id !== event.id) return; + if (event.operation !== 'read') throw new Error('invalid terminal cancellation'); + this.reader = undefined; + return { run: this.run, id: event.id, kind: 'cancelled' }; + } + if (event.id <= this.nextAfter) throw new Error('stale terminal event'); + if (event.kind === 'read') { + if (this.reader || !Number.isInteger(event.limit) || event.limit < 1 || event.limit > 4096) throw new Error('invalid terminal read'); + this.reader = { ...event }; + } else if (event.kind === 'write') this.output.append(event.stream, event.bytes); + else throw new Error('invalid terminal operation'); + this.nextAfter = event.id; + return event.kind === 'read' ? this.read() : { run: this.run, id: event.id, kind: 'write', count: event.bytes.length }; + } + // Explicit one-line UI: no CR/LF in the submitted text, append ONE LF; an + // empty line is data, never EOF. Scalars, not UTF-16 code units, are bounded. + line(run: string, id: number, value: unknown): Reply | undefined { + if (run !== this.run || this.reader?.id !== id || typeof value !== 'string' || value.length > 512 || + /[\r\n\uD800-\uDFFF]/u.test(value) || Array.from(value).length > 256) return; + this.buffered = [...new TextEncoder().encode(value), 10]; + return this.read(); + } + endInput(run: string, id: number): Reply | undefined { + if (run !== this.run || this.reader?.id !== id) return; + this.eof = true; return this.read(); + } + finish(): void { + if (this.reader) throw new Error('terminal input still pending'); + this.ended = true; this.buffered = []; this.output.finish(); + } +} diff --git a/ext/demo-site/src/io-v2-utf8.ts b/ext/demo-site/src/io-utf8.ts diff --git a/ext/demo-site/src/io-v2-core.ts b/ext/demo-site/src/io-v2-core.ts @@ -1,233 +0,0 @@ -// Private resumable tree interpreter. No public session dispatch or capabilities. -// Adapters must retain roots() between turns and keep temporary evaluator handles -// alive for a whole turn. Provider promises acknowledge physical completion; -// providers return deferred Result encoders, never task outcomes. Encoding runs -// in an interpreter turn, not in a promise/GC gap; cancelled encoders never run. -import { canonicalNat, decodeAction, ProtocolError, type Reader, type Action } from './io-v2-protocol.js'; -import { lifecycle, type Resource } from './io-v2-resources.js'; -import { HostWork } from './io-v2-host-work.js'; -import type { Token, Outcome, Diagnostics } from './io-v2-tasks.js'; -export interface Trees<H> extends Reader<H> { - readonly leaf: H; fork(a: H, b: H): H; stem(a: H): H; app(f: H, x: H): H; - natural(value: bigint): H; text(value: string): H; -} -type Request<H> = Extract<Action<H>, { kind: 'request' }>; -type Completion<H> = { kind: 'body'; encode: () => H } | { kind: 'release'; errors: Diagnostics<H> }; -type Body<H> = { action: H; frames: H[]; mode: 'runnable' | 'lexical' | 'join' | 'host' | 'done' }; -const operationErrors = new Set(['stale-task', 'join-cycle', 'out-of-scope', 'stale-handle', 'not-owner', - 'borrowed-resource', 'internal-resource', 'closed-handle', 'busy', 'duplicate-handle', 'closed-scope', 'invalid-argument']); -export class OperationError extends Error {} -function logical<T>(operation: () => T): T { - try { return operation(); } - catch (error) { - if (error instanceof Error && operationErrors.has(error.message)) throw new OperationError(error.message); - throw error; - } -} -export class Wire<H> { - constructor(readonly tree: Trees<H>, readonly identity: bigint) {} - pair(value: H): [H, H] { - const node = this.tree.observe(value).node; - if (node.kind !== 'fork') throw new OperationError('invalid-argument'); - return [node.left, node.right]; - } - list(values: readonly H[]): H { let out = this.tree.leaf; for (let i = values.length - 1; i >= 0; i--) out = this.tree.fork(values[i]!, out); return out; } - result(ok: boolean, value: H): H { return this.tree.fork(ok ? this.tree.stem(this.tree.leaf) : this.tree.leaf, this.tree.fork(value, this.tree.leaf)); } - problem(category: string, operation: string, detail = '', progress: 'none' | 'unknown' = 'none'): H { - return this.list([category, operation, detail, progress].map(s => this.tree.text(s))); - } - marker(name: string, key: number): H { return this.list([this.tree.text(name), this.tree.natural(this.identity), this.tree.natural(BigInt(key))]); } - token(value: H, name: string): Token { - const [tag, rest] = this.pair(value), [run, tail] = this.pair(rest), [key, end] = this.pair(tail); - if (this.tree.observe(end).node.kind !== 'leaf') throw new OperationError('invalid-argument'); - let cursor = tag; - for (const char of name) { - const [head, next] = this.pair(cursor); - if (canonicalNat(this.tree, head, 7) !== BigInt(char.charCodeAt(0))) throw new OperationError('invalid-argument'); - cursor = next; - } - if (this.tree.observe(cursor).node.kind !== 'leaf') throw new OperationError('invalid-argument'); - const r = canonicalNat(this.tree, run, 64), k = canonicalNat(this.tree, key, 64); - if (r === undefined || k === undefined) throw new OperationError('invalid-argument'); - // Unrepresentable keys cannot alias a live JS identity. Keep the ordinary - // stale-handle/task path rather than rounding into a different live handle. - return { run: r, key: k > BigInt(Number.MAX_SAFE_INTEGER) ? -1 : Number(k) }; - } - outcome(value: Outcome<H>): H { - const name = this.tree.text(value.kind); - if (value.kind === 'completed') return this.list([name, value.value]); - const errors = [...value.cleanup?.items ?? []], omitted = value.cleanup?.omitted ?? 0; - if (omitted) errors.push(this.problem('cleanup-omitted', 'close', String(omitted))); - return this.list([name, value.value, this.list(errors)]); - } -} -export interface Provider<H, B> { - request(engine: Core<H, B>, scope: number, request: Request<H>, signal: AbortSignal): (() => H) | Promise<() => H>; - release(resource: Resource<B>): void | Promise<void>; - roots?(): Iterable<H>; -} -export class Core<H, B> { - readonly wire: Wire<H>; - readonly lifecycle: ReturnType<typeof lifecycle<H, B>>; - readonly work: HostWork<H, Completion<H>>; - private readonly bodies = new Map<number, Body<H>>(); - private readonly constants: { stopped: H; cycle: H; fault: H; cleanup: H; budget: H }; - private readonly pendingArguments = new Map<number, H>(); - private cursor = -1; - private actions = 0; - result: Outcome<H> | null = null; - // Embedding/evaluator failure is retained separately and never stringified or - // mistaken for an ordinary Result. Drain cleanup before rethrowing at boundary. - fault: unknown = undefined; - hasFault = false; - constructor(readonly tree: Trees<H>, identity: bigint, action: H, standard: readonly [B, B, B], - private readonly provider: Provider<H, B>, private readonly actionLimit = 65536) { - if (!Number.isSafeInteger(actionLimit) || actionLimit < 1) throw new Error('invalid action limit'); - this.wire = new Wire(tree, identity); - this.constants = { - stopped: this.wire.problem('cancelled', 'scope', 'owner stopped'), - cycle: this.wire.problem('join-cycle', 'scope'), fault: this.wire.problem('runtime-error', 'core'), - cleanup: this.wire.problem('cleanup-failed', 'resource/release', '', 'unknown'), budget: this.wire.problem('limit', 'core', 'IO action limit exceeded') }; - this.lifecycle = lifecycle(identity, this.constants.stopped, this.constants.cycle, standard); - this.work = new HostWork(this.lifecycle.tasks); - this.bodies.set(0, this.body(action)); - } - private body(action: H): Body<H> { return { action, frames: [], mode: 'runnable' }; } - stop(reason: H): void { if (!this.result) { this.lifecycle.tasks.stop(0, { kind: 'cancelled', value: reason }); this.work.cancelStopped(); } } - private done(scope: number, outcome: Outcome<H>): void { - this.lifecycle.tasks.bodyDone(scope, outcome); - const body = this.bodies.get(scope)!; body.mode = 'done'; body.action = this.tree.leaf; body.frames = []; - } - private value(scope: number, value: H): void { - const body = this.bodies.get(scope)!; - if (body.frames.length) { body.action = this.tree.app(body.frames.pop()!, value); body.mode = 'runnable'; } - else this.done(scope, { kind: 'completed', value }); - } - private operation(scope: number, name: string, action: () => void): void { - try { action(); } - catch (error) { - if (!(error instanceof OperationError)) throw error; - this.value(scope, this.wire.result(false, this.wire.problem(error.message, name))); - } - } - private step(scope: number): boolean { - const { tasks, resources } = this.lifecycle, body = this.bodies.get(scope)!, state = tasks.state(scope); - if (body.mode === 'done' || body.mode === 'lexical') return false; - if (state.reason) { this.done(scope, state.reason); return true; } - if (body.mode === 'host') return false; - if (body.mode === 'join') { - const outcome = tasks.pollJoin(scope); if (!outcome) return false; - this.value(scope, this.wire.result(true, this.wire.outcome(outcome))); return true; - } - if (++this.actions > this.actionLimit) { tasks.stop(0, { kind: 'failed', value: this.constants.budget }); return true; } - let action: Action<H>; - try { action = decodeAction(this.tree, body.action); } - catch (error) { - if (!(error instanceof ProtocolError)) throw error; - this.done(scope, { kind: 'failed', value: this.wire.problem('protocol-error', 'core/decode', error.message) }); return true; - } - if (action.kind === 'request') { - // Keep action/arguments rooted until provider acknowledgement, including - // cancellation. The abandoned continuation is cleared by done(). - body.mode = 'host'; - const request = action; this.pendingArguments.set(scope, request.arguments); - this.work.start(scope, async signal => ({ kind: 'body', encode: await this.provider.request(this, scope, request, signal) })); - return true; - } - const p = action.payload; - switch (action.code) { - case 0: this.value(scope, p); break; - case 1: { - const node = this.tree.observe(p).node; - if (node.kind !== 'fork') this.done(scope, { kind: 'failed', value: this.wire.problem('protocol-error', 'core/decode', 'invalid v2 computation') }); - else { body.frames.push(node.right); body.action = node.left; } - break; - } - case 2: this.done(scope, { kind: 'failed', value: p }); break; - case 9: this.done(scope, { kind: 'cancelled', value: p }); break; - case 3: { - const child = tasks.nested(scope); this.bodies.set(child, this.body(this.tree.leaf)); body.mode = 'lexical'; body.action = this.tree.leaf; - this.bodies.get(child)!.action = this.tree.app(p, this.wire.marker('io.v2.scope', child)); break; - } - case 4: this.value(scope, this.wire.marker('io.v2.scope', scope)); break; - case 5: this.operation(scope, 'core/spawn', () => { - const [sup, a] = this.wire.pair(p), [keep, b] = this.wire.pair(a), [handles, callback] = this.wire.pair(b); - const supervised = canonicalNat(this.tree, sup, 1), retained = canonicalNat(this.tree, keep, 1); - if (supervised === undefined || retained === undefined) throw new OperationError('invalid-argument'); - const tokens: Token[] = []; let rest = handles; - while (this.tree.observe(rest).node.kind !== 'leaf') { - if (tokens.length === 256) throw new OperationError('invalid-argument'); - const [head, tail] = this.wire.pair(rest); tokens.push(this.wire.token(head, 'io.v2.resource')); rest = tail; - } - const moved = logical(() => resources.spawn(scope, supervised === 1n, retained === 1n, tokens)), child = moved.task.key; - this.bodies.set(child, this.body(this.tree.leaf)); - this.bodies.get(child)!.action = this.tree.app(callback, this.wire.list(moved.handles.map(t => this.wire.marker('io.v2.resource', t.key)))); - this.value(scope, this.wire.result(true, this.wire.list([this.wire.marker('io.v2.task', child), retained === 1n ? this.wire.marker('io.v2.join', child) : this.tree.leaf]))); - }); break; - case 6: case 7: this.operation(scope, action.code === 6 ? 'core/join' : 'core/discard', () => { - const token = this.wire.token(p, 'io.v2.join'); - if (action.code === 7) { logical(() => tasks.discard(scope, token)); this.value(scope, this.wire.result(true, this.tree.leaf)); } - else { logical(() => tasks.claimJoin(scope, token)); body.mode = 'join'; body.action = this.tree.leaf; } - }); break; - case 8: this.operation(scope, 'core/cancel', () => { - const [handle, reason] = this.wire.pair(p), token = this.wire.token(handle, 'io.v2.task'); logical(() => tasks.cancel(scope, token, reason)); - this.value(scope, this.wire.result(true, this.tree.leaf)); - }); break; - } - return true; - } - private settled(scope: number, errors: Diagnostics<H>): void { - const { tasks } = this.lifecycle, state = tasks.state(scope), outcome = tasks.settle(scope, errors); - this.bodies.delete(scope); - if (scope === 0) this.result = outcome; - else if (state.kind === 'lexical') { - const parent = state.parent!, reason = tasks.state(parent).reason; - this.bodies.get(parent)!.mode = 'runnable'; - if (reason) this.done(parent, reason); - else if (outcome.kind === 'completed') this.value(parent, outcome.value); - else this.done(parent, outcome); - } - } - // One progress turn, never waits for a provider. False means wait for host - // acknowledgement. The embedding must yield to its event loop between batches. - advance(): boolean { - if (this.result) return false; - try { - this.work.cancelStopped(); - for (const scope of this.pendingArguments.keys()) - if (this.lifecycle.tasks.state(scope).hostCalls === 0) this.pendingArguments.delete(scope); - const reply = this.work.take(); - if (reply) { - if (!reply.outcome.ok) throw reply.outcome.error; - const value = reply.outcome.value; - if (value.kind === 'release') this.settled(reply.token.scope, value.errors); - else this.value(reply.token.scope, value.encode()); - return true; - } - const { tasks, resources } = this.lifecycle; - for (const scope of tasks.scopeIds()) if (tasks.state(scope).phase === 'joining' && tasks.beginRelease(scope)) { - this.work.release(scope, async () => ({ kind: 'release', errors: await resources.releaseScope(scope, - resource => this.provider.release(resource), () => this.constants.cleanup, this.constants.cleanup) })); - return true; - } - const ids = tasks.scopeIds(), ordered = [...ids.filter(id => id > this.cursor), ...ids.filter(id => id <= this.cursor)]; - for (const scope of ordered) if (this.step(scope)) { this.cursor = scope; return true; } - return false; - } catch (error) { - if (!this.hasFault) this.fault = error; - this.hasFault = true; - this.lifecycle.tasks.stop(0, { kind: 'failed', value: this.constants.fault }); - this.work.cancelStopped(); return true; - } - } - wait(): Promise<void> { return this.work.wait(); } - *roots(): Iterable<H> { - yield this.tree.leaf; yield* Object.values(this.constants); yield* this.lifecycle.tasks.payloads(); - yield* this.pendingArguments.values(); yield* this.provider.roots?.() ?? []; - for (const body of this.bodies.values()) { yield body.action; yield* body.frames; } - for (const value of this.work.values()) { - if (value.kind === 'release') yield* value.errors.items; - } - if (this.result) { yield this.result.value; yield* this.result.cleanup?.items ?? []; } - } -} diff --git a/ext/demo-site/src/io-v2-driver.ts b/ext/demo-site/src/io-v2-driver.ts @@ -1,82 +0,0 @@ -import type { IOHost } from './io.js'; -import type { Command, Session } from './io-v2-session.js'; -import type { ValueView } from './codecs.js'; -import type { Event } from './io-v2-events.js'; -export interface V2Host { - readonly stepping?: boolean; - // These transport callbacks must not throw or synchronously reenter a turn. - open(run: string, command: (command: Command) => void): void; - event(event: Event): void; - close(run: string, settled: boolean): void; -} -export type Report = Awaited<ReturnType<Session<unknown>['turn']>>; -export interface Driver { - run: string; - // Emergency runtime loss, distinct from structured stop/acknowledgement. - signal?: AbortSignal; - turn(command: Command): Promise<Report>; - view(): Promise<ValueView>; - discard?(): void; -} -// Unlike repeated Promise.race against a lifetime-long promise, listeners are -// removed on EVERY turn/step settlement, not retained until runtime disposal. -function interrupt<T>(promise: Promise<T>, signal: AbortSignal | undefined, aborted: () => T): Promise<T> { - if (!signal) return promise; - let onAbort = () => {}; - return new Promise<T>((resolve, reject) => { - onAbort = () => { try { resolve(aborted()); } catch (error) { reject(error); } }; - if (signal.aborted) onAbort(); else signal.addEventListener('abort', onAbort, { once: true }); - void promise.then(resolve, reject); - }).finally(() => signal.removeEventListener('abort', onAbort)); -} -// The queue stays alive while a read or step prompt is waiting. Stop is sticky, -// independent of the resume queue, and never implemented by worker destruction. -export async function drive(driver: Driver, host: IOHost): Promise<ValueView> { - const transport = host.v2; - if (!transport) throw new Error('IO version 2 requires a structured terminal host'); - let settled = false; - const alive = <T>(promise: Promise<T>): Promise<T> => interrupt(promise, driver.signal, () => { throw driver.signal!.reason; }); - let active = true, stopping = false, stopSent = false, wake = () => {}, step = 0; - const replies: Command[] = []; - let boundaryFault = false, fault: unknown; - const stopped = new AbortController(); - const enqueue = (command: Command) => { - if (!active || !command || command.run !== driver.run) return; - if (command.kind === 'stop') { stopping = true; stopped.abort(); } - else if (command.kind === 'reply') replies.push(command); - wake(); - }; - const close = () => { - if (!active) return; - active = false; stopped.abort(); wake(); wake = () => {}; replies.length = 0; - // A failed transport/runtime cannot be reused with admitted work abandoned. - if (!settled) driver.discard?.(); - transport.close(driver.run, settled); - }; - try { - transport.open(driver.run, enqueue); - for (;;) { - if (!stopping && transport.stepping === true) { - try { await alive(interrupt(host.before({ step: ++step, name: 'v2 scheduler turn', detail: 'one scheduler advance; all tasks paused', depth: 0 }), stopped.signal, () => {})); } - catch (error) { if (driver.signal?.aborted) throw error; boundaryFault = true; fault = error; enqueue({ run: driver.run, kind: 'stop' }); } - } - const command: Command = stopping && !stopSent ? (stopSent = true, { run: driver.run, kind: 'stop' }) : - replies.shift() ?? { run: driver.run, kind: 'poll' }; - const report = await alive(driver.turn(command)); - for (const event of report.events) transport.event(event); - if (report.done) { - if (!Object.values(report.counts).every(group => Object.values(group).every(value => value === 0))) throw new Error('IO v2 settlement retains work'); - settled = true; close(); - if (boundaryFault) throw fault; - if (report.outcome?.fault) throw new Error('IO v2 interpreter fault'); - if (report.outcome?.kind !== 'completed') throw new Error(`IO v2 ${report.outcome?.kind ?? 'failed'}`); - return await alive(driver.view()); // Full codec, only after physical settlement. - } - // Yield even during pure scheduler progress so the outer worker can receive - // stop/replies. This is not a hard deadline for native tree reduction. - if (report.waiting && !replies.length && (!stopping || stopSent)) await alive(new Promise<void>(resolve => { wake = resolve; })); - else await alive(new Promise<void>(resolve => { const timer = setTimeout(resolve, 0); wake = () => { clearTimeout(timer); resolve(); }; })); - wake = () => {}; - } - } finally { close(); } -} diff --git a/ext/demo-site/src/io-v2-host-work.ts b/ext/demo-site/src/io-v2-host-work.ts @@ -1,80 +0,0 @@ -import { Tasks, hostKey, type HostToken } from './io-v2-tasks.js'; -export type Reply<T> = { readonly token: HostToken; readonly outcome: - { readonly ok: true; readonly value: T } | { readonly ok: false; readonly error: unknown } }; -// Adapter-side asynchronous work. Starting one callback never waits for it, so -// sibling actions/cancellation can proceed. The provider must settle its promise -// AFTER cancellation cleanup; abort delivery alone is never an acknowledgement. -export class HostWork<P, T> { - private readonly active = new Map<string, { token: HostToken; controller: AbortController }>(); - private readonly replies = new Map<string, Reply<T>>(); - private notification: { promise: Promise<void>; resolve: () => void } | null = null; - constructor(private readonly tasks: Tasks<P>) {} - start(scope: number, operation: (signal: AbortSignal) => T | Promise<T>): HostToken { - return this.register(this.tasks.beginHost(scope), operation); - } - // Cleanup is not cancelled by a new stop request. Its result (including a - // failure) must still reach the driver to preserve primary/secondary outcomes. - release(scope: number, operation: () => T | Promise<T>): HostToken { - return this.register(this.tasks.beginReleaseHost(scope), operation); - } - private register(token: HostToken, operation: (signal: AbortSignal) => T | Promise<T>): HostToken { - const controller = new AbortController(); - this.active.set(hostKey(token), { token, controller }); - const complete = (outcome: Reply<T>['outcome']) => { - // Settle first; a cancelled reply is discarded without observing its value. - const disposition = this.tasks.endHost(token); - this.active.delete(hostKey(token)); - if (disposition === 'reply') this.replies.set(hostKey(token), { token, outcome }); - const notice = this.notification; this.notification = null; notice?.resolve(); - }; - try { Promise.resolve(operation(controller.signal)).then( - value => complete({ ok: true, value }), error => complete({ ok: false, error })); } - catch (error) { complete({ ok: false, error }); } - return token; - } - private usable(token: HostToken): boolean { - if (!this.tasks.within(token.scope, token.scope)) return false; - const state = this.tasks.state(token.scope); - return token.kind === 'release' ? state.phase === 'releasing' : state.phase === 'open' && state.reason === null; - } - // Resource close may interrupt one operation without cancelling its task. - // The resulting provider reply still reaches that body as an ordinary error. - // Never abort a cleanup batch or acknowledge work merely by sending this signal. - cancelBody(token: HostToken): boolean { - const work = this.active.get(hostKey(token)); - if (token.kind !== 'body' || !work || work.token.run !== token.run || - work.token.scope !== token.scope || work.controller.signal.aborted) return false; - work.controller.abort(); return true; - } - cancelStopped(): void { - for (const token of this.tasks.takeCancellations()) { - const work = this.active.get(hostKey(token)); - if (!work || work.token.scope !== token.scope) throw new Error('host work ownership mismatch'); - work.controller.abort(); - } - // Completion may have won the race with cancellation. Drop undelivered data - // too; a queued reply cannot revive a stopped/settled body. - for (const [key, reply] of this.replies) if (!this.usable(reply.token)) this.replies.delete(key); - } - take(): Reply<T> | null { - for (const [key, reply] of this.replies) { - this.replies.delete(key); - if (this.usable(reply.token)) return reply; - } - return null; - } - wait(): Promise<void> { - if (this.replies.size || !this.active.size) return Promise.resolve(); - if (!this.notification) { - let resolve!: () => void; - const promise = new Promise<void>(done => { resolve = done; }); - this.notification = { promise, resolve }; - } - return this.notification.promise; - } - get counts() { return { pending: this.active.size, replies: this.replies.size }; } - // Reply data roots are the embedding's responsibility until taken/discarded. - *values(): Iterable<T> { - for (const reply of this.replies.values()) if (this.usable(reply.token) && reply.outcome.ok) yield reply.outcome.value; - } -} diff --git a/ext/demo-site/src/io-v2-output.ts b/ext/demo-site/src/io-v2-output.ts @@ -1,61 +0,0 @@ -// Presentation only. Byte acceptance and strict source UTF-8 Results are separate. -// No evaluator handles, host callbacks, IO acknowledgements or terminal escapes. -import { decodeUTF8, UTF8Error } from './io-v2-utf8.js'; -export type OutputStream = 'output' | 'error'; -export type OutputToken = { kind: 'text'; offset: number; text: string } | - { kind: 'bytes'; offset: number; bytes: number[]; reason: 'invalid' | 'incomplete' | 'pending' }; -export interface OutputView { bytes: number[]; hex: string; tokens: OutputToken[] } - -function view(bytes: number[], final: boolean): OutputView { - const tokens: OutputToken[] = []; - for (let offset = 0; offset < bytes.length;) { - const lead = bytes[offset]!; - const width = lead <= 0x7f ? 1 : lead >= 0xc2 && lead <= 0xdf ? 2 : - lead >= 0xe0 && lead <= 0xef ? 3 : lead >= 0xf0 && lead <= 0xf4 ? 4 : 1; - const candidate = bytes.slice(offset, offset + width); - try { - const decoded = decodeUTF8(candidate, false); - if (decoded.carry.length) { - tokens.push({ kind: 'bytes', offset, bytes: candidate, reason: final ? 'incomplete' : 'pending' }); - break; - } - const previous = tokens.at(-1); - if (previous?.kind === 'text') previous.text += decoded.text; - else tokens.push({ kind: 'text', offset, text: decoded.text }); - offset += width; - } catch (error) { - if (!(error instanceof UTF8Error)) throw error; - // Escape one offending byte; reconsider following bytes independently. - // Thus invalid prefixes cannot swallow subsequent valid text. - tokens.push({ kind: 'bytes', offset, bytes: [lead], reason: 'invalid' }); - offset++; - } - } - return { bytes, hex: bytes.map(b => b.toString(16).padStart(2, '0')).join(' '), tokens }; -} - -// Owned by one UI run. The session separately enforces attempted-output budgets; -// this buffer bounds retained accepted bytes, without refunds or history. -export class OutputBuffer { - private readonly streams: Record<OutputStream, number[]> = { output: [], error: [] }; - private size = 0; - private ended = false; - constructor(readonly limit = 16384) { - if (!Number.isSafeInteger(limit) || limit < 0 || limit > 16384) throw new Error('invalid output limit'); - } - append(stream: OutputStream, bytes: readonly number[]): void { - if (this.ended) throw new Error('output presentation ended'); - if (stream !== 'output' && stream !== 'error') throw new Error('invalid output stream'); - if (!Array.isArray(bytes) || bytes.length === 0 || bytes.length > 4096) throw new Error('invalid output bytes'); - for (const byte of bytes) if (!Number.isInteger(byte) || byte < 0 || byte > 255) throw new Error('invalid output bytes'); - if (bytes.length > this.limit - this.size) throw new Error('output presentation limit'); - this.streams[stream].push(...bytes); this.size += bytes.length; - } - // End-of-presentation, including after structured cancellation, not closing a - // borrowed stream. Incomplete suffixes become visible bytes, never IO failures. - finish(): void { this.ended = true; } - snapshot(): { done: boolean; size: number; output: OutputView; error: OutputView } { - return { done: this.ended, size: this.size, - output: view([...this.streams.output], this.ended), error: view([...this.streams.error], this.ended) }; - } -} diff --git a/ext/demo-site/src/io-v2-private.ts b/ext/demo-site/src/io-v2-private.ts @@ -1,37 +0,0 @@ -// Private native-adapter validation runner, not RuntimeSession IO dispatch. -// Bare computations only; no facility implementation/capability advertisement. -import { Core, type Trees } from './io-v2-core.js'; -import { canonicalNat, type Reader } from './io-v2-protocol.js'; -export function constructors<H>(base: Reader<H> & Pick<Trees<H>, 'leaf' | 'fork' | 'stem' | 'app'>): Trees<H> { - const natural = (value: bigint): H => { - const bits: H[] = []; - if (value < 0n) throw new Error('negative natural'); - for (; value; value >>= 1n) bits.push(value & 1n ? base.stem(base.leaf) : base.leaf); - let out = base.leaf; for (let i = bits.length - 1; i >= 0; i--) out = base.fork(bits[i]!, out); return out; - }; - return { ...base, natural, text(value: string): H { - let out = base.leaf; const chars = Array.from(value); - for (let i = chars.length - 1; i >= 0; i--) out = base.fork(natural(BigInt(chars[i]!.codePointAt(0)!)), out); - return out; - } }; -} -export async function runPrivate<H>(tree: Trees<H>, action: H, identity: bigint, sweep: (roots: Iterable<H>) => void) { - const engine = new Core(tree, identity, action, [null, null, null], { - request: (core, _scope, request) => () => core.wire.result(false, core.wire.problem('unsupported', `${request.group}/${request.operation}`)), - release: () => { throw new Error('unexpected owned backend in private core probe'); } }); - sweep(engine.roots()); - for (let turns = 0; !engine.result; turns++) { - const progress = engine.advance(); sweep(engine.roots()); - if (!progress) { - if (!engine.work.counts.pending) throw new Error('private interpreter stalled'); - await engine.wait(); - } - if (turns % 32 === 0) await new Promise<void>(resolve => setTimeout(resolve, 0)); - } - if (engine.hasFault) throw engine.fault; - const out = engine.result; - // Terminal diagnostics stay opaque, including when the test requests a number. - const value = out.kind === 'completed' ? canonicalNat(tree, out.value, 64)?.toString() ?? null : null; - return { kind: out.kind, value, cleanup: out.cleanup?.items.length ?? 0, - tasks: engine.lifecycle.tasks.counts, resources: engine.lifecycle.resources.counts, host: engine.work.counts }; -} diff --git a/ext/demo-site/src/io-v2-profile.ts b/ext/demo-site/src/io-v2-profile.ts @@ -1,43 +0,0 @@ -// This table is the implemented browser provider, not the native catalogue. -import { canonicalNat, decodeAction, ProtocolError, type Reader } from './io-v2-protocol.js'; -import type { Wire } from './io-v2-core.js'; -export const coreNames = ['pure', 'bind', 'fail', 'scope', 'current-scope', 'spawn', 'join', 'discard', 'cancel', 'cancelled'] as const; -const groups: Readonly<Record<string, readonly string[]>> = Object.freeze({ - runtime: ['capabilities', 'inspect'], standard: ['input', 'output', 'error'], - stream: ['read', 'write'], resource: ['close'], utf8: ['encode', 'decode'], -}); -export function available(group: string, revision: number, operation: string): boolean { - return revision === 1 && Object.hasOwn(groups, group) && groups[group]!.includes(operation); -} -export function capabilities<H>(wire: Wire<H>, backend: string): H { - const { tree } = wire, text = (s: string) => tree.text(s), number = (n: number) => tree.natural(BigInt(n)); - const strings = (xs: readonly string[]) => wire.list(xs.map(text)), row = (name: string, value: H) => wire.list([text(name), value]); - return wire.list([ - row('schema', number(1)), row('io-version', number(2)), row('backend', text(backend)), row('authority', text('browser-terminal')), - row('core', strings(coreNames)), row('groups', wire.list(Object.entries(groups).map(([name, ops]) => wire.list([text(name), number(1), strings(ops)])))), - row('limits', wire.list(Object.entries({ 'io-actions': 65536, 'chunk-bytes': 4096, 'transfer-handles': 256, 'utf8-encode-scalars': 1024, 'input-line-scalars': 256, 'output-attempted-bytes': 16384 }).map(([key, value]) => row(key, number(value))))), - row('restrictions', strings(['action-boundary-cancellation', 'no-hard-deadlines', 'borrowed-standard-streams', 'standard-stream-leases-per-run', 'utf8-strict-scalars', 'text-line-input', 'byte-preserving-output', 'no-filesystem', 'no-network', 'no-processes'])), - ]); -} -export function inspect<H>(wire: Wire<H>, value: H): H { - const action = decodeAction(wire.tree, value), t = wire.tree; - return action.kind === 'core' ? wire.list([t.text('core'), t.text(coreNames[action.code]!)]) : - wire.list([t.text('facility'), t.text(action.group), t.natural(BigInt(action.revision)), t.text(action.operation), t.text(available(action.group, action.revision, action.operation) ? 'available' : 'unsupported')]); -} -// Authenticate framing before selecting an interpreter. No action is observed. -export function envelope<H>(tree: Reader<H>, value: H): { version: 2; action: H } { - const root = tree.observe(value).node; - if (root.kind !== 'fork') throw new ProtocolError('invalid IO envelope'); - let rest = root.left; - for (const char of 'tricuIO') { - const node = tree.observe(rest).node; - if (node.kind !== 'fork' || canonicalNat(tree, node.left, 7) !== BigInt(char.charCodeAt(0))) throw new ProtocolError('Missing tricuIO sentinel'); - rest = node.right; - } - if (tree.observe(rest).node.kind !== 'leaf') throw new ProtocolError('Missing tricuIO sentinel'); - const body = tree.observe(root.right).node; - if (body.kind !== 'fork') throw new ProtocolError('invalid IO envelope'); - const version = canonicalNat(tree, body.left, 2); - if (version !== 2n) throw new ProtocolError('Unsupported tricuIO version'); - return { version: 2, action: body.right }; -} diff --git a/ext/demo-site/src/io-v2-resources.ts b/ext/demo-site/src/io-v2-resources.ts @@ -1,169 +0,0 @@ -import { Tasks, type Token, type Diagnostics } from './io-v2-tasks.js'; -export interface Flags { readonly borrowed?: boolean; readonly internal?: boolean; readonly duplex?: boolean; } -export interface Resource<B> { - readonly owner: number; readonly kind: string; readonly backend: B; readonly flags: Flags; - readonly busy: number; readonly closing: boolean; readonly releasing: boolean; -} -type Entry<B> = { -readonly [K in keyof Resource<B>]: Resource<B>[K] }; -export type Access = 'exclusive' | 'read' | 'write'; -// State only. Construct via lifecycle() to bind task settlement to registry guards. -// Single owning event loop; host effects occur AFTER admission/lease/close claims. -class Resources<P, B> { - private next = 4; - private readonly resources = new Map<number, Entry<B>>(); - private readonly admissions = new Map<number, number>(); - private readonly leases = new Map<number, { scope: number; resource: number; mask: number }>(); - constructor(private readonly tasks: Tasks<P>, standard: readonly [B, B, B], private readonly limit: number) { - if (!Number.isSafeInteger(limit) || limit < 3) throw new Error('invalid resource identity limit'); - standard.forEach((backend, index) => this.resources.set(index + 1, { - owner: 0, kind: index === 0 ? 'reader' : 'writer', backend, flags: Object.freeze({ borrowed: true }), - busy: 0, closing: false, releasing: false })); - } - get counts() { return { resources: this.resources.size, admissions: this.admissions.size, leases: this.leases.size }; } - private token(key: number): Token { return Object.freeze({ run: this.tasks.identity, key }); } - private key(token: Token): number { - if (token.run !== this.tasks.identity) throw new Error('stale-handle'); - return token.key; - } - private room(count: number): void { - if (count > this.limit - this.next + 1) throw new Error('resource-identity-exhausted'); - } - private open(scope: number): void { - const state = this.tasks.state(scope); - if (state.phase !== 'open' || state.reason) throw new Error('closed-scope'); - } - private entry(current: number, token: Token): Entry<B> { - const resource = this.resources.get(this.key(token)); - if (!resource) throw new Error('stale-handle'); - if (!this.tasks.within(current, resource.owner)) throw new Error('out-of-scope'); - return resource; - } - lookup(current: number, token: Token): Resource<B> { return { ...this.entry(current, token) }; } - beginAcquire(current: number, owner = current): Token { - this.tasks.checkpoint(current); this.open(owner); - if (!this.tasks.within(current, owner)) throw new Error('out-of-scope'); - this.room(1); const token = this.token(this.next++); - this.admissions.set(token.key, owner); return token; - } - abortAcquire(token: Token): void { - if (!this.admissions.delete(this.key(token))) throw new Error('invalid-admission'); - } - // An admitted acquisition can finish after owner stop/admission closure. - // Publication failure leaves the admission live: release raw backend, then abort. - publish(admission: Token, kind: string, backend: B, flags: Flags = {}): Token { - const key = this.key(admission), owner = this.admissions.get(key); - if (owner === undefined) throw new Error('invalid-admission'); - if (!kind || Object.values(flags).some(value => typeof value !== 'boolean')) throw new Error('invalid-argument'); - this.room(1); - const savedFlags = Object.freeze({ ...flags }), token = this.token(this.next); - this.resources.set(token.key, { owner, kind, backend, flags: savedFlags, busy: 0, closing: false, releasing: false }); - this.next++; this.admissions.delete(key); return token; - } - // All recoverable validation and preparation precede task admission. No host - // callback/await separates admission from rekeying. JS OOM is fatal, not rollback. - spawn(owner: number, supervised: boolean, keep: boolean, tokens: readonly Token[]) { - this.tasks.checkpoint(owner); this.open(owner); - if (typeof supervised !== 'boolean' || typeof keep !== 'boolean') throw new Error('invalid-argument'); - if (tokens.length > 256) throw new Error('transfer-limit'); - const seen = new Set<number>(); - const entries = tokens.map(token => { - const resource = this.entry(owner, token); - if (seen.has(token.key)) throw new Error('duplicate-handle'); - seen.add(token.key); - if (resource.owner !== owner) throw new Error('not-owner'); - if (resource.flags.borrowed) throw new Error('borrowed-resource'); - if (resource.flags.internal) throw new Error('internal-resource'); - if (resource.closing) throw new Error('closed-handle'); - if (resource.busy) throw new Error('busy'); - return { old: token.key, resource }; - }).sort((a, b) => a.old - b.old); - this.room(tokens.length); - const fresh = new Map(entries.map((entry, i) => [entry.old, this.token(this.next + i)])); - const handles = tokens.map(token => fresh.get(token.key)!); - const task = this.tasks.spawn(owner, supervised, keep); - for (const entry of entries) { - this.resources.delete(entry.old); entry.resource.owner = task.key; - this.resources.set(fresh.get(entry.old)!.key, entry.resource); - } - this.next += tokens.length; - return { task, handles }; - } - beginLease(current: number, token: Token, kind: string, access: Access, internal = false) { - this.tasks.checkpoint(current); - const resource = this.entry(current, token); - if (resource.kind !== kind) throw new Error('wrong-kind'); - if (resource.closing) throw new Error('closed-handle'); - if (resource.flags.internal && !internal) throw new Error('internal-resource'); - if (!['exclusive', 'read', 'write'].includes(access)) throw new Error('invalid-argument'); - const mask = !resource.flags.duplex || access === 'exclusive' ? 3 : access === 'read' ? 1 : 2; - if (resource.busy & mask) throw new Error('busy'); - this.room(1); const lease = this.token(this.next++); - this.leases.set(lease.key, { scope: current, resource: token.key, mask }); resource.busy |= mask; - return { token: lease, backend: resource.backend }; - } - endLease(token: Token): void { - const key = this.key(token), lease = this.leases.get(key); - if (!lease) throw new Error('invalid-lease'); - this.resources.get(lease.resource)!.busy &= ~lease.mask; this.leases.delete(key); - } - claimClose(current: number, token: Token, settling = false): Resource<B> | null { - const resource = this.entry(current, token); - if (settling) { - if (resource.owner !== current || this.tasks.state(current).phase !== 'releasing') throw new Error('invalid-close'); - } else { - this.tasks.checkpoint(current); - if (resource.flags.internal) throw new Error('internal-resource'); - if (resource.flags.borrowed) throw new Error('borrowed-resource'); - } - resource.closing = true; - if (resource.busy || resource.releasing) return null; - resource.releasing = true; return { ...resource }; - } - // Once, including failed release. Never retry a physically ambiguous close. - finishClose(token: Token): void { - const key = this.key(token), resource = this.resources.get(key); - if (!resource) throw new Error('stale-handle'); - if (!resource.releasing) throw new Error('invalid-close'); - this.resources.delete(key); - } - newestOwned(scope: number): Token | null { - let newest = -1; - for (const [key, resource] of this.resources) if (resource.owner === scope && key > newest) newest = key; - return newest < 0 ? null : this.token(newest); - } - readyForRelease(scope: number): boolean { - return ![...this.admissions.values()].includes(scope) && ![...this.leases.values()].some(lease => lease.scope === scope); - } - finishScope(scope: number): void { - if (!this.readyForRelease(scope) || this.newestOwned(scope)) throw new Error('busy'); - } - // Use inside HostWork.release, after Tasks.beginRelease. Each provider is - // attempted once, sequentially in reverse publication order; borrowed backends - // stay open. Diagnostics construction failure must not skip remaining releases. - async releaseScope(scope: number, release: (resource: Resource<B>) => void | Promise<void>, - problem: (error: unknown) => P, fallback: P): Promise<Diagnostics<P>> { - if (this.tasks.state(scope).phase !== 'releasing') throw new Error('invalid-transition'); - const items: P[] = []; let omitted = 0; - for (let token = this.newestOwned(scope); token; token = this.newestOwned(scope)) { - const resource = this.claimClose(scope, token, true); - if (!resource) throw new Error('busy'); - try { if (!resource.flags.borrowed) await release(resource); } - catch (error) { - if (items.length === 16) omitted++; - else { - let value = fallback; try { value = problem(error); } catch { /* retain prebuilt fallback */ } - items.push(value); - } - } finally { this.finishClose(token); } - } - return { items, omitted }; - } -} -export function lifecycle<P, B>(identity: bigint, ownerStopped: P, cycle: P, standard: readonly [B, B, B], - limits: { taskIdentities?: number; resourceIdentities?: number } = {}) { - let resources!: Resources<P, B>; - const tasks = new Tasks(identity, ownerStopped, cycle, limits.taskIdentities ?? Number.MAX_SAFE_INTEGER, { - readyForRelease: scope => resources.readyForRelease(scope), finishScope: scope => resources.finishScope(scope) }); - resources = new Resources(tasks, standard, limits.resourceIdentities ?? Number.MAX_SAFE_INTEGER); - return { tasks, resources }; -} diff --git a/ext/demo-site/src/io-v2-session.ts b/ext/demo-site/src/io-v2-session.ts @@ -1,157 +0,0 @@ -// Cloneable event session shared by private probes and public native adapters. -import { Core, OperationError, type Trees, type Provider } from './io-v2-core.js'; -import { available, capabilities, inspect } from './io-v2-profile.js'; -import { ProtocolError, decodeAction, canonicalNat } from './io-v2-protocol.js'; -import { encodeUTF8, decodeUTF8, UTF8Error } from './io-v2-utf8.js'; -import { Events, type Completion } from './io-v2-events.js'; -interface Slot { name: 'input' | 'output' | 'error'; poisoned: boolean; eof: boolean; } -const faults = new Set(['stale-handle', 'out-of-scope', 'closed-handle', 'wrong-kind', 'internal-resource', 'borrowed-resource', 'busy']); -function logical<T>(f: () => T): T { - try { return f(); } catch (error) { - if (error instanceof Error && faults.has(error.message)) throw new OperationError(error.message); - throw error; - } -} -function provider<H>(events: Events, backend: string): Provider<H, Slot> { - let outputBudget = 16384; // Attempted bytes across both slots; no replay/refund on uncertainty. - return { - release: () => { throw new Error('unexpected owned terminal resource'); }, - async request(engine, scope, request, signal) { - const { tree, wire } = engine, registry = engine.lifecycle.resources, op = `${request.group}/${request.operation}`; - const bad = (category: string, progress: 'none' | 'unknown' = 'none') => () => wire.result(false, wire.problem(category, op, '', progress)); - // Unsupported names/revisions leave arguments completely opaque. - if (!available(request.group, request.revision, request.operation)) return bad('unsupported'); - try { - if (request.group === 'runtime') { - if (request.operation === 'capabilities') { - if (tree.observe(request.arguments).node.kind !== 'leaf') return bad('invalid-argument'); - return () => wire.result(true, capabilities(wire, backend)); - } - // Classify in a live turn; defer construction so no newly allocated - // handles can be swept between promise resolution and consumption. - try { decodeAction(tree, request.arguments); } - catch (error) { if (error instanceof ProtocolError) return bad('invalid-argument'); throw error; } - return () => wire.result(true, inspect(wire, request.arguments)); - } - if (request.group === 'utf8') { - const readList = (value: H, limit: number, bits: number, scalar: boolean): number[] => { - const values: number[] = []; let rest = value; - for (;;) { - const node = tree.observe(rest).node; - if (node.kind === 'leaf') return values; - // Reject the excess spine cell before observing its head or tail. - if (values.length === limit || node.kind !== 'fork') throw new OperationError('invalid-argument'); - const number = canonicalNat(tree, node.left, bits); - if (number === undefined || scalar && (number > 0x10ffffn || number >= 0xd800n && number <= 0xdfffn)) throw new OperationError('invalid-argument'); - values.push(Number(number)); rest = node.right; - } - }; - if (request.operation === 'encode') { - const scalars = readList(request.arguments, 1024, 21, true); - const bytes = encodeUTF8(String.fromCodePoint(...scalars)); - return () => wire.result(true, wire.list(bytes.map(byte => tree.natural(BigInt(byte))))); - } - const [final, rest] = wire.pair(request.arguments), [bytes, end] = wire.pair(rest); - if (tree.observe(end).node.kind !== 'leaf') throw new OperationError('invalid-argument'); - const last = canonicalNat(tree, final, 1); - if (last === undefined) throw new OperationError('invalid-argument'); - const input = readList(bytes, 4096, 8, false); - try { - const decoded = decodeUTF8(input, last === 1n); - return () => wire.result(true, wire.list([tree.text(decoded.text), wire.list(decoded.carry.map(byte => tree.natural(BigInt(byte))))])); - } catch (error) { - if (!(error instanceof UTF8Error)) throw error; - return () => wire.result(false, wire.problem('invalid-data', op, error.message)); - } - } - if (request.group === 'standard') { - if (tree.observe(request.arguments).node.kind !== 'leaf') throw new OperationError('invalid-argument'); - const key = ['input', 'output', 'error'].indexOf(request.operation) + 1; - return () => wire.result(true, wire.marker('io.v2.resource', key)); - } - if (request.group === 'resource') { - const token = wire.token(request.arguments, 'io.v2.resource'); - logical(() => registry.claimClose(scope, token)); - throw new Error('unexpected owned terminal close'); - } - const [handle, argument] = wire.pair(request.arguments), token = wire.token(handle, 'io.v2.resource'); - const read = request.operation === 'read'; - const lease = logical(() => registry.beginLease(scope, token, read ? 'reader' : 'writer', read ? 'read' : 'write')); - try { - const slot = lease.backend; - if (slot.poisoned) return bad('invalid-state', 'unknown'); - let reply: Completion; - if (read) { - const limit = canonicalNat(tree, argument, 16); - if (limit === undefined) throw new OperationError('invalid-argument'); - if (limit < 1n || limit > 4096n) return bad('invalid-bound'); - if (slot.eof) return () => wire.result(true, wire.list([tree.text('eof')])); - reply = await events.read(Number(limit), signal); - if (reply.kind === 'read') { - const received = reply; slot.eof = received.eof; - return () => wire.result(true, received.eof ? wire.list([tree.text('eof')]) : - tree.fork(tree.text('data'), wire.list(received.bytes.map(byte => tree.natural(BigInt(byte)))))); - } - } else { - const bytes: number[] = []; let rest = argument; - while (tree.observe(rest).node.kind !== 'leaf') { - if (bytes.length === 4096) throw new OperationError('invalid-argument'); - const [head, tail] = wire.pair(rest), byte = canonicalNat(tree, head, 8); - if (byte === undefined) throw new OperationError('invalid-argument'); - bytes.push(Number(byte)); rest = tail; - } - if (!bytes.length) return bad('invalid-bound'); - if (bytes.length > outputBudget) return bad('invalid-bound'); - outputBudget -= bytes.length; - reply = await events.write(slot.name === 'error' ? 'error' : 'output', bytes, signal); - if (reply.kind === 'write') { const count = reply.count; return () => wire.result(true, tree.natural(BigInt(count))); } - } - if (!read) slot.poisoned = true; - return bad(reply.kind === 'cancelled' ? 'cancelled' : 'io-error', 'unknown'); - } finally { registry.endLease(lease.token); } - } catch (error) { - if (!(error instanceof OperationError)) throw error; - return bad(error.message); - } - } - }; -} -export type Command = { run: string; kind: 'poll' | 'stop' } | { run: string; kind: 'reply'; reply: unknown }; -export class Session<H> { - readonly events: Events; - readonly core: Core<H, Slot>; - private busy = false; - private finished = false; - constructor(readonly tree: Trees<H>, action: H, readonly run: string, private readonly sweep: (roots: Iterable<H>) => void, private readonly options: { summarize?: boolean; steps?: number; backend?: string } = {}) { - if (options.steps !== undefined && (!Number.isInteger(options.steps) || options.steps < 1 || options.steps > 128)) throw new Error('invalid session step bound'); - this.events = new Events(run); - this.core = new Core(tree, BigInt(run), action, - ['input', 'output', 'error'].map(name => ({ name, poisoned: false, eof: false })) as [Slot, Slot, Slot], provider(this.events, options.backend ?? 'browser')); - sweep(this.core.roots()); - } - async turn(command: Command) { - if (this.busy) throw new Error('session turn already active'); - if (this.finished || command.run !== this.run) throw new Error('stale session run'); - if (!['poll', 'stop', 'reply'].includes(command.kind)) throw new Error('invalid session command'); - this.busy = true; - try { - const accepted = command.kind === 'reply' ? this.events.reply(command.reply) : null; - if (command.kind === 'stop') this.core.stop(this.tree.leaf); - // Never await a terminal operation. Bounded progress batches leave the - // transport available for multiple replies, polls and structured stop. - let progress = false; - for (let turns = 0; turns < (this.options.steps ?? 128) && !this.core.result; turns++) { - progress = this.core.advance(); this.sweep(this.core.roots()); - await Promise.resolve(); - if (!progress) break; - } - const outcome = this.core.result; - if (outcome) this.finished = true; - return { run: this.run, accepted, waiting: !progress && this.events.counts.pending > 0 && this.core.work.counts.pending === this.events.counts.pending && this.core.work.counts.replies === 0, done: !!outcome, events: this.events.take(), - outcome: outcome ? { kind: outcome.kind, value: outcome.kind === 'completed' && this.options.summarize !== false ? canonicalNat(this.tree, outcome.value, 64)?.toString() ?? null : null, - cleanup: outcome.cleanup?.items.length ?? 0, fault: this.core.hasFault } : null, - counts: { tasks: this.core.lifecycle.tasks.counts, resources: this.core.lifecycle.resources.counts, - work: this.core.work.counts, events: this.events.counts } }; - } finally { this.busy = false; } - } -} diff --git a/ext/demo-site/src/io-v2-terminal-view.ts b/ext/demo-site/src/io-v2-terminal-view.ts @@ -1,82 +0,0 @@ -import { Terminal, type Reply } from './io-v2-terminal.js'; -import type { Event } from './io-v2-events.js'; -// One identity-bound UI per run. Callbacks enqueue cloneable replies/stop requests; -// they must not synchronously re-enter this widget or throw. -export function terminalView(parent: HTMLElement, run: string, reply: (value: Reply) => void, stop: () => void) { - const terminal = new Terminal(run), doc = parent.ownerDocument; - const root = doc.createElement('section'), status = doc.createElement('p'); - const outputs = ['output', 'error'].map(name => { - const section = doc.createElement('section'), title = doc.createElement('h4'); title.textContent = name === 'output' ? 'Standard output' : 'Standard error'; - const text = doc.createElement('pre'), details = doc.createElement('details'), summary = doc.createElement('summary'), hex = doc.createElement('pre'); - summary.textContent = 'Exact bytes (hexadecimal)'; details.append(summary, hex); section.append(title, text, details); root.append(section); - text.dataset.stream = name; hex.dataset.hex = name; - return { text, hex }; - }); - let form = doc.createElement('form'), clearInput = () => {}, waitingId: number | undefined; - const stopping = doc.createElement('button'); - stopping.textContent = 'Stop (cancel and wait for cleanup)'; stopping.type = 'button'; - root.append(form, stopping, status); parent.append(root); - let stopped = false, finished = false; - const onStop = () => { if (stopped || finished) return; stopped = true; stopping.disabled = true; status.textContent = 'Cancelling; waiting for cleanup…'; stop(); }; - stopping.addEventListener('click', onStop); - function inputView(): void { - const wait = terminal.waiting; - if (wait?.id === waitingId) { form.hidden = !wait; return; } - clearInput(); - const fresh = doc.createElement('form'); form.replaceWith(fresh); form = fresh; form.hidden = !wait; waitingId = wait?.id; - if (!wait) return; - const label = doc.createElement('label'), input = doc.createElement('input'), send = doc.createElement('button'), eof = doc.createElement('button'); - label.textContent = 'One line, at most 256 Unicode scalars (Send appends LF)'; input.maxLength = 512; label.append(input); - send.textContent = 'Send'; send.type = 'submit'; eof.textContent = 'End input (EOF)'; eof.type = 'button'; - form.append(label, send, eof); - function submit(end: boolean): void { - const result = end ? terminal.endInput(wait!.run, wait!.id) : terminal.line(wait!.run, wait!.id, input.value); - if (!result) { status.textContent = 'Enter at most 256 Unicode scalars without CR or LF.'; return; } - render(); reply(result); - } - const onSubmit = (event: SubmitEvent) => { event.preventDefault(); submit(false); }, onEOF = () => submit(true); - form.addEventListener('submit', onSubmit); eof.addEventListener('click', onEOF); - const owned = form; - clearInput = () => { owned.removeEventListener('submit', onSubmit); eof.removeEventListener('click', onEOF); input.value = ''; }; - } - function render(): void { - const snapshot = terminal.output.snapshot(); - [snapshot.output, snapshot.error].forEach((view, index) => { - const target = outputs[index]!; target.text.replaceChildren(); target.hex.textContent = view.hex; - for (const token of view.tokens) { - if (token.kind === 'text') target.text.append(doc.createTextNode(token.text)); - else { - const span = doc.createElement('mark'); span.dataset.byteReason = token.reason; - span.title = `${token.reason} UTF-8 bytes at stream offset ${token.offset}; see exact hex view`; - span.textContent = token.bytes.map(b => `\\x${b.toString(16).padStart(2, '0')}`).join(''); target.text.append(span); - } - } - }); - inputView(); - if (!stopped) status.textContent = terminal.waiting ? 'Waiting for a line or explicit EOF.' : 'Running…'; - } - render(); - return { - root, - handle(event: Event): void { - if (finished) throw new Error('terminal view retired'); - const result = terminal.handle(event); - // Physically hide/clear an abandoned input BEFORE acknowledging cancel. - render(); if (result) reply(result); - }, - finish(): void { - terminal.finish(); finished = true; render(); - clearInput(); stopping.removeEventListener('click', onStop); - form.remove(); stopping.disabled = true; status.textContent = 'Settled.'; - }, - // Fatal runtime loss/navigation is NOT a physical cleanup acknowledgement. - // Retire DOM callbacks without fabricating a settled interpreter outcome. - abandon(): void { - if (finished) return; - finished = true; clearInput(); form.remove(); - stopping.removeEventListener('click', onStop); stopping.disabled = true; - status.textContent = 'Runtime lost; cleanup was not acknowledged.'; - }, - snapshot: () => terminal.output.snapshot() - }; -} diff --git a/ext/demo-site/src/io-v2-terminal.ts b/ext/demo-site/src/io-v2-terminal.ts @@ -1,60 +0,0 @@ -// Terminal provider; cloneable identities only, independent of evaluators. -import type { Event, Completion } from './io-v2-events.js'; -import { OutputBuffer } from './io-v2-output.js'; -export type Reply = Completion & { run: string; id: number }; -export class Terminal { - readonly output = new OutputBuffer(); - private nextAfter = 0; - private reader: Extract<Event, { kind: 'read' }> | undefined; - private buffered: number[] = []; - private eof = false; - private ended = false; - constructor(readonly run: string) { - if (!/^[1-9][0-9]*$/.test(run) || run.length > 20 || BigInt(run) > 0xffffffffffffffffn) throw new Error('invalid terminal run'); - } - get waiting(): { run: string; id: number } | undefined { - return this.reader ? { run: this.run, id: this.reader.id } : undefined; - } - private read(): Reply | undefined { - const reader = this.reader; - if (!reader || !this.buffered.length && !this.eof) return; - const bytes = this.buffered.splice(0, reader.limit); this.reader = undefined; - return { run: this.run, id: reader.id, kind: 'read', bytes, eof: bytes.length === 0 }; - } - handle(event: Event): Reply | undefined { - if (this.ended || event.run !== this.run || !Number.isSafeInteger(event.id) || event.id < 1) throw new Error('invalid terminal event'); - if (event.kind === 'cancel') { - if (event.id > this.nextAfter || !['read', 'write'].includes(event.operation)) throw new Error('invalid terminal cancellation'); - // Completed synchronous writes/reads already have a normal acknowledgement - // queued. A racing abort does not erase it or manufacture a second reply. - if (this.reader?.id !== event.id) return; - if (event.operation !== 'read') throw new Error('invalid terminal cancellation'); - this.reader = undefined; - return { run: this.run, id: event.id, kind: 'cancelled' }; - } - if (event.id <= this.nextAfter) throw new Error('stale terminal event'); - if (event.kind === 'read') { - if (this.reader || !Number.isInteger(event.limit) || event.limit < 1 || event.limit > 4096) throw new Error('invalid terminal read'); - this.reader = { ...event }; - } else if (event.kind === 'write') this.output.append(event.stream, event.bytes); - else throw new Error('invalid terminal operation'); - this.nextAfter = event.id; - return event.kind === 'read' ? this.read() : { run: this.run, id: event.id, kind: 'write', count: event.bytes.length }; - } - // Explicit one-line UI: no CR/LF in the submitted text, append ONE LF; an - // empty line is data, never EOF. Scalars, not UTF-16 code units, are bounded. - line(run: string, id: number, value: unknown): Reply | undefined { - if (run !== this.run || this.reader?.id !== id || typeof value !== 'string' || value.length > 512 || - /[\r\n\uD800-\uDFFF]/u.test(value) || Array.from(value).length > 256) return; - this.buffered = [...new TextEncoder().encode(value), 10]; - return this.read(); - } - endInput(run: string, id: number): Reply | undefined { - if (run !== this.run || this.reader?.id !== id) return; - this.eof = true; return this.read(); - } - finish(): void { - if (this.reader) throw new Error('terminal input still pending'); - this.ended = true; this.buffered = []; this.output.finish(); - } -} diff --git a/ext/demo-site/src/io.ts b/ext/demo-site/src/io.ts @@ -1,10 +1,10 @@ export interface Trace { step: number; name: string; detail: string; depth: number; } export interface IOHost { selected?(version: 2): void; - readonly v2?: import('./io-v2-driver.js').V2Host; + readonly structured?: import('./io-driver.js').StructuredHost; before(event: Trace): Promise<void>; } // A scheduler-step acknowledgement carries no terminal text. Malformed replies -// leave the waiting token live; terminal input uses run-bound v2 byte replies. +// leave the waiting token live; terminal input uses run-bound byte replies. export function validStepResume(value: unknown): boolean { return value === undefined; } diff --git a/ext/demo-site/src/protocol.ts b/ext/demo-site/src/protocol.ts @@ -12,7 +12,7 @@ export interface CatalogueEntry { presets?: Record<string, { arguments: unknown[]; output: OutputType; mode: 'pure' | 'io' }>; } export type InspectionReply = { type: 'inspected'; id: number; io: true } | { type: 'inspected'; id: number; result: ValueView } | { type: 'inspection-error'; id: number; message: string; fatal: boolean }; -export type ClientMessage = { type: 'v2-command'; command: import('./io-v2-session.js').Command } | { type: 'io-stop' } | { type: 'inspect'; id: number; name: string; output: import('./auto-interpretation.js').InspectionFormat } +export type ClientMessage = { type: 'io-command'; command: import('./io-session.js').Command } | { type: 'io-stop' } | { type: 'inspect'; id: number; name: string; output: import('./auto-interpretation.js').InspectionFormat } | { type: 'run-io'; name: string; output: OutputType; step: boolean } | { type: 'load'; hash: BundleHash; localId?: string } | { type: 'run'; export: string; arguments: unknown; expression?: string; output: OutputType; mode: 'pure' | 'io'; step: boolean } @@ -20,7 +20,7 @@ export type ClientMessage = { type: 'v2-command'; command: import('./io-v2-sessi | { type: 'export'; export: string; format: TreeFormat } | { type: 'resume'; id: RequestId }; export type WorkerMessage = InspectionReply | { type: 'io-version'; version: 2 } - | { type: 'v2-open'; run: string } | { type: 'v2-event'; event: import('./io-v2-events.js').Event } | { type: 'v2-close'; run: string; settled: boolean } | { type: 'module-exported'; bytes: Uint8Array; hash: BundleHash; manifest: string } + | { type: 'io-open'; run: string } | { type: 'io-event'; event: import('./io-events.js').Event } | { type: 'io-close'; run: string; settled: boolean } | { type: 'module-exported'; bytes: Uint8Array; hash: BundleHash; manifest: string } | { type: 'module-export-error'; message: string } | { type: 'state'; state: 'running' } | { type: 'state'; state: 'step'; id: RequestId } diff --git a/ext/demo-site/src/wasm-worker.ts b/ext/demo-site/src/wasm-worker.ts @@ -23,17 +23,17 @@ function requestStep(): Promise<void> { const id = ++requestId as RequestId; return new Promise((resolve, reject) => { pending = { id, resolve, reject }; send({ type: 'state', state: 'step', id }); }); } -let v2Command: ((command: import('./io-v2-session.js').Command) => void) | undefined, v2Run: string | undefined, stopRequested = false; +let ioCommand: ((command: import('./io-session.js').Command) => void) | undefined, ioRun: string | undefined, stopRequested = false; function host(stepping: boolean): import('./io.js').IOHost { stopRequested = false; return { selected(version) { send({ type: 'io-version', version }); }, async before(event) { send({ type: 'trace', phase: 'before', event }); if (stepping) await requestStep(); running(); }, - v2: { + structured: { stepping, - open(run, command) { v2Run = run; v2Command = command; send({ type: 'v2-open', run }); if (stopRequested) command({ run, kind: 'stop' }); }, - event(event) { send({ type: 'v2-event', event }); }, - close(run, settled) { v2Command = undefined; v2Run = undefined; pending = undefined; send({ type: 'v2-close', run, settled }); }, + open(run, command) { ioRun = run; ioCommand = command; send({ type: 'io-open', run }); if (stopRequested) command({ run, kind: 'stop' }); }, + event(event) { send({ type: 'io-event', event }); }, + close(run, settled) { ioCommand = undefined; ioRun = undefined; pending = undefined; send({ type: 'io-close', run, settled }); }, }, }; } @@ -43,13 +43,13 @@ scope.onmessage = ({ data }) => { if (m['type'] === 'io-stop') { if (!busy) return; stopRequested = true; - if (v2Run) v2Command?.({ run: v2Run, kind: 'stop' }); + if (ioRun) ioCommand?.({ run: ioRun, kind: 'stop' }); if (pending) { const waiter = pending; pending = undefined; waiter.resolve(); } return; } - if (m['type'] === 'v2-command') { + if (m['type'] === 'io-command') { const command = m['command']; - if (command && typeof command === 'object') v2Command?.(command as import('./io-v2-session.js').Command); + if (command && typeof command === 'object') ioCommand?.(command as import('./io-session.js').Command); return; } if (m['type'] === 'resume') { diff --git a/ext/demo-site/test/backend-browser.mjs b/ext/demo-site/test/backend-browser.mjs @@ -61,12 +61,12 @@ try { console.log('Test phases:',JSON.stringify(marks.slice(-4))); void evaluate("({status:document.getElementById('status')?.textContent,activity:document.getElementById('compile-activity')?.textContent,card:document.querySelector('.definition-card [role=status]')?.textContent})").then(state=>console.log('Page:',JSON.stringify(state))).catch(()=>{}); },30000); - const report=await evaluate(`import(${JSON.stringify(process.env.TEST_IO_V2_PUBLIC?'/io-v2-public-probe.mjs':process.env.TEST_IO_V2_SOURCE?'/io-v2-source-probe.mjs':process.env.TEST_IO_V2_SESSION?'/io-v2-session-probe.mjs':process.env.TEST_IO_V2_CORE?'/io-v2-core-probe.mjs':process.env.TEST_IO_V2_LIFECYCLE?'/io-v2-lifecycle-probe.mjs':process.env.TEST_IO_V2_PROTOCOL?'/io-v2-protocol-probe.mjs':process.env.TEST_IMPORT_FOLDS?'/import-folds-probe.mjs':process.env.TEST_IO_VERSION?'/io-version-probe.mjs':process.env.TEST_DEFINITION_NAMES?'/definition-names-probe.mjs':process.env.TEST_INLINE_IO?'/inline-io-probe.mjs':process.env.TEST_BASIC_EXAMPLES?'/basic-examples-probe.mjs':process.env.TEST_IMPORT_PICKER?'/import-picker-probe.mjs':process.env.TEST_COLD_LOAD?'/cold-load-probe.mjs':process.env.TEST_ASSET_CACHE?'/asset-cache-probe.mjs':process.env.TEST_LOCAL_MODULES?'/local-module-probe.mjs':process.env.TEST_MODULES?'/module-probe.mjs':process.env.TEST_IDE?'/ide-probe.mjs':process.env.TEST_LIVE_UX?'/live-ux-probe.mjs':process.env.TEST_BENCHMARK?'/backend-benchmark.mjs':'/backend-probe.mjs')}).then(m=>m.run(${JSON.stringify(control)},${JSON.stringify(process.env.TEST_BACKEND||'haskell-wasm')},${!!(process.env.BENCHMARK_PSS||process.env.TEST_IO_V2_TERMINAL)}))`); + const report=await evaluate(`import(${JSON.stringify(process.env.TEST_IO_PUBLIC?'/io-public-probe.mjs':process.env.TEST_IO_SOURCE?'/io-source-probe.mjs':process.env.TEST_IO_SESSION?'/io-session-probe.mjs':process.env.TEST_IO_CORE?'/io-core-probe.mjs':process.env.TEST_IO_LIFECYCLE?'/io-lifecycle-probe.mjs':process.env.TEST_IO_PROTOCOL?'/io-protocol-probe.mjs':process.env.TEST_IMPORT_FOLDS?'/import-folds-probe.mjs':process.env.TEST_IO_VERSION?'/io-version-probe.mjs':process.env.TEST_DEFINITION_NAMES?'/definition-names-probe.mjs':process.env.TEST_INLINE_IO?'/inline-io-probe.mjs':process.env.TEST_BASIC_EXAMPLES?'/basic-examples-probe.mjs':process.env.TEST_IMPORT_PICKER?'/import-picker-probe.mjs':process.env.TEST_COLD_LOAD?'/cold-load-probe.mjs':process.env.TEST_ASSET_CACHE?'/asset-cache-probe.mjs':process.env.TEST_LOCAL_MODULES?'/local-module-probe.mjs':process.env.TEST_MODULES?'/module-probe.mjs':process.env.TEST_IDE?'/ide-probe.mjs':process.env.TEST_LIVE_UX?'/live-ux-probe.mjs':process.env.TEST_BENCHMARK?'/backend-benchmark.mjs':'/backend-probe.mjs')}).then(m=>m.run(${JSON.stringify(control)},${JSON.stringify(process.env.TEST_BACKEND||'haskell-wasm')},${!!(process.env.BENCHMARK_PSS||process.env.TEST_IO_TERMINAL)}))`); if(process.env.TEST_IMPORT_FOLDS)await (await import('./import-fold-inputs.mjs')).importFoldInputs({send,evaluate,report,prefix:process.env.TEST_SCREENSHOT_PREFIX}); if(process.env.TEST_DEFINITION_NAMES)await (await import('./definition-name-inputs.mjs')).definitionNameInputs({send,evaluate,report,prefix:process.env.TEST_SCREENSHOT_PREFIX}); if(process.env.TEST_INLINE_IO&&process.env.TEST_SCREENSHOT_PREFIX){ await evaluate("document.getElementById('run-io').click()"); - for(let i=0;!await evaluate("!!document.querySelector('#terminal-v2 form input')");i++){assert(i<3600);await new Promise(r=>setTimeout(r,25));} + for(let i=0;!await evaluate("!!document.querySelector('#terminal-io form input')");i++){assert(i<3600);await new Promise(r=>setTimeout(r,25));} for(const width of [390,1440]){ await send('Emulation.setDeviceMetricsOverride',{width,height:1000,deviceScaleFactor:1,mobile:width<741}); await evaluate("new Promise(r=>{document.activeElement?.blur();window.scrollTo({top:0,behavior:'instant'});requestAnimationFrame(()=>requestAnimationFrame(r));})"); @@ -75,7 +75,7 @@ try { const shot=await send('Page.captureScreenshot',{format:'png',captureBeyondViewport:true,clip:{x:0,y:0,width:r.width,height:r.height,scale:1}}); await writeFile(process.env.TEST_SCREENSHOT_PREFIX+'-io-'+width+'.png',Buffer.from(shot.data,'base64')); } - await evaluate("document.querySelector('#terminal-v2 form input').value='Screenshot';document.querySelector('#terminal-v2 form').requestSubmit()"); + await evaluate("document.querySelector('#terminal-io form input').value='Screenshot';document.querySelector('#terminal-io form').requestSubmit()"); for(let i=0;!await evaluate("document.getElementById('status').textContent==='Finished'");i++){assert(i<3600);await new Promise(r=>setTimeout(r,25));} } if(process.env.TEST_RELOAD){ diff --git a/ext/demo-site/test/backend-resume-probe.mjs b/ext/demo-site/test/backend-resume-probe.mjs @@ -1,7 +1,7 @@ // Actual page-facing step IDs and run-bound v2 terminal replies. export async function probeResume(backend){ const base=new URL('../',document.querySelector('script[type=module][src]').src); - const api=await import(new URL('dist/probe-api.js',base)),{Terminal}=api.ioV2Terminal; + const api=await import(new URL('dist/probe-api.js',base)),{Terminal}=api.ioTerminal; const releases=api.packages.packageIndex(await(await fetch(new URL('packages.json',base))).json()); const lib=name=>api.packages.packageReleases(releases,'tricu-lib-'+name)[0].manifest; const worker=new Worker(new URL('./wasm-worker.js?backend='+backend,document.querySelector('script[type=module][src]').src),{type:'module'}),events=[]; @@ -18,10 +18,10 @@ export async function probeResume(backend){ } throw Error('Resume probe timed out'); }; - const reply=value=>worker.postMessage({type:'v2-command',command:{run:value.run,kind:'reply',reply:value}}); + const reply=value=>worker.postMessage({type:'io-command',command:{run:value.run,kind:'reply',reply:value}}); try{ worker.postMessage({type:'compile',source:`!import ${lib('io')} as I -!import ${lib('io-v2-text')} as T +!import ${lib('io-text')} as T !export main main = I.io (I.require I.stdin (reader : I.require (T.readLine (T.lineCursor reader) 256) (reply : case reply of [["line" value _] _] = I.pure value @@ -31,8 +31,8 @@ main = I.io (I.require I.stdin (reader : I.require (T.readLine (T.lineCursor rea worker.postMessage(run);const step=await wait(x=>x.type==='state'&&x.state==='step'); worker.postMessage({type:'resume',id:step.id,value:'wrong-kind'}); worker.postMessage({type:'resume',id:step.id+999});await sleep(); - assert(!events.some(x=>x.type==='error'||x.type==='done'||x.type==='v2-event'),'Malformed/stale step consumed wait'); - const input=(await wait(x=>x.type==='v2-event'&&x.event.kind==='read',0,true)).event; + assert(!events.some(x=>x.type==='error'||x.type==='done'||x.type==='io-event'),'Malformed/stale step consumed wait'); + const input=(await wait(x=>x.type==='io-event'&&x.event.kind==='read',0,true)).event; const terminal=new Terminal(input.run);terminal.handle(input); for(const value of [undefined,'bad\nline','\ud800','a'.repeat(257)])assert(!terminal.line(input.run,input.id,value),'Invalid UI line admitted'); for(const invalid of [ @@ -46,7 +46,7 @@ main = I.io (I.require I.stdin (reader : I.require (T.readLine (T.lineCursor rea const accepted=terminal.line(input.run,input.id,'λ雪🙂');reply(accepted); assert((await wait(x=>x.type==='done',0,true)).result.value==='λ雪🙂','Fresh reply mismatch'); const mark=events.length;worker.postMessage({...run,step:false}); - const next=(await wait(x=>x.type==='v2-event'&&x.event.kind==='read',mark)).event; + const next=(await wait(x=>x.type==='io-event'&&x.event.kind==='read',mark)).event; assert(next.run!==input.run,'Run identity reused');reply(accepted);await sleep(); assert(!events.slice(mark).some(x=>x.type==='done'||x.type==='error'),'Previous run reply accepted'); const fresh=new Terminal(next.run);fresh.handle(next);reply(fresh.line(next.run,next.id,'fresh')); diff --git a/ext/demo-site/test/backend-server.mjs b/ext/demo-site/test/backend-server.mjs @@ -39,13 +39,13 @@ export async function startSiteServer(directory,autoRun=false) { if(path==='/asset-cache-probe.mjs'){res.setHeader('Content-Type','text/javascript');res.end(await readFile(new URL('./asset-cache-probe.mjs',import.meta.url)));return;} if(path==='/runtime-fixtures.json'){res.setHeader('Content-Type','application/json');res.end(JSON.stringify(fixtures()));return;} if(path==='/program-fixtures.json'){res.setHeader('Content-Type','application/json');const data=JSON.parse(await readFile(new URL('../demos.json',import.meta.url)));for(const id of ['parser','lexer'])data.demos[id]=JSON.parse(await readFile(new URL('../'+id+'-demo.json',import.meta.url)));res.end(JSON.stringify(data));return;} - if(path==='/io-v2-lifecycle-probe.mjs'){res.setHeader('Content-Type','text/javascript');res.end(await readFile(new URL('./io-v2-lifecycle-probe.mjs',import.meta.url)));return;} - if(path==='/io-v2-source.json'&&process.env.IO_V2_SOURCE_BUNDLE){const bytes=await readFile(process.env.IO_V2_SOURCE_BUNDLE);res.setHeader('Content-Type','application/json');res.end(JSON.stringify({bytes:[...bytes],hash:createHash('sha256').update(bytes).digest('hex')}));return;} - if(path==='/io-v2-public-probe.mjs'){res.setHeader('Content-Type','text/javascript');res.end(await readFile(new URL('./io-v2-public-probe.mjs',import.meta.url)));return;} - if(path==='/io-v2-source-probe.mjs'){res.setHeader('Content-Type','text/javascript');res.end(await readFile(new URL('./io-v2-source-probe.mjs',import.meta.url)));return;} - if(path==='/io-v2-session-probe.mjs'){res.setHeader('Content-Type','text/javascript');res.end(await readFile(new URL('./io-v2-session-probe.mjs',import.meta.url)));return;} - if(path==='/io-v2-core-probe.mjs'){res.setHeader('Content-Type','text/javascript');res.end(await readFile(new URL('./io-v2-core-probe.mjs',import.meta.url)));return;} - if(path==='/io-v2-protocol-probe.mjs'){res.setHeader('Content-Type','text/javascript');res.end(await readFile(new URL('./io-v2-protocol-probe.mjs',import.meta.url)));return;} + if(path==='/io-lifecycle-probe.mjs'){res.setHeader('Content-Type','text/javascript');res.end(await readFile(new URL('./io-lifecycle-probe.mjs',import.meta.url)));return;} + if(path==='/io-source.json'&&process.env.IO_SOURCE_BUNDLE){const bytes=await readFile(process.env.IO_SOURCE_BUNDLE);res.setHeader('Content-Type','application/json');res.end(JSON.stringify({bytes:[...bytes],hash:createHash('sha256').update(bytes).digest('hex')}));return;} + if(path==='/io-public-probe.mjs'){res.setHeader('Content-Type','text/javascript');res.end(await readFile(new URL('./io-public-probe.mjs',import.meta.url)));return;} + if(path==='/io-source-probe.mjs'){res.setHeader('Content-Type','text/javascript');res.end(await readFile(new URL('./io-source-probe.mjs',import.meta.url)));return;} + if(path==='/io-session-probe.mjs'){res.setHeader('Content-Type','text/javascript');res.end(await readFile(new URL('./io-session-probe.mjs',import.meta.url)));return;} + if(path==='/io-core-probe.mjs'){res.setHeader('Content-Type','text/javascript');res.end(await readFile(new URL('./io-core-probe.mjs',import.meta.url)));return;} + if(path==='/io-protocol-probe.mjs'){res.setHeader('Content-Type','text/javascript');res.end(await readFile(new URL('./io-protocol-probe.mjs',import.meta.url)));return;} if(path==='/io-version-probe.mjs'){res.setHeader('Content-Type','text/javascript');res.end(await readFile(new URL('./io-version-probe.mjs',import.meta.url)));return;} if(path==='/backend-resume-probe.mjs'){res.setHeader('Content-Type','text/javascript');res.end(await readFile(new URL('./backend-resume-probe.mjs',import.meta.url)));return;} if(path==='/backend-benchmark.mjs'){res.setHeader('Content-Type','text/javascript');res.end(await readFile(new URL('./backend-benchmark.mjs',import.meta.url)));return;} diff --git a/ext/demo-site/test/graph.test.mjs b/ext/demo-site/test/graph.test.mjs @@ -1,9 +1,9 @@ import {terminalHost} from './terminal-host.mjs'; -import {Terminal} from '../dist/io-v2-terminal.js'; -import {constructors} from '../dist/io-v2-private.js'; -import {Session} from '../dist/io-v2-session.js'; -import {envelope} from '../dist/io-v2-profile.js'; -import {drive} from '../dist/io-v2-driver.js'; +import {Terminal} from '../dist/io-terminal.js'; +import {constructors} from '../dist/io-private.js'; +import {Session} from '../dist/io-session.js'; +import {envelope} from '../dist/io-profile.js'; +import {drive} from '../dist/io-driver.js'; import test from 'node:test'; import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; diff --git a/ext/demo-site/test/inline-io-probe.mjs b/ext/demo-site/test/inline-io-probe.mjs @@ -12,12 +12,12 @@ export async function run(control,backend='haskell-wasm'){ assert(!el('expression-editor')&&!el('run')&&!document.querySelector('.expression-dock'),'No separate evaluation component'); const base=new URL('../',document.querySelector('script[type=module][src]').src); const api=await import(new URL('dist/probe-api.js',base)); - const {createRuntimeSession}=api.runtimeSession,{Terminal}=api.ioV2Terminal; + const {createRuntimeSession}=api.runtimeSession,{Terminal}=api.ioTerminal; const releases=api.packages.packageIndex(await(await fetch(new URL('packages.json',base))).json()); const lib=name=>api.packages.packageReleases(releases,'tricu-lib-'+name)[0].manifest; const ioDefinition='io = IO.io (IO.require IO.stdin readAction)'; const fixture=`!import ${lib('io')} as IO -!import ${lib('io-v2-text')} as Text +!import ${lib('io-text')} as Text !export answer answer = 42 readAction reader = IO.require (Text.readLine (Text.lineCursor reader) 256) (reply : case reply of @@ -42,24 +42,24 @@ abort = IO.io (IO.fail loop)`; assert((await session.runDefinitionIO('io','string',terminalHost(Terminal,'λ雪🙂'))).value==='λ雪🙂','Private IO definition runs directly'); const output=terminalHost(Terminal,null); await session.runDefinitionIO('write','tricu',output);assert(output.terminalText==='Explicit output','Explicit IO executes once'); - let aborted=false;try{await session.runDefinitionIO('abort','tricu',terminalHost(Terminal,null));}catch(error){aborted=/IO v2 failed/.test(String(error));}assert(aborted,'Failure does not force divergent diagnostic'); + let aborted=false;try{await session.runDefinitionIO('abort','tricu',terminalHost(Terminal,null));}catch(error){aborted=/IO failed/.test(String(error));}assert(aborted,'Failure does not force divergent diagnostic'); assert((await session.inspectDefinition('answer','number')).value==='42','Failure preserves definitions'); }finally{session.dispose();} document.querySelector('[data-compiler-example=identity]').click();input('source-editor',fixture);await status('Compiled'); const button=name=>document.querySelector(`[aria-label="Inspect compiled definition ${name}"]`); const card=()=>document.querySelector('.definition-card'),view=()=>card().querySelector('pre'); - const form=()=>el('terminal-v2').querySelector('form:has(input)'); - const output=()=>el('terminal-v2').querySelector('[data-stream=output]').textContent; + const form=()=>el('terminal-io').querySelector('form:has(input)'); + const output=()=>el('terminal-io').querySelector('[data-stream=output]').textContent; const open=async name=>{button(name).click();await pause(0);await wait(()=>!card().hidden&&!card().querySelector('select').disabled,'inspect '+name);}; const advance=async check=>{for(let i=0;i<3600;i++){if(check())return;if(el('status').textContent==='Paused before action')el('next').click();await pause(25);}throw Error('Stepped IO did not progress');}; await open('answer');assert(view().textContent==='42','Pure Auto inspection'); assert(el('run-io').closest('section').hidden,'Pure binding has no IO run surface'); assert(!card().querySelector('.definition-actions button').disabled,'Pure result can be copied/downloaded');button('answer').click(); await open('latent');assert(card().textContent.includes('IO program · authenticated tricuIO envelope'),'Detection never forces divergent action'); - assert(el('terminal-v2').textContent===''&&!el('input-form'),'Opening IO never executes');button('latent').click(); + assert(el('terminal-io').textContent===''&&!el('input-form'),'Opening IO never executes');button('latent').click(); // Structured Stop is not a hard interruption of divergent native reduction. // Exercise acknowledged cancellation below at a real pending terminal read. - await open('write');await pause(100);assert(el('terminal-v2').textContent==='','Detection emits nothing');el('run-io').click();await status('Finished'); + await open('write');await pause(100);assert(el('terminal-io').textContent==='','Detection emits nothing');el('run-io').click();await status('Finished'); assert(output()==='Explicit output','Explicit inline output');el('run-io').click();await status('Finished');assert(output()==='Explicit output','Rerun clears terminal');button('write').click(); await open('io');el('io-return-format').value='string';el('step-mode').checked=true;el('run-io').click();await status('Paused before action'); assert(!form(),'Step stops before input admission');await advance(()=>!!form()); @@ -81,7 +81,7 @@ abort = IO.io (IO.fail loop)`; [...el('package-definition-list').querySelectorAll('button')].find(b=>b.textContent==='main').click();await wait(()=>!el('run-io').disabled,'source-free IO detection'); const sendLine=async value=>{await wait(()=>form(),'line input');form().querySelector('input').value=value;form().requestSubmit();await status('Finished');}; el('io-return-format').value='string';el('run-io').click();await sendLine('Source-free');assert(output()==='What is your name?\nHello, Source-free!\n','Source-free IO executes'); - change('program-picker','demo:greet');await status('Ready');await open('main');assert(el('terminal-v2').textContent===''&&!el('input-form'),'Published IO is opt-in'); + change('program-picker','demo:greet');await status('Ready');await open('main');assert(el('terminal-io').textContent===''&&!el('input-form'),'Published IO is opt-in'); el('io-return-format').value='string';el('run-io').click();await sendLine('Inline tester');assert(output().includes('Inline tester'),'Published conversation'); el('run-io').click();await sendLine('');assert(output()==='What is your name?\nHello, !\n','Empty line is data'); el('run-io').click();await wait(()=>form(),'EOF prompt');form().querySelector('button[type=button]').click();await status('Finished');assert(output()==='What is your name?\nEnd of input.\n'&&view().textContent==='','Explicit EOF is distinct'); diff --git a/ext/demo-site/test/io-core-probe.mjs b/ext/demo-site/test/io-core-probe.mjs @@ -0,0 +1,22 @@ +// Executes bare computation trees privately on native evaluators. Production IO +// envelopes and terminal providers remain gated; no JS evaluator substitution. +export async function run(_control,backend){ + const base=new URL('../',document.querySelector('script[type=module][src]').src); + const fixtures=await(await fetch('/runtime-fixtures.json')).json(),checks=[]; + let execute,dispose; + const {CompilerWorker}=await import(new URL('runtime/haskell/client.mjs',base)); + const client=new CompilerWorker();dispose=()=>client.dispose();await client.request({type:'init',compiler:false}); + execute=async fixture=>{await client.request({type:'load',bytes:new Uint8Array(fixture.bytes).buffer,hash:fixture.hash});return JSON.parse((await client.request({type:'io-core',name:'root',arguments:[]})).text);}; + const cases=[['pure','completed'],['bind','completed'],['scope','completed'],['child-0','failed'],['child-1','completed'],['unsupported','completed'],['discard-invalid','completed'],['terminal-2','failed'],['terminal-9','cancelled'],['malformed','failed']]; + try{ + for(const [name,kind]of cases){ + const out=await execute(fixtures['v2-run-'+name]); + const expected={kind,value:kind==='completed'?'42':null,cleanup:0,tasks:{scopes:0,tasks:0,hosts:0},resources:{resources:0,admissions:0,leases:0},host:{pending:0,replies:0}}; + if(JSON.stringify(out)!==JSON.stringify(expected))throw Error(name+': '+JSON.stringify(out)); + checks.push(name); + } + for(let i=0;i<5;i++)await execute(fixtures['v2-run-scope']); + checks.push('repeated run retirement and reuse'); + }finally{dispose();} + return {backend,checks}; +} diff --git a/ext/demo-site/test/io-core.test.mjs b/ext/demo-site/test/io-core.test.mjs @@ -0,0 +1,111 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {Graph} from '../dist/graph.js'; +import {natural,text,readNatural,readList,readText} from '../dist/codecs.js'; +import {Core,Wire} from '../dist/io-core.js'; +function fixture(){ + const g=new Graph(),tree={leaf:g.leaf,observe:g.observe.bind(g),fork:g.fork.bind(g),stem:g.stem.bind(g),app:g.app.bind(g),natural:n=>natural(g,n),text:s=>text(g,s)}; + const w=new Wire(tree,1n),n=x=>natural(g,BigInt(x)),act=(code,p=g.leaf)=>g.fork(n(code),p),k=x=>g.fork(g.leaf,x); + const bind=(left,right)=>act(1,g.fork(left,k(right))); + const spawn=(child,sup=true,keep=true)=>act(5,g.fork(n(sup?1:0),g.fork(n(keep?1:0),g.fork(g.leaf,k(child))))); + const request=(args=g.leaf)=>act(10,g.fork(text(g,'future'),g.fork(n(1),g.fork(text(g,'read'),args)))); + const provider={request:e=>()=>e.wire.result(false,e.wire.problem('unsupported','future/read')),release:()=>assert.fail('borrowed backend close')}; + const core=(action,p=provider,limit)=>new Core(tree,1n,action,[null,null,null],p,limit); + return {g,tree,w,n,act,k,bind,spawn,request,provider,core}; +} +async function drain(e,turn=()=>{}){ + for(let i=0;i<20000&&!e.result;i++){ + const progress=e.advance();turn(); + if(!progress){assert(e.work.counts.pending,'interpreter stalled');await e.wait();} + } + assert(e.result,'interpreter did not settle'); + assert.deepEqual(e.lifecycle.tasks.counts,{scopes:0,tasks:0,hosts:0}); + assert.deepEqual(e.lifecycle.resources.counts,{resources:0,admissions:0,leases:0}); + assert.deepEqual(e.work.counts,{pending:0,replies:0});return e.result; +} +function result(f,value){const [flag,tail]=f.w.pair(value);return {ok:f.g.observe(flag).node.kind==='stem',value:f.w.pair(tail)[0]};} + +test('real tree bind/scope/current-scope execute, with roots collected between turns',async()=>{ + const f=fixture(),e=f.core(f.bind(f.act(0,f.n(1)),f.act(3,f.k(f.act(4))))); + const out=await drain(e,()=>f.g.collect([...e.roots()]));assert.equal(out.kind,'completed'); + const parts=readList(f.g,out.value,3);assert.equal(readText(f.g,parts[0]),'io.v2.scope');assert.equal(readNatural(f.g,parts[2]),1n); +}); +test('opaque terminal diagnostics never demand payloads or abandoned continuations',async()=>{ + for(const tag of [2,9]){ + const f=fixture(),poison=f.g.app(f.g.leaf,f.g.leaf),observe=f.tree.observe; + f.tree.observe=h=>{if(h===poison)throw Error('forced opaque diagnostic');return observe(h);}; + const e=f.core(f.act(1,f.g.fork(f.act(tag,poison),poison))),out=await drain(e); + assert.equal(out.kind,tag===2?'failed':'cancelled');assert.equal(out.value,poison);assert.equal(e.fault,undefined); + } +}); +test('spawn/join encode the native outcome contract and consume tickets once',async()=>{ + const f=fixture(),join=f.act(6,f.w.marker('io.v2.join',1)); + const e=f.core(f.bind(f.spawn(f.act(0,f.n(42))),join)),out=await drain(e),joined=result(f,out.value); + assert(joined.ok);const parts=readList(f.g,joined.value,2);assert.equal(readText(f.g,parts[0]),'completed');assert.equal(readNatural(f.g,parts[1]),42n); + const other=fixture(),again=other.act(6,other.w.marker('io.v2.join',1)); + const repeated=await drain(other.core(other.bind(other.spawn(other.act(0)),other.bind(again,again)))); + const stale=result(other,repeated.value);assert(!stale.ok);assert.equal(readText(other.g,readList(other.g,stale.value,4)[0]),'stale-task'); +}); +test('supervised failure is data; uncontained failure defeats parent success',async()=>{ + for(const supervised of [true,false]){ + const f=fixture(),out=await drain(f.core(f.bind(f.spawn(f.act(2,f.n(17)),supervised),f.act(6,f.w.marker('io.v2.join',1))))); + assert.equal(out.kind,supervised?'completed':'failed'); + if(supervised){const joined=result(f,out.value);assert(joined.ok);assert.equal(readText(f.g,readList(f.g,joined.value,3)[0]),'failed');} + else assert.equal(readNatural(f.g,out.value),17n); + } +}); +test('unsupported facilities stay ordinary Results without argument demand',async()=>{ + const f=fixture(),poison=f.g.app(f.g.leaf,f.g.leaf),observe=f.tree.observe;f.tree.observe=h=>{if(h===poison)throw Error('demanded arguments');return observe(h);}; + const e=f.core(f.request(poison)),out=await drain(e,()=>f.g.collect([...e.roots()]));assert.equal(out.kind,'completed');assert.equal(result(f,out.value).ok,false); +}); +test('tree cancellation drains pending provider before join and suppresses abandoned continuation',async()=>{ + const f=fixture(),args=f.n(123);let acknowledge,aborts=0,argumentLive=false; + const gate=new Promise(resolve=>{acknowledge=resolve;}); + const p={...f.provider,request:async(e,_scope,request,signal)=>{ + await new Promise(resolve=>signal.addEventListener('abort',()=>{aborts++;resolve();},{once:true})); + await gate;argumentLive=readNatural(f.g,request.arguments)===123n;return ()=>{throw Error('cancelled reply encoder ran');}; + }}; + const cancel=f.act(8,f.g.fork(f.w.marker('io.v2.task',1),f.n(7))); + const e=f.core(f.bind(f.spawn(f.request(args)),f.bind(cancel,f.act(6,f.w.marker('io.v2.join',1)))),p); + for(let i=0;i<40;i++){const progress=e.advance();f.g.collect([...e.roots()]);if(!progress)break;} + assert.equal(aborts,1);assert.equal(e.result,null);assert.equal(e.work.counts.pending,1); + acknowledge();await e.wait();const out=await drain(e,()=>f.g.collect([...e.roots()]));assert(argumentLive); + const joined=result(f,out.value),parts=readList(f.g,joined.value,3);assert.equal(readText(f.g,parts[0]),'cancelled');assert.equal(readNatural(f.g,parts[1]),7n); +}); +test('malformed action is terminal, malformed operation is an ordinary Result',async()=>{ + const f=fixture(),terminal=await drain(f.core(f.act(1,f.g.leaf)));assert.equal(terminal.kind,'failed'); + const ordinary=await drain(f.core(f.act(6,f.g.leaf)));assert.equal(ordinary.kind,'completed');assert(!result(f,ordinary.value).ok); +}); +test('evaluator fault after lexical admission drains admitted scopes without retrying evaluation',async()=>{ + const f=fixture(),failure=new Error('injected evaluator failure');let applications=0; + f.tree.app=()=>{applications++;throw failure;};const e=f.core(f.act(3,f.k(f.act(0)))); + assert.equal((await drain(e)).kind,'failed');assert.equal(e.fault,failure);assert.equal(applications,1); +}); +test('action bound stops the entire run and cleans up',async()=>{ + const f=fixture(),e=f.core(f.bind(f.act(0),f.act(0)),f.provider,1); + assert.equal((await drain(e)).kind,'failed');assert.equal(e.fault,undefined); +}); + +test('evaluator errors cannot masquerade as protocol or ordinary-operation errors',async()=>{ + for(const message of ['invalid computation shape','invalid-argument']){ + const f=fixture(),fault=new Error(message),action=f.act(6,f.g.leaf),observe=f.tree.observe; + f.tree.observe=h=>{if(h===(message==='invalid-argument'?f.g.leaf:action))throw fault;return observe(h);}; + const e=f.core(action);assert.equal((await drain(e)).kind,'failed');assert.equal(e.fault,fault);assert(e.hasFault); + } +}); +test('undefined provider rejection is retained as an embedding fault, not successful undefined data',async()=>{ + const f=fixture(),e=f.core(f.request(),{...f.provider,request:()=>Promise.reject(undefined)}); + assert.equal((await drain(e)).kind,'failed');assert(e.hasFault);assert.equal(e.fault,undefined); +}); + +test('lexical physical cleanup failure defeats success once, with uncertain release progress',async()=>{ + const f=fixture();let closed=0; + const e=f.core(f.act(3,f.k(f.request())),{ + request:(engine,scope)=>{ + const r=engine.lifecycle.resources;r.publish(r.beginAcquire(scope),'test',null); + return ()=>engine.wire.result(true,engine.tree.leaf); + },release:()=>{closed++;throw Error('uncertain close');} + }); + const out=await drain(e);assert.equal(out.kind,'failed');assert.equal(closed,1);assert.equal(out.cleanup.items.length,0); + const problem=readList(f.g,out.value,4);assert.equal(readText(f.g,problem[0]),'cleanup-failed');assert.equal(readText(f.g,problem[3]),'unknown'); +}); diff --git a/ext/demo-site/test/io-events.test.mjs b/ext/demo-site/test/io-events.test.mjs @@ -0,0 +1,33 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {Events} from '../dist/io-events.js'; +test('invalid/stale/wrong-kind replies preserve the live read',async()=>{ + const e=new Events('1'),c=new AbortController(),pending=e.read(3,c.signal); + assert.equal(e.reply({run:'1',id:1,kind:'read',bytes:[1],eof:false}),false); + const event=e.take()[0]; + for(const change of [{run:'2'},{id:2},{kind:'write',count:1},{bytes:[0,1,2,3]},{bytes:[256]},{bytes:[],eof:false},{bytes:[1],eof:true},{kind:'cancelled'}]) + assert.equal(e.reply({...event,bytes:[1],eof:false,...change}),false); + assert.equal(e.counts.pending,1);assert(e.reply({...event,bytes:[1,2],eof:false}));assert.deepEqual(await pending,{kind:'read',bytes:[1,2],eof:false}); + assert.equal(e.reply({...event,bytes:[1],eof:false}),false);assert.deepEqual(e.counts,{pending:0,queued:0}); +}); +test('cancellation before dispatch has no host effect; delivered cancellation waits for acknowledgement',async()=>{ + const e=new Events('1'),a=new AbortController(),p=e.read(4,a.signal);a.abort();assert.deepEqual(e.take(),[]);assert.deepEqual(await p,{kind:'cancelled'}); + const b=new AbortController(),q=e.read(4,b.signal),event=e.take()[0];let settled=false;q.then(()=>{settled=true;}); + b.abort();b.abort();await Promise.resolve();assert(!settled);assert.deepEqual(e.take(),[{run:'1',id:event.id,kind:'cancel',operation:'read'}]); + assert(e.reply({...event,kind:'cancelled'}));assert.deepEqual(await q,{kind:'cancelled'});assert.deepEqual(e.counts,{pending:0,queued:0}); +}); +test('normal completion racing abort acknowledges cleanup but discards its data',async()=>{ + const e=new Events('1'),c=new AbortController(),p=e.write('output',[1,2],c.signal),event=e.take()[0];c.abort(); + assert(e.reply({...event,kind:'write',count:1}));assert.deepEqual(await p,{kind:'cancelled'});assert.deepEqual(e.take(),[]); +}); +test('output snapshots cannot mutate validation; partial progress is explicit',async()=>{ + const e=new Events('1'),bytes=[1,2],c=new AbortController(),p=e.write('error',bytes,c.signal);bytes.push(3); + const event=e.take()[0];event.bytes.push(4);event.kind='read'; + assert(!e.reply({run:'1',id:1,kind:'write',count:3}));assert(e.reply({run:'1',id:1,kind:'write',count:1})); + assert.deepEqual(await p,{kind:'write',count:1}); +}); +test('identity exhaustion cannot prevent cancellation or retirement; repeated operations retain no history',async()=>{ + const e=new Events('1',1),c=new AbortController(),p=e.read(1,c.signal),event=e.take()[0]; + assert.throws(()=>e.read(1,c.signal),/exhausted/);c.abort();assert(e.reply({...event,kind:'cancelled'}));await p; + const many=new Events('2');for(let i=0;i<1000;i++){const pending=many.read(1,new AbortController().signal),ev=many.take()[0];assert(many.reply({...ev,bytes:[],eof:true}));await pending;assert.deepEqual(many.counts,{pending:0,queued:0});} +}); diff --git a/ext/demo-site/test/io-host-work.test.mjs b/ext/demo-site/test/io-host-work.test.mjs @@ -0,0 +1,94 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {Tasks} from '../dist/io-tasks.js'; +import {HostWork} from '../dist/io-host-work.js'; +const ok=value=>({kind:'completed',value}),failed=value=>({kind:'failed',value}); +const finish=(tasks,scope)=>{tasks.bodyDone(scope,ok(null));assert(tasks.beginRelease(scope));return tasks.settle(scope);}; +const deferred=()=>{let resolve;const promise=new Promise(done=>{resolve=done;});return {promise,resolve};}; +const setup=()=>{const tasks=new Tasks(1n,'owner-stopped','cycle');return {tasks,work:new HostWork(tasks)};}; + +test('pending read never blocks sibling work; cancellation waits for actual provider cleanup',async()=>{ + const {tasks,work}=setup(),read=tasks.spawn(0,true,true),writer=tasks.spawn(0,true,false),cleanup=deferred(),events=[]; + const opaque=new Proxy({},{get(){throw Error('late value forced');}}); + work.start(read.key,async signal=>{ + events.push('read started'); + await new Promise(resolve=>signal.addEventListener('abort',()=>{events.push('abort requested');resolve();},{once:true})); + await cleanup.promise;events.push('read cleanup finished');return opaque; + }); + work.start(writer.key,()=>{events.push('sibling wrote');return 7;}); + await work.wait();assert.equal(work.take().outcome.value,7);finish(tasks,writer.key); + tasks.cancel(0,read,'stop');work.cancelStopped();work.cancelStopped(); + tasks.bodyDone(read.key,ok(null));assert.equal(tasks.beginRelease(read.key),false); + await Promise.resolve();assert.deepEqual(events,['read started','sibling wrote','abort requested']); + const settled=work.wait();cleanup.resolve();await settled; + assert.equal(work.take(),null);assert.deepEqual(work.counts,{pending:0,replies:0}); + assert(tasks.beginRelease(read.key));tasks.settle(read.key); + tasks.claimJoin(0,read);assert.equal(tasks.pollJoin(0).kind,'cancelled');finish(tasks,0); + assert.equal(events.at(-1),'read cleanup finished');assert.deepEqual(tasks.counts,{scopes:0,tasks:0,hosts:0}); +}); +test('completion queued before cancellation is discarded and cannot revive the body',async()=>{ + const {tasks,work}=setup(),child=tasks.spawn(0,true,false),value={retained:'reply'}; + work.start(child.key,()=>value);await work.wait();assert.deepEqual([...work.values()],[value]); + tasks.cancel(0,child,'stop');work.cancelStopped();assert.deepEqual([...work.values()],[]);assert.equal(work.take(),null); + finish(tasks,child.key);finish(tasks,0);assert.deepEqual(work.counts,{pending:0,replies:0}); +}); +test('queued data for an already settled scope is dropped; no scope history required',async()=>{ + const {tasks,work}=setup(),child=tasks.spawn(0,true,false); + work.start(child.key,()=>42);await work.wait();finish(tasks,child.key); + work.cancelStopped();assert.equal(work.take(),null);assert.deepEqual(work.counts,{pending:0,replies:0});finish(tasks,0); +}); +test('synchronous throws and async rejections settle ownership once without inspecting errors',async()=>{ + for(const asynchronous of [false,true]){ + const {tasks,work}=setup(),error=new Proxy({},{get(){throw Error('opaque error forced');}}); + work.start(0,()=>{if(asynchronous)return Promise.reject(error);throw error;}); + await work.wait();const reply=work.take();assert.equal(reply.outcome.ok,false);assert.equal(reply.outcome.error,error); + assert.equal(work.take(),null);assert.equal(tasks.state(0).hostCalls,0);finish(tasks,0); + } +}); +test('read rejection after cancellation is acknowledgement, not a fresh task failure',async()=>{ + const {tasks,work}=setup(),child=tasks.spawn(0,true,false); + work.start(child.key,signal=>new Promise((_resolve,reject)=>signal.addEventListener('abort',()=>reject(Error('abort')),{once:true}))); + tasks.stop(child.key,failed('original'));work.cancelStopped();tasks.bodyDone(child.key,ok(null)); + await work.wait();assert.equal(work.take(),null);assert(tasks.beginRelease(child.key));assert.equal(tasks.settle(child.key).value,'original'); + finish(tasks,0); +}); +test('multiple waiters share notification; out-of-order replies have correct identities',async()=>{ + const {tasks,work}=setup(),a=deferred(),b=deferred(); + const first=work.start(0,()=>a.promise),second=work.start(0,()=>b.promise); + const one=work.wait(),two=work.wait();assert.equal(one,two);b.resolve('second');await one; + assert.deepEqual(work.take(),{token:second,outcome:{ok:true,value:'second'}}); + const next=work.wait();a.resolve('first');await next; + assert.deepEqual(work.take(),{token:first,outcome:{ok:true,value:'first'}});finish(tasks,0); +}); +test('sequential host operations retain neither callback history nor consumed reply data',async()=>{ + const {tasks,work}=setup(); + for(let i=0;i<1000;i++){ + const value={iteration:i};work.start(0,()=>value);await work.wait();assert.equal(work.take().outcome.value,value); + assert.deepEqual(work.counts,{pending:0,replies:0});assert.equal(tasks.state(0).hostCalls,0);assert.deepEqual([...work.values()],[]); + } + finish(tasks,0); +}); +test('async physical release survives new cancellation and reports cleanup failure',async()=>{ + const {tasks,work}=setup(),release=deferred(); + assert.throws(()=>work.release(0,()=>null),/invalid-transition/); + tasks.bodyDone(0,ok('body'));assert(tasks.beginRelease(0)); + const token=work.release(0,async()=>{await release.promise;throw 'cleanup failed';}); + assert.equal(token.kind,'release');assert.throws(()=>tasks.settle(0),/invalid-transition/); + tasks.stop(0,{kind:'cancelled',value:'primary'});work.cancelStopped(); + assert.deepEqual(tasks.takeCancellations(),[]);assert.throws(()=>tasks.settle(0),/invalid-transition/); + const waiting=work.wait();release.resolve();await waiting; + const reply=work.take();assert.equal(reply.outcome.ok,false);assert.equal(reply.outcome.error,'cleanup failed'); + const result=tasks.settle(0,{items:[reply.outcome.error],omitted:0}); + assert.equal(result.kind,'cancelled');assert.equal(result.value,'primary');assert.deepEqual(result.cleanup.items,['cleanup failed']); + assert.deepEqual(work.counts,{pending:0,replies:0}); +}); +test('body callback identity exhaustion cannot prevent the reserved cleanup batch',async()=>{ + const tasks=new Tasks(1n,'stop','cycle',1),work=new HostWork(tasks); + work.start(0,()=>1);await work.wait();work.take(); + assert.throws(()=>work.start(0,()=>2),/identity-exhausted/); + tasks.bodyDone(0,ok(null));assert(tasks.beginRelease(0)); + const release=work.release(0,()=>3);await work.wait();assert.equal(work.take().outcome.value,3); + assert.equal(release.kind,'release');assert.equal(tasks.endHost(release),'stale'); + assert.throws(()=>work.release(0,()=>4),/invalid-transition/);tasks.settle(0); + assert.deepEqual(tasks.counts,{scopes:0,tasks:0,hosts:0}); +}); diff --git a/ext/demo-site/test/io-lifecycle-probe.mjs b/ext/demo-site/test/io-lifecycle-probe.mjs @@ -0,0 +1,49 @@ +// Lifecycle building blocks with a real DOM read/provider and native pure work. +// This does NOT execute a v2 language program or bypass the production v2 gate. +export async function run(_control,backend){ + const assert=(value,message)=>{if(!value)throw Error(message);}; + const base=new URL('../',document.querySelector('script[type=module][src]').src); + const {ioResources:{lifecycle},ioHostWork:{HostWork},runtimeSession}=await import(new URL('dist/probe-api.js',base)); + const session=await runtimeSession.createRuntimeSession(backend,false),checks=[]; + const {tasks,resources}=lifecycle(1n,'owner-stopped','cycle',[null,null,null]),work=new HostWork(tasks); + const input=document.createElement('input');input.id='lifecycle-test-input';document.body.append(input); + const original=resources.publish(resources.beginAcquire(0),'reader',input); + const {task:read,handles}=resources.spawn(0,true,true,[original]),other=tasks.spawn(0,true,false); + let stale=false;try{resources.lookup(0,original);}catch(error){stale=error.message==='stale-handle';}assert(stale,'old alias survived transfer'); + let acknowledge,aborts=0,events=0,signal; + const cleanup=new Promise(resolve=>{acknowledge=resolve;}); + const listener=()=>{events++;};input.addEventListener('input',listener); + const release=async id=>{work.release(id,()=>resources.releaseScope(id,r=>r.backend.remove(),e=>e,'cleanup-failed'));await work.wait();const reply=work.take();assert(reply?.outcome.ok,'release failed');return tasks.settle(id,reply.outcome.value);}; + const finish=async id=>{tasks.bodyDone(id,{kind:'completed',value:null});assert(tasks.beginRelease(id),'release blocked');return release(id);}; + try{ + const fixture=(await(await fetch('/runtime-fixtures.json')).json())['pure-number']; + await session.load(new Uint8Array(fixture.bytes),fixture.hash); + work.start(read.key,async abort=>{ + signal=abort;const lease=resources.beginLease(read.key,handles[0],'reader','read'); + try{await new Promise(resolve=>abort.addEventListener('abort',()=>{aborts++;input.removeEventListener('input',listener);resolve();},{once:true})); + await cleanup;return 'late read value';}finally{resources.endLease(lease.token);} + }); + const forbidden=()=>{throw Error('pure work invoked IO hook');}; + work.start(other.key,()=>session.run('root',[],'number','pure',{before:forbidden,after:forbidden,read:forbidden,write:forbidden,running:forbidden})); + await work.wait();const reply=work.take();assert(reply.outcome.ok&&reply.outcome.value.value==='42','native sibling result');await finish(other.key); + assert(!signal.aborted&&tasks.state(read.key).hostCalls===1,'read was detached'); + input.dispatchEvent(new Event('input'));assert(events===1,'listener not live'); + checks.push('pending DOM read does not block sibling native pure evaluation'); + tasks.cancel(0,read,'cancel read');work.cancelStopped();work.cancelStopped(); + tasks.bodyDone(read.key,{kind:'completed',value:null}); + assert(aborts===1&&signal.aborted,'abort delivered more than once'); + input.dispatchEvent(new Event('input'));assert(events===1,'cancelled listener still active'); + assert(!tasks.beginRelease(read.key)&&input.isConnected,'cancellation request was treated as completion'); + const wait=work.wait();acknowledge();await wait; + assert(input.isConnected&&work.take()===null,'resource closed before scope release or late reply escaped'); + assert(resources.counts.leases===0,'read lease survived acknowledgement'); + assert(tasks.beginRelease(read.key),'acknowledged read did not release');await release(read.key); + assert(!input.isConnected,'owned DOM resource escaped physical release'); + tasks.claimJoin(0,read);assert(tasks.pollJoin(0).kind==='cancelled','cancelled join outcome');await finish(0); + assert(tasks.counts.scopes===0&&tasks.counts.tasks===0&&tasks.counts.hosts===0,'lifecycle retained history'); + assert(work.counts.pending===0&&work.counts.replies===0,'host work retained history'); + assert(resources.counts.resources===0&&resources.counts.leases===0&&resources.counts.admissions===0,'registry retained history'); + checks.push('transferred alias invalidation; leased DOM read; one abort; physical acknowledgement and resource release before cancelled join'); + }finally{acknowledge?.();input.removeEventListener('input',listener);input.remove();session.dispose();} + return {backend,checks}; +} diff --git a/ext/demo-site/test/io-output.test.mjs b/ext/demo-site/test/io-output.test.mjs @@ -0,0 +1,51 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {OutputBuffer} from '../dist/io-output.js'; +const encode=text=>[...new TextEncoder().encode(text)]; +const reconstruct=view=>view.tokens.flatMap(t=>t.kind==='text'?encode(t.text):t.bytes); +test('valid UTF8 remains text including BOM, NUL, CRLF, combining and literal escape-looking text',()=>{ + const text='\ufeff\0\r\nλ😀e\u0301\\xff <script>\x1b[31m',buffer=new OutputBuffer(); + buffer.append('output',encode(text));buffer.finish();const out=buffer.snapshot().output; + assert.deepEqual(out.tokens,[{kind:'text',offset:0,text}]);assert.deepEqual(reconstruct(out),encode(text)); +}); +test('all byte values survive exact raw, hex and token reconstruction',()=>{ + const bytes=Array.from({length:256},(_,i)=>i),buffer=new OutputBuffer();buffer.append('output',bytes);buffer.finish(); + const out=buffer.snapshot().output;assert.deepEqual(out.bytes,bytes);assert.deepEqual(reconstruct(out),bytes); + assert.deepEqual(out.hex.split(' ').map(s=>parseInt(s,16)),bytes); + assert(out.tokens.some(t=>t.kind==='bytes'&&t.reason==='invalid')); +}); +test('partitioning never changes final presentation and valid text after bad prefixes survives',()=>{ + const bytes=[...encode('a😀λ'),0xe0,0x80,65,0xed,0xa0,0x80,0xf4,0x90,0x80,0x80,66,0xf0,0x9f]; + const all=new OutputBuffer();all.append('output',bytes);all.finish();const expected=all.snapshot(); + for(let split=1;split<bytes.length;split++){ + const b=new OutputBuffer();b.append('output',bytes.slice(0,split)); + assert.deepEqual(reconstruct(b.snapshot().output),bytes.slice(0,split)); + b.append('output',bytes.slice(split));b.finish();assert.deepEqual(b.snapshot(),expected); + } + assert(expected.output.tokens.some(t=>t.kind==='text'&&t.text==='A')); + assert.equal(expected.output.tokens.at(-1).reason,'incomplete'); +}); +test('pending suffixes resolve across writes but never across stdout/stderr',()=>{ + const b=new OutputBuffer();b.append('output',[0xf0,0x9f]);b.append('error',[0x98,0x80]); + assert.equal(b.snapshot().output.tokens[0].reason,'pending'); + assert(b.snapshot().error.tokens.every(t=>t.reason==='invalid')); + b.append('output',[0x98,0x80]);assert.equal(b.snapshot().output.tokens[0].text,'😀'); + b.append('error',[0xc2]);b.finish();b.finish();assert.equal(b.snapshot().error.tokens.at(-1).reason,'incomplete'); + assert.throws(()=>b.append('output',[65]),/ended/); +}); +test('accepted-byte bound is shared and rejection is atomic; copies have no aliases',()=>{ + const b=new OutputBuffer(3),bytes=[65,66];b.append('output',bytes);bytes[0]=0; + const snapshot=b.snapshot();snapshot.output.bytes[0]=0;snapshot.output.tokens[0].text='changed'; + assert.equal(b.snapshot().output.tokens[0].text,'AB');b.append('error',[255]); + const before=b.snapshot();assert.throws(()=>b.append('error',[1]),/limit/);assert.deepEqual(b.snapshot(),before); + for(const bytes of [[256],[-1],[1.5],Array(1),[],Array(4097).fill(0)])assert.throws(()=>b.append('output',bytes),/bytes/); + assert.deepEqual(b.snapshot(),before); + for(const limit of [-1,16385,NaN,1.5])assert.throws(()=>new OutputBuffer(limit),/limit/); +}); +test('many independent malformed streams reconstruct exactly without replacement scalars',()=>{ + let state=12345;for(let run=0;run<128;run++){ + const bytes=Array.from({length:128},()=>{state=(Math.imul(state,1664525)+1013904223)>>>0;return state>>>24;}); + const b=new OutputBuffer();for(const byte of bytes)b.append('output',[byte]);b.finish(); + assert.deepEqual(reconstruct(b.snapshot().output),bytes); + } +}); diff --git a/ext/demo-site/test/io-protocol-probe.mjs b/ext/demo-site/test/io-protocol-probe.mjs @@ -0,0 +1,25 @@ +// Real WASM engines, inert header inspection only, no source compiler substitute. +export async function run(_control,backend){ + const base=new URL('../',document.querySelector('script[type=module][src]').src); + const fixtures=await(await fetch('/runtime-fixtures.json')).json(),checks=[]; + let inspect,dispose; + const {CompilerWorker}=await import(new URL('runtime/haskell/client.mjs',base)); + const client=new CompilerWorker();dispose=()=>client.dispose(); + await client.request({type:'init',compiler:false}); + inspect=async fixture=>{await client.request({type:'load',bytes:new Uint8Array(fixture.bytes).buffer,hash:fixture.hash});return JSON.parse((await client.request({type:'io-header',name:'root',arguments:[]})).text);}; + const equal=(got,want)=>{if(JSON.stringify(got)!==JSON.stringify(want))throw Error('Header mismatch: '+JSON.stringify(got));}; + try{ + for(let code=0;code<10;code++)equal(await inspect(fixtures['v2-header-core-'+code]),{kind:'core',code}); + checks.push('all ten core headers without expanding shared payloads'); + equal(await inspect(fixtures['v2-header-future']),{kind:'request',group:'future-9',revision:65535,operation:'unknown'}); + equal(await inspect(fixtures['v2-header-long-name']),{kind:'request',group:'a'.repeat(64),revision:1,operation:'read'}); + checks.push('future named request and exact name/revision bounds'); + for(const [name,message] of [['bad-tag','unknown computation constructor'],['bad-name','invalid facility header'],['bad-revision','invalid facility header'],['zero-revision','invalid facility header'],['bad-shape','invalid computation shape']]){ + let rejected=false;try{await inspect(fixtures['v2-header-'+name]);}catch(error){if(!error.message.includes(message))throw error;rejected=true;} + if(!rejected)throw Error('Accepted '+name); + equal(await inspect(fixtures['v2-header-core-0']),{kind:'core',code:0}); + } + checks.push('five malformed headers reject; runtime remains usable after each'); + }finally{dispose();} + return {backend,checks}; +} diff --git a/ext/demo-site/test/io-protocol.test.mjs b/ext/demo-site/test/io-protocol.test.mjs @@ -0,0 +1,37 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {Graph} from '../dist/graph.js'; +import {natural,text} from '../dist/codecs.js'; +import {canonicalNat,decodeAction,header} from '../dist/io-protocol.js'; +function setup(){ + const g=new Graph(),poison=g.app(g.leaf,g.leaf),observe=g.observe.bind(g); + g.observe=id=>{if(id===poison)throw Error('FORCED opaque payload');return observe(id);}; + const request=(group,revision,op,args=poison)=>g.fork(natural(g,10n),g.fork(group,g.fork(revision,g.fork(op,args)))); + return {g,poison,request}; +} +test('v2 header decoding preserves opaque core and future request payloads',()=>{ + const {g,poison,request}=setup(); + for(let n=0;n<10;n++)assert.deepEqual(decodeAction(g,g.fork(natural(g,BigInt(n)),poison)),{kind:'core',code:n,payload:poison}); + for(const n of [11n,255n,256n])assert.throws(()=>decodeAction(g,g.fork(natural(g,n),poison)),/unknown computation/); + assert.deepEqual(header(g,request(text(g,'future-9'),natural(g,65535n),text(g,'unknown'))),{kind:'request',group:'future-9',revision:65535,operation:'unknown'}); + assert.throws(()=>decodeAction(g,g.leaf),/invalid computation shape/); +}); +test('v2 names, revisions and bits are bounded and strictly canonical',()=>{ + const {g,poison,request}=setup(),good=text(g,'file'),one=natural(g,1n),zero=g.fork(g.leaf,g.leaf); + for(const invalid of ['', '-file','1file','File','a_b','a b','a\x7f','a'.repeat(65)]){ + assert.throws(()=>decodeAction(g,request(text(g,invalid),one,good)),/invalid facility header/); + assert.throws(()=>decodeAction(g,request(good,one,text(g,invalid))),/invalid facility header/); + } + for(const revision of [g.leaf,zero,natural(g,65536n)])assert.throws(()=>decodeAction(g,request(good,revision,good)),/invalid facility header/); + assert.throws(()=>decodeAction(g,request(g.leaf,poison,poison)),/invalid facility header/); + assert.equal(canonicalNat(g,zero,8),undefined); + assert.throws(()=>decodeAction(g,g.fork(zero,poison)),/unknown computation/); + assert.equal(header(g,request(text(g,'a'.repeat(64)),one,good)).group.length,64); + let excess=g.fork(poison,poison); + for(let i=0;i<64;i++)excess=g.fork(natural(g,97n),excess); + assert.throws(()=>decodeAction(g,request(excess,one,good)),/invalid facility header/); + let long=g.fork(poison,poison); + for(let i=0;i<8;i++)long=g.fork(g.leaf,long); + assert.equal(canonicalNat(g,long,8),undefined); + assert.throws(()=>decodeAction(g,poison),/FORCED/); // reduction faults are not malformed-header Results +}); diff --git a/ext/demo-site/test/io-public-probe.mjs b/ext/demo-site/test/io-public-probe.mjs @@ -0,0 +1,86 @@ +import {wait,status,change,inspect,el,pause,input} from './source-probe-helpers.mjs'; +export async function run(_control,backend){ + const assert=(ok,message)=>{if(!ok)throw Error(message);},checks=[]; + const base=new URL('../',document.querySelector('script[type=module][src]').src); + const {runtimeSession:{createRuntimeSession},ioTerminalView:{terminalView}}=await import(new URL('dist/probe-api.js',base)); + const fixture=await(await fetch('/io-source.json')).json(),s=await createRuntimeSession(backend,false); + const legacy={before:async()=>{},after(){},read(){throw Error('legacy read invoked');},write(){throw Error('legacy write invoked');},running(){}}; + const parent=document.createElement('div');document.body.append(parent); + let widget,send,closed,steps=0,submitted=false,guardTask; + function host(mode='normal'){ + parent.replaceChildren();closed=false;submitted=false; + return {...legacy,before:async()=>{steps++;},structured:{stepping:mode==='step', + open(run,command){send=command;widget=terminalView(parent,run,reply=>command({run,kind:'reply',reply}),()=>command({run,kind:'stop'}));}, + event(event){widget.handle(event);if(event.kind==='read'&&mode==='stop'){ + guardTask=(async()=>{try{ + for(const operation of [()=>s.inspectDefinition('main','tree'),()=>s.compile('changed = 9','empty'),()=>s.runExpression('42','number','pure',legacy)]){ + let blocked=false;try{await operation();}catch(error){blocked=/pending|busy/.test(error.message);}assert(blocked,'unrelated work entered active v2 run'); + } + }finally{send({run:event.run,kind:'stop'});}})(); + } + if(event.kind==='read'&&mode==='input'){ + const form=parent.querySelector('form');if(form&&!form.hidden){if(!submitted){submitted=true;form.querySelector('input').value='λ😀';form.requestSubmit();}else form.querySelector('button[type=button]').click();} + } + }, + close(_run,settled){assert(settled,'unacknowledged runtime loss');widget.finish();closed=true;}, + }}; + } + try{ + await s.load(new Uint8Array(fixture.bytes),fixture.hash); + assert(await s.probeIO('main'),'v2 inert recognition');assert(!await s.probeIO('direct'),'bare computation is not an envelope'); + for(const name of ['main','publicHandled','publicCancellation','publicBinary']){ + const out=await s.runDefinitionIO(name,'number',host());assert(out.value==='42'&&closed,'public compiled outcome '+name); + const bytes=widget.snapshot().output.bytes,expected=name==='publicBinary'?[92,120,102,102,255,240,159]:[...new TextEncoder().encode(name==='publicCancellation'?'ready':'λ😀e\u0301\r\n')]; + assert(JSON.stringify(bytes)===JSON.stringify(expected),'public exact output '+name); + } + assert((await s.runDefinitionIO('publicRoundTrip','number',host('input'))).value==='42','line and EOF outcome'); + assert(JSON.stringify(widget.snapshot().output.bytes)===JSON.stringify([...new TextEncoder().encode('λ😀\n')]),'line/EOF byte preservation'); + checks.push('compiled direct/handled/cancellation/binary programs through public RuntimeSession; explicit line/EOF'); + const cases=[['string',{type:'string',value:'λ😀\0'},'λ😀\0'],['number',{type:'number',value:'184467440737095516160'},'184467440737095516160'],['bool',{type:'bool',value:true},true],['bytes',{type:'bytes',value:[0,255,240]},[0,255,240]]]; + for(const [type,literal,expected]of cases){const out=await s.run('returnValue',[literal],type,'io',host());assert(JSON.stringify(out.value)===JSON.stringify(expected),'full codec '+type);} + for(const type of ['tree','list','tricu','triangle','dag','ternary']){ + const literal=type==='list'?{type:'list',value:[{type:'number',value:'42'}]}:{type:'tree',value:'200'}; + const observed=await s.run('returnValue',[literal],type,'io',host());assert(observed.type===type&&observed.value!==null,'full graph codec '+type); + if(['tricu','triangle','dag','ternary'].includes(type)){const expected=await s.runExpression('t t t',type,'pure',legacy);// DAG binders reflect physical node numbering, not semantic identities. + const alpha=text=>{const names=new Map();return text.replace(/:[fn][0-9]+/g,name=>{if(!names.has(name))names.set(name,':v'+names.size);return names.get(name);});}; + assert((type==='dag'?alpha(expected.value)===alpha(observed.value):expected.value===observed.value),'complete graph result differs from independent pure result '+type);} + } + const values=await s.run('returnValue',[{type:'list',value:[{type:'number',value:'42'},{type:'string',value:'hello'}]}],'values','io',host()); + assert(JSON.stringify(values.value)===JSON.stringify([{type:'number',value:'42'},{type:'string',value:'hello'}]),'values codec'); + checks.push('all public codecs including >64-bit number, full trees and decoded values'); + let rejected=false;try{await s.runDefinitionIO('publicRoundTrip','number',host('stop'));}catch(error){rejected=/cancelled/.test(error.message);}assert(rejected&&closed,'public structured cancellation');await guardTask; + let badCodec=false;try{await s.run('returnValue',[{type:'tree',value:'10'}],'number','io',host());}catch{badCodec=true;}assert(badCodec&&closed,'codec failure after cleanup'); + assert((await s.runExpression('returnValue "expression result"','string','io',host())).value==='expression result','IO expression result'); + assert((await s.run('returnValue',[{type:'number',value:'42'}],'number','io',host('step'))).value==='42'&&steps>0,'public scheduler stepping'); + checks.push('structured Stop drains read, definitions remain usable; expression and stepping paths'); + }finally{s.dispose();parent.remove();} + const doomed=await createRuntimeSession(backend,false);let abandoned=false; + try{ + await doomed.load(new Uint8Array(fixture.bytes),fixture.hash); + let rejected=false;try{await doomed.runDefinitionIO('publicRoundTrip','number',{...legacy,structured:{open(){},event(event){if(event.kind==='read')doomed.dispose();},close(_run,settled){abandoned=!settled;}}});} + catch(error){rejected=/disposed|terminated/i.test(error.message);}assert(rejected&&abandoned,'emergency dispose must reject without claiming cleanup'); + }finally{doomed.dispose();} + checks.push('emergency runtime disposal rejects idle read and explicitly does not acknowledge cleanup'); + // Real mounted editor + outer worker + nested native worker, not a mock host. + await status('Ready');if(el('runtime-backend').value!==backend){change('runtime-backend',backend);await status('Ready');} + const index=await(await fetch(new URL('packages.json',base))).json(); + const io=index.releases.find(r=>r.title==='lib/io.tri'&&r.version==='0.4.0');assert(io,'authenticated complete library'); + const source=`!import ${io.manifest} as IO\n!export answer\n!export binary\n!export waiting\n!export caps\n!export classification\n!export divergentValue\ndivergentValue = IO.io (IO.pure ((x : x x) (x : x x)))\nbackendOf rows = case rows of\n [["backend" value] | _] = value\n [_ | rest] = backendOf rest\n _ = "missing"\ncaps = IO.io (IO.require IO.capabilities (rows : IO.pure (backendOf rows)))\nloop = (x : x x) (x : x x)\nclassification = IO.io (IO.require (IO.inspect (IO.request "file" 1 "open" loop)) IO.pure)\nanswer = 42\nbinary = IO.io (do IO.require\n writer <- IO.stdout\n IO.writeAll writer [92 120 102 102 255 240 159]\n IO.pure "returned text")\nwaiting = IO.io (do IO.require\n reader <- IO.stdin\n IO.readSome reader 4\n IO.pure 42)`; + document.querySelector('[data-compiler-example=identity]').click();input('source-editor',source);await status('Compiled'); + await inspect('caps');el('io-return-format').value='string';el('run-io').click();await status('Finished');assert(document.querySelector('.definition-card').querySelector('pre').textContent===backend,'actual native capabilities'); + await inspect('classification');el('io-return-format').value='values';el('run-io').click();await status('Finished');assert(document.querySelector('.definition-card').querySelector('pre').textContent.includes('unsupported'),'inert inspection leaves divergent arguments opaque'); + await inspect('binary');el('io-return-format').value='string';el('run-io').click();await status('Finished'); + assert(el('terminal-io').querySelector('[data-hex=output]').textContent==='5c 78 66 66 ff f0 9f','actual page lossless output'); + assert(document.querySelector('.definition-card').querySelector('pre').textContent==='returned text','actual page full returned text'); + await inspect('waiting');el('io-return-format').value='number';el('run-io').click();await wait(()=>el('terminal-io').querySelector('form input'),'v2 input'); + const old=el('terminal-io').querySelector('form');el('stop').click();assert(!el('stop').hidden,'Stop waits rather than disposing worker'); + await status('Stopped');assert(!el('terminal-io').querySelector('form'),'input physically retired'); + old.dispatchEvent(new Event('submit',{cancelable:true}));assert((await inspect('answer','number'))==='42','compiled state survives structured Stop'); + // Stop also bypasses a suspended scheduler turn. + await inspect('waiting');el('step-mode').checked=true;el('run-io').click();await status('Paused before action');el('stop').click();await status('Stopped');el('step-mode').checked=false; + assert((await inspect('answer','number'))==='42','compiled state survives paused Stop'); + await inspect('divergentValue');el('io-return-format').value='number';el('run-io').click();await status('IO settled; interpreting returned value');el('stop').click();await status('Stopped'); + el('compile').click();await status('Compiled');assert((await inspect('answer','number'))==='42','hard Stop after acknowledged IO settlement can rebuild'); + await pause(10);checks.push('actual page exact bytes/full return, pending-input and paused-step Stop, stale DOM isolation and reuse without recompilation'); + return {backend,checks}; +} diff --git a/ext/demo-site/test/io-public.test.mjs b/ext/demo-site/test/io-public.test.mjs @@ -0,0 +1,104 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {Graph} from '../dist/graph.js'; +import {readText,readNatural,readList,view} from '../dist/codecs.js'; +import {constructors} from '../dist/io-private.js'; +import {Wire} from '../dist/io-core.js'; +import {Session} from '../dist/io-session.js'; +import {envelope,available} from '../dist/io-profile.js'; +import {drive} from '../dist/io-driver.js'; +function fixture(){ + const g=new Graph(),t=constructors({leaf:g.leaf,observe:g.observe.bind(g),stem:g.stem.bind(g),fork:g.fork.bind(g),app:g.app.bind(g)}),w=new Wire(t,1n); + const n=x=>t.natural(BigInt(x)),a=(tag,p=t.leaf)=>t.fork(n(tag),p),request=(group,op,p=t.leaf,revision=1)=>a(10,t.fork(t.text(group),t.fork(n(revision),t.fork(t.text(op),p)))); + return {g,t,w,n,a,request,session:(action,steps=128)=>new Session(t,action,'1',roots=>g.collect([...roots]),{summarize:false,steps,backend:'test-native'})}; +} +const host=()=>({before:async()=>{},after(){},read(){throw Error('legacy read');},write(){throw Error('legacy write');},running(){}}); +const driver=(s,read)=>({run:s.run,turn:c=>s.turn(c),view:async()=>read(s.core.result.value)}); +async function finish(s){for(let i=0;i<100;i++){const r=await s.turn({run:s.run,kind:'poll'});if(r.done)return r;}throw Error('stalled');} +const result=(f,s)=>f.w.pair(f.w.pair(s.core.result.value)[1])[0]; +test('public framing is bounded and leaves action opaque; unknown versions never trial-execute',()=>{ + const f=fixture(),poison=f.g.app(f.g.leaf,f.g.leaf),original=f.t.observe;f.t.observe=h=>{if(h===poison)throw Error('payload forced');return original(h);}; + for(const version of [2])assert.deepEqual(envelope(f.t,f.t.fork(f.t.text('tricuIO'),f.t.fork(f.n(version),poison))),{version,action:poison}); + const padded=f.t.fork(f.t.stem(f.t.leaf),f.t.fork(f.t.leaf,f.t.leaf)); + assert.throws(()=>envelope(f.t,f.t.fork(f.t.text('tricuIO'),f.t.fork(padded,poison))),/version/); + for(const version of [0,1,3,4])assert.throws(()=>envelope(f.t,f.t.fork(f.t.text('tricuIO'),f.t.fork(f.n(version),poison))),/version/); + assert.throws(()=>envelope(f.t,f.t.fork(f.t.text('tricuIOx'),poison)),/sentinel/); +}); +test('browser capabilities and inert inspection agree on exact implemented facilities',async()=>{ + const f=fixture(),s=f.session(f.request('runtime','capabilities'));await finish(s); + const rows=new Map(readList(f.g,result(f,s),20).map(h=>{const [k,v]=readList(f.g,h,2);return [readText(f.g,k),v];})); + assert.equal(readText(f.g,rows.get('backend')),'test-native');assert.equal(readText(f.g,rows.get('authority')),'browser-terminal'); + const groups=readList(f.g,rows.get('groups'),20).map(h=>{const [group,revision,ops]=readList(f.g,h,3);return [readText(f.g,group),readNatural(f.g,revision).toString(),readList(f.g,ops,10).map(h=>readText(f.g,h))];}); + assert.deepEqual(groups,[['runtime','1',['capabilities','inspect']],['standard','1',['input','output','error']],['stream','1',['read','write']],['resource','1',['close']],['utf8','1',['encode','decode']]]); + for(const [group,,ops]of groups)for(const op of ops)assert(available(group,1,op)); + for(const group of ['file','tcp','process','clock','__proto__','constructor'])assert(!available(group,1,'open')); + for(const [group,op,rev,label]of [['stream','write',1,'available'],['stream','write',2,'unsupported'],['file','open',1,'unsupported']]){ + const q=fixture(),poison=q.g.app(q.g.leaf,q.g.leaf),observe=q.t.observe;q.t.observe=h=>{if(h===poison)throw Error('argument forced');return observe(h);}; + const inspect=q.session(q.request('runtime','inspect',q.request(group,op,poison,rev)));await finish(inspect); + const out=readList(q.g,result(q,inspect),5);assert.equal(readText(q.g,out[4]),label);assert(!inspect.core.hasFault); + } +}); +test('public sessions never summarize a completed payload before the selected full codec',async()=>{ + const f=fixture(),payload=f.t.text('λ😀'),s=f.session(f.a(0,payload)); + let rendered=false,closed; + const out=await drive(driver(s,h=>{rendered=true;return view(f.g,h,'string');}),{...host(),structured:{open(){},event(){throw Error('unexpected event');},close(_run,settled){closed=settled;assert(!rendered);}}}); + assert.equal(out.value,'λ😀');assert.equal(closed,true); + const q=fixture(),opaque=q.g.app(q.g.leaf,q.g.leaf),observe=q.t.observe;q.t.observe=h=>{if(h===opaque)throw Error('summary forced payload');return observe(h);}; + const retained=q.session(q.a(0,opaque));assert.equal((await finish(retained)).outcome.value,null); +}); +test('public Stop cancels a pending read and waits for physical acknowledgement',async()=>{ + const f=fixture(),s=f.session(f.request('stream','read',f.t.fork(f.w.marker('io.v2.resource',1),f.n(4)))); + let send,closed=false,cancelled=false,stale; + await assert.rejects(drive(driver(s,()=>{throw Error('cancelled payload viewed');}),{...host(),structured:{ + open(_run,command){send=command;stale=command;}, + event(event){if(event.kind==='read')send({run:'1',kind:'stop'});else{ + assert.equal(event.kind,'cancel');cancelled=true; + setTimeout(()=>{assert(!closed);send({run:'1',kind:'reply',reply:{...event,kind:'cancelled'}});},10); + }},close(_run,settled){assert(cancelled);assert(settled);closed=true;} + }}),/cancelled/); + assert(closed);stale({run:'1',kind:'stop'});assert.equal(s.events.counts.pending,0); +}); +test('step hooks cannot block structured stop, including a never-resolving pause',async()=>{ + const f=fixture(),s=f.session(f.a(0,f.n(42)),1);let send,seen=0; + await assert.rejects(drive(driver(s,()=>{throw Error('unexpected view');}),{...host(),before(){seen++;queueMicrotask(()=>send({run:'1',kind:'stop'}));return new Promise(()=>{});},structured:{stepping:true,open(_r,c){send=c;},event(){throw Error('unexpected event');},close(_r,settled){assert(settled);}}}),/cancelled/); + assert.equal(seen,1); +}); +test('undefined step-hook rejection is preserved after cleanup, and renderer failure occurs after settlement',async()=>{ + for(const phase of ['step','view']){ + const f=fixture(),s=f.session(f.a(0,f.n(42)),1);let closed=false; + try{await drive(driver(s,()=>{if(phase==='view')throw undefined;return {}; }),{...host(),before:async()=>{if(phase==='step')throw undefined;},structured:{stepping:true,open(){},event(){},close(_r,settled){closed=settled;}}});assert.fail('must reject');} + catch(e){assert.equal(e,undefined);assert(closed);} + } +}); + +test('unexpected driver loss is labelled unacknowledged, never a successful cleanup',async()=>{ + let settled,viewed=false,discarded=false; + await assert.rejects(drive({run:'1',discard(){discarded=true;},turn:async()=>{throw Error('runtime lost');},view:async()=>{viewed=true;}},{...host(),structured:{open(){},event(){},close(_r,value){settled=value;}}}),/runtime lost/); + assert.equal(settled,false);assert.equal(viewed,false);assert(discarded); +}); +test('unknown facilities/revisions are ordinary opaque errors; malformed inspection is not execution',async()=>{ + for(const [group,operation,revision]of [['file','open',1],['runtime','capabilities',2]]){ + const f=fixture(),poison=f.g.app(f.g.leaf,f.g.leaf),observe=f.t.observe;f.t.observe=h=>{if(h===poison)throw Error('unknown argument forced');return observe(h);}; + const s=f.session(f.request(group,operation,poison,revision));await finish(s);assert(!s.core.hasFault); + assert.equal(readText(f.g,readList(f.g,result(f,s),4)[0]),'unsupported'); + } + const f=fixture(),s=f.session(f.request('runtime','inspect',f.t.leaf));await finish(s);assert(!s.core.hasFault); + assert.equal(readText(f.g,readList(f.g,result(f,s),4)[0]),'invalid-argument'); +}); + +test('emergency disposal interrupts an idle read without inventing physical acknowledgement',async()=>{ + const f=fixture(),s=f.session(f.request('stream','read',f.t.fork(f.w.marker('io.v2.resource',1),f.n(4)))); + let settled;const lifetime=new AbortController(); + await assert.rejects(drive({...driver(s,()=>{throw Error('unexpected view');}),signal:lifetime.signal},{...host(),structured:{open(){},event(event){assert.equal(event.kind,'read');setTimeout(()=>lifetime.abort(Error('runtime disposed')),10);},close(_r,value){settled=value;}}}),/disposed/); + assert.equal(settled,false);assert.equal(s.events.counts.pending,1,'no fabricated cancelled reply'); +}); + +test('completed runs remove emergency-disposal listeners rather than retaining per-turn history',async()=>{ + const controller=new AbortController(),signal=controller.signal,add=signal.addEventListener.bind(signal),remove=signal.removeEventListener.bind(signal);let listeners=0,peak=0; + signal.addEventListener=(...args)=>{listeners++;peak=Math.max(peak,listeners);return add(...args);};signal.removeEventListener=(...args)=>{listeners--;return remove(...args);}; + for(let run=0;run<5;run++){ + const f=fixture();let action=f.a(0,f.n(42));for(let i=0;i<20;i++)action=f.a(1,f.t.fork(f.a(0),f.t.fork(f.t.leaf,action))); + const s=f.session(action,1);await drive({...driver(s,h=>view(f.g,h,'number')),signal},{...host(),structured:{stepping:true,open(){},event(){},close(){}}});assert.equal(listeners,0); + } + assert.equal(peak,1); +}); diff --git a/ext/demo-site/test/io-resources.test.mjs b/ext/demo-site/test/io-resources.test.mjs @@ -0,0 +1,144 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {lifecycle} from '../dist/io-resources.js'; +import {HostWork} from '../dist/io-host-work.js'; +const ok=value=>({kind:'completed',value}); +const setup=(limits={})=>lifecycle(1n,'owner-stopped','cycle',['stdin','stdout','stderr'],limits); +const create=(s,owner,backend,flags={},kind='file')=>s.resources.publish(s.resources.beginAcquire(owner),kind,backend,flags); +async function finish(s,scope,release=()=>{},outcome=ok(null)){ + s.tasks.bodyDone(scope,outcome);assert(s.tasks.beginRelease(scope)); + const errors=await s.resources.releaseScope(scope,release,error=>error,'fallback');return s.tasks.settle(scope,errors); +} +function retired(s){assert.deepEqual(s.tasks.counts,{scopes:0,tasks:0,hosts:0});assert.deepEqual(s.resources.counts,{resources:0,admissions:0,leases:0});} + +test('admission closure preserves admitted opens and existing descendant borrowing',async()=>{ + const s=setup(),child=s.tasks.spawn(0,true,false),admission=s.resources.beginAcquire(0); + s.tasks.bodyDone(0,ok(null));assert.equal(s.tasks.beginRelease(0),false); + assert.throws(()=>s.resources.beginAcquire(child.key,0),/closed-scope/); + const file=s.resources.publish(admission,'file','backend'),lease=s.resources.beginLease(child.key,file,'file','read'); + assert.equal(lease.backend,'backend');s.tasks.bodyDone(child.key,ok(null));assert.equal(s.tasks.beginRelease(child.key),false); + s.resources.endLease(lease.token);assert(s.tasks.beginRelease(child.key));s.tasks.settle(child.key); + assert(s.tasks.beginRelease(0));assert.throws(()=>s.tasks.settle(0),/busy/); + const released=[];await s.resources.releaseScope(0,r=>released.push(r.backend),e=>e,'fallback');s.tasks.settle(0); + assert.deepEqual(released,['backend']);retired(s); +}); +test('pending ancestor acquisition blocks owner even after acquiring child has stopped',async()=>{ + const s=setup(),child=s.tasks.spawn(0,true,false),admission=s.resources.beginAcquire(child.key,0); + s.tasks.cancel(0,child,'cancel');await finish(s,child.key);s.tasks.bodyDone(0,ok(null)); + assert.equal(s.tasks.beginRelease(0),false);s.resources.abortAcquire(admission); + assert.throws(()=>s.resources.abortAcquire(admission),/invalid-admission/); + assert(s.tasks.beginRelease(0));await s.resources.releaseScope(0,()=>assert.fail('borrowed close'),e=>e,'fallback');s.tasks.settle(0);retired(s); +}); +test('transfer validation is atomic; rekeying invalidates aliases and preserves acquisition order',async()=>{ + const s=setup(),a=create(s,0,'first'),b=create(s,0,'second'); + const before={tasks:s.tasks.counts,resources:s.resources.counts}; + for(const [tokens,message] of [[[a,a],/duplicate/],[[a,{...b,run:2n}],/stale/],[[a,{run:1n,key:1}],/borrowed/]]){ + assert.throws(()=>s.resources.spawn(0,false,true,tokens),message); + assert.deepEqual({tasks:s.tasks.counts,resources:s.resources.counts},before);assert.equal(s.resources.lookup(0,a).backend,'first'); + } + const {task,handles}=s.resources.spawn(0,true,true,[b,a]); + assert.throws(()=>s.resources.lookup(0,a),/stale/);assert.throws(()=>s.resources.lookup(task.key,b),/stale/); + assert.throws(()=>s.resources.lookup(0,handles[0]),/out-of-scope/); + assert.equal(s.resources.lookup(task.key,handles[0]).backend,'second');assert.deepEqual(s.resources.newestOwned(task.key),handles[0]); + const nested=s.tasks.nested(task.key);assert.throws(()=>s.resources.spawn(nested,true,false,[handles[1]]),/not-owner/); + await finish(s,nested);const released=[];await finish(s,task.key,r=>released.push(r.backend)); + assert.deepEqual(released,['second','first']);await finish(s,0);retired(s); +}); +test('failed task admission cannot move resources, start a child or consume resource IDs',async()=>{ + const s=setup({taskIdentities:1}),file=create(s,0,'owner'),only=s.tasks.spawn(0,true,false);await finish(s,only.key); + const before=s.resources.counts; + assert.throws(()=>s.resources.spawn(0,true,true,[file]),/scope-identity-exhausted/); + assert.equal(s.resources.lookup(0,file).owner,0);assert.deepEqual(s.resources.counts,before); + assert.deepEqual(s.tasks.counts,{scopes:1,tasks:0,hosts:0}); + const next=create(s,0,'next');assert.equal(next.key,file.key+2,'failed spawn consumed resource identities'); + await finish(s,0);retired(s); +}); +test('direction leases, wrong-kind rejection and exactly-once close acknowledgements',async()=>{ + const s=setup(),file=create(s,0,'duplex',{duplex:true}); + assert.throws(()=>s.resources.beginLease(0,file,'reader','read'),/wrong-kind/);assert.equal(s.resources.counts.leases,0); + const r=s.resources.beginLease(0,file,'file','read'),w=s.resources.beginLease(0,file,'file','write'); + assert.throws(()=>s.resources.beginLease(0,file,'file','read'),/busy/);assert.throws(()=>s.resources.spawn(0,true,false,[file]),/busy/); + assert.equal(s.resources.claimClose(0,file),null);assert.throws(()=>s.resources.beginLease(0,file,'file','read'),/closed-handle/); + s.resources.endLease(r.token);assert.throws(()=>s.resources.endLease(r.token),/invalid-lease/);s.resources.endLease(w.token); + assert(s.resources.claimClose(0,file));assert.equal(s.resources.claimClose(0,file),null); + s.tasks.bodyDone(0,ok(null));assert(s.tasks.beginRelease(0));assert.throws(()=>s.tasks.settle(0),/busy/); + s.resources.finishClose(file);assert.throws(()=>s.resources.finishClose(file),/stale-handle/); + await s.resources.releaseScope(0,()=>{},e=>e,'fallback');s.tasks.settle(0);retired(s); +}); +test('ordinary single-cursor resources, internal resources and borrowed standards stay distinct',async()=>{ + const s=setup(),file=create(s,0,'file'),lease=s.resources.beginLease(0,file,'file','read'); + assert.throws(()=>s.resources.beginLease(0,file,'file','write'),/busy/);s.resources.endLease(lease.token); + const hidden=create(s,0,'hidden',{internal:true}); + assert.throws(()=>s.resources.beginLease(0,hidden,'file','read'),/internal-resource/); + assert.throws(()=>s.resources.claimClose(0,hidden),/internal-resource/); + assert.throws(()=>s.resources.spawn(0,true,false,[hidden]),/internal-resource/); + assert.throws(()=>s.resources.claimClose(0,{run:1n,key:2}),/borrowed-resource/); + const internal=s.resources.beginLease(0,hidden,'file','exclusive',true);s.resources.endLease(internal.token); + const view=s.resources.lookup(0,file);view.owner=999;assert.equal(s.resources.lookup(0,file).owner,0); + assert.throws(()=>{view.flags.borrowed=true;},TypeError); + await finish(s,0);retired(s); +}); +test('reverse publication order, not open-start order, determines cleanup',async()=>{ + const s=setup(),first=s.resources.beginAcquire(0),second=s.resources.beginAcquire(0); + s.resources.publish(second,'file','published first');s.resources.publish(first,'file','published second'); + const order=[];await finish(s,0,r=>order.push(r.backend));assert.deepEqual(order,['published second','published first']);retired(s); +}); +test('publication identity exhaustion leaves admission live for raw release/abort; cleanup needs no IDs',async()=>{ + const s=setup({resourceIdentities:4}),admission=s.resources.beginAcquire(0); + assert.throws(()=>s.resources.publish(admission,'file','raw backend'),/identity-exhausted/); + assert.deepEqual(s.resources.counts,{resources:3,admissions:1,leases:0}); + assert.throws(()=>s.resources.beginAcquire(0),/identity-exhausted/); + assert.throws(()=>s.resources.beginLease(0,{run:1n,key:1},'reader','read'),/identity-exhausted/); + s.resources.abortAcquire(admission);await finish(s,0);retired(s); +}); +test('physical cleanup attempts every provider once and diagnostics failure cannot skip releases',async()=>{ + const s=setup(),order=[];let classified=0;for(let i=0;i<20;i++)create(s,0,i); + s.tasks.bodyDone(0,{kind:'failed',value:'primary'});assert(s.tasks.beginRelease(0)); + const errors=await s.resources.releaseScope(0,r=>{order.push(r.backend);throw 'release failed';},()=>{classified++;throw 'classifier failed';},'fallback'); + assert.deepEqual(order,Array.from({length:20},(_,i)=>19-i));assert.equal(errors.items.length,16);assert.equal(errors.omitted,4);assert.equal(classified,16); + const out=s.tasks.settle(0,errors);assert.equal(out.value,'primary');assert.deepEqual(out.cleanup,errors);retired(s); +}); +test('asynchronous release stays registered until provider acknowledgement, even on failure',async()=>{ + const s=setup(),work=new HostWork(s.tasks),file=create(s,0,'file');let acknowledge; + s.tasks.bodyDone(0,ok(null));assert(s.tasks.beginRelease(0)); + const pending=new Promise(resolve=>{acknowledge=resolve;}); + work.release(0,()=>s.resources.releaseScope(0,async()=>{await pending;throw 'close failed';},e=>e,'fallback')); + assert(s.resources.lookup(0,file).releasing);assert.throws(()=>s.tasks.settle(0),/invalid-transition/); + const wait=work.wait();acknowledge();await wait;const reply=work.take();assert(reply.outcome.ok); + assert.throws(()=>s.resources.lookup(0,file),/stale-handle/); + const out=s.tasks.settle(0,reply.outcome.value);assert.equal(out.kind,'failed');assert.equal(out.value,'close failed');retired(s); +}); +test('repeated scoped resources and leases retain no registry history',async()=>{ + const s=setup();for(let i=0;i<1000;i++){ + const child=s.resources.spawn(0,true,false,[]).task,file=create(s,child.key,i),lease=s.resources.beginLease(child.key,file,'file','exclusive'); + s.resources.endLease(lease.token);await finish(s,child.key); + assert.deepEqual(s.resources.counts,{resources:3,admissions:0,leases:0});assert.deepEqual(s.tasks.counts,{scopes:1,tasks:0,hosts:0}); + } + await finish(s,0);retired(s); +}); +test('explicit close cancels a leased operation, not its task, and waits for lease acknowledgement',async()=>{ + const s=setup(),work=new HostWork(s.tasks),file=create(s,0,'reader',{},'reader'),child=s.tasks.spawn(0,true,false);let acknowledge; + const drained=new Promise(resolve=>{acknowledge=resolve;}); + const read=work.start(child.key,async signal=>{ + const lease=s.resources.beginLease(child.key,file,'reader','read'); + try {await new Promise(resolve=>signal.addEventListener('abort',resolve,{once:true}));await drained;throw 'resource closed';} + finally{s.resources.endLease(lease.token);} + }); + assert.equal(s.resources.claimClose(0,file),null); + assert.equal(work.cancelBody({...read,run:2n}),false);assert.equal(work.cancelBody(read),true);assert.equal(work.cancelBody(read),false); + assert.equal(s.resources.claimClose(0,file),null);assert.equal(s.tasks.state(child.key).reason,null); + const waiting=work.wait();acknowledge();await waiting;const reply=work.take(); + assert.equal(reply.outcome.ok,false);assert.equal(reply.outcome.error,'resource closed');assert.equal(s.tasks.state(child.key).reason,null); + let physical=0;work.start(0,()=>{assert(s.resources.claimClose(0,file));physical++;s.resources.finishClose(file);}); + await work.wait();assert(work.take().outcome.ok);assert.equal(physical,1); + await finish(s,child.key);await finish(s,0);retired(s); +}); + +test('resource exhaustion and oversize transfers do not admit tasks or invalidate aliases',async()=>{ + const s=setup({resourceIdentities:5}),file=create(s,0,'file'); + for(const [tokens,error] of [[[file],/identity-exhausted/],[Array(257).fill(file),/transfer-limit/]]){ + assert.throws(()=>s.resources.spawn(0,true,false,tokens),error); + assert.equal(s.resources.lookup(0,file).owner,0);assert.deepEqual(s.tasks.counts,{scopes:1,tasks:0,hosts:0}); + } + await finish(s,0);retired(s); +}); diff --git a/ext/demo-site/test/io-session-probe.mjs b/ext/demo-site/test/io-session-probe.mjs @@ -0,0 +1,47 @@ +// Native tree sibling cancellation through cloneable events, with a real DOM +// provider. No raw handles leave the worker/session boundary. +export async function run(_control,backend){ + const base=new URL('../',document.querySelector('script[type=module][src]').src),fixture=(await(await fetch('/runtime-fixtures.json')).json())['v2-session-streams']; + const assert=(ok,message)=>{if(!ok)throw Error(message);}; + async function exercise(start,turn,stop){ + let report=await start();const events=[]; + for(let i=0;i<30&&!events.some(e=>e.kind==='write');i++){events.push(...report.events);if(!events.some(e=>e.kind==='write'))report=await turn({run:report.run,kind:'poll'});} + const read=events.find(e=>e.kind==='read'),write=events.find(e=>e.kind==='write');assert(read&&write,'pending read blocked sibling write'); + const input=document.createElement('input');document.body.append(input);let hits=0; + const listener=()=>{hits++;};input.addEventListener('input',listener); + try{ + if(stop){ + report=await turn({run:report.run,kind:'stop'}); + assert(!report.done&&report.events.filter(e=>e.kind==='cancel').length===2,'stop did not cancel both providers'); + input.removeEventListener('input',listener);input.remove(); + report=await turn({run:report.run,kind:'reply',reply:{...read,kind:'cancelled'}}); + assert(!report.done,'stop forgot pending output acknowledgement'); + report=await turn({run:report.run,kind:'reply',reply:{...write,kind:'cancelled'}}); + for(let i=0;i<30&&!report.done;i++)report=await turn({run:report.run,kind:'poll'}); + assert(report.done&&report.outcome.kind==='cancelled','structured session stop did not settle'); + assert(Object.values(report.counts).every(group=>Object.values(group).every(value=>value===0)),'stop retained lifecycle state'); + return; + } + let rejected=false;try{await turn({run:'999',kind:'stop'});}catch{rejected=true;}assert(rejected,'wrong run accepted'); + report=await turn({run:report.run,kind:'reply',reply:{...read,run:'999',bytes:[1],eof:false}});assert(report.accepted===false,'wrong reply consumed wait'); + assert(write.bytes.length===1&&write.bytes[0]===65,'write payload mismatch'); + report=await turn({run:report.run,kind:'reply',reply:{run:write.run,id:write.id,kind:'write',count:1}}); + let cancel=report.events.find(e=>e.kind==='cancel'); + for(let i=0;i<30&&!cancel;i++){report=await turn({run:report.run,kind:'poll'});cancel=report.events.find(e=>e.kind==='cancel');} + assert(cancel?.id===read.id,'missing read cancellation');assert(!report.done&&report.counts.events.pending===1,'abort delivery settled provider'); + input.removeEventListener('input',listener);input.dispatchEvent(new Event('input'));assert(hits===0,'listener survived cancellation'); + report=await turn({run:report.run,kind:'poll'});assert(!report.done&&input.isConnected,'unacknowledged cleanup retired run'); + input.remove();report=await turn({run:report.run,kind:'reply',reply:{...cancel,kind:'cancelled'}}); + for(let i=0;i<30&&!report.done;i++)report=await turn({run:report.run,kind:'poll'}); + assert(report.done&&report.outcome.kind==='completed'&&report.outcome.value==='42','tree run result mismatch'); + assert(Object.values(report.counts).every(group=>Object.values(group).every(value=>value===0)),'historical transport/lifecycle state retained'); + }finally{input.removeEventListener('input',listener);input.remove();} + } + for(const stop of [false,true]){ + const {CompilerWorker}=await import(new URL('runtime/haskell/client.mjs',base)),client=new CompilerWorker(); + try{await client.request({type:'init',compiler:false});await client.request({type:'load',bytes:new Uint8Array(fixture.bytes).buffer,hash:fixture.hash}); + await exercise(async()=>JSON.parse((await client.request({type:'io-session-start',name:'root',arguments:[]})).text),async command=>JSON.parse((await client.request({type:'io-session-turn',...command})).text),stop); + }finally{client.dispose();} + } + return {backend,checks:['native tree read permits sibling write','wrong-run stop and reply reject','tree cancellation waits for DOM cleanup acknowledgement','complete42; no retained task/resource/event records','external structured stop awaits both provider acknowledgements']}; +} diff --git a/ext/demo-site/test/io-session.test.mjs b/ext/demo-site/test/io-session.test.mjs @@ -0,0 +1,73 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {Graph} from '../dist/graph.js'; +import {readText,readNatural,readList} from '../dist/codecs.js'; +import {constructors} from '../dist/io-private.js'; +import {Wire} from '../dist/io-core.js'; +import {Session} from '../dist/io-session.js'; +function fixture(budget={}){ + const g=new Graph(budget),t=constructors({leaf:g.leaf,observe:g.observe.bind(g),stem:g.stem.bind(g),fork:g.fork.bind(g),app:g.app.bind(g)}),w=new Wire(t,1n); + const n=x=>t.natural(BigInt(x)),a=(tag,p=t.leaf)=>t.fork(n(tag),p),request=(group,op,p=t.leaf)=>a(10,t.fork(t.text(group),t.fork(n(1),t.fork(t.text(op),p)))); + const ref=key=>w.marker('io.v2.resource',key),read=request('stream','read',t.fork(ref(1),n(4))),write=bytes=>request('stream','write',t.fork(ref(2),w.list(bytes.map(n)))); + const seq=(left,right)=>a(1,t.fork(left,t.fork(t.leaf,right))),session=action=>new Session(t,action,'1',roots=>g.collect([...roots])); + return {g,t,w,n,a,request,ref,read,write,seq,session}; +} +async function poll(s,predicate){for(let i=0;i<100;i++){const r=await s.turn({run:'1',kind:'poll'});if(predicate(r))return r;}throw Error('poll did not progress');} +const value=(f,s)=>f.w.pair(f.w.pair(s.core.result.value)[1])[0]; +test('stream read uses the independent native data-pair encoding, not a nested tuple',async()=>{ + const f=fixture(),s=f.session(f.read),r=await poll(s,r=>r.events.length);const event=r.events[0]; + await s.turn({run:'1',kind:'reply',reply:{...event,bytes:[65,0,255],eof:false}});if(!s.core.result)await poll(s,r=>r.done); + const [tag,bytes]=f.w.pair(value(f,s));assert.equal(readText(f.g,tag),'data');assert.deepEqual(readList(f.g,bytes,3).map(h=>readNatural(f.g,h)),[65n,0n,255n]); +}); +test('EOF is sticky and standard handles are borrowed, not physically closed',async()=>{ + const f=fixture(),s=f.session(f.seq(f.read,f.read)),r=await poll(s,r=>r.events.length); + let out=await s.turn({run:'1',kind:'reply',reply:{...r.events[0],bytes:[],eof:true}}); + if(!out.done)out=await poll(s,r=>{assert.equal(r.events.length,0);return r.done;}); + assert.equal(readText(f.g,readList(f.g,value(f,s),1)[0]),'eof'); + const other=fixture(),close=other.session(other.request('resource','close',other.ref(2)));await poll(close,r=>r.done); + assert.equal(readText(other.g,readList(other.g,value(other,close),4)[0]),'borrowed-resource'); +}); +test('structured session stop waits for provider acknowledgement and refuses reuse',async()=>{ + const f=fixture(),s=f.session(f.read),r=await poll(s,r=>r.events.length),event=r.events[0]; + await assert.rejects(s.turn({run:'2',kind:'stop'}),/stale/); + const stopped=await s.turn({run:'1',kind:'stop'});assert(!stopped.done);assert.equal(stopped.events[0].kind,'cancel'); + let done=await s.turn({run:'1',kind:'reply',reply:{...event,kind:'cancelled'}});if(!done.done)done=await poll(s,r=>r.done); + assert.equal(done.outcome.kind,'cancelled');assert(Object.values(done.counts).every(c=>Object.values(c).every(n=>n===0))); + await assert.rejects(s.turn({run:'1',kind:'poll'}),/stale/); +}); +test('failed output poisons the slot before demanding any later byte payload',async()=>{ + const f=fixture(),poison=f.g.app(f.g.leaf,f.g.leaf),observe=f.t.observe; + f.t.observe=h=>{if(h===poison)throw Error('forced poisoned write data');return observe(h);}; + const later=f.request('stream','write',f.t.fork(f.ref(2),poison)),s=f.session(f.seq(f.write([65]),later)),r=await poll(s,r=>r.events.length); + await s.turn({run:'1',kind:'reply',reply:{...r.events[0],kind:'error'}});if(!s.core.result)await poll(s,r=>r.done); + assert.equal(s.core.hasFault,false);const error=readList(f.g,value(f,s),4);assert.equal(readText(f.g,error[0]),'invalid-state');assert.equal(readText(f.g,error[3]),'unknown'); +}); +test('attempted output has an aggregate bound and never silently retries partial writes',async()=>{ + const f=fixture({fuel:20000000});let action=f.write([1]);for(let i=0;i<4;i++)action=f.seq(f.write(Array(4096).fill(65)),action); + const s=f.session(action);let events=0; + for(let i=0;i<100&&!s.core.result;i++){ + const r=await s.turn({run:'1',kind:'poll'}); + for(const event of r.events){events++;await s.turn({run:'1',kind:'reply',reply:{...event,kind:'write',count:1}});} + } + assert(s.core.result);assert.equal(events,4);assert.equal(readText(f.g,readList(f.g,value(f,s),4)[0]),'invalid-bound'); +}); +test('named UTF8 facilities preserve nonfinal carry and generic BOM text',async()=>{ + const f=fixture(),bytes=[0xef,0xbb,0xbf,65,0xf0,0x9f],s=f.session(f.request('utf8','decode',f.w.list([f.n(0),f.w.list(bytes.map(f.n))]))); + await poll(s,r=>r.done);const [text,carry]=readList(f.g,value(f,s),2); + assert.equal(readText(f.g,text),'\ufeffA');assert.deepEqual(readList(f.g,carry,2).map(h=>readNatural(f.g,h)),[240n,159n]); + const other=fixture(),encoded=other.session(other.request('utf8','encode',other.t.text('λ😀')));await poll(encoded,r=>r.done); + assert.deepEqual(readList(other.g,value(other,encoded),6).map(h=>Number(readNatural(other.g,h))),[206,187,240,159,152,128]); +}); +test('named UTF8 rejects excess heads/tails without demand and malformed scalar values as ordinary errors',async()=>{ + for(const [op,limit]of [['encode',1024],['decode',4096]]){ + const f=fixture({fuel:20000000}),poison=f.g.app(f.g.leaf,f.g.leaf),observe=f.t.observe; + f.t.observe=h=>{if(h===poison)throw Error('forced excess payload');return observe(h);}; + let list=f.t.fork(poison,poison);for(let i=0;i<limit;i++)list=f.t.fork(f.n(65),list); + const s=f.session(f.request('utf8',op,op==='encode'?list:f.w.list([f.n(0),list])));await poll(s,r=>r.done); + assert(!s.core.hasFault);assert.equal(readText(f.g,readList(f.g,value(f,s),4)[0]),'invalid-argument'); + } + for(const scalar of [0xd800,0x110000]){ + const f=fixture(),s=f.session(f.request('utf8','encode',f.w.list([f.n(scalar)])));await poll(s,r=>r.done); + assert.equal(readText(f.g,readList(f.g,value(f,s),4)[0]),'invalid-argument'); + } +}); diff --git a/ext/demo-site/test/io-source-probe.mjs b/ext/demo-site/test/io-source-probe.mjs @@ -0,0 +1,104 @@ +// Real compiled source libraries, native evaluators and the private event session. +export async function run(_control,backend,terminalMode=false){ + const base=new URL('../',document.querySelector('script[type=module][src]').src),fixture=await(await fetch('/io-source.json')).json(),checks=[]; + const input=[...new TextEncoder().encode('\ufeffA😀λe\u0301\r\n\0')]; + async function drive(name,start,turn){ + if(terminalMode)return driveTerminal(name,start,turn); + let report=await start(),queue=[...report.events],offset=0,pendingRead=null,cancelled=false;const output=[]; + for(let i=0;i<2000&&!report.done;i++){ + if(!queue.length){report=await turn({run:report.run,kind:'poll'});queue.push(...report.events);continue;} + const event=queue.shift();let reply; + if(event.kind==='read'){ + if(name==='cancellation'){pendingRead=event.id;continue;} + if(name!=='roundTrip')throw Error('Unexpected read'); + const bytes=input.slice(offset,offset+Math.min(event.limit,1));offset+=bytes.length; + reply={...event,bytes,eof:bytes.length===0}; + }else if(event.kind==='write'){ + if(name==='cancellation'&&pendingRead===null)throw Error('Write ran without the expected pending reader'); + // Deliberate positive partial progress: the SOURCE writeAll must advance + // exactly once, including through a split multi-byte scalar. + output.push(event.bytes[0]);reply={...event,count:1}; + }else{ + if(event.id!==pendingRead||cancelled)throw Error('Unexpected cancellation'); + cancelled=true;reply={...event,kind:'cancelled'}; + } + report=await turn({run:report.run,kind:'reply',reply});if(report.accepted!==true)throw Error('Provider reply rejected');queue.push(...report.events); + } + const expected=name.startsWith('invalid')?[]:name==='roundTrip'?input:[...new TextEncoder().encode(name==='cancellation'?'ready':'λ😀e\u0301\r\n')]; + if(!report.done||report.outcome.kind!=='completed'||report.outcome.value!=='42'||report.outcome.fault)throw Error(name+': '+JSON.stringify(report)); + if(JSON.stringify(output)!==JSON.stringify(expected))throw Error(name+' output byte mismatch: '+JSON.stringify(output)); + if(name==='cancellation'&&!cancelled)throw Error('Missing source cancellation'); + if(!Object.values(report.counts).every(group=>Object.values(group).every(v=>v===0)))throw Error('Retained run records'); + checks.push(name+': full bytes/result42; split UTF8 and source partial-progress handling'); + } + async function driveTerminal(name,start,turn){ + const {ioTerminalView:{terminalView}}=await import(new URL('dist/probe-api.js',base)); + let report=await start(),stop=false,sent=false,aborted=false;const replies=[],parent=document.createElement('div');document.body.append(parent); + const widget=terminalView(parent,report.run,r=>replies.push(r),()=>{stop=true;}); + try{ + for(let i=0;i<2000&&!report.done;i++){ + for(const event of report.events){ + const old=parent.querySelector('form'),oldEOF=old?.querySelector('button[type=button]'); + widget.handle(event); + if(event.kind==='cancel'){ + aborted=true;if(!parent.querySelector('form').hidden)throw Error('Input still visible at cancellation acknowledgement'); + const count=replies.length;old.dispatchEvent(new Event('submit',{cancelable:true}));oldEOF?.click(); + if(replies.length!==count)throw Error('Stale DOM prompt emitted reply'); + } + if(event.kind==='read'&&name==='roundTrip'){ + const form=parent.querySelector('form'); + if(!sent){form.querySelector('input').value='\ufeffA😀λe\u0301\0';form.dispatchEvent(new Event('submit',{cancelable:true}));sent=true;} + else form.querySelector('button[type=button]').click(); + } + if(event.kind==='read'&&name==='externalStop')parent.querySelector('section > button').click(); + } + const command=stop?{kind:'stop'}:replies.length?{kind:'reply',reply:replies.shift()}:{kind:'poll'};stop=false; + report=await turn({run:report.run,...command}); + if(command.kind==='reply'&&report.accepted!==true)throw Error('UI completion rejected'); + } + if(!report.done||report.outcome.kind!==(name==='externalStop'?'cancelled':'completed')||report.outcome.fault)throw Error('UI run: '+JSON.stringify(report)); + if(name!=='externalStop'&&report.outcome.value!=='42')throw Error('UI result mismatch'); + widget.finish();const snapshot=widget.snapshot(); + const expected=name==='binary'?[92,120,102,102,255,240,159]:name==='roundTrip'?[...new TextEncoder().encode('\ufeffA😀λe\u0301\0\n')]:name.startsWith('invalid')||name==='externalStop'?[]:[...new TextEncoder().encode(name==='cancellation'?'ready':'λ😀e\u0301\r\n')]; + if(JSON.stringify(snapshot.output.bytes)!==JSON.stringify(expected))throw Error('UI output mismatch '+name); + if(parent.querySelector('[data-hex=output]').textContent!==expected.map(b=>b.toString(16).padStart(2,'0')).join(' '))throw Error('Wrong hex view'); + if(name==='binary'){ + const text=parent.querySelector('[data-stream=output]'); + if(text.firstChild.nodeType!==Node.TEXT_NODE||text.firstChild.textContent!=='\\xff'||text.querySelectorAll('mark').length!==2||!text.querySelector('[data-byte-reason=incomplete]'))throw Error('Byte escapes indistinguishable from literal text'); + } + if(['externalStop','cancellation'].includes(name)&&!aborted)throw Error('No UI cancellation'); + if(!Object.values(report.counts).every(group=>Object.values(group).every(v=>v===0)))throw Error('UI run retained records'); + if(replies.length||parent.querySelector('form'))throw Error('UI not retired'); + checks.push('terminal '+name+': exact bytes, DOM lifecycle and outcome'); + }finally{parent.remove();} + } + if(terminalMode){ + const {ioTerminalView:{terminalView}}=await import(new URL('dist/probe-api.js',base)); + const parent=document.createElement('div'),replies=[];document.body.append(parent);let stops=0; + const widget=terminalView(parent,'900',r=>replies.push(r),()=>{stops++;}); + try{ + widget.handle({run:'900',id:1,kind:'read',limit:4096});const stale=parent.querySelector('form'),staleEOF=stale.querySelector('button[type=button]'); + widget.handle({run:'900',id:1,kind:'cancel',operation:'read'});widget.handle({run:'900',id:2,kind:'read',limit:4096}); + stale.dispatchEvent(new Event('submit',{cancelable:true}));staleEOF.click(); + if(replies.length!==1||parent.querySelector('form').hidden)throw Error('Old prompt consumed successor'); + const form=parent.querySelector('form');form.querySelector('input').value='\ud800';form.dispatchEvent(new Event('submit',{cancelable:true})); + if(replies.length!==1||form.hidden)throw Error('Invalid line consumed wait'); + form.querySelector('input').value='';form.dispatchEvent(new Event('submit',{cancelable:true})); + if(replies.at(-1).eof||JSON.stringify(replies.at(-1).bytes)!=='[10]')throw Error('Empty line conflated with EOF'); + const text='<script>throw Error("injected")</script><img src=x onerror=alert(1)>'; + widget.handle({run:'900',id:3,kind:'write',stream:'error',bytes:[...new TextEncoder().encode(text)]}); + if(parent.querySelector('script,img')||parent.querySelector('[data-stream=error]').textContent!==text)throw Error('Output interpreted as markup'); + const button=parent.querySelector('section > button');button.click();button.click();if(stops!==1)throw Error('Repeated stop'); + widget.finish();checks.push('terminal DOM: stale prompt isolation, malformed/empty line, inert markup, once-only stop'); + }finally{parent.remove();} + } + const names=terminalMode?['direct','handled','roundTrip','cancellation','invalidUTF8','invalidScalar','binary','externalStop']:['direct','handled','roundTrip','cancellation','invalidUTF8','invalidScalar']; + const {CompilerWorker}=await import(new URL('runtime/haskell/client.mjs',base)),client=new CompilerWorker(); + try{ + await client.request({type:'init',compiler:false});await client.request({type:'load',bytes:new Uint8Array(fixture.bytes).buffer,hash:fixture.hash}); + for(const name of names)await drive(name, + async()=>JSON.parse((await client.request({type:'io-session-start',name:name==='externalStop'?'roundTrip':name,arguments:[]})).text), + async command=>JSON.parse((await client.request({type:'io-session-turn',...command})).text)); + }finally{client.dispose();} + return {backend,checks}; +} diff --git a/ext/demo-site/test/io-tasks.test.mjs b/ext/demo-site/test/io-tasks.test.mjs @@ -0,0 +1,132 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {Tasks} from '../dist/io-tasks.js'; +const ok=value=>({kind:'completed',value}),failed=value=>({kind:'failed',value}),cancelled=value=>({kind:'cancelled',value}); +const diagnostics=items=>({items,omitted:0}); +const fresh=(run=1n,limit)=>new Tasks(run,'owner-stopped','join-cycle',limit); +function finish(s,id,outcome=ok(null),cleanup){s.bodyDone(id,outcome);assert.equal(s.beginRelease(id),true);return s.settle(id,cleanup);} +function retired(s){assert.deepEqual(s.counts,{scopes:0,tasks:0,hosts:0});assert.deepEqual([...s.payloads()],['owner-stopped','join-cycle']);} + +test('normal exit closes admission but waits for children; ordinary errors remain values',()=>{ + const s=fresh(),child=s.spawn(0,false,false),ordinary={err:'domain error'}; + s.bodyDone(0,ok(ordinary));assert.equal(s.beginRelease(0),false); + assert.throws(()=>s.spawn(0,false,false),/invalid-transition/); + const grandchild=s.spawn(child.key,false,false); // existing child remains live during ancestor join + finish(s,grandchild.key);finish(s,child.key); + assert.equal(s.beginRelease(0),true);const out=s.settle(0); + assert.equal(out.kind,'completed');assert.equal(out.value,ordinary);retired(s); +}); +test('normal joining remains cancellable and the first reason is sticky',()=>{ + const s=fresh(),child=s.spawn(0,false,false); + s.bodyDone(0,ok('return'));s.stop(0,cancelled('stop'));s.stop(0,failed('late')); + assert.deepEqual(s.state(child.key).reason,cancelled('owner-stopped')); + finish(s,child.key,failed('late child')); + assert(s.beginRelease(0));assert.equal(s.settle(0).value,'stop');retired(s); +}); +test('supervision contains failure; unsupervised failure stops siblings immediately',()=>{ + const s=fresh(),contained=s.spawn(0,true,true); + finish(s,contained.key,failed('contained'));assert.equal(s.state(0).reason,null); + s.claimJoin(0,contained);assert.equal(s.pollJoin(0).value,'contained'); + const a=s.spawn(0,false,true),b=s.spawn(0,false,true); + s.stop(a.key,failed('first'));s.stop(b.key,failed('second')); + assert.deepEqual(s.state(0).reason,failed('first')); + assert.deepEqual(s.state(b.key).reason,cancelled('owner-stopped')); + finish(s,a.key);finish(s,b.key);assert.equal(finish(s,0).value,'first');retired(s); +}); +test('joins consume before waiting; abandoned joins never restore tickets or retain results',()=>{ + const s=fresh(),child=s.spawn(0,true,true),waiter=s.spawn(0,true,true); + s.claimJoin(waiter.key,child);assert.equal(s.pollJoin(waiter.key),null); + assert.throws(()=>s.discard(0,child),/stale-task/); + s.cancel(0,waiter,'cancel wait');finish(s,waiter.key); + const result={unique:'unclaimed'};finish(s,child.key,ok(result)); + assert(![...s.payloads()].includes(result)); + s.claimJoin(0,waiter);assert.equal(s.pollJoin(0).value,'cancel wait'); + assert.throws(()=>s.claimJoin(0,waiter),/stale-task/); + finish(s,0);retired(s); +}); +test('explicit and implicit cycles use task identities across lexical scopes',()=>{ + const s=fresh(),a=s.spawn(0,true,true);let b=s.spawn(0,true,true); + const inner=s.nested(a.key); + assert.throws(()=>s.claimJoin(inner,a),/join-cycle/); + s.claimJoin(inner,b);assert.throws(()=>s.claimJoin(b.key,a),/join-cycle/); + s.abandonJoin(inner);finish(s,b.key);b=s.spawn(0,true,true); + const c=s.spawn(inner,true,true); + s.claimJoin(b.key,a);s.claimJoin(c.key,b);s.bodyDone(inner,ok(null)); + assert.equal(s.state(inner).reason.value,'join-cycle');assert.equal(s.state(a.key).reason.value,'join-cycle'); + finish(s,c.key);assert(s.beginRelease(inner));s.settle(inner);finish(s,a.key); + assert.equal(s.pollJoin(b.key).value,'join-cycle');finish(s,b.key);finish(s,0);retired(s); +}); +test('out-of-scope, cross-run and completed task cancellation reject without consumption',()=>{ + const s=fresh(),a=s.spawn(0,true,true),b=s.spawn(0,true,true),owned=s.spawn(a.key,true,true); + assert.throws(()=>s.claimJoin(b.key,owned),/out-of-scope/); + assert.throws(()=>s.cancel(0,{...a,run:2n},'bad'),/stale-task/); + finish(s,owned.key,ok('child'));s.claimJoin(a.key,owned);assert.equal(s.pollJoin(a.key).value,'child'); + finish(s,a.key);assert.throws(()=>s.cancel(0,a,'late'),/stale-task/);s.discard(0,a); + finish(s,b.key);finish(s,0);retired(s); +}); +test('cleanup defeats success, preserves the primary and propagates bounded diagnostics once',()=>{ + const s=fresh(),inner=s.nested(0),child=s.spawn(inner,false,false); + assert.equal(finish(s,child.key,ok(null),diagnostics([17])).value,17); + const lexical=finish(s,inner,ok(null),diagnostics(Array.from({length:25},(_,i)=>100+i))); + assert.equal(lexical.cleanup,undefined); + const out=finish(s,0,failed(999),diagnostics([200])); + assert.equal(out.kind,'failed');assert.equal(out.value,17); + assert.deepEqual(out.cleanup,{items:Array.from({length:16},(_,i)=>100+i),omitted:10});retired(s); +}); +test('pending host cancellation is delivered once but release requires acknowledgement',async()=>{ + const s=fresh(),reader=s.spawn(0,true,true),read=s.beginHost(reader.key),controller=new AbortController(),events=[]; + let acknowledge; + const physicalWork=new Promise(resolve=>{acknowledge=resolve;}); + controller.signal.addEventListener('abort',()=>events.push('abort-requested'),{once:true}); + s.cancel(0,reader,'cancel input');s.bodyDone(reader.key,ok('ignored value')); + assert.deepEqual(s.takeCancellations(),[read]);controller.abort(); + assert.deepEqual(s.takeCancellations(),[]);assert.equal(s.beginRelease(reader.key),false); + s.bodyDone(0,ok('parent'));assert.equal(s.beginRelease(0),false); + await Promise.resolve();assert.deepEqual(events,['abort-requested']);assert.equal(s.beginRelease(reader.key),false); + acknowledge();await physicalWork;events.push('physical-work-settled'); + assert.equal(s.endHost(read),'ignored');assert.equal(s.endHost(read),'stale'); + assert(s.beginRelease(reader.key));assert.equal(s.settle(reader.key).kind,'cancelled'); + assert(s.beginRelease(0));s.settle(0);retired(s); +}); +test('wrong/stale host resumes leave the correct wait intact',()=>{ + const s=fresh(),child=s.spawn(0,true,false),host=s.beginHost(child.key); + for(const bad of [{...host,scope:0},{...host,run:2n},{...host,key:host.key+1}])assert.equal(s.endHost(bad),'stale'); + assert.equal(s.state(child.key).hostCalls,1);assert.equal(s.endHost(host),'reply'); + const next=s.beginHost(child.key);assert.notEqual(next.key,host.key);assert.equal(s.endHost(host),'stale'); + assert.equal(s.endHost(next),'reply');finish(s,child.key);finish(s,0);retired(s); +}); +test('all callback completion orders retain cancelled scopes until their own work settles',()=>{ + for(const order of [[0,1,2],[0,2,1],[1,0,2],[1,2,0],[2,0,1],[2,1,0]]){ + const s=fresh(),children=order.map(()=>s.spawn(0,false,false)),hosts=children.map(c=>s.beginHost(c.key)); + s.stop(0,failed('stop'));s.bodyDone(0,ok(null)); + for(const child of children)s.bodyDone(child.key,ok(null)); + assert.equal(s.takeCancellations().length,3);assert.equal(s.takeCancellations().length,0); + for(const index of order){ + assert.equal(s.beginRelease(0),false);assert.equal(s.beginRelease(children[index].key),false); + assert.equal(s.endHost(hosts[index]),'ignored');assert(s.beginRelease(children[index].key));s.settle(children[index].key); + } + assert(s.beginRelease(0));assert.equal(s.settle(0).value,'stop');retired(s); + } +}); +test('nonwrapping identities, scope state copies and immutable diagnostic capture',()=>{ + for(const bad of [0n,-1n,1,2n**64n])assert.throws(()=>fresh(bad),/identity/); + const s=fresh(1n,2),a=s.spawn(0,true,true),copy=s.state(a.key);copy.phase='releasing'; + assert.equal(s.state(a.key).phase,'open'); + const items=['cleanup'];s.bodyDone(a.key,failed('primary'));assert(s.beginRelease(a.key)); + const result=s.settle(a.key,diagnostics(items));items[0]='changed';result.cleanup.items[0]='also changed'; + s.claimJoin(0,a);assert.equal(s.pollJoin(0).cleanup.items[0],'cleanup'); + const b=s.spawn(0,true,false);finish(s,b.key); + assert.throws(()=>s.spawn(0,true,false),/identity-exhausted/);assert.deepEqual(s.counts,{scopes:1,tasks:0,hosts:0}); + const h=s.beginHost(0);assert(Object.isFrozen(h));s.endHost(h);s.endHost(s.beginHost(0)); + assert.throws(()=>s.beginHost(0),/identity-exhausted/);finish(s,0);retired(s); +}); +test('joined, discarded and non-result tasks retain no history or payload roots',()=>{ + const s=fresh(); + for(let i=0;i<2000;i++){ + const t=s.spawn(0,true,i%3!==2),payload={unique:i};finish(s,t.key,ok(payload)); + if(i%3===0){s.claimJoin(0,t);assert.equal(s.pollJoin(0).value,payload);} + if(i%3===1)s.discard(0,t); + assert.deepEqual(s.counts,{scopes:1,tasks:0,hosts:0});assert(![...s.payloads()].includes(payload)); + } + finish(s,0);retired(s); +}); diff --git a/ext/demo-site/test/io-terminal.test.mjs b/ext/demo-site/test/io-terminal.test.mjs @@ -0,0 +1,37 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {Terminal} from '../dist/io-terminal.js'; +const read=(id,limit=4096)=>({run:'1',id,kind:'read',limit}); +test('line input is scalar bounded, strict, LF framed and explicitly distinct from EOF',()=>{ + const t=new Terminal('1');assert.equal(t.handle(read(1)),undefined); + for(const value of ['x\ny','\ud800','😀'.repeat(257),null])assert.equal(t.line('1',1,value),undefined); + assert.equal(t.line('2',1,'x'),undefined);assert.deepEqual(t.waiting,{run:'1',id:1}); + assert.deepEqual(t.line('1',1,''),{run:'1',id:1,kind:'read',bytes:[10],eof:false}); + t.handle(read(2));assert.deepEqual(t.endInput('1',2),{run:'1',id:2,kind:'read',bytes:[],eof:true}); + assert.equal(t.handle(read(3)).eof,true);t.finish(); +}); +test('encoded line is split at requested byte bounds, without loss or invented EOF',()=>{ + const t=new Terminal('1');t.handle(read(1,1));const parts=[...t.line('1',1,'😀'.repeat(256)).bytes]; + for(let id=2;id<=1025;id++) {const r=t.handle(read(id,1));assert.equal(r.eof,false);parts.push(...r.bytes);} + assert.deepEqual(parts,[...new TextEncoder().encode('😀'.repeat(256)),10]); + assert.equal(t.handle(read(1026)),undefined);assert.throws(()=>t.finish(),/pending/); +}); +test('cancellation retires exactly the pending prompt; stale submissions do not consume its successor',()=>{ + const t=new Terminal('1');t.handle(read(1)); + assert.deepEqual(t.handle({run:'1',id:1,kind:'cancel',operation:'read'}),{run:'1',id:1,kind:'cancelled'}); + t.handle(read(2));assert.equal(t.line('1',1,'stale'),undefined);assert.equal(t.endInput('1',1),undefined); + assert.equal(t.handle({run:'1',id:1,kind:'cancel',operation:'read'}),undefined); + assert.equal(t.waiting.id,2);assert.deepEqual(t.line('1',2,'ok').bytes,[111,107,10]);t.finish(); +}); +test('binary write acknowledgement is independent of display decoding and abort races never duplicate it',()=>{ + const t=new Terminal('1'),event={run:'1',id:1,kind:'write',stream:'output',bytes:[255,240,159]}; + assert.deepEqual(t.handle(event),{run:'1',id:1,kind:'write',count:3}); + assert.equal(t.handle({run:'1',id:1,kind:'cancel',operation:'write'}),undefined); + assert.throws(()=>t.handle(event),/stale/);t.finish(); + assert.deepEqual(t.output.snapshot().output.bytes,event.bytes);assert.equal(t.output.snapshot().output.tokens.at(-1).reason,'incomplete'); +}); +test('invalid input events preserve current waits and do not consume future identities',()=>{ + const t=new Terminal('1');assert.throws(()=>t.handle(read(2,0)),/read/);t.handle(read(1)); + assert.throws(()=>t.handle(read(2)),/read/);assert.throws(()=>t.handle({...read(2),run:'2'}),/event/); + assert.equal(t.waiting.id,1);t.endInput('1',1);assert.equal(t.handle(read(2)).eof,true); +}); diff --git a/ext/demo-site/test/io-utf8.test.mjs b/ext/demo-site/test/io-utf8.test.mjs @@ -0,0 +1,36 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {encodeUTF8,decodeUTF8} from '../dist/io-utf8.js'; +test('all Unicode scalars match independent platform encoding and strict decoding',()=>{ + const encoder=new TextEncoder(); + for(let start=0;start<=0x10ffff;start+=1024){ + const scalars=[];for(let n=start;n<Math.min(start+1024,0x110000);n++)if(n<0xd800||n>0xdfff)scalars.push(n); + const text=String.fromCodePoint(...scalars),expected=[...encoder.encode(text)]; + assert.deepEqual(encodeUTF8(text),expected);assert.deepEqual(decodeUTF8(expected,true),{text,carry:[]}); + } +}); +test('every valid multibyte scalar prefix yields only copied bounded carry, and fails when final',()=>{ + const encoder=new TextEncoder(); + for(let n=128;n<=0x10ffff;n++){ + if(n>=0xd800&&n<=0xdfff)continue; + const bytes=[...encoder.encode(String.fromCodePoint(n))]; + for(let split=1;split<bytes.length;split++){ + const prefix=bytes.slice(0,split),out=decodeUTF8(prefix,false); + assert.equal(out.text,'');assert.deepEqual(out.carry,prefix);assert.notEqual(out.carry,prefix); + } + } + // Exhaustive prefixes above; explicit final/invalid-prefix controls below keep + // exception creation out of the million-scalar loop. + for(const bytes of [[0xc2],[0xe0,0xa0],[0xf4,0x8f,0xbf]])assert.throws(()=>decodeUTF8(bytes,true),/incomplete/); +}); +test('invalid lead/continuation/overlong/surrogate/range prefixes fail before finality',()=>{ + for(const bytes of [[0x80],[0xc0],[0xc1],[0xf5],[0xff],[0xe0,0x9f],[0xed,0xa0],[0xf0,0x8f],[0xf4,0x90],[0xc2,0x41],[0xe2,0x82,0x41]]) + for(const final of [false,true])assert.throws(()=>decodeUTF8(bytes,final),/invalid UTF-8/); + assert.throws(()=>decodeUTF8([65,0xff],false),/at byte 1$/); +}); +test('BOM, NUL, combining scalars and line endings are unchanged; scalar/byte bounds are separate',()=>{ + const text='\ufeffa\0\r\nλ😀e\u0301';assert.deepEqual(decodeUTF8(encodeUTF8(text),true),{text,carry:[]}); + assert.equal(encodeUTF8('😀'.repeat(1024)).length,4096); + assert.throws(()=>encodeUTF8('a'.repeat(1025)),/scalar/);assert.throws(()=>encodeUTF8('\ud800'),/scalar/); + assert.throws(()=>decodeUTF8(Array(4097).fill(65),false),/byte/); +}); diff --git a/ext/demo-site/test/io-v2-core-probe.mjs b/ext/demo-site/test/io-v2-core-probe.mjs @@ -1,22 +0,0 @@ -// Executes bare computation trees privately on native evaluators. Production IO -// envelopes and terminal providers remain gated; no JS evaluator substitution. -export async function run(_control,backend){ - const base=new URL('../',document.querySelector('script[type=module][src]').src); - const fixtures=await(await fetch('/runtime-fixtures.json')).json(),checks=[]; - let execute,dispose; - const {CompilerWorker}=await import(new URL('runtime/haskell/client.mjs',base)); - const client=new CompilerWorker();dispose=()=>client.dispose();await client.request({type:'init',compiler:false}); - execute=async fixture=>{await client.request({type:'load',bytes:new Uint8Array(fixture.bytes).buffer,hash:fixture.hash});return JSON.parse((await client.request({type:'io-v2-core',name:'root',arguments:[]})).text);}; - const cases=[['pure','completed'],['bind','completed'],['scope','completed'],['child-0','failed'],['child-1','completed'],['unsupported','completed'],['discard-invalid','completed'],['terminal-2','failed'],['terminal-9','cancelled'],['malformed','failed']]; - try{ - for(const [name,kind]of cases){ - const out=await execute(fixtures['v2-run-'+name]); - const expected={kind,value:kind==='completed'?'42':null,cleanup:0,tasks:{scopes:0,tasks:0,hosts:0},resources:{resources:0,admissions:0,leases:0},host:{pending:0,replies:0}}; - if(JSON.stringify(out)!==JSON.stringify(expected))throw Error(name+': '+JSON.stringify(out)); - checks.push(name); - } - for(let i=0;i<5;i++)await execute(fixtures['v2-run-scope']); - checks.push('repeated run retirement and reuse'); - }finally{dispose();} - return {backend,checks}; -} diff --git a/ext/demo-site/test/io-v2-core.test.mjs b/ext/demo-site/test/io-v2-core.test.mjs @@ -1,111 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import {Graph} from '../dist/graph.js'; -import {natural,text,readNatural,readList,readText} from '../dist/codecs.js'; -import {Core,Wire} from '../dist/io-v2-core.js'; -function fixture(){ - const g=new Graph(),tree={leaf:g.leaf,observe:g.observe.bind(g),fork:g.fork.bind(g),stem:g.stem.bind(g),app:g.app.bind(g),natural:n=>natural(g,n),text:s=>text(g,s)}; - const w=new Wire(tree,1n),n=x=>natural(g,BigInt(x)),act=(code,p=g.leaf)=>g.fork(n(code),p),k=x=>g.fork(g.leaf,x); - const bind=(left,right)=>act(1,g.fork(left,k(right))); - const spawn=(child,sup=true,keep=true)=>act(5,g.fork(n(sup?1:0),g.fork(n(keep?1:0),g.fork(g.leaf,k(child))))); - const request=(args=g.leaf)=>act(10,g.fork(text(g,'future'),g.fork(n(1),g.fork(text(g,'read'),args)))); - const provider={request:e=>()=>e.wire.result(false,e.wire.problem('unsupported','future/read')),release:()=>assert.fail('borrowed backend close')}; - const core=(action,p=provider,limit)=>new Core(tree,1n,action,[null,null,null],p,limit); - return {g,tree,w,n,act,k,bind,spawn,request,provider,core}; -} -async function drain(e,turn=()=>{}){ - for(let i=0;i<20000&&!e.result;i++){ - const progress=e.advance();turn(); - if(!progress){assert(e.work.counts.pending,'interpreter stalled');await e.wait();} - } - assert(e.result,'interpreter did not settle'); - assert.deepEqual(e.lifecycle.tasks.counts,{scopes:0,tasks:0,hosts:0}); - assert.deepEqual(e.lifecycle.resources.counts,{resources:0,admissions:0,leases:0}); - assert.deepEqual(e.work.counts,{pending:0,replies:0});return e.result; -} -function result(f,value){const [flag,tail]=f.w.pair(value);return {ok:f.g.observe(flag).node.kind==='stem',value:f.w.pair(tail)[0]};} - -test('real tree bind/scope/current-scope execute, with roots collected between turns',async()=>{ - const f=fixture(),e=f.core(f.bind(f.act(0,f.n(1)),f.act(3,f.k(f.act(4))))); - const out=await drain(e,()=>f.g.collect([...e.roots()]));assert.equal(out.kind,'completed'); - const parts=readList(f.g,out.value,3);assert.equal(readText(f.g,parts[0]),'io.v2.scope');assert.equal(readNatural(f.g,parts[2]),1n); -}); -test('opaque terminal diagnostics never demand payloads or abandoned continuations',async()=>{ - for(const tag of [2,9]){ - const f=fixture(),poison=f.g.app(f.g.leaf,f.g.leaf),observe=f.tree.observe; - f.tree.observe=h=>{if(h===poison)throw Error('forced opaque diagnostic');return observe(h);}; - const e=f.core(f.act(1,f.g.fork(f.act(tag,poison),poison))),out=await drain(e); - assert.equal(out.kind,tag===2?'failed':'cancelled');assert.equal(out.value,poison);assert.equal(e.fault,undefined); - } -}); -test('spawn/join encode the native outcome contract and consume tickets once',async()=>{ - const f=fixture(),join=f.act(6,f.w.marker('io.v2.join',1)); - const e=f.core(f.bind(f.spawn(f.act(0,f.n(42))),join)),out=await drain(e),joined=result(f,out.value); - assert(joined.ok);const parts=readList(f.g,joined.value,2);assert.equal(readText(f.g,parts[0]),'completed');assert.equal(readNatural(f.g,parts[1]),42n); - const other=fixture(),again=other.act(6,other.w.marker('io.v2.join',1)); - const repeated=await drain(other.core(other.bind(other.spawn(other.act(0)),other.bind(again,again)))); - const stale=result(other,repeated.value);assert(!stale.ok);assert.equal(readText(other.g,readList(other.g,stale.value,4)[0]),'stale-task'); -}); -test('supervised failure is data; uncontained failure defeats parent success',async()=>{ - for(const supervised of [true,false]){ - const f=fixture(),out=await drain(f.core(f.bind(f.spawn(f.act(2,f.n(17)),supervised),f.act(6,f.w.marker('io.v2.join',1))))); - assert.equal(out.kind,supervised?'completed':'failed'); - if(supervised){const joined=result(f,out.value);assert(joined.ok);assert.equal(readText(f.g,readList(f.g,joined.value,3)[0]),'failed');} - else assert.equal(readNatural(f.g,out.value),17n); - } -}); -test('unsupported facilities stay ordinary Results without argument demand',async()=>{ - const f=fixture(),poison=f.g.app(f.g.leaf,f.g.leaf),observe=f.tree.observe;f.tree.observe=h=>{if(h===poison)throw Error('demanded arguments');return observe(h);}; - const e=f.core(f.request(poison)),out=await drain(e,()=>f.g.collect([...e.roots()]));assert.equal(out.kind,'completed');assert.equal(result(f,out.value).ok,false); -}); -test('tree cancellation drains pending provider before join and suppresses abandoned continuation',async()=>{ - const f=fixture(),args=f.n(123);let acknowledge,aborts=0,argumentLive=false; - const gate=new Promise(resolve=>{acknowledge=resolve;}); - const p={...f.provider,request:async(e,_scope,request,signal)=>{ - await new Promise(resolve=>signal.addEventListener('abort',()=>{aborts++;resolve();},{once:true})); - await gate;argumentLive=readNatural(f.g,request.arguments)===123n;return ()=>{throw Error('cancelled reply encoder ran');}; - }}; - const cancel=f.act(8,f.g.fork(f.w.marker('io.v2.task',1),f.n(7))); - const e=f.core(f.bind(f.spawn(f.request(args)),f.bind(cancel,f.act(6,f.w.marker('io.v2.join',1)))),p); - for(let i=0;i<40;i++){const progress=e.advance();f.g.collect([...e.roots()]);if(!progress)break;} - assert.equal(aborts,1);assert.equal(e.result,null);assert.equal(e.work.counts.pending,1); - acknowledge();await e.wait();const out=await drain(e,()=>f.g.collect([...e.roots()]));assert(argumentLive); - const joined=result(f,out.value),parts=readList(f.g,joined.value,3);assert.equal(readText(f.g,parts[0]),'cancelled');assert.equal(readNatural(f.g,parts[1]),7n); -}); -test('malformed action is terminal, malformed operation is an ordinary Result',async()=>{ - const f=fixture(),terminal=await drain(f.core(f.act(1,f.g.leaf)));assert.equal(terminal.kind,'failed'); - const ordinary=await drain(f.core(f.act(6,f.g.leaf)));assert.equal(ordinary.kind,'completed');assert(!result(f,ordinary.value).ok); -}); -test('evaluator fault after lexical admission drains admitted scopes without retrying evaluation',async()=>{ - const f=fixture(),failure=new Error('injected evaluator failure');let applications=0; - f.tree.app=()=>{applications++;throw failure;};const e=f.core(f.act(3,f.k(f.act(0)))); - assert.equal((await drain(e)).kind,'failed');assert.equal(e.fault,failure);assert.equal(applications,1); -}); -test('action bound stops the entire run and cleans up',async()=>{ - const f=fixture(),e=f.core(f.bind(f.act(0),f.act(0)),f.provider,1); - assert.equal((await drain(e)).kind,'failed');assert.equal(e.fault,undefined); -}); - -test('evaluator errors cannot masquerade as protocol or ordinary-operation errors',async()=>{ - for(const message of ['invalid computation shape','invalid-argument']){ - const f=fixture(),fault=new Error(message),action=f.act(6,f.g.leaf),observe=f.tree.observe; - f.tree.observe=h=>{if(h===(message==='invalid-argument'?f.g.leaf:action))throw fault;return observe(h);}; - const e=f.core(action);assert.equal((await drain(e)).kind,'failed');assert.equal(e.fault,fault);assert(e.hasFault); - } -}); -test('undefined provider rejection is retained as an embedding fault, not successful undefined data',async()=>{ - const f=fixture(),e=f.core(f.request(),{...f.provider,request:()=>Promise.reject(undefined)}); - assert.equal((await drain(e)).kind,'failed');assert(e.hasFault);assert.equal(e.fault,undefined); -}); - -test('lexical physical cleanup failure defeats success once, with uncertain release progress',async()=>{ - const f=fixture();let closed=0; - const e=f.core(f.act(3,f.k(f.request())),{ - request:(engine,scope)=>{ - const r=engine.lifecycle.resources;r.publish(r.beginAcquire(scope),'test',null); - return ()=>engine.wire.result(true,engine.tree.leaf); - },release:()=>{closed++;throw Error('uncertain close');} - }); - const out=await drain(e);assert.equal(out.kind,'failed');assert.equal(closed,1);assert.equal(out.cleanup.items.length,0); - const problem=readList(f.g,out.value,4);assert.equal(readText(f.g,problem[0]),'cleanup-failed');assert.equal(readText(f.g,problem[3]),'unknown'); -}); diff --git a/ext/demo-site/test/io-v2-events.test.mjs b/ext/demo-site/test/io-v2-events.test.mjs @@ -1,33 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import {Events} from '../dist/io-v2-events.js'; -test('invalid/stale/wrong-kind replies preserve the live read',async()=>{ - const e=new Events('1'),c=new AbortController(),pending=e.read(3,c.signal); - assert.equal(e.reply({run:'1',id:1,kind:'read',bytes:[1],eof:false}),false); - const event=e.take()[0]; - for(const change of [{run:'2'},{id:2},{kind:'write',count:1},{bytes:[0,1,2,3]},{bytes:[256]},{bytes:[],eof:false},{bytes:[1],eof:true},{kind:'cancelled'}]) - assert.equal(e.reply({...event,bytes:[1],eof:false,...change}),false); - assert.equal(e.counts.pending,1);assert(e.reply({...event,bytes:[1,2],eof:false}));assert.deepEqual(await pending,{kind:'read',bytes:[1,2],eof:false}); - assert.equal(e.reply({...event,bytes:[1],eof:false}),false);assert.deepEqual(e.counts,{pending:0,queued:0}); -}); -test('cancellation before dispatch has no host effect; delivered cancellation waits for acknowledgement',async()=>{ - const e=new Events('1'),a=new AbortController(),p=e.read(4,a.signal);a.abort();assert.deepEqual(e.take(),[]);assert.deepEqual(await p,{kind:'cancelled'}); - const b=new AbortController(),q=e.read(4,b.signal),event=e.take()[0];let settled=false;q.then(()=>{settled=true;}); - b.abort();b.abort();await Promise.resolve();assert(!settled);assert.deepEqual(e.take(),[{run:'1',id:event.id,kind:'cancel',operation:'read'}]); - assert(e.reply({...event,kind:'cancelled'}));assert.deepEqual(await q,{kind:'cancelled'});assert.deepEqual(e.counts,{pending:0,queued:0}); -}); -test('normal completion racing abort acknowledges cleanup but discards its data',async()=>{ - const e=new Events('1'),c=new AbortController(),p=e.write('output',[1,2],c.signal),event=e.take()[0];c.abort(); - assert(e.reply({...event,kind:'write',count:1}));assert.deepEqual(await p,{kind:'cancelled'});assert.deepEqual(e.take(),[]); -}); -test('output snapshots cannot mutate validation; partial progress is explicit',async()=>{ - const e=new Events('1'),bytes=[1,2],c=new AbortController(),p=e.write('error',bytes,c.signal);bytes.push(3); - const event=e.take()[0];event.bytes.push(4);event.kind='read'; - assert(!e.reply({run:'1',id:1,kind:'write',count:3}));assert(e.reply({run:'1',id:1,kind:'write',count:1})); - assert.deepEqual(await p,{kind:'write',count:1}); -}); -test('identity exhaustion cannot prevent cancellation or retirement; repeated operations retain no history',async()=>{ - const e=new Events('1',1),c=new AbortController(),p=e.read(1,c.signal),event=e.take()[0]; - assert.throws(()=>e.read(1,c.signal),/exhausted/);c.abort();assert(e.reply({...event,kind:'cancelled'}));await p; - const many=new Events('2');for(let i=0;i<1000;i++){const pending=many.read(1,new AbortController().signal),ev=many.take()[0];assert(many.reply({...ev,bytes:[],eof:true}));await pending;assert.deepEqual(many.counts,{pending:0,queued:0});} -}); diff --git a/ext/demo-site/test/io-v2-host-work.test.mjs b/ext/demo-site/test/io-v2-host-work.test.mjs @@ -1,94 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import {Tasks} from '../dist/io-v2-tasks.js'; -import {HostWork} from '../dist/io-v2-host-work.js'; -const ok=value=>({kind:'completed',value}),failed=value=>({kind:'failed',value}); -const finish=(tasks,scope)=>{tasks.bodyDone(scope,ok(null));assert(tasks.beginRelease(scope));return tasks.settle(scope);}; -const deferred=()=>{let resolve;const promise=new Promise(done=>{resolve=done;});return {promise,resolve};}; -const setup=()=>{const tasks=new Tasks(1n,'owner-stopped','cycle');return {tasks,work:new HostWork(tasks)};}; - -test('pending read never blocks sibling work; cancellation waits for actual provider cleanup',async()=>{ - const {tasks,work}=setup(),read=tasks.spawn(0,true,true),writer=tasks.spawn(0,true,false),cleanup=deferred(),events=[]; - const opaque=new Proxy({},{get(){throw Error('late value forced');}}); - work.start(read.key,async signal=>{ - events.push('read started'); - await new Promise(resolve=>signal.addEventListener('abort',()=>{events.push('abort requested');resolve();},{once:true})); - await cleanup.promise;events.push('read cleanup finished');return opaque; - }); - work.start(writer.key,()=>{events.push('sibling wrote');return 7;}); - await work.wait();assert.equal(work.take().outcome.value,7);finish(tasks,writer.key); - tasks.cancel(0,read,'stop');work.cancelStopped();work.cancelStopped(); - tasks.bodyDone(read.key,ok(null));assert.equal(tasks.beginRelease(read.key),false); - await Promise.resolve();assert.deepEqual(events,['read started','sibling wrote','abort requested']); - const settled=work.wait();cleanup.resolve();await settled; - assert.equal(work.take(),null);assert.deepEqual(work.counts,{pending:0,replies:0}); - assert(tasks.beginRelease(read.key));tasks.settle(read.key); - tasks.claimJoin(0,read);assert.equal(tasks.pollJoin(0).kind,'cancelled');finish(tasks,0); - assert.equal(events.at(-1),'read cleanup finished');assert.deepEqual(tasks.counts,{scopes:0,tasks:0,hosts:0}); -}); -test('completion queued before cancellation is discarded and cannot revive the body',async()=>{ - const {tasks,work}=setup(),child=tasks.spawn(0,true,false),value={retained:'reply'}; - work.start(child.key,()=>value);await work.wait();assert.deepEqual([...work.values()],[value]); - tasks.cancel(0,child,'stop');work.cancelStopped();assert.deepEqual([...work.values()],[]);assert.equal(work.take(),null); - finish(tasks,child.key);finish(tasks,0);assert.deepEqual(work.counts,{pending:0,replies:0}); -}); -test('queued data for an already settled scope is dropped; no scope history required',async()=>{ - const {tasks,work}=setup(),child=tasks.spawn(0,true,false); - work.start(child.key,()=>42);await work.wait();finish(tasks,child.key); - work.cancelStopped();assert.equal(work.take(),null);assert.deepEqual(work.counts,{pending:0,replies:0});finish(tasks,0); -}); -test('synchronous throws and async rejections settle ownership once without inspecting errors',async()=>{ - for(const asynchronous of [false,true]){ - const {tasks,work}=setup(),error=new Proxy({},{get(){throw Error('opaque error forced');}}); - work.start(0,()=>{if(asynchronous)return Promise.reject(error);throw error;}); - await work.wait();const reply=work.take();assert.equal(reply.outcome.ok,false);assert.equal(reply.outcome.error,error); - assert.equal(work.take(),null);assert.equal(tasks.state(0).hostCalls,0);finish(tasks,0); - } -}); -test('read rejection after cancellation is acknowledgement, not a fresh task failure',async()=>{ - const {tasks,work}=setup(),child=tasks.spawn(0,true,false); - work.start(child.key,signal=>new Promise((_resolve,reject)=>signal.addEventListener('abort',()=>reject(Error('abort')),{once:true}))); - tasks.stop(child.key,failed('original'));work.cancelStopped();tasks.bodyDone(child.key,ok(null)); - await work.wait();assert.equal(work.take(),null);assert(tasks.beginRelease(child.key));assert.equal(tasks.settle(child.key).value,'original'); - finish(tasks,0); -}); -test('multiple waiters share notification; out-of-order replies have correct identities',async()=>{ - const {tasks,work}=setup(),a=deferred(),b=deferred(); - const first=work.start(0,()=>a.promise),second=work.start(0,()=>b.promise); - const one=work.wait(),two=work.wait();assert.equal(one,two);b.resolve('second');await one; - assert.deepEqual(work.take(),{token:second,outcome:{ok:true,value:'second'}}); - const next=work.wait();a.resolve('first');await next; - assert.deepEqual(work.take(),{token:first,outcome:{ok:true,value:'first'}});finish(tasks,0); -}); -test('sequential host operations retain neither callback history nor consumed reply data',async()=>{ - const {tasks,work}=setup(); - for(let i=0;i<1000;i++){ - const value={iteration:i};work.start(0,()=>value);await work.wait();assert.equal(work.take().outcome.value,value); - assert.deepEqual(work.counts,{pending:0,replies:0});assert.equal(tasks.state(0).hostCalls,0);assert.deepEqual([...work.values()],[]); - } - finish(tasks,0); -}); -test('async physical release survives new cancellation and reports cleanup failure',async()=>{ - const {tasks,work}=setup(),release=deferred(); - assert.throws(()=>work.release(0,()=>null),/invalid-transition/); - tasks.bodyDone(0,ok('body'));assert(tasks.beginRelease(0)); - const token=work.release(0,async()=>{await release.promise;throw 'cleanup failed';}); - assert.equal(token.kind,'release');assert.throws(()=>tasks.settle(0),/invalid-transition/); - tasks.stop(0,{kind:'cancelled',value:'primary'});work.cancelStopped(); - assert.deepEqual(tasks.takeCancellations(),[]);assert.throws(()=>tasks.settle(0),/invalid-transition/); - const waiting=work.wait();release.resolve();await waiting; - const reply=work.take();assert.equal(reply.outcome.ok,false);assert.equal(reply.outcome.error,'cleanup failed'); - const result=tasks.settle(0,{items:[reply.outcome.error],omitted:0}); - assert.equal(result.kind,'cancelled');assert.equal(result.value,'primary');assert.deepEqual(result.cleanup.items,['cleanup failed']); - assert.deepEqual(work.counts,{pending:0,replies:0}); -}); -test('body callback identity exhaustion cannot prevent the reserved cleanup batch',async()=>{ - const tasks=new Tasks(1n,'stop','cycle',1),work=new HostWork(tasks); - work.start(0,()=>1);await work.wait();work.take(); - assert.throws(()=>work.start(0,()=>2),/identity-exhausted/); - tasks.bodyDone(0,ok(null));assert(tasks.beginRelease(0)); - const release=work.release(0,()=>3);await work.wait();assert.equal(work.take().outcome.value,3); - assert.equal(release.kind,'release');assert.equal(tasks.endHost(release),'stale'); - assert.throws(()=>work.release(0,()=>4),/invalid-transition/);tasks.settle(0); - assert.deepEqual(tasks.counts,{scopes:0,tasks:0,hosts:0}); -}); diff --git a/ext/demo-site/test/io-v2-lifecycle-probe.mjs b/ext/demo-site/test/io-v2-lifecycle-probe.mjs @@ -1,49 +0,0 @@ -// Lifecycle building blocks with a real DOM read/provider and native pure work. -// This does NOT execute a v2 language program or bypass the production v2 gate. -export async function run(_control,backend){ - const assert=(value,message)=>{if(!value)throw Error(message);}; - const base=new URL('../',document.querySelector('script[type=module][src]').src); - const {ioV2Resources:{lifecycle},ioV2HostWork:{HostWork},runtimeSession}=await import(new URL('dist/probe-api.js',base)); - const session=await runtimeSession.createRuntimeSession(backend,false),checks=[]; - const {tasks,resources}=lifecycle(1n,'owner-stopped','cycle',[null,null,null]),work=new HostWork(tasks); - const input=document.createElement('input');input.id='lifecycle-test-input';document.body.append(input); - const original=resources.publish(resources.beginAcquire(0),'reader',input); - const {task:read,handles}=resources.spawn(0,true,true,[original]),other=tasks.spawn(0,true,false); - let stale=false;try{resources.lookup(0,original);}catch(error){stale=error.message==='stale-handle';}assert(stale,'old alias survived transfer'); - let acknowledge,aborts=0,events=0,signal; - const cleanup=new Promise(resolve=>{acknowledge=resolve;}); - const listener=()=>{events++;};input.addEventListener('input',listener); - const release=async id=>{work.release(id,()=>resources.releaseScope(id,r=>r.backend.remove(),e=>e,'cleanup-failed'));await work.wait();const reply=work.take();assert(reply?.outcome.ok,'release failed');return tasks.settle(id,reply.outcome.value);}; - const finish=async id=>{tasks.bodyDone(id,{kind:'completed',value:null});assert(tasks.beginRelease(id),'release blocked');return release(id);}; - try{ - const fixture=(await(await fetch('/runtime-fixtures.json')).json())['pure-number']; - await session.load(new Uint8Array(fixture.bytes),fixture.hash); - work.start(read.key,async abort=>{ - signal=abort;const lease=resources.beginLease(read.key,handles[0],'reader','read'); - try{await new Promise(resolve=>abort.addEventListener('abort',()=>{aborts++;input.removeEventListener('input',listener);resolve();},{once:true})); - await cleanup;return 'late read value';}finally{resources.endLease(lease.token);} - }); - const forbidden=()=>{throw Error('pure work invoked IO hook');}; - work.start(other.key,()=>session.run('root',[],'number','pure',{before:forbidden,after:forbidden,read:forbidden,write:forbidden,running:forbidden})); - await work.wait();const reply=work.take();assert(reply.outcome.ok&&reply.outcome.value.value==='42','native sibling result');await finish(other.key); - assert(!signal.aborted&&tasks.state(read.key).hostCalls===1,'read was detached'); - input.dispatchEvent(new Event('input'));assert(events===1,'listener not live'); - checks.push('pending DOM read does not block sibling native pure evaluation'); - tasks.cancel(0,read,'cancel read');work.cancelStopped();work.cancelStopped(); - tasks.bodyDone(read.key,{kind:'completed',value:null}); - assert(aborts===1&&signal.aborted,'abort delivered more than once'); - input.dispatchEvent(new Event('input'));assert(events===1,'cancelled listener still active'); - assert(!tasks.beginRelease(read.key)&&input.isConnected,'cancellation request was treated as completion'); - const wait=work.wait();acknowledge();await wait; - assert(input.isConnected&&work.take()===null,'resource closed before scope release or late reply escaped'); - assert(resources.counts.leases===0,'read lease survived acknowledgement'); - assert(tasks.beginRelease(read.key),'acknowledged read did not release');await release(read.key); - assert(!input.isConnected,'owned DOM resource escaped physical release'); - tasks.claimJoin(0,read);assert(tasks.pollJoin(0).kind==='cancelled','cancelled join outcome');await finish(0); - assert(tasks.counts.scopes===0&&tasks.counts.tasks===0&&tasks.counts.hosts===0,'lifecycle retained history'); - assert(work.counts.pending===0&&work.counts.replies===0,'host work retained history'); - assert(resources.counts.resources===0&&resources.counts.leases===0&&resources.counts.admissions===0,'registry retained history'); - checks.push('transferred alias invalidation; leased DOM read; one abort; physical acknowledgement and resource release before cancelled join'); - }finally{acknowledge?.();input.removeEventListener('input',listener);input.remove();session.dispose();} - return {backend,checks}; -} diff --git a/ext/demo-site/test/io-v2-output.test.mjs b/ext/demo-site/test/io-v2-output.test.mjs @@ -1,51 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import {OutputBuffer} from '../dist/io-v2-output.js'; -const encode=text=>[...new TextEncoder().encode(text)]; -const reconstruct=view=>view.tokens.flatMap(t=>t.kind==='text'?encode(t.text):t.bytes); -test('valid UTF8 remains text including BOM, NUL, CRLF, combining and literal escape-looking text',()=>{ - const text='\ufeff\0\r\nλ😀e\u0301\\xff <script>\x1b[31m',buffer=new OutputBuffer(); - buffer.append('output',encode(text));buffer.finish();const out=buffer.snapshot().output; - assert.deepEqual(out.tokens,[{kind:'text',offset:0,text}]);assert.deepEqual(reconstruct(out),encode(text)); -}); -test('all byte values survive exact raw, hex and token reconstruction',()=>{ - const bytes=Array.from({length:256},(_,i)=>i),buffer=new OutputBuffer();buffer.append('output',bytes);buffer.finish(); - const out=buffer.snapshot().output;assert.deepEqual(out.bytes,bytes);assert.deepEqual(reconstruct(out),bytes); - assert.deepEqual(out.hex.split(' ').map(s=>parseInt(s,16)),bytes); - assert(out.tokens.some(t=>t.kind==='bytes'&&t.reason==='invalid')); -}); -test('partitioning never changes final presentation and valid text after bad prefixes survives',()=>{ - const bytes=[...encode('a😀λ'),0xe0,0x80,65,0xed,0xa0,0x80,0xf4,0x90,0x80,0x80,66,0xf0,0x9f]; - const all=new OutputBuffer();all.append('output',bytes);all.finish();const expected=all.snapshot(); - for(let split=1;split<bytes.length;split++){ - const b=new OutputBuffer();b.append('output',bytes.slice(0,split)); - assert.deepEqual(reconstruct(b.snapshot().output),bytes.slice(0,split)); - b.append('output',bytes.slice(split));b.finish();assert.deepEqual(b.snapshot(),expected); - } - assert(expected.output.tokens.some(t=>t.kind==='text'&&t.text==='A')); - assert.equal(expected.output.tokens.at(-1).reason,'incomplete'); -}); -test('pending suffixes resolve across writes but never across stdout/stderr',()=>{ - const b=new OutputBuffer();b.append('output',[0xf0,0x9f]);b.append('error',[0x98,0x80]); - assert.equal(b.snapshot().output.tokens[0].reason,'pending'); - assert(b.snapshot().error.tokens.every(t=>t.reason==='invalid')); - b.append('output',[0x98,0x80]);assert.equal(b.snapshot().output.tokens[0].text,'😀'); - b.append('error',[0xc2]);b.finish();b.finish();assert.equal(b.snapshot().error.tokens.at(-1).reason,'incomplete'); - assert.throws(()=>b.append('output',[65]),/ended/); -}); -test('accepted-byte bound is shared and rejection is atomic; copies have no aliases',()=>{ - const b=new OutputBuffer(3),bytes=[65,66];b.append('output',bytes);bytes[0]=0; - const snapshot=b.snapshot();snapshot.output.bytes[0]=0;snapshot.output.tokens[0].text='changed'; - assert.equal(b.snapshot().output.tokens[0].text,'AB');b.append('error',[255]); - const before=b.snapshot();assert.throws(()=>b.append('error',[1]),/limit/);assert.deepEqual(b.snapshot(),before); - for(const bytes of [[256],[-1],[1.5],Array(1),[],Array(4097).fill(0)])assert.throws(()=>b.append('output',bytes),/bytes/); - assert.deepEqual(b.snapshot(),before); - for(const limit of [-1,16385,NaN,1.5])assert.throws(()=>new OutputBuffer(limit),/limit/); -}); -test('many independent malformed streams reconstruct exactly without replacement scalars',()=>{ - let state=12345;for(let run=0;run<128;run++){ - const bytes=Array.from({length:128},()=>{state=(Math.imul(state,1664525)+1013904223)>>>0;return state>>>24;}); - const b=new OutputBuffer();for(const byte of bytes)b.append('output',[byte]);b.finish(); - assert.deepEqual(reconstruct(b.snapshot().output),bytes); - } -}); diff --git a/ext/demo-site/test/io-v2-protocol-probe.mjs b/ext/demo-site/test/io-v2-protocol-probe.mjs @@ -1,25 +0,0 @@ -// Real WASM engines, inert header inspection only, no source compiler substitute. -export async function run(_control,backend){ - const base=new URL('../',document.querySelector('script[type=module][src]').src); - const fixtures=await(await fetch('/runtime-fixtures.json')).json(),checks=[]; - let inspect,dispose; - const {CompilerWorker}=await import(new URL('runtime/haskell/client.mjs',base)); - const client=new CompilerWorker();dispose=()=>client.dispose(); - await client.request({type:'init',compiler:false}); - inspect=async fixture=>{await client.request({type:'load',bytes:new Uint8Array(fixture.bytes).buffer,hash:fixture.hash});return JSON.parse((await client.request({type:'io-v2-header',name:'root',arguments:[]})).text);}; - const equal=(got,want)=>{if(JSON.stringify(got)!==JSON.stringify(want))throw Error('Header mismatch: '+JSON.stringify(got));}; - try{ - for(let code=0;code<10;code++)equal(await inspect(fixtures['v2-header-core-'+code]),{kind:'core',code}); - checks.push('all ten core headers without expanding shared payloads'); - equal(await inspect(fixtures['v2-header-future']),{kind:'request',group:'future-9',revision:65535,operation:'unknown'}); - equal(await inspect(fixtures['v2-header-long-name']),{kind:'request',group:'a'.repeat(64),revision:1,operation:'read'}); - checks.push('future named request and exact name/revision bounds'); - for(const [name,message] of [['bad-tag','unknown computation constructor'],['bad-name','invalid facility header'],['bad-revision','invalid facility header'],['zero-revision','invalid facility header'],['bad-shape','invalid computation shape']]){ - let rejected=false;try{await inspect(fixtures['v2-header-'+name]);}catch(error){if(!error.message.includes(message))throw error;rejected=true;} - if(!rejected)throw Error('Accepted '+name); - equal(await inspect(fixtures['v2-header-core-0']),{kind:'core',code:0}); - } - checks.push('five malformed headers reject; runtime remains usable after each'); - }finally{dispose();} - return {backend,checks}; -} diff --git a/ext/demo-site/test/io-v2-protocol.test.mjs b/ext/demo-site/test/io-v2-protocol.test.mjs @@ -1,37 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import {Graph} from '../dist/graph.js'; -import {natural,text} from '../dist/codecs.js'; -import {canonicalNat,decodeAction,header} from '../dist/io-v2-protocol.js'; -function setup(){ - const g=new Graph(),poison=g.app(g.leaf,g.leaf),observe=g.observe.bind(g); - g.observe=id=>{if(id===poison)throw Error('FORCED opaque payload');return observe(id);}; - const request=(group,revision,op,args=poison)=>g.fork(natural(g,10n),g.fork(group,g.fork(revision,g.fork(op,args)))); - return {g,poison,request}; -} -test('v2 header decoding preserves opaque core and future request payloads',()=>{ - const {g,poison,request}=setup(); - for(let n=0;n<10;n++)assert.deepEqual(decodeAction(g,g.fork(natural(g,BigInt(n)),poison)),{kind:'core',code:n,payload:poison}); - for(const n of [11n,255n,256n])assert.throws(()=>decodeAction(g,g.fork(natural(g,n),poison)),/unknown computation/); - assert.deepEqual(header(g,request(text(g,'future-9'),natural(g,65535n),text(g,'unknown'))),{kind:'request',group:'future-9',revision:65535,operation:'unknown'}); - assert.throws(()=>decodeAction(g,g.leaf),/invalid computation shape/); -}); -test('v2 names, revisions and bits are bounded and strictly canonical',()=>{ - const {g,poison,request}=setup(),good=text(g,'file'),one=natural(g,1n),zero=g.fork(g.leaf,g.leaf); - for(const invalid of ['', '-file','1file','File','a_b','a b','a\x7f','a'.repeat(65)]){ - assert.throws(()=>decodeAction(g,request(text(g,invalid),one,good)),/invalid facility header/); - assert.throws(()=>decodeAction(g,request(good,one,text(g,invalid))),/invalid facility header/); - } - for(const revision of [g.leaf,zero,natural(g,65536n)])assert.throws(()=>decodeAction(g,request(good,revision,good)),/invalid facility header/); - assert.throws(()=>decodeAction(g,request(g.leaf,poison,poison)),/invalid facility header/); - assert.equal(canonicalNat(g,zero,8),undefined); - assert.throws(()=>decodeAction(g,g.fork(zero,poison)),/unknown computation/); - assert.equal(header(g,request(text(g,'a'.repeat(64)),one,good)).group.length,64); - let excess=g.fork(poison,poison); - for(let i=0;i<64;i++)excess=g.fork(natural(g,97n),excess); - assert.throws(()=>decodeAction(g,request(excess,one,good)),/invalid facility header/); - let long=g.fork(poison,poison); - for(let i=0;i<8;i++)long=g.fork(g.leaf,long); - assert.equal(canonicalNat(g,long,8),undefined); - assert.throws(()=>decodeAction(g,poison),/FORCED/); // reduction faults are not malformed-header Results -}); diff --git a/ext/demo-site/test/io-v2-public-probe.mjs b/ext/demo-site/test/io-v2-public-probe.mjs @@ -1,86 +0,0 @@ -import {wait,status,change,inspect,el,pause,input} from './source-probe-helpers.mjs'; -export async function run(_control,backend){ - const assert=(ok,message)=>{if(!ok)throw Error(message);},checks=[]; - const base=new URL('../',document.querySelector('script[type=module][src]').src); - const {runtimeSession:{createRuntimeSession},ioV2TerminalView:{terminalView}}=await import(new URL('dist/probe-api.js',base)); - const fixture=await(await fetch('/io-v2-source.json')).json(),s=await createRuntimeSession(backend,false); - const legacy={before:async()=>{},after(){},read(){throw Error('legacy read invoked');},write(){throw Error('legacy write invoked');},running(){}}; - const parent=document.createElement('div');document.body.append(parent); - let widget,send,closed,steps=0,submitted=false,guardTask; - function host(mode='normal'){ - parent.replaceChildren();closed=false;submitted=false; - return {...legacy,before:async()=>{steps++;},v2:{stepping:mode==='step', - open(run,command){send=command;widget=terminalView(parent,run,reply=>command({run,kind:'reply',reply}),()=>command({run,kind:'stop'}));}, - event(event){widget.handle(event);if(event.kind==='read'&&mode==='stop'){ - guardTask=(async()=>{try{ - for(const operation of [()=>s.inspectDefinition('main','tree'),()=>s.compile('changed = 9','empty'),()=>s.runExpression('42','number','pure',legacy)]){ - let blocked=false;try{await operation();}catch(error){blocked=/pending|busy/.test(error.message);}assert(blocked,'unrelated work entered active v2 run'); - } - }finally{send({run:event.run,kind:'stop'});}})(); - } - if(event.kind==='read'&&mode==='input'){ - const form=parent.querySelector('form');if(form&&!form.hidden){if(!submitted){submitted=true;form.querySelector('input').value='λ😀';form.requestSubmit();}else form.querySelector('button[type=button]').click();} - } - }, - close(_run,settled){assert(settled,'unacknowledged runtime loss');widget.finish();closed=true;}, - }}; - } - try{ - await s.load(new Uint8Array(fixture.bytes),fixture.hash); - assert(await s.probeIO('main'),'v2 inert recognition');assert(!await s.probeIO('direct'),'bare computation is not an envelope'); - for(const name of ['main','publicHandled','publicCancellation','publicBinary']){ - const out=await s.runDefinitionIO(name,'number',host());assert(out.value==='42'&&closed,'public compiled outcome '+name); - const bytes=widget.snapshot().output.bytes,expected=name==='publicBinary'?[92,120,102,102,255,240,159]:[...new TextEncoder().encode(name==='publicCancellation'?'ready':'λ😀e\u0301\r\n')]; - assert(JSON.stringify(bytes)===JSON.stringify(expected),'public exact output '+name); - } - assert((await s.runDefinitionIO('publicRoundTrip','number',host('input'))).value==='42','line and EOF outcome'); - assert(JSON.stringify(widget.snapshot().output.bytes)===JSON.stringify([...new TextEncoder().encode('λ😀\n')]),'line/EOF byte preservation'); - checks.push('compiled direct/handled/cancellation/binary programs through public RuntimeSession; explicit line/EOF'); - const cases=[['string',{type:'string',value:'λ😀\0'},'λ😀\0'],['number',{type:'number',value:'184467440737095516160'},'184467440737095516160'],['bool',{type:'bool',value:true},true],['bytes',{type:'bytes',value:[0,255,240]},[0,255,240]]]; - for(const [type,literal,expected]of cases){const out=await s.run('returnValue',[literal],type,'io',host());assert(JSON.stringify(out.value)===JSON.stringify(expected),'full codec '+type);} - for(const type of ['tree','list','tricu','triangle','dag','ternary']){ - const literal=type==='list'?{type:'list',value:[{type:'number',value:'42'}]}:{type:'tree',value:'200'}; - const observed=await s.run('returnValue',[literal],type,'io',host());assert(observed.type===type&&observed.value!==null,'full graph codec '+type); - if(['tricu','triangle','dag','ternary'].includes(type)){const expected=await s.runExpression('t t t',type,'pure',legacy);// DAG binders reflect physical node numbering, not semantic identities. - const alpha=text=>{const names=new Map();return text.replace(/:[fn][0-9]+/g,name=>{if(!names.has(name))names.set(name,':v'+names.size);return names.get(name);});}; - assert((type==='dag'?alpha(expected.value)===alpha(observed.value):expected.value===observed.value),'complete graph result differs from independent pure result '+type);} - } - const values=await s.run('returnValue',[{type:'list',value:[{type:'number',value:'42'},{type:'string',value:'hello'}]}],'values','io',host()); - assert(JSON.stringify(values.value)===JSON.stringify([{type:'number',value:'42'},{type:'string',value:'hello'}]),'values codec'); - checks.push('all public codecs including >64-bit number, full trees and decoded values'); - let rejected=false;try{await s.runDefinitionIO('publicRoundTrip','number',host('stop'));}catch(error){rejected=/cancelled/.test(error.message);}assert(rejected&&closed,'public structured cancellation');await guardTask; - let badCodec=false;try{await s.run('returnValue',[{type:'tree',value:'10'}],'number','io',host());}catch{badCodec=true;}assert(badCodec&&closed,'codec failure after cleanup'); - assert((await s.runExpression('returnValue "expression result"','string','io',host())).value==='expression result','IO expression result'); - assert((await s.run('returnValue',[{type:'number',value:'42'}],'number','io',host('step'))).value==='42'&&steps>0,'public scheduler stepping'); - checks.push('structured Stop drains read, definitions remain usable; expression and stepping paths'); - }finally{s.dispose();parent.remove();} - const doomed=await createRuntimeSession(backend,false);let abandoned=false; - try{ - await doomed.load(new Uint8Array(fixture.bytes),fixture.hash); - let rejected=false;try{await doomed.runDefinitionIO('publicRoundTrip','number',{...legacy,v2:{open(){},event(event){if(event.kind==='read')doomed.dispose();},close(_run,settled){abandoned=!settled;}}});} - catch(error){rejected=/disposed|terminated/i.test(error.message);}assert(rejected&&abandoned,'emergency dispose must reject without claiming cleanup'); - }finally{doomed.dispose();} - checks.push('emergency runtime disposal rejects idle read and explicitly does not acknowledge cleanup'); - // Real mounted editor + outer worker + nested native worker, not a mock host. - await status('Ready');if(el('runtime-backend').value!==backend){change('runtime-backend',backend);await status('Ready');} - const index=await(await fetch(new URL('packages.json',base))).json(); - const io=index.releases.find(r=>r.title==='lib/io-v2.tri'&&r.version==='0.4.0');assert(io,'authenticated complete library'); - const source=`!import ${io.manifest} as IO\n!export answer\n!export binary\n!export waiting\n!export caps\n!export classification\n!export divergentValue\ndivergentValue = IO.io (IO.pure ((x : x x) (x : x x)))\nbackendOf rows = case rows of\n [["backend" value] | _] = value\n [_ | rest] = backendOf rest\n _ = "missing"\ncaps = IO.io (IO.require IO.capabilities (rows : IO.pure (backendOf rows)))\nloop = (x : x x) (x : x x)\nclassification = IO.io (IO.require (IO.inspect (IO.request "file" 1 "open" loop)) IO.pure)\nanswer = 42\nbinary = IO.io (do IO.require\n writer <- IO.stdout\n IO.writeAll writer [92 120 102 102 255 240 159]\n IO.pure "returned text")\nwaiting = IO.io (do IO.require\n reader <- IO.stdin\n IO.readSome reader 4\n IO.pure 42)`; - document.querySelector('[data-compiler-example=identity]').click();input('source-editor',source);await status('Compiled'); - await inspect('caps');el('io-return-format').value='string';el('run-io').click();await status('Finished');assert(document.querySelector('.definition-card').querySelector('pre').textContent===backend,'actual native capabilities'); - await inspect('classification');el('io-return-format').value='values';el('run-io').click();await status('Finished');assert(document.querySelector('.definition-card').querySelector('pre').textContent.includes('unsupported'),'inert inspection leaves divergent arguments opaque'); - await inspect('binary');el('io-return-format').value='string';el('run-io').click();await status('Finished'); - assert(el('terminal-v2').querySelector('[data-hex=output]').textContent==='5c 78 66 66 ff f0 9f','actual page lossless output'); - assert(document.querySelector('.definition-card').querySelector('pre').textContent==='returned text','actual page full returned text'); - await inspect('waiting');el('io-return-format').value='number';el('run-io').click();await wait(()=>el('terminal-v2').querySelector('form input'),'v2 input'); - const old=el('terminal-v2').querySelector('form');el('stop').click();assert(!el('stop').hidden,'Stop waits rather than disposing worker'); - await status('Stopped');assert(!el('terminal-v2').querySelector('form'),'input physically retired'); - old.dispatchEvent(new Event('submit',{cancelable:true}));assert((await inspect('answer','number'))==='42','compiled state survives structured Stop'); - // Stop also bypasses a suspended scheduler turn. - await inspect('waiting');el('step-mode').checked=true;el('run-io').click();await status('Paused before action');el('stop').click();await status('Stopped');el('step-mode').checked=false; - assert((await inspect('answer','number'))==='42','compiled state survives paused Stop'); - await inspect('divergentValue');el('io-return-format').value='number';el('run-io').click();await status('IO settled; interpreting returned value');el('stop').click();await status('Stopped'); - el('compile').click();await status('Compiled');assert((await inspect('answer','number'))==='42','hard Stop after acknowledged IO settlement can rebuild'); - await pause(10);checks.push('actual page exact bytes/full return, pending-input and paused-step Stop, stale DOM isolation and reuse without recompilation'); - return {backend,checks}; -} diff --git a/ext/demo-site/test/io-v2-public.test.mjs b/ext/demo-site/test/io-v2-public.test.mjs @@ -1,104 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import {Graph} from '../dist/graph.js'; -import {readText,readNatural,readList,view} from '../dist/codecs.js'; -import {constructors} from '../dist/io-v2-private.js'; -import {Wire} from '../dist/io-v2-core.js'; -import {Session} from '../dist/io-v2-session.js'; -import {envelope,available} from '../dist/io-v2-profile.js'; -import {drive} from '../dist/io-v2-driver.js'; -function fixture(){ - const g=new Graph(),t=constructors({leaf:g.leaf,observe:g.observe.bind(g),stem:g.stem.bind(g),fork:g.fork.bind(g),app:g.app.bind(g)}),w=new Wire(t,1n); - const n=x=>t.natural(BigInt(x)),a=(tag,p=t.leaf)=>t.fork(n(tag),p),request=(group,op,p=t.leaf,revision=1)=>a(10,t.fork(t.text(group),t.fork(n(revision),t.fork(t.text(op),p)))); - return {g,t,w,n,a,request,session:(action,steps=128)=>new Session(t,action,'1',roots=>g.collect([...roots]),{summarize:false,steps,backend:'test-native'})}; -} -const host=()=>({before:async()=>{},after(){},read(){throw Error('legacy read');},write(){throw Error('legacy write');},running(){}}); -const driver=(s,read)=>({run:s.run,turn:c=>s.turn(c),view:async()=>read(s.core.result.value)}); -async function finish(s){for(let i=0;i<100;i++){const r=await s.turn({run:s.run,kind:'poll'});if(r.done)return r;}throw Error('stalled');} -const result=(f,s)=>f.w.pair(f.w.pair(s.core.result.value)[1])[0]; -test('public framing is bounded and leaves action opaque; unknown versions never trial-execute',()=>{ - const f=fixture(),poison=f.g.app(f.g.leaf,f.g.leaf),original=f.t.observe;f.t.observe=h=>{if(h===poison)throw Error('payload forced');return original(h);}; - for(const version of [2])assert.deepEqual(envelope(f.t,f.t.fork(f.t.text('tricuIO'),f.t.fork(f.n(version),poison))),{version,action:poison}); - const padded=f.t.fork(f.t.stem(f.t.leaf),f.t.fork(f.t.leaf,f.t.leaf)); - assert.throws(()=>envelope(f.t,f.t.fork(f.t.text('tricuIO'),f.t.fork(padded,poison))),/version/); - for(const version of [0,1,3,4])assert.throws(()=>envelope(f.t,f.t.fork(f.t.text('tricuIO'),f.t.fork(f.n(version),poison))),/version/); - assert.throws(()=>envelope(f.t,f.t.fork(f.t.text('tricuIOx'),poison)),/sentinel/); -}); -test('browser capabilities and inert inspection agree on exact implemented facilities',async()=>{ - const f=fixture(),s=f.session(f.request('runtime','capabilities'));await finish(s); - const rows=new Map(readList(f.g,result(f,s),20).map(h=>{const [k,v]=readList(f.g,h,2);return [readText(f.g,k),v];})); - assert.equal(readText(f.g,rows.get('backend')),'test-native');assert.equal(readText(f.g,rows.get('authority')),'browser-terminal'); - const groups=readList(f.g,rows.get('groups'),20).map(h=>{const [group,revision,ops]=readList(f.g,h,3);return [readText(f.g,group),readNatural(f.g,revision).toString(),readList(f.g,ops,10).map(h=>readText(f.g,h))];}); - assert.deepEqual(groups,[['runtime','1',['capabilities','inspect']],['standard','1',['input','output','error']],['stream','1',['read','write']],['resource','1',['close']],['utf8','1',['encode','decode']]]); - for(const [group,,ops]of groups)for(const op of ops)assert(available(group,1,op)); - for(const group of ['file','tcp','process','clock','__proto__','constructor'])assert(!available(group,1,'open')); - for(const [group,op,rev,label]of [['stream','write',1,'available'],['stream','write',2,'unsupported'],['file','open',1,'unsupported']]){ - const q=fixture(),poison=q.g.app(q.g.leaf,q.g.leaf),observe=q.t.observe;q.t.observe=h=>{if(h===poison)throw Error('argument forced');return observe(h);}; - const inspect=q.session(q.request('runtime','inspect',q.request(group,op,poison,rev)));await finish(inspect); - const out=readList(q.g,result(q,inspect),5);assert.equal(readText(q.g,out[4]),label);assert(!inspect.core.hasFault); - } -}); -test('public sessions never summarize a completed payload before the selected full codec',async()=>{ - const f=fixture(),payload=f.t.text('λ😀'),s=f.session(f.a(0,payload)); - let rendered=false,closed; - const out=await drive(driver(s,h=>{rendered=true;return view(f.g,h,'string');}),{...host(),v2:{open(){},event(){throw Error('unexpected event');},close(_run,settled){closed=settled;assert(!rendered);}}}); - assert.equal(out.value,'λ😀');assert.equal(closed,true); - const q=fixture(),opaque=q.g.app(q.g.leaf,q.g.leaf),observe=q.t.observe;q.t.observe=h=>{if(h===opaque)throw Error('summary forced payload');return observe(h);}; - const retained=q.session(q.a(0,opaque));assert.equal((await finish(retained)).outcome.value,null); -}); -test('public Stop cancels a pending read and waits for physical acknowledgement',async()=>{ - const f=fixture(),s=f.session(f.request('stream','read',f.t.fork(f.w.marker('io.v2.resource',1),f.n(4)))); - let send,closed=false,cancelled=false,stale; - await assert.rejects(drive(driver(s,()=>{throw Error('cancelled payload viewed');}),{...host(),v2:{ - open(_run,command){send=command;stale=command;}, - event(event){if(event.kind==='read')send({run:'1',kind:'stop'});else{ - assert.equal(event.kind,'cancel');cancelled=true; - setTimeout(()=>{assert(!closed);send({run:'1',kind:'reply',reply:{...event,kind:'cancelled'}});},10); - }},close(_run,settled){assert(cancelled);assert(settled);closed=true;} - }}),/cancelled/); - assert(closed);stale({run:'1',kind:'stop'});assert.equal(s.events.counts.pending,0); -}); -test('step hooks cannot block structured stop, including a never-resolving pause',async()=>{ - const f=fixture(),s=f.session(f.a(0,f.n(42)),1);let send,seen=0; - await assert.rejects(drive(driver(s,()=>{throw Error('unexpected view');}),{...host(),before(){seen++;queueMicrotask(()=>send({run:'1',kind:'stop'}));return new Promise(()=>{});},v2:{stepping:true,open(_r,c){send=c;},event(){throw Error('unexpected event');},close(_r,settled){assert(settled);}}}),/cancelled/); - assert.equal(seen,1); -}); -test('undefined step-hook rejection is preserved after cleanup, and renderer failure occurs after settlement',async()=>{ - for(const phase of ['step','view']){ - const f=fixture(),s=f.session(f.a(0,f.n(42)),1);let closed=false; - try{await drive(driver(s,()=>{if(phase==='view')throw undefined;return {}; }),{...host(),before:async()=>{if(phase==='step')throw undefined;},v2:{stepping:true,open(){},event(){},close(_r,settled){closed=settled;}}});assert.fail('must reject');} - catch(e){assert.equal(e,undefined);assert(closed);} - } -}); - -test('unexpected driver loss is labelled unacknowledged, never a successful cleanup',async()=>{ - let settled,viewed=false,discarded=false; - await assert.rejects(drive({run:'1',discard(){discarded=true;},turn:async()=>{throw Error('runtime lost');},view:async()=>{viewed=true;}},{...host(),v2:{open(){},event(){},close(_r,value){settled=value;}}}),/runtime lost/); - assert.equal(settled,false);assert.equal(viewed,false);assert(discarded); -}); -test('unknown facilities/revisions are ordinary opaque errors; malformed inspection is not execution',async()=>{ - for(const [group,operation,revision]of [['file','open',1],['runtime','capabilities',2]]){ - const f=fixture(),poison=f.g.app(f.g.leaf,f.g.leaf),observe=f.t.observe;f.t.observe=h=>{if(h===poison)throw Error('unknown argument forced');return observe(h);}; - const s=f.session(f.request(group,operation,poison,revision));await finish(s);assert(!s.core.hasFault); - assert.equal(readText(f.g,readList(f.g,result(f,s),4)[0]),'unsupported'); - } - const f=fixture(),s=f.session(f.request('runtime','inspect',f.t.leaf));await finish(s);assert(!s.core.hasFault); - assert.equal(readText(f.g,readList(f.g,result(f,s),4)[0]),'invalid-argument'); -}); - -test('emergency disposal interrupts an idle read without inventing physical acknowledgement',async()=>{ - const f=fixture(),s=f.session(f.request('stream','read',f.t.fork(f.w.marker('io.v2.resource',1),f.n(4)))); - let settled;const lifetime=new AbortController(); - await assert.rejects(drive({...driver(s,()=>{throw Error('unexpected view');}),signal:lifetime.signal},{...host(),v2:{open(){},event(event){assert.equal(event.kind,'read');setTimeout(()=>lifetime.abort(Error('runtime disposed')),10);},close(_r,value){settled=value;}}}),/disposed/); - assert.equal(settled,false);assert.equal(s.events.counts.pending,1,'no fabricated cancelled reply'); -}); - -test('completed runs remove emergency-disposal listeners rather than retaining per-turn history',async()=>{ - const controller=new AbortController(),signal=controller.signal,add=signal.addEventListener.bind(signal),remove=signal.removeEventListener.bind(signal);let listeners=0,peak=0; - signal.addEventListener=(...args)=>{listeners++;peak=Math.max(peak,listeners);return add(...args);};signal.removeEventListener=(...args)=>{listeners--;return remove(...args);}; - for(let run=0;run<5;run++){ - const f=fixture();let action=f.a(0,f.n(42));for(let i=0;i<20;i++)action=f.a(1,f.t.fork(f.a(0),f.t.fork(f.t.leaf,action))); - const s=f.session(action,1);await drive({...driver(s,h=>view(f.g,h,'number')),signal},{...host(),v2:{stepping:true,open(){},event(){},close(){}}});assert.equal(listeners,0); - } - assert.equal(peak,1); -}); diff --git a/ext/demo-site/test/io-v2-resources.test.mjs b/ext/demo-site/test/io-v2-resources.test.mjs @@ -1,144 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import {lifecycle} from '../dist/io-v2-resources.js'; -import {HostWork} from '../dist/io-v2-host-work.js'; -const ok=value=>({kind:'completed',value}); -const setup=(limits={})=>lifecycle(1n,'owner-stopped','cycle',['stdin','stdout','stderr'],limits); -const create=(s,owner,backend,flags={},kind='file')=>s.resources.publish(s.resources.beginAcquire(owner),kind,backend,flags); -async function finish(s,scope,release=()=>{},outcome=ok(null)){ - s.tasks.bodyDone(scope,outcome);assert(s.tasks.beginRelease(scope)); - const errors=await s.resources.releaseScope(scope,release,error=>error,'fallback');return s.tasks.settle(scope,errors); -} -function retired(s){assert.deepEqual(s.tasks.counts,{scopes:0,tasks:0,hosts:0});assert.deepEqual(s.resources.counts,{resources:0,admissions:0,leases:0});} - -test('admission closure preserves admitted opens and existing descendant borrowing',async()=>{ - const s=setup(),child=s.tasks.spawn(0,true,false),admission=s.resources.beginAcquire(0); - s.tasks.bodyDone(0,ok(null));assert.equal(s.tasks.beginRelease(0),false); - assert.throws(()=>s.resources.beginAcquire(child.key,0),/closed-scope/); - const file=s.resources.publish(admission,'file','backend'),lease=s.resources.beginLease(child.key,file,'file','read'); - assert.equal(lease.backend,'backend');s.tasks.bodyDone(child.key,ok(null));assert.equal(s.tasks.beginRelease(child.key),false); - s.resources.endLease(lease.token);assert(s.tasks.beginRelease(child.key));s.tasks.settle(child.key); - assert(s.tasks.beginRelease(0));assert.throws(()=>s.tasks.settle(0),/busy/); - const released=[];await s.resources.releaseScope(0,r=>released.push(r.backend),e=>e,'fallback');s.tasks.settle(0); - assert.deepEqual(released,['backend']);retired(s); -}); -test('pending ancestor acquisition blocks owner even after acquiring child has stopped',async()=>{ - const s=setup(),child=s.tasks.spawn(0,true,false),admission=s.resources.beginAcquire(child.key,0); - s.tasks.cancel(0,child,'cancel');await finish(s,child.key);s.tasks.bodyDone(0,ok(null)); - assert.equal(s.tasks.beginRelease(0),false);s.resources.abortAcquire(admission); - assert.throws(()=>s.resources.abortAcquire(admission),/invalid-admission/); - assert(s.tasks.beginRelease(0));await s.resources.releaseScope(0,()=>assert.fail('borrowed close'),e=>e,'fallback');s.tasks.settle(0);retired(s); -}); -test('transfer validation is atomic; rekeying invalidates aliases and preserves acquisition order',async()=>{ - const s=setup(),a=create(s,0,'first'),b=create(s,0,'second'); - const before={tasks:s.tasks.counts,resources:s.resources.counts}; - for(const [tokens,message] of [[[a,a],/duplicate/],[[a,{...b,run:2n}],/stale/],[[a,{run:1n,key:1}],/borrowed/]]){ - assert.throws(()=>s.resources.spawn(0,false,true,tokens),message); - assert.deepEqual({tasks:s.tasks.counts,resources:s.resources.counts},before);assert.equal(s.resources.lookup(0,a).backend,'first'); - } - const {task,handles}=s.resources.spawn(0,true,true,[b,a]); - assert.throws(()=>s.resources.lookup(0,a),/stale/);assert.throws(()=>s.resources.lookup(task.key,b),/stale/); - assert.throws(()=>s.resources.lookup(0,handles[0]),/out-of-scope/); - assert.equal(s.resources.lookup(task.key,handles[0]).backend,'second');assert.deepEqual(s.resources.newestOwned(task.key),handles[0]); - const nested=s.tasks.nested(task.key);assert.throws(()=>s.resources.spawn(nested,true,false,[handles[1]]),/not-owner/); - await finish(s,nested);const released=[];await finish(s,task.key,r=>released.push(r.backend)); - assert.deepEqual(released,['second','first']);await finish(s,0);retired(s); -}); -test('failed task admission cannot move resources, start a child or consume resource IDs',async()=>{ - const s=setup({taskIdentities:1}),file=create(s,0,'owner'),only=s.tasks.spawn(0,true,false);await finish(s,only.key); - const before=s.resources.counts; - assert.throws(()=>s.resources.spawn(0,true,true,[file]),/scope-identity-exhausted/); - assert.equal(s.resources.lookup(0,file).owner,0);assert.deepEqual(s.resources.counts,before); - assert.deepEqual(s.tasks.counts,{scopes:1,tasks:0,hosts:0}); - const next=create(s,0,'next');assert.equal(next.key,file.key+2,'failed spawn consumed resource identities'); - await finish(s,0);retired(s); -}); -test('direction leases, wrong-kind rejection and exactly-once close acknowledgements',async()=>{ - const s=setup(),file=create(s,0,'duplex',{duplex:true}); - assert.throws(()=>s.resources.beginLease(0,file,'reader','read'),/wrong-kind/);assert.equal(s.resources.counts.leases,0); - const r=s.resources.beginLease(0,file,'file','read'),w=s.resources.beginLease(0,file,'file','write'); - assert.throws(()=>s.resources.beginLease(0,file,'file','read'),/busy/);assert.throws(()=>s.resources.spawn(0,true,false,[file]),/busy/); - assert.equal(s.resources.claimClose(0,file),null);assert.throws(()=>s.resources.beginLease(0,file,'file','read'),/closed-handle/); - s.resources.endLease(r.token);assert.throws(()=>s.resources.endLease(r.token),/invalid-lease/);s.resources.endLease(w.token); - assert(s.resources.claimClose(0,file));assert.equal(s.resources.claimClose(0,file),null); - s.tasks.bodyDone(0,ok(null));assert(s.tasks.beginRelease(0));assert.throws(()=>s.tasks.settle(0),/busy/); - s.resources.finishClose(file);assert.throws(()=>s.resources.finishClose(file),/stale-handle/); - await s.resources.releaseScope(0,()=>{},e=>e,'fallback');s.tasks.settle(0);retired(s); -}); -test('ordinary single-cursor resources, internal resources and borrowed standards stay distinct',async()=>{ - const s=setup(),file=create(s,0,'file'),lease=s.resources.beginLease(0,file,'file','read'); - assert.throws(()=>s.resources.beginLease(0,file,'file','write'),/busy/);s.resources.endLease(lease.token); - const hidden=create(s,0,'hidden',{internal:true}); - assert.throws(()=>s.resources.beginLease(0,hidden,'file','read'),/internal-resource/); - assert.throws(()=>s.resources.claimClose(0,hidden),/internal-resource/); - assert.throws(()=>s.resources.spawn(0,true,false,[hidden]),/internal-resource/); - assert.throws(()=>s.resources.claimClose(0,{run:1n,key:2}),/borrowed-resource/); - const internal=s.resources.beginLease(0,hidden,'file','exclusive',true);s.resources.endLease(internal.token); - const view=s.resources.lookup(0,file);view.owner=999;assert.equal(s.resources.lookup(0,file).owner,0); - assert.throws(()=>{view.flags.borrowed=true;},TypeError); - await finish(s,0);retired(s); -}); -test('reverse publication order, not open-start order, determines cleanup',async()=>{ - const s=setup(),first=s.resources.beginAcquire(0),second=s.resources.beginAcquire(0); - s.resources.publish(second,'file','published first');s.resources.publish(first,'file','published second'); - const order=[];await finish(s,0,r=>order.push(r.backend));assert.deepEqual(order,['published second','published first']);retired(s); -}); -test('publication identity exhaustion leaves admission live for raw release/abort; cleanup needs no IDs',async()=>{ - const s=setup({resourceIdentities:4}),admission=s.resources.beginAcquire(0); - assert.throws(()=>s.resources.publish(admission,'file','raw backend'),/identity-exhausted/); - assert.deepEqual(s.resources.counts,{resources:3,admissions:1,leases:0}); - assert.throws(()=>s.resources.beginAcquire(0),/identity-exhausted/); - assert.throws(()=>s.resources.beginLease(0,{run:1n,key:1},'reader','read'),/identity-exhausted/); - s.resources.abortAcquire(admission);await finish(s,0);retired(s); -}); -test('physical cleanup attempts every provider once and diagnostics failure cannot skip releases',async()=>{ - const s=setup(),order=[];let classified=0;for(let i=0;i<20;i++)create(s,0,i); - s.tasks.bodyDone(0,{kind:'failed',value:'primary'});assert(s.tasks.beginRelease(0)); - const errors=await s.resources.releaseScope(0,r=>{order.push(r.backend);throw 'release failed';},()=>{classified++;throw 'classifier failed';},'fallback'); - assert.deepEqual(order,Array.from({length:20},(_,i)=>19-i));assert.equal(errors.items.length,16);assert.equal(errors.omitted,4);assert.equal(classified,16); - const out=s.tasks.settle(0,errors);assert.equal(out.value,'primary');assert.deepEqual(out.cleanup,errors);retired(s); -}); -test('asynchronous release stays registered until provider acknowledgement, even on failure',async()=>{ - const s=setup(),work=new HostWork(s.tasks),file=create(s,0,'file');let acknowledge; - s.tasks.bodyDone(0,ok(null));assert(s.tasks.beginRelease(0)); - const pending=new Promise(resolve=>{acknowledge=resolve;}); - work.release(0,()=>s.resources.releaseScope(0,async()=>{await pending;throw 'close failed';},e=>e,'fallback')); - assert(s.resources.lookup(0,file).releasing);assert.throws(()=>s.tasks.settle(0),/invalid-transition/); - const wait=work.wait();acknowledge();await wait;const reply=work.take();assert(reply.outcome.ok); - assert.throws(()=>s.resources.lookup(0,file),/stale-handle/); - const out=s.tasks.settle(0,reply.outcome.value);assert.equal(out.kind,'failed');assert.equal(out.value,'close failed');retired(s); -}); -test('repeated scoped resources and leases retain no registry history',async()=>{ - const s=setup();for(let i=0;i<1000;i++){ - const child=s.resources.spawn(0,true,false,[]).task,file=create(s,child.key,i),lease=s.resources.beginLease(child.key,file,'file','exclusive'); - s.resources.endLease(lease.token);await finish(s,child.key); - assert.deepEqual(s.resources.counts,{resources:3,admissions:0,leases:0});assert.deepEqual(s.tasks.counts,{scopes:1,tasks:0,hosts:0}); - } - await finish(s,0);retired(s); -}); -test('explicit close cancels a leased operation, not its task, and waits for lease acknowledgement',async()=>{ - const s=setup(),work=new HostWork(s.tasks),file=create(s,0,'reader',{},'reader'),child=s.tasks.spawn(0,true,false);let acknowledge; - const drained=new Promise(resolve=>{acknowledge=resolve;}); - const read=work.start(child.key,async signal=>{ - const lease=s.resources.beginLease(child.key,file,'reader','read'); - try {await new Promise(resolve=>signal.addEventListener('abort',resolve,{once:true}));await drained;throw 'resource closed';} - finally{s.resources.endLease(lease.token);} - }); - assert.equal(s.resources.claimClose(0,file),null); - assert.equal(work.cancelBody({...read,run:2n}),false);assert.equal(work.cancelBody(read),true);assert.equal(work.cancelBody(read),false); - assert.equal(s.resources.claimClose(0,file),null);assert.equal(s.tasks.state(child.key).reason,null); - const waiting=work.wait();acknowledge();await waiting;const reply=work.take(); - assert.equal(reply.outcome.ok,false);assert.equal(reply.outcome.error,'resource closed');assert.equal(s.tasks.state(child.key).reason,null); - let physical=0;work.start(0,()=>{assert(s.resources.claimClose(0,file));physical++;s.resources.finishClose(file);}); - await work.wait();assert(work.take().outcome.ok);assert.equal(physical,1); - await finish(s,child.key);await finish(s,0);retired(s); -}); - -test('resource exhaustion and oversize transfers do not admit tasks or invalidate aliases',async()=>{ - const s=setup({resourceIdentities:5}),file=create(s,0,'file'); - for(const [tokens,error] of [[[file],/identity-exhausted/],[Array(257).fill(file),/transfer-limit/]]){ - assert.throws(()=>s.resources.spawn(0,true,false,tokens),error); - assert.equal(s.resources.lookup(0,file).owner,0);assert.deepEqual(s.tasks.counts,{scopes:1,tasks:0,hosts:0}); - } - await finish(s,0);retired(s); -}); diff --git a/ext/demo-site/test/io-v2-session-probe.mjs b/ext/demo-site/test/io-v2-session-probe.mjs @@ -1,47 +0,0 @@ -// Native tree sibling cancellation through cloneable events, with a real DOM -// provider. No raw handles leave the worker/session boundary. -export async function run(_control,backend){ - const base=new URL('../',document.querySelector('script[type=module][src]').src),fixture=(await(await fetch('/runtime-fixtures.json')).json())['v2-session-streams']; - const assert=(ok,message)=>{if(!ok)throw Error(message);}; - async function exercise(start,turn,stop){ - let report=await start();const events=[]; - for(let i=0;i<30&&!events.some(e=>e.kind==='write');i++){events.push(...report.events);if(!events.some(e=>e.kind==='write'))report=await turn({run:report.run,kind:'poll'});} - const read=events.find(e=>e.kind==='read'),write=events.find(e=>e.kind==='write');assert(read&&write,'pending read blocked sibling write'); - const input=document.createElement('input');document.body.append(input);let hits=0; - const listener=()=>{hits++;};input.addEventListener('input',listener); - try{ - if(stop){ - report=await turn({run:report.run,kind:'stop'}); - assert(!report.done&&report.events.filter(e=>e.kind==='cancel').length===2,'stop did not cancel both providers'); - input.removeEventListener('input',listener);input.remove(); - report=await turn({run:report.run,kind:'reply',reply:{...read,kind:'cancelled'}}); - assert(!report.done,'stop forgot pending output acknowledgement'); - report=await turn({run:report.run,kind:'reply',reply:{...write,kind:'cancelled'}}); - for(let i=0;i<30&&!report.done;i++)report=await turn({run:report.run,kind:'poll'}); - assert(report.done&&report.outcome.kind==='cancelled','structured session stop did not settle'); - assert(Object.values(report.counts).every(group=>Object.values(group).every(value=>value===0)),'stop retained lifecycle state'); - return; - } - let rejected=false;try{await turn({run:'999',kind:'stop'});}catch{rejected=true;}assert(rejected,'wrong run accepted'); - report=await turn({run:report.run,kind:'reply',reply:{...read,run:'999',bytes:[1],eof:false}});assert(report.accepted===false,'wrong reply consumed wait'); - assert(write.bytes.length===1&&write.bytes[0]===65,'write payload mismatch'); - report=await turn({run:report.run,kind:'reply',reply:{run:write.run,id:write.id,kind:'write',count:1}}); - let cancel=report.events.find(e=>e.kind==='cancel'); - for(let i=0;i<30&&!cancel;i++){report=await turn({run:report.run,kind:'poll'});cancel=report.events.find(e=>e.kind==='cancel');} - assert(cancel?.id===read.id,'missing read cancellation');assert(!report.done&&report.counts.events.pending===1,'abort delivery settled provider'); - input.removeEventListener('input',listener);input.dispatchEvent(new Event('input'));assert(hits===0,'listener survived cancellation'); - report=await turn({run:report.run,kind:'poll'});assert(!report.done&&input.isConnected,'unacknowledged cleanup retired run'); - input.remove();report=await turn({run:report.run,kind:'reply',reply:{...cancel,kind:'cancelled'}}); - for(let i=0;i<30&&!report.done;i++)report=await turn({run:report.run,kind:'poll'}); - assert(report.done&&report.outcome.kind==='completed'&&report.outcome.value==='42','tree run result mismatch'); - assert(Object.values(report.counts).every(group=>Object.values(group).every(value=>value===0)),'historical transport/lifecycle state retained'); - }finally{input.removeEventListener('input',listener);input.remove();} - } - for(const stop of [false,true]){ - const {CompilerWorker}=await import(new URL('runtime/haskell/client.mjs',base)),client=new CompilerWorker(); - try{await client.request({type:'init',compiler:false});await client.request({type:'load',bytes:new Uint8Array(fixture.bytes).buffer,hash:fixture.hash}); - await exercise(async()=>JSON.parse((await client.request({type:'io-v2-session-start',name:'root',arguments:[]})).text),async command=>JSON.parse((await client.request({type:'io-v2-session-turn',...command})).text),stop); - }finally{client.dispose();} - } - return {backend,checks:['native tree read permits sibling write','wrong-run stop and reply reject','tree cancellation waits for DOM cleanup acknowledgement','complete42; no retained task/resource/event records','external structured stop awaits both provider acknowledgements']}; -} diff --git a/ext/demo-site/test/io-v2-session.test.mjs b/ext/demo-site/test/io-v2-session.test.mjs @@ -1,73 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import {Graph} from '../dist/graph.js'; -import {readText,readNatural,readList} from '../dist/codecs.js'; -import {constructors} from '../dist/io-v2-private.js'; -import {Wire} from '../dist/io-v2-core.js'; -import {Session} from '../dist/io-v2-session.js'; -function fixture(budget={}){ - const g=new Graph(budget),t=constructors({leaf:g.leaf,observe:g.observe.bind(g),stem:g.stem.bind(g),fork:g.fork.bind(g),app:g.app.bind(g)}),w=new Wire(t,1n); - const n=x=>t.natural(BigInt(x)),a=(tag,p=t.leaf)=>t.fork(n(tag),p),request=(group,op,p=t.leaf)=>a(10,t.fork(t.text(group),t.fork(n(1),t.fork(t.text(op),p)))); - const ref=key=>w.marker('io.v2.resource',key),read=request('stream','read',t.fork(ref(1),n(4))),write=bytes=>request('stream','write',t.fork(ref(2),w.list(bytes.map(n)))); - const seq=(left,right)=>a(1,t.fork(left,t.fork(t.leaf,right))),session=action=>new Session(t,action,'1',roots=>g.collect([...roots])); - return {g,t,w,n,a,request,ref,read,write,seq,session}; -} -async function poll(s,predicate){for(let i=0;i<100;i++){const r=await s.turn({run:'1',kind:'poll'});if(predicate(r))return r;}throw Error('poll did not progress');} -const value=(f,s)=>f.w.pair(f.w.pair(s.core.result.value)[1])[0]; -test('stream read uses the independent native data-pair encoding, not a nested tuple',async()=>{ - const f=fixture(),s=f.session(f.read),r=await poll(s,r=>r.events.length);const event=r.events[0]; - await s.turn({run:'1',kind:'reply',reply:{...event,bytes:[65,0,255],eof:false}});if(!s.core.result)await poll(s,r=>r.done); - const [tag,bytes]=f.w.pair(value(f,s));assert.equal(readText(f.g,tag),'data');assert.deepEqual(readList(f.g,bytes,3).map(h=>readNatural(f.g,h)),[65n,0n,255n]); -}); -test('EOF is sticky and standard handles are borrowed, not physically closed',async()=>{ - const f=fixture(),s=f.session(f.seq(f.read,f.read)),r=await poll(s,r=>r.events.length); - let out=await s.turn({run:'1',kind:'reply',reply:{...r.events[0],bytes:[],eof:true}}); - if(!out.done)out=await poll(s,r=>{assert.equal(r.events.length,0);return r.done;}); - assert.equal(readText(f.g,readList(f.g,value(f,s),1)[0]),'eof'); - const other=fixture(),close=other.session(other.request('resource','close',other.ref(2)));await poll(close,r=>r.done); - assert.equal(readText(other.g,readList(other.g,value(other,close),4)[0]),'borrowed-resource'); -}); -test('structured session stop waits for provider acknowledgement and refuses reuse',async()=>{ - const f=fixture(),s=f.session(f.read),r=await poll(s,r=>r.events.length),event=r.events[0]; - await assert.rejects(s.turn({run:'2',kind:'stop'}),/stale/); - const stopped=await s.turn({run:'1',kind:'stop'});assert(!stopped.done);assert.equal(stopped.events[0].kind,'cancel'); - let done=await s.turn({run:'1',kind:'reply',reply:{...event,kind:'cancelled'}});if(!done.done)done=await poll(s,r=>r.done); - assert.equal(done.outcome.kind,'cancelled');assert(Object.values(done.counts).every(c=>Object.values(c).every(n=>n===0))); - await assert.rejects(s.turn({run:'1',kind:'poll'}),/stale/); -}); -test('failed output poisons the slot before demanding any later byte payload',async()=>{ - const f=fixture(),poison=f.g.app(f.g.leaf,f.g.leaf),observe=f.t.observe; - f.t.observe=h=>{if(h===poison)throw Error('forced poisoned write data');return observe(h);}; - const later=f.request('stream','write',f.t.fork(f.ref(2),poison)),s=f.session(f.seq(f.write([65]),later)),r=await poll(s,r=>r.events.length); - await s.turn({run:'1',kind:'reply',reply:{...r.events[0],kind:'error'}});if(!s.core.result)await poll(s,r=>r.done); - assert.equal(s.core.hasFault,false);const error=readList(f.g,value(f,s),4);assert.equal(readText(f.g,error[0]),'invalid-state');assert.equal(readText(f.g,error[3]),'unknown'); -}); -test('attempted output has an aggregate bound and never silently retries partial writes',async()=>{ - const f=fixture({fuel:20000000});let action=f.write([1]);for(let i=0;i<4;i++)action=f.seq(f.write(Array(4096).fill(65)),action); - const s=f.session(action);let events=0; - for(let i=0;i<100&&!s.core.result;i++){ - const r=await s.turn({run:'1',kind:'poll'}); - for(const event of r.events){events++;await s.turn({run:'1',kind:'reply',reply:{...event,kind:'write',count:1}});} - } - assert(s.core.result);assert.equal(events,4);assert.equal(readText(f.g,readList(f.g,value(f,s),4)[0]),'invalid-bound'); -}); -test('named UTF8 facilities preserve nonfinal carry and generic BOM text',async()=>{ - const f=fixture(),bytes=[0xef,0xbb,0xbf,65,0xf0,0x9f],s=f.session(f.request('utf8','decode',f.w.list([f.n(0),f.w.list(bytes.map(f.n))]))); - await poll(s,r=>r.done);const [text,carry]=readList(f.g,value(f,s),2); - assert.equal(readText(f.g,text),'\ufeffA');assert.deepEqual(readList(f.g,carry,2).map(h=>readNatural(f.g,h)),[240n,159n]); - const other=fixture(),encoded=other.session(other.request('utf8','encode',other.t.text('λ😀')));await poll(encoded,r=>r.done); - assert.deepEqual(readList(other.g,value(other,encoded),6).map(h=>Number(readNatural(other.g,h))),[206,187,240,159,152,128]); -}); -test('named UTF8 rejects excess heads/tails without demand and malformed scalar values as ordinary errors',async()=>{ - for(const [op,limit]of [['encode',1024],['decode',4096]]){ - const f=fixture({fuel:20000000}),poison=f.g.app(f.g.leaf,f.g.leaf),observe=f.t.observe; - f.t.observe=h=>{if(h===poison)throw Error('forced excess payload');return observe(h);}; - let list=f.t.fork(poison,poison);for(let i=0;i<limit;i++)list=f.t.fork(f.n(65),list); - const s=f.session(f.request('utf8',op,op==='encode'?list:f.w.list([f.n(0),list])));await poll(s,r=>r.done); - assert(!s.core.hasFault);assert.equal(readText(f.g,readList(f.g,value(f,s),4)[0]),'invalid-argument'); - } - for(const scalar of [0xd800,0x110000]){ - const f=fixture(),s=f.session(f.request('utf8','encode',f.w.list([f.n(scalar)])));await poll(s,r=>r.done); - assert.equal(readText(f.g,readList(f.g,value(f,s),4)[0]),'invalid-argument'); - } -}); diff --git a/ext/demo-site/test/io-v2-source-probe.mjs b/ext/demo-site/test/io-v2-source-probe.mjs @@ -1,104 +0,0 @@ -// Real compiled source libraries, native evaluators and the private event session. -export async function run(_control,backend,terminalMode=false){ - const base=new URL('../',document.querySelector('script[type=module][src]').src),fixture=await(await fetch('/io-v2-source.json')).json(),checks=[]; - const input=[...new TextEncoder().encode('\ufeffA😀λe\u0301\r\n\0')]; - async function drive(name,start,turn){ - if(terminalMode)return driveTerminal(name,start,turn); - let report=await start(),queue=[...report.events],offset=0,pendingRead=null,cancelled=false;const output=[]; - for(let i=0;i<2000&&!report.done;i++){ - if(!queue.length){report=await turn({run:report.run,kind:'poll'});queue.push(...report.events);continue;} - const event=queue.shift();let reply; - if(event.kind==='read'){ - if(name==='cancellation'){pendingRead=event.id;continue;} - if(name!=='roundTrip')throw Error('Unexpected read'); - const bytes=input.slice(offset,offset+Math.min(event.limit,1));offset+=bytes.length; - reply={...event,bytes,eof:bytes.length===0}; - }else if(event.kind==='write'){ - if(name==='cancellation'&&pendingRead===null)throw Error('Write ran without the expected pending reader'); - // Deliberate positive partial progress: the SOURCE writeAll must advance - // exactly once, including through a split multi-byte scalar. - output.push(event.bytes[0]);reply={...event,count:1}; - }else{ - if(event.id!==pendingRead||cancelled)throw Error('Unexpected cancellation'); - cancelled=true;reply={...event,kind:'cancelled'}; - } - report=await turn({run:report.run,kind:'reply',reply});if(report.accepted!==true)throw Error('Provider reply rejected');queue.push(...report.events); - } - const expected=name.startsWith('invalid')?[]:name==='roundTrip'?input:[...new TextEncoder().encode(name==='cancellation'?'ready':'λ😀e\u0301\r\n')]; - if(!report.done||report.outcome.kind!=='completed'||report.outcome.value!=='42'||report.outcome.fault)throw Error(name+': '+JSON.stringify(report)); - if(JSON.stringify(output)!==JSON.stringify(expected))throw Error(name+' output byte mismatch: '+JSON.stringify(output)); - if(name==='cancellation'&&!cancelled)throw Error('Missing source cancellation'); - if(!Object.values(report.counts).every(group=>Object.values(group).every(v=>v===0)))throw Error('Retained run records'); - checks.push(name+': full bytes/result42; split UTF8 and source partial-progress handling'); - } - async function driveTerminal(name,start,turn){ - const {ioV2TerminalView:{terminalView}}=await import(new URL('dist/probe-api.js',base)); - let report=await start(),stop=false,sent=false,aborted=false;const replies=[],parent=document.createElement('div');document.body.append(parent); - const widget=terminalView(parent,report.run,r=>replies.push(r),()=>{stop=true;}); - try{ - for(let i=0;i<2000&&!report.done;i++){ - for(const event of report.events){ - const old=parent.querySelector('form'),oldEOF=old?.querySelector('button[type=button]'); - widget.handle(event); - if(event.kind==='cancel'){ - aborted=true;if(!parent.querySelector('form').hidden)throw Error('Input still visible at cancellation acknowledgement'); - const count=replies.length;old.dispatchEvent(new Event('submit',{cancelable:true}));oldEOF?.click(); - if(replies.length!==count)throw Error('Stale DOM prompt emitted reply'); - } - if(event.kind==='read'&&name==='roundTrip'){ - const form=parent.querySelector('form'); - if(!sent){form.querySelector('input').value='\ufeffA😀λe\u0301\0';form.dispatchEvent(new Event('submit',{cancelable:true}));sent=true;} - else form.querySelector('button[type=button]').click(); - } - if(event.kind==='read'&&name==='externalStop')parent.querySelector('section > button').click(); - } - const command=stop?{kind:'stop'}:replies.length?{kind:'reply',reply:replies.shift()}:{kind:'poll'};stop=false; - report=await turn({run:report.run,...command}); - if(command.kind==='reply'&&report.accepted!==true)throw Error('UI completion rejected'); - } - if(!report.done||report.outcome.kind!==(name==='externalStop'?'cancelled':'completed')||report.outcome.fault)throw Error('UI run: '+JSON.stringify(report)); - if(name!=='externalStop'&&report.outcome.value!=='42')throw Error('UI result mismatch'); - widget.finish();const snapshot=widget.snapshot(); - const expected=name==='binary'?[92,120,102,102,255,240,159]:name==='roundTrip'?[...new TextEncoder().encode('\ufeffA😀λe\u0301\0\n')]:name.startsWith('invalid')||name==='externalStop'?[]:[...new TextEncoder().encode(name==='cancellation'?'ready':'λ😀e\u0301\r\n')]; - if(JSON.stringify(snapshot.output.bytes)!==JSON.stringify(expected))throw Error('UI output mismatch '+name); - if(parent.querySelector('[data-hex=output]').textContent!==expected.map(b=>b.toString(16).padStart(2,'0')).join(' '))throw Error('Wrong hex view'); - if(name==='binary'){ - const text=parent.querySelector('[data-stream=output]'); - if(text.firstChild.nodeType!==Node.TEXT_NODE||text.firstChild.textContent!=='\\xff'||text.querySelectorAll('mark').length!==2||!text.querySelector('[data-byte-reason=incomplete]'))throw Error('Byte escapes indistinguishable from literal text'); - } - if(['externalStop','cancellation'].includes(name)&&!aborted)throw Error('No UI cancellation'); - if(!Object.values(report.counts).every(group=>Object.values(group).every(v=>v===0)))throw Error('UI run retained records'); - if(replies.length||parent.querySelector('form'))throw Error('UI not retired'); - checks.push('terminal '+name+': exact bytes, DOM lifecycle and outcome'); - }finally{parent.remove();} - } - if(terminalMode){ - const {ioV2TerminalView:{terminalView}}=await import(new URL('dist/probe-api.js',base)); - const parent=document.createElement('div'),replies=[];document.body.append(parent);let stops=0; - const widget=terminalView(parent,'900',r=>replies.push(r),()=>{stops++;}); - try{ - widget.handle({run:'900',id:1,kind:'read',limit:4096});const stale=parent.querySelector('form'),staleEOF=stale.querySelector('button[type=button]'); - widget.handle({run:'900',id:1,kind:'cancel',operation:'read'});widget.handle({run:'900',id:2,kind:'read',limit:4096}); - stale.dispatchEvent(new Event('submit',{cancelable:true}));staleEOF.click(); - if(replies.length!==1||parent.querySelector('form').hidden)throw Error('Old prompt consumed successor'); - const form=parent.querySelector('form');form.querySelector('input').value='\ud800';form.dispatchEvent(new Event('submit',{cancelable:true})); - if(replies.length!==1||form.hidden)throw Error('Invalid line consumed wait'); - form.querySelector('input').value='';form.dispatchEvent(new Event('submit',{cancelable:true})); - if(replies.at(-1).eof||JSON.stringify(replies.at(-1).bytes)!=='[10]')throw Error('Empty line conflated with EOF'); - const text='<script>throw Error("injected")</script><img src=x onerror=alert(1)>'; - widget.handle({run:'900',id:3,kind:'write',stream:'error',bytes:[...new TextEncoder().encode(text)]}); - if(parent.querySelector('script,img')||parent.querySelector('[data-stream=error]').textContent!==text)throw Error('Output interpreted as markup'); - const button=parent.querySelector('section > button');button.click();button.click();if(stops!==1)throw Error('Repeated stop'); - widget.finish();checks.push('terminal DOM: stale prompt isolation, malformed/empty line, inert markup, once-only stop'); - }finally{parent.remove();} - } - const names=terminalMode?['direct','handled','roundTrip','cancellation','invalidUTF8','invalidScalar','binary','externalStop']:['direct','handled','roundTrip','cancellation','invalidUTF8','invalidScalar']; - const {CompilerWorker}=await import(new URL('runtime/haskell/client.mjs',base)),client=new CompilerWorker(); - try{ - await client.request({type:'init',compiler:false});await client.request({type:'load',bytes:new Uint8Array(fixture.bytes).buffer,hash:fixture.hash}); - for(const name of names)await drive(name, - async()=>JSON.parse((await client.request({type:'io-v2-session-start',name:name==='externalStop'?'roundTrip':name,arguments:[]})).text), - async command=>JSON.parse((await client.request({type:'io-v2-session-turn',...command})).text)); - }finally{client.dispose();} - return {backend,checks}; -} diff --git a/ext/demo-site/test/io-v2-tasks.test.mjs b/ext/demo-site/test/io-v2-tasks.test.mjs @@ -1,132 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import {Tasks} from '../dist/io-v2-tasks.js'; -const ok=value=>({kind:'completed',value}),failed=value=>({kind:'failed',value}),cancelled=value=>({kind:'cancelled',value}); -const diagnostics=items=>({items,omitted:0}); -const fresh=(run=1n,limit)=>new Tasks(run,'owner-stopped','join-cycle',limit); -function finish(s,id,outcome=ok(null),cleanup){s.bodyDone(id,outcome);assert.equal(s.beginRelease(id),true);return s.settle(id,cleanup);} -function retired(s){assert.deepEqual(s.counts,{scopes:0,tasks:0,hosts:0});assert.deepEqual([...s.payloads()],['owner-stopped','join-cycle']);} - -test('normal exit closes admission but waits for children; ordinary errors remain values',()=>{ - const s=fresh(),child=s.spawn(0,false,false),ordinary={err:'domain error'}; - s.bodyDone(0,ok(ordinary));assert.equal(s.beginRelease(0),false); - assert.throws(()=>s.spawn(0,false,false),/invalid-transition/); - const grandchild=s.spawn(child.key,false,false); // existing child remains live during ancestor join - finish(s,grandchild.key);finish(s,child.key); - assert.equal(s.beginRelease(0),true);const out=s.settle(0); - assert.equal(out.kind,'completed');assert.equal(out.value,ordinary);retired(s); -}); -test('normal joining remains cancellable and the first reason is sticky',()=>{ - const s=fresh(),child=s.spawn(0,false,false); - s.bodyDone(0,ok('return'));s.stop(0,cancelled('stop'));s.stop(0,failed('late')); - assert.deepEqual(s.state(child.key).reason,cancelled('owner-stopped')); - finish(s,child.key,failed('late child')); - assert(s.beginRelease(0));assert.equal(s.settle(0).value,'stop');retired(s); -}); -test('supervision contains failure; unsupervised failure stops siblings immediately',()=>{ - const s=fresh(),contained=s.spawn(0,true,true); - finish(s,contained.key,failed('contained'));assert.equal(s.state(0).reason,null); - s.claimJoin(0,contained);assert.equal(s.pollJoin(0).value,'contained'); - const a=s.spawn(0,false,true),b=s.spawn(0,false,true); - s.stop(a.key,failed('first'));s.stop(b.key,failed('second')); - assert.deepEqual(s.state(0).reason,failed('first')); - assert.deepEqual(s.state(b.key).reason,cancelled('owner-stopped')); - finish(s,a.key);finish(s,b.key);assert.equal(finish(s,0).value,'first');retired(s); -}); -test('joins consume before waiting; abandoned joins never restore tickets or retain results',()=>{ - const s=fresh(),child=s.spawn(0,true,true),waiter=s.spawn(0,true,true); - s.claimJoin(waiter.key,child);assert.equal(s.pollJoin(waiter.key),null); - assert.throws(()=>s.discard(0,child),/stale-task/); - s.cancel(0,waiter,'cancel wait');finish(s,waiter.key); - const result={unique:'unclaimed'};finish(s,child.key,ok(result)); - assert(![...s.payloads()].includes(result)); - s.claimJoin(0,waiter);assert.equal(s.pollJoin(0).value,'cancel wait'); - assert.throws(()=>s.claimJoin(0,waiter),/stale-task/); - finish(s,0);retired(s); -}); -test('explicit and implicit cycles use task identities across lexical scopes',()=>{ - const s=fresh(),a=s.spawn(0,true,true);let b=s.spawn(0,true,true); - const inner=s.nested(a.key); - assert.throws(()=>s.claimJoin(inner,a),/join-cycle/); - s.claimJoin(inner,b);assert.throws(()=>s.claimJoin(b.key,a),/join-cycle/); - s.abandonJoin(inner);finish(s,b.key);b=s.spawn(0,true,true); - const c=s.spawn(inner,true,true); - s.claimJoin(b.key,a);s.claimJoin(c.key,b);s.bodyDone(inner,ok(null)); - assert.equal(s.state(inner).reason.value,'join-cycle');assert.equal(s.state(a.key).reason.value,'join-cycle'); - finish(s,c.key);assert(s.beginRelease(inner));s.settle(inner);finish(s,a.key); - assert.equal(s.pollJoin(b.key).value,'join-cycle');finish(s,b.key);finish(s,0);retired(s); -}); -test('out-of-scope, cross-run and completed task cancellation reject without consumption',()=>{ - const s=fresh(),a=s.spawn(0,true,true),b=s.spawn(0,true,true),owned=s.spawn(a.key,true,true); - assert.throws(()=>s.claimJoin(b.key,owned),/out-of-scope/); - assert.throws(()=>s.cancel(0,{...a,run:2n},'bad'),/stale-task/); - finish(s,owned.key,ok('child'));s.claimJoin(a.key,owned);assert.equal(s.pollJoin(a.key).value,'child'); - finish(s,a.key);assert.throws(()=>s.cancel(0,a,'late'),/stale-task/);s.discard(0,a); - finish(s,b.key);finish(s,0);retired(s); -}); -test('cleanup defeats success, preserves the primary and propagates bounded diagnostics once',()=>{ - const s=fresh(),inner=s.nested(0),child=s.spawn(inner,false,false); - assert.equal(finish(s,child.key,ok(null),diagnostics([17])).value,17); - const lexical=finish(s,inner,ok(null),diagnostics(Array.from({length:25},(_,i)=>100+i))); - assert.equal(lexical.cleanup,undefined); - const out=finish(s,0,failed(999),diagnostics([200])); - assert.equal(out.kind,'failed');assert.equal(out.value,17); - assert.deepEqual(out.cleanup,{items:Array.from({length:16},(_,i)=>100+i),omitted:10});retired(s); -}); -test('pending host cancellation is delivered once but release requires acknowledgement',async()=>{ - const s=fresh(),reader=s.spawn(0,true,true),read=s.beginHost(reader.key),controller=new AbortController(),events=[]; - let acknowledge; - const physicalWork=new Promise(resolve=>{acknowledge=resolve;}); - controller.signal.addEventListener('abort',()=>events.push('abort-requested'),{once:true}); - s.cancel(0,reader,'cancel input');s.bodyDone(reader.key,ok('ignored value')); - assert.deepEqual(s.takeCancellations(),[read]);controller.abort(); - assert.deepEqual(s.takeCancellations(),[]);assert.equal(s.beginRelease(reader.key),false); - s.bodyDone(0,ok('parent'));assert.equal(s.beginRelease(0),false); - await Promise.resolve();assert.deepEqual(events,['abort-requested']);assert.equal(s.beginRelease(reader.key),false); - acknowledge();await physicalWork;events.push('physical-work-settled'); - assert.equal(s.endHost(read),'ignored');assert.equal(s.endHost(read),'stale'); - assert(s.beginRelease(reader.key));assert.equal(s.settle(reader.key).kind,'cancelled'); - assert(s.beginRelease(0));s.settle(0);retired(s); -}); -test('wrong/stale host resumes leave the correct wait intact',()=>{ - const s=fresh(),child=s.spawn(0,true,false),host=s.beginHost(child.key); - for(const bad of [{...host,scope:0},{...host,run:2n},{...host,key:host.key+1}])assert.equal(s.endHost(bad),'stale'); - assert.equal(s.state(child.key).hostCalls,1);assert.equal(s.endHost(host),'reply'); - const next=s.beginHost(child.key);assert.notEqual(next.key,host.key);assert.equal(s.endHost(host),'stale'); - assert.equal(s.endHost(next),'reply');finish(s,child.key);finish(s,0);retired(s); -}); -test('all callback completion orders retain cancelled scopes until their own work settles',()=>{ - for(const order of [[0,1,2],[0,2,1],[1,0,2],[1,2,0],[2,0,1],[2,1,0]]){ - const s=fresh(),children=order.map(()=>s.spawn(0,false,false)),hosts=children.map(c=>s.beginHost(c.key)); - s.stop(0,failed('stop'));s.bodyDone(0,ok(null)); - for(const child of children)s.bodyDone(child.key,ok(null)); - assert.equal(s.takeCancellations().length,3);assert.equal(s.takeCancellations().length,0); - for(const index of order){ - assert.equal(s.beginRelease(0),false);assert.equal(s.beginRelease(children[index].key),false); - assert.equal(s.endHost(hosts[index]),'ignored');assert(s.beginRelease(children[index].key));s.settle(children[index].key); - } - assert(s.beginRelease(0));assert.equal(s.settle(0).value,'stop');retired(s); - } -}); -test('nonwrapping identities, scope state copies and immutable diagnostic capture',()=>{ - for(const bad of [0n,-1n,1,2n**64n])assert.throws(()=>fresh(bad),/identity/); - const s=fresh(1n,2),a=s.spawn(0,true,true),copy=s.state(a.key);copy.phase='releasing'; - assert.equal(s.state(a.key).phase,'open'); - const items=['cleanup'];s.bodyDone(a.key,failed('primary'));assert(s.beginRelease(a.key)); - const result=s.settle(a.key,diagnostics(items));items[0]='changed';result.cleanup.items[0]='also changed'; - s.claimJoin(0,a);assert.equal(s.pollJoin(0).cleanup.items[0],'cleanup'); - const b=s.spawn(0,true,false);finish(s,b.key); - assert.throws(()=>s.spawn(0,true,false),/identity-exhausted/);assert.deepEqual(s.counts,{scopes:1,tasks:0,hosts:0}); - const h=s.beginHost(0);assert(Object.isFrozen(h));s.endHost(h);s.endHost(s.beginHost(0)); - assert.throws(()=>s.beginHost(0),/identity-exhausted/);finish(s,0);retired(s); -}); -test('joined, discarded and non-result tasks retain no history or payload roots',()=>{ - const s=fresh(); - for(let i=0;i<2000;i++){ - const t=s.spawn(0,true,i%3!==2),payload={unique:i};finish(s,t.key,ok(payload)); - if(i%3===0){s.claimJoin(0,t);assert.equal(s.pollJoin(0).value,payload);} - if(i%3===1)s.discard(0,t); - assert.deepEqual(s.counts,{scopes:1,tasks:0,hosts:0});assert(![...s.payloads()].includes(payload)); - } - finish(s,0);retired(s); -}); diff --git a/ext/demo-site/test/io-v2-terminal.test.mjs b/ext/demo-site/test/io-v2-terminal.test.mjs @@ -1,37 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import {Terminal} from '../dist/io-v2-terminal.js'; -const read=(id,limit=4096)=>({run:'1',id,kind:'read',limit}); -test('line input is scalar bounded, strict, LF framed and explicitly distinct from EOF',()=>{ - const t=new Terminal('1');assert.equal(t.handle(read(1)),undefined); - for(const value of ['x\ny','\ud800','😀'.repeat(257),null])assert.equal(t.line('1',1,value),undefined); - assert.equal(t.line('2',1,'x'),undefined);assert.deepEqual(t.waiting,{run:'1',id:1}); - assert.deepEqual(t.line('1',1,''),{run:'1',id:1,kind:'read',bytes:[10],eof:false}); - t.handle(read(2));assert.deepEqual(t.endInput('1',2),{run:'1',id:2,kind:'read',bytes:[],eof:true}); - assert.equal(t.handle(read(3)).eof,true);t.finish(); -}); -test('encoded line is split at requested byte bounds, without loss or invented EOF',()=>{ - const t=new Terminal('1');t.handle(read(1,1));const parts=[...t.line('1',1,'😀'.repeat(256)).bytes]; - for(let id=2;id<=1025;id++) {const r=t.handle(read(id,1));assert.equal(r.eof,false);parts.push(...r.bytes);} - assert.deepEqual(parts,[...new TextEncoder().encode('😀'.repeat(256)),10]); - assert.equal(t.handle(read(1026)),undefined);assert.throws(()=>t.finish(),/pending/); -}); -test('cancellation retires exactly the pending prompt; stale submissions do not consume its successor',()=>{ - const t=new Terminal('1');t.handle(read(1)); - assert.deepEqual(t.handle({run:'1',id:1,kind:'cancel',operation:'read'}),{run:'1',id:1,kind:'cancelled'}); - t.handle(read(2));assert.equal(t.line('1',1,'stale'),undefined);assert.equal(t.endInput('1',1),undefined); - assert.equal(t.handle({run:'1',id:1,kind:'cancel',operation:'read'}),undefined); - assert.equal(t.waiting.id,2);assert.deepEqual(t.line('1',2,'ok').bytes,[111,107,10]);t.finish(); -}); -test('binary write acknowledgement is independent of display decoding and abort races never duplicate it',()=>{ - const t=new Terminal('1'),event={run:'1',id:1,kind:'write',stream:'output',bytes:[255,240,159]}; - assert.deepEqual(t.handle(event),{run:'1',id:1,kind:'write',count:3}); - assert.equal(t.handle({run:'1',id:1,kind:'cancel',operation:'write'}),undefined); - assert.throws(()=>t.handle(event),/stale/);t.finish(); - assert.deepEqual(t.output.snapshot().output.bytes,event.bytes);assert.equal(t.output.snapshot().output.tokens.at(-1).reason,'incomplete'); -}); -test('invalid input events preserve current waits and do not consume future identities',()=>{ - const t=new Terminal('1');assert.throws(()=>t.handle(read(2,0)),/read/);t.handle(read(1)); - assert.throws(()=>t.handle(read(2)),/read/);assert.throws(()=>t.handle({...read(2),run:'2'}),/event/); - assert.equal(t.waiting.id,1);t.endInput('1',1);assert.equal(t.handle(read(2)).eof,true); -}); diff --git a/ext/demo-site/test/io-v2-utf8.test.mjs b/ext/demo-site/test/io-v2-utf8.test.mjs @@ -1,36 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import {encodeUTF8,decodeUTF8} from '../dist/io-v2-utf8.js'; -test('all Unicode scalars match independent platform encoding and strict decoding',()=>{ - const encoder=new TextEncoder(); - for(let start=0;start<=0x10ffff;start+=1024){ - const scalars=[];for(let n=start;n<Math.min(start+1024,0x110000);n++)if(n<0xd800||n>0xdfff)scalars.push(n); - const text=String.fromCodePoint(...scalars),expected=[...encoder.encode(text)]; - assert.deepEqual(encodeUTF8(text),expected);assert.deepEqual(decodeUTF8(expected,true),{text,carry:[]}); - } -}); -test('every valid multibyte scalar prefix yields only copied bounded carry, and fails when final',()=>{ - const encoder=new TextEncoder(); - for(let n=128;n<=0x10ffff;n++){ - if(n>=0xd800&&n<=0xdfff)continue; - const bytes=[...encoder.encode(String.fromCodePoint(n))]; - for(let split=1;split<bytes.length;split++){ - const prefix=bytes.slice(0,split),out=decodeUTF8(prefix,false); - assert.equal(out.text,'');assert.deepEqual(out.carry,prefix);assert.notEqual(out.carry,prefix); - } - } - // Exhaustive prefixes above; explicit final/invalid-prefix controls below keep - // exception creation out of the million-scalar loop. - for(const bytes of [[0xc2],[0xe0,0xa0],[0xf4,0x8f,0xbf]])assert.throws(()=>decodeUTF8(bytes,true),/incomplete/); -}); -test('invalid lead/continuation/overlong/surrogate/range prefixes fail before finality',()=>{ - for(const bytes of [[0x80],[0xc0],[0xc1],[0xf5],[0xff],[0xe0,0x9f],[0xed,0xa0],[0xf0,0x8f],[0xf4,0x90],[0xc2,0x41],[0xe2,0x82,0x41]]) - for(const final of [false,true])assert.throws(()=>decodeUTF8(bytes,final),/invalid UTF-8/); - assert.throws(()=>decodeUTF8([65,0xff],false),/at byte 1$/); -}); -test('BOM, NUL, combining scalars and line endings are unchanged; scalar/byte bounds are separate',()=>{ - const text='\ufeffa\0\r\nλ😀e\u0301';assert.deepEqual(decodeUTF8(encodeUTF8(text),true),{text,carry:[]}); - assert.equal(encodeUTF8('😀'.repeat(1024)).length,4096); - assert.throws(()=>encodeUTF8('a'.repeat(1025)),/scalar/);assert.throws(()=>encodeUTF8('\ud800'),/scalar/); - assert.throws(()=>decodeUTF8(Array(4097).fill(65),false),/byte/); -}); diff --git a/ext/demo-site/test/io-version-boundary.test.mjs b/ext/demo-site/test/io-version-boundary.test.mjs @@ -2,7 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import {Graph} from '../dist/graph.js'; import {natural,text} from '../dist/codecs.js'; -import {envelope} from '../dist/io-v2-profile.js'; +import {envelope} from '../dist/io-profile.js'; test('current framing rejects v1 before observing action and never guesses a protocol',()=>{ const g=new Graph(),poison=g.app(g.leaf,g.leaf),observe=g.observe.bind(g); diff --git a/ext/demo-site/test/io-version-probe.mjs b/ext/demo-site/test/io-version-probe.mjs @@ -21,7 +21,7 @@ export async function run(_control,backend){ for(const name of ['unknown-version','padded-v1','v1-output']){ await load(name);assert(!await s.probeIO('root'),'unknown/noncanonical version inert rejection'); let unknownCallbacks=0;const never=()=>{unknownCallbacks++;throw Error('unknown version invoked host');}; - try{await s.run('root',[],'tree','io',{...host,selected:never,v2:{open:never,event:never,close:never}});throw Error('unknown version accepted');} + try{await s.run('root',[],'tree','io',{...host,selected:never,structured:{open:never,event:never,close:never}});throw Error('unknown version accepted');} catch(error){assert(/Unsupported tricuIO version/.test(error.message),'unknown version error');} assert(unknownCallbacks===0,'unknown framing reached callbacks'); } diff --git a/ext/demo-site/test/live-ux-probe.mjs b/ext/demo-site/test/live-ux-probe.mjs @@ -29,9 +29,9 @@ export async function run(control,backend='haskell-wasm'){ el('source-editor').dispatchEvent(new CompositionEvent('compositionstart'));input('source-editor','answer = 49');await pause(900);assert(el('status').textContent==='Changes pending','IME does not compile mid-composition'); el('source-editor').dispatchEvent(new CompositionEvent('compositionend'));await status('Compiled');assert(await inspect('answer','number')==='49','IME completion compiles'); input('source-editor','answer = 50');change('program-picker','demo:language-reference');await status('Ready');await pause(900);assert(el('status').textContent==='Ready','Package change cancels queued compilation'); - document.querySelector('[data-compiler-example=input]').click();await status('Compiled');assert(!el('input-form')&&el('terminal-v2').textContent==='','Compilation never runs IO'); - await inspect('io');assert(!el('terminal-v2').querySelector('form'),'Recognition never runs IO');el('io-return-format').value='string';el('run-io').click();await wait(()=>el('terminal-v2').querySelector('form input'),'structured input'); - assert(globalThis.__sourceEditor.isDisabled&&el('reset').disabled,'Active IO cannot be replaced by background edits or Clear');el('terminal-v2').querySelector('form input').value='hello λ🙂';el('terminal-v2').querySelector('form').requestSubmit();await status('Finished');assert(result()==='hello λ🙂','Inline IO return'); + document.querySelector('[data-compiler-example=input]').click();await status('Compiled');assert(!el('input-form')&&el('terminal-io').textContent==='','Compilation never runs IO'); + await inspect('io');assert(!el('terminal-io').querySelector('form'),'Recognition never runs IO');el('io-return-format').value='string';el('run-io').click();await wait(()=>el('terminal-io').querySelector('form input'),'structured input'); + assert(globalThis.__sourceEditor.isDisabled&&el('reset').disabled,'Active IO cannot be replaced by background edits or Clear');el('terminal-io').querySelector('form input').value='hello λ🙂';el('terminal-io').querySelector('form').requestSubmit();await status('Finished');assert(result()==='hello λ🙂','Inline IO return'); input('source-editor','id x = x\nanswer = id 51\nbomb = (x : x x) (x : x x)');await status('Compiled');assert(await inspect('answer','number')==='51','Unused divergent binding is not forced'); document.querySelector('[aria-label="Inspect compiled definition bomb"]').click();await wait(()=>el('status').textContent==='Inspecting bomb','divergent observation');await pause(150);el('stop').click();await status('Stopped'); }finally{globalThis.Worker=NativeWorker;} diff --git a/ext/demo-site/test/module-probe.mjs b/ext/demo-site/test/module-probe.mjs @@ -12,7 +12,7 @@ const change=(id,value)=>{el(id).value=value;el(id).dispatchEvent(new Event('cha export async function run(control,backend='haskell-wasm'){ const base=new URL('../',document.querySelector('script[type=module][src]').src); const {createRuntimeSession}=(await import(new URL('dist/probe-api.js',base))).runtimeSession; - const {Terminal}=(await import(new URL('dist/probe-api.js',base))).ioV2Terminal; + const {Terminal}=(await import(new URL('dist/probe-api.js',base))).ioTerminal; const {packageIndex,packageReleases}=(await import(new URL('dist/probe-api.js',base))).packages; const releases=packageIndex(await(await fetch(new URL('packages.json',base))).json()); const lib=name=>packageReleases(releases,'tricu-lib-'+name)[0]; @@ -77,7 +77,7 @@ export async function run(control,backend='haskell-wasm'){ equal((await s.run('io',[{type:'number',value:'42'}],'tree','pure',host)).type,'tree','Re-export is executable, not source expansion'); await rejects(()=>value('openReader'),/unresolved name/); // reexports do not introduce lexical imports await s.compile(`!import ${io.manifest} as I\n!export main\nmain = I.io (I.scope (owner : I.require (I.openReader owner "no-browser-filesystem") I.pure))`); - await rejects(()=>s.run('main',[],'string','io',terminalHost(Terminal,null)),/IO v2 failed/i); + await rejects(()=>s.run('main',[],'string','io',terminalHost(Terminal,null)),/IO failed/i); await s.compile(`!import ${math.manifest} as N\n!export answer\nloop x = loop x\nanswer = N.add 40 2`); equal(await value('answer'),'42','Unused divergent function is never normalized'); const failures=[ diff --git a/ext/demo-site/test/probe-api.mjs b/ext/demo-site/test/probe-api.mjs @@ -9,10 +9,10 @@ export * as codecs from './codecs.js'; export * as literalInput from './literal-input.js'; export { editorFor } from './source-editor.js'; -export * as ioV2Tasks from './io-v2-tasks.js'; -export * as ioV2HostWork from './io-v2-host-work.js'; -export * as ioV2Resources from './io-v2-resources.js'; +export * as ioTasks from './io-tasks.js'; +export * as ioHostWork from './io-host-work.js'; +export * as ioResources from './io-resources.js'; -export * as ioV2TerminalView from './io-v2-terminal-view.js'; +export * as ioTerminalView from './io-terminal-view.js'; -export * as ioV2Terminal from './io-v2-terminal.js'; +export * as ioTerminal from './io-terminal.js'; diff --git a/ext/demo-site/test/site.test.mjs b/ext/demo-site/test/site.test.mjs @@ -55,7 +55,7 @@ test('conformance workers retain unbounded execution and shared transport limits test('inline IO has explicit authority, revision checks and full-result actions',async()=>{ const html=await read('index.html'),app=await read('src/app.ts'),inspector=await read('src/definition-inspector.ts'),worker=await read('src/wasm-worker.ts'); assert.doesNotMatch(html,/id="run"|id="stop-run"|id="mode"|id="arguments"|id="output-type"/); - for(const token of ['run-io','stop-io','terminal-v2','step-mode','next','Returned value','Copy','Download'])assert.ok(inspector.includes(token),token); + for(const token of ['run-io','stop-io','terminal-io','step-mode','next','Returned value','Copy','Download'])assert.ok(inspector.includes(token),token); assert.doesNotMatch(inspector,/input-form|line-input|tricuIO v1/); assert.match(inspector,/io\.run\(selected/);assert.match(inspector,/ioDetected/);assert.match(inspector,/valueText\(value\)/);assert.match(inspector,/saved\.text/); assert.match(app,/editRevision !== compiledRevision/);assert.match(worker,/session\.probeIO\(name\)/);assert.match(worker,/session\.runDefinitionIO/); diff --git a/ext/demo-site/test/terminal-host.mjs b/ext/demo-site/test/terminal-host.mjs @@ -11,7 +11,7 @@ export function terminalHost(Terminal, input) { if(snapshot.error.bytes.length)throw Error('unexpected stderr'); return new TextDecoder('utf-8',{fatal:true}).decode(new Uint8Array(snapshot.output.bytes)); }, - v2:{ + structured:{ open(run,command){submitted=false;settled=false;terminal=new Terminal(run);send=command;}, event(event){ let reply=terminal.handle(event); diff --git a/ext/haskell-wasm/BrowserRuntime.hs b/ext/haskell-wasm/BrowserRuntime.hs @@ -4,7 +4,7 @@ module BrowserRuntime where import Runtime.Tree import Runtime.Value -import qualified IOV2.Wire as Wire +import qualified IO.Wire as Wire import Control.Exception (evaluate) import Control.Monad (unless, forM_) import Data.Array (Array, listArray, (!), elems) @@ -184,8 +184,8 @@ ioEnvelope root=do -- Private embedding inspection, not an IO start or capability claim. Only a -- computation header is classified; arguments and terminal payloads stay lazy. -ioV2Header :: T -> IO J -ioV2Header tree = case Wire.decodeAction tree of +ioHeader :: T -> IO J +ioHeader tree = case Wire.decodeAction tree of Left message -> fail message Right (Wire.Core code _) -> pure (O [("kind",S "core"),("code",N code)]) Right (Wire.Request group revision operation _) -> pure (O diff --git a/ext/haskell-wasm/README.md b/ext/haskell-wasm/README.md @@ -4,7 +4,7 @@ These opt-in targets compile the production `Runtime.Tree` and `Runtime.Value` modules unchanged for native GHC and upstream GHC WASM. Command-line probes measure reduction and the site's pinned portable compiler. The browser target adds an isolated package/compiler session with the site's codecs, inspection/export -and structured IO v2 terminal profile. +and structured IO terminal profile. ```sh nix build .#haskellWasmProbe --out-link /tmp/tricu-haskell-wasm-probe @@ -236,9 +236,9 @@ protocol: | `preview` / `snapshot` | `name`, typed `arguments`, optional `limit`; returns `{root,nodes,aliases,truncated}`. | | `export` | `name`, `format` (`ternary`, `tricu`, `triangle`, `dag`); returns the shared exporter's `TreeFile`. Stored bundles are exported without evaluation; compiled definitions require a complete snapshot. | | `io-start` / `io-definition-start` | Select a canonical v2 program with `selectIO:true`; return its run identity without executing terminal work. | -| `io-v2-session-turn` | Run-bound `poll`, `reply` or `stop`; advances the structured session and returns cloneable events/outcome. | -| `io-v2-public-view` | Decode a completed result for the selected run. | -| `io-v2-public-clear` | Release an unstarted or settled run while retaining definitions. Not an emergency cancellation operation. | +| `io-session-turn` | Run-bound `poll`, `reply` or `stop`; advances the structured session and returns cloneable events/outcome. | +| `io-public-view` | Decode a completed result for the selected run. | +| `io-public-clear` | Release an unstarted or settled run while retaining definitions. Not an emergency cancellation operation. | | `reset` | Drop idle definitions and collect, retaining the compiler. | | `collect` | Explicit idle collection; active sessions retain their own tree roots. | | `stats` | Actual WASM `linearMemoryBytes`. | @@ -251,7 +251,7 @@ selection and checks its compiler pin against ready's `compilerHash`. ### Structured browser IO -`BrowserTrees.hs` retains native trees while the shared v2 scheduler interprets +`BrowserTrees.hs` retains native trees while the shared IO scheduler interprets core/task and terminal operations. `BrowserRuntime.hs` handles tree observation and codecs. Filesystem, networking and process facilities require native hosts. diff --git a/ext/haskell-wasm/Reactor.hs b/ext/haskell-wasm/Reactor.hs @@ -313,19 +313,19 @@ request ptr len = guardRequest $ do defs<-readIORef expressionBindings term<-maybe (fail "missing definition") pure (lookup name defs) beginSelectedIO term - ("io-v2-tree-start",name:dag:roots)->do + ("io-tree-start",name:dag:roots)->do requireIdle term<-select name dag roots value<-BT.begin term reply("ok\n"++R.json value) - ("io-v2-tree",fields)->do + ("io-tree",fields)->do decoded<-mapM R.utf8 fields value<-BT.command decoded reply("ok\n"++R.json value) - ("io-v2-header",name:dag:roots)->do + ("io-header",name:dag:roots)->do requireIdle term<-select name dag roots - value<-R.ioV2Header term + value<-R.ioHeader term reply("ok\n"++R.json value) ("io-start",name:dag:roots)->do requireIdle diff --git a/ext/haskell-wasm/browser-parity.mjs b/ext/haskell-wasm/browser-parity.mjs @@ -1,6 +1,6 @@ import {CompilerWorker} from './client.mjs'; -import {drive} from './site/io-v2-driver.js'; -import {Terminal} from './site/io-v2-terminal.js'; +import {drive} from './site/io-driver.js'; +import {Terminal} from './site/io-terminal.js'; import {treeResult} from './site/tree-result.js'; import {terminalHost} from './terminal-host.mjs'; import {snapshotBundle,exportedTree} from './bridge.mjs'; @@ -24,12 +24,12 @@ export async function runParity(){ const host=terminalHost(Terminal,input),run=selected.run; try { const value=await drive({run,signal:worker.signal,discard:()=>worker.dispose(), - turn:command=>call({type:'io-v2-session-turn',...command}), - view:async()=>treeResult((await call({type:'io-v2-public-view',run,output:'ternary'})).value,'ternary'), + turn:command=>call({type:'io-session-turn',...command}), + view:async()=>treeResult((await call({type:'io-public-view',run,output:'ternary'})).value,'ternary'), },host); report.ioRuns++; return {terminal:host.terminalText,tree:value.value}; - }finally{if(!worker.disposed)await call({type:'io-v2-public-clear',run});} + }finally{if(!worker.disposed)await call({type:'io-public-clear',run});} } try{ const ready=await w.request({type:'init',compiler:false}); @@ -86,7 +86,7 @@ export async function runParity(){ assert(stored.extension==='dag'&&stored.bytes>0,'Stored definition export'); } report.checks.push('all curated published packages and Haskell result/terminal goldens'); - for(const [name,pattern] of [['bad-version',/version/],['unsupported-io',/IO v2 failed/],['abort-io',/IO v2 failed/],['retired-output',/version/],['retired-actions',/version/]]){ + for(const [name,pattern] of [['bad-version',/version/],['unsupported-io',/IO failed/],['abort-io',/IO failed/],['retired-output',/version/],['retired-actions',/version/]]){ await load(name); await rejects(()=>runIO('root',[],''),pattern); } @@ -96,15 +96,15 @@ export async function runParity(){ await load('greet'); const selected=await request({type:'io-start',selectIO:true,name:demos.demos.greet.haskellCases[0].export,arguments:[]}); await rejects(()=>load('identity'),/pending/); - await rejects(()=>request({type:'io-v2-session-turn',run:'0',kind:'poll'}),/Stale/); - const host=terminalHost(Terminal,null),open=host.v2.open; - host.v2.open=(run,command)=>{open(run,command);command({run,kind:'stop'});}; + await rejects(()=>request({type:'io-session-turn',run:'0',kind:'poll'}),/Stale/); + const host=terminalHost(Terminal,null),open=host.structured.open; + host.structured.open=(run,command)=>{open(run,command);command({run,kind:'stop'});}; await rejects(()=>drive({run:selected.run,signal:w.signal,discard:()=>w.dispose(), - turn:command=>request({type:'io-v2-session-turn',...command}), + turn:command=>request({type:'io-session-turn',...command}), view:async()=>{throw Error('Cancelled result was decoded');}, },host),/cancelled/); - await request({type:'io-v2-public-clear',run:selected.run}); - await rejects(()=>request({type:'io-v2-session-turn',run:selected.run,kind:'poll'}),/Stale/); + await request({type:'io-public-clear',run:selected.run}); + await rejects(()=>request({type:'io-session-turn',run:selected.run,kind:'poll'}),/Stale/); await load('identity'); equal((await request({type:'evaluate',name:id,arguments:[{type:'string',value:'recovered'}],output:'string'})).value,'recovered','IO errors poisoned runtime'); report.checks.push('v2 failed diagnostics; stale run rejection; acknowledged cancellation and recovery; legacy limit fixtures reject inertly'); @@ -133,7 +133,7 @@ export async function runParity(){ await c.request({type:'compile',source:'q x = x x\nlazy = t (q q) t\naborted = t "tricuIO" (t 2 (t 2 (q q)))'},null); const partial=JSON.parse((await c.request({type:'preview',name:'lazy',limit:1},2000)).text); assert(partial.nodes.length===1&&partial.truncated,'Preview forced excluded divergent child'); - await rejects(()=>runIO('aborted',[],'',c),/IO v2 failed/); + await rejects(()=>runIO('aborted',[],'',c),/IO failed/); report.checks.push('all native demo sources compile with explicit imports; compiled graph-preserving export; environment authentication; ready compiler pin'); report.checks.push('preview and terminal failure do not force excluded divergent payloads; null/Infinity disable request timer'); }finally{c.dispose();} diff --git a/ext/haskell-wasm/browser-worker.mjs b/ext/haskell-wasm/browser-worker.mjs @@ -1,6 +1,6 @@ import {WASI,File,OpenFile,ConsoleStdout,PreopenDirectory} from './shim/index.js'; -import {Session} from './site/io-v2-session.js'; -import {constructors,runPrivate} from './site/io-v2-private.js'; +import {Session} from './site/io-session.js'; +import {constructors,runPrivate} from './site/io-private.js'; import pins,{compilerHash} from './pins.mjs'; import {moduleDependencies} from './site/module-stage.js'; import {fetchBytes} from './site/bundle.js'; @@ -81,18 +81,18 @@ onmessage=async({data:m})=>{ phases={data_ms:fetched-begin,wasm_fetch_compile_ms:compiled-fetched,instantiate_rts_ms:initialized-compiled,load_ms:performance.now()-initialized}; }else{ if(!instance)throw Error('Not initialized'); - if(privateSession&&!['io-v2-session-turn','io-v2-public-view','io-v2-public-clear'].includes(m.type))await safe(()=>{throw Error('Private v2 session is pending');}); + if(privateSession&&!['io-session-turn','io-public-view','io-public-clear'].includes(m.type))await safe(()=>{throw Error('Private IO session is pending');}); if(m.type==='compiler-init'){ if(!compilerReady){ const files=await safe(()=>Promise.all(Object.keys(pins).map(async name=>[name,await checkedFile(name)]))); for(const [name,file] of files)dataFiles.set(name,file); bytes=call('tricu_initialize');compilerReady=true; }else bytes=encoder.encode('ready').buffer; - }else if(m.type==='io-v2-session-start'){ + }else if(m.type==='io-session-start'){ const args=await safe(()=>application(m.name,m.arguments)); if(privateRun===0xffffffffffffffffn)throw Error('Run identity exhausted'); - const start=JSON.parse(decoder.decode(operation(['io-v2-tree-start',...args]))); - const invoke=(name,...fields)=>JSON.parse(decoder.decode(operation(['io-v2-tree',name,...fields.map(String)]))); + const start=JSON.parse(decoder.decode(operation(['io-tree-start',...args]))); + const invoke=(name,...fields)=>JSON.parse(decoder.decode(operation(['io-tree',name,...fields.map(String)]))); try{ const tree=constructors({leaf:start.leaf,observe:h=>invoke('observe',h),stem:h=>invoke('stem',h),fork:(a,b)=>invoke('fork',a,b),app:(a,b)=>invoke('app',a,b)}); const run=String(++privateRun),session=new Session(tree,start.action,run,roots=>invoke('retain',...new Set(roots))); @@ -100,29 +100,29 @@ onmessage=async({data:m})=>{ const report=await session.turn({run,kind:'poll'});bytes=encode(report); if(report.done){privateSession.clear();privateSession=null;} }catch(error){privateSession=null;try{invoke('clear');}catch{}throw error;} - }else if(m.type==='io-v2-public-view'){ - await safe(()=>{if(!privateSession?.public||m.run!==privateSession.session.run||privateSession.session.core.result?.kind!=='completed')throw Error('No completed v2 result');}); + }else if(m.type==='io-public-view'){ + await safe(()=>{if(!privateSession?.public||m.run!==privateSession.session.run||privateSession.session.core.result?.kind!=='completed')throw Error('No completed IO result');}); bytes=encode(privateSession.view(m.output)); - }else if(m.type==='io-v2-public-clear'){ - await safe(()=>{if(!privateSession?.public||m.run!==privateSession.session.run||privateSession.started&&!privateSession.session.core.result)throw Error('V2 cleanup is pending');}); + }else if(m.type==='io-public-clear'){ + await safe(()=>{if(!privateSession?.public||m.run!==privateSession.session.run||privateSession.started&&!privateSession.session.core.result)throw Error('HostIO cleanup is pending');}); privateSession.clear();privateSession=null;bytes=encode({}); - }else if(m.type==='io-v2-session-turn'){ + }else if(m.type==='io-session-turn'){ await safe(()=>{if(!privateSession||m.run!==privateSession.session.run)throw Error('Stale session run');if(!['poll','reply','stop'].includes(m.kind))throw Error('Invalid session command');}); privateSession.started=true; const report=await privateSession.session.turn({run:m.run,kind:m.kind,reply:m.reply});bytes=encode(report); if(report.done&&!privateSession.public){privateSession.clear();privateSession=null;} - }else if(m.type==='io-v2-core'){ + }else if(m.type==='io-core'){ const args=await safe(()=>application(m.name,m.arguments)); if(privateRun===0xffffffffffffffffn)throw Error('Run identity exhausted'); - const start=JSON.parse(decoder.decode(operation(['io-v2-tree-start',...args]))); - const invoke=(name,...fields)=>JSON.parse(decoder.decode(operation(['io-v2-tree',name,...fields.map(String)]))); + const start=JSON.parse(decoder.decode(operation(['io-tree-start',...args]))); + const invoke=(name,...fields)=>JSON.parse(decoder.decode(operation(['io-tree',name,...fields.map(String)]))); try{ const tree=constructors({leaf:start.leaf,observe:h=>invoke('observe',h),stem:h=>invoke('stem',h),fork:(a,b)=>invoke('fork',a,b),app:(a,b)=>invoke('app',a,b)}); bytes=encode(await runPrivate(tree,start.action,++privateRun,roots=>invoke('retain',...new Set(roots)))); }finally{invoke('clear');} - }else if(m.type==='io-v2-header'){ + }else if(m.type==='io-header'){ const args=await safe(()=>application(m.name,m.arguments)); - bytes=operation(['io-v2-header',...args]); + bytes=operation(['io-header',...args]); }else if(m.type==='expression'){ await safe(()=>{scalarText(m.source,16384);if(typeof m.name!=='string'||!/^browserExpression[0-9]+$/.test(m.name)||!['pure','io'].includes(m.mode))throw Error('Invalid expression request');}); bytes=operation(['expression',m.name,m.source,m.mode,m.output]); @@ -213,12 +213,12 @@ onmessage=async({data:m})=>{ if(selected.kind==='v2'){ if(privateRun===0xffffffffffffffffn)throw Error('Run identity exhausted'); const operation=fields=>call('tricu_request',frame(fields)); - const invoke=(name,...fields)=>JSON.parse(decoder.decode(operation(['io-v2-tree',name,...fields.map(String)]))); + const invoke=(name,...fields)=>JSON.parse(decoder.decode(operation(['io-tree',name,...fields.map(String)]))); if(m.selectIO!==true){invoke('clear');const error=Error('Unsupported tricuIO version for legacy worker client');error.recoverable=true;throw error;} try{ const start=selected.tree,run=String(++privateRun); const tree=constructors({leaf:start.leaf,observe:h=>invoke('observe',h),stem:h=>invoke('stem',h),fork:(a,b)=>invoke('fork',a,b),app:(a,b)=>invoke('app',a,b)}); - const session=new Session(tree,start.action,run,roots=>invoke('retain',...new Set(roots)),{summarize:false,steps:m.v2Step===true?1:128,backend:'haskell-wasm'}); + const session=new Session(tree,start.action,run,roots=>invoke('retain',...new Set(roots)),{summarize:false,steps:m.ioStep===true?1:128,backend:'haskell-wasm'}); privateSession={session,public:true,started:false,clear:()=>invoke('clear'),view:output=>invoke('view',session.core.result.value,output)}; bytes=encoder.encode(JSON.stringify({kind:'v2',run})).buffer; }catch(error){privateSession=null;try{invoke('clear');}catch{}throw error;} diff --git a/ext/haskell/README.md b/ext/haskell/README.md @@ -1,6 +1,6 @@ # Native Haskell embedding -The Haskell shared library exposes tricu trees and the native IO-v2 driver through +The Haskell shared library exposes tricu trees and the native IO driver through [`include/tricu.h`](include/tricu.h), embedding ABI 1. It uses the same `Runtime.Tree`, value codecs, bundle verifier and IO driver as the Haskell executable. Native consumers use the @@ -13,7 +13,7 @@ executable. Native consumers use the nix build .#tricu-haskell-ffi --no-link EMBED=$(nix path-info .#tricu-haskell-ffi) -# Compiled IO-v2 copier and a two-module portable compiler job +# Compiled IO copier and a two-module portable compiler job nix build .#tricu-haskell-ffi-integration --no-link CHECK=$(nix path-info .#tricu-haskell-ffi-integration) cat "$CHECK/integration.json" "$CHECK/compiler.json" @@ -52,7 +52,7 @@ cat "$CHECK/compatibility/report.json" "$CHECK/programs/report.json" "$CHECK/sco ``` The generation runner accepts `--execution-host haskell-ffi --haskell-library PATH` -with explicit IO-v2 kit selection. A fresh execution process authenticates the +with explicit IO kit selection. A fresh execution process authenticates the captured compiler/adapter, six arguments, runtime, executor and binding identities before compiler effects. Its one-capability RTS is independent of CLI transport capabilities. External CPU/address-space/RSS/wall guards bound the job; compiler @@ -234,7 +234,7 @@ Handles created directly through the context remain caller-owned. `Context.run_io(program)` returns `(outcome, value_handle, cleanup_handle)`. Outcome `0` means completed, `1` failed, and `2` cancelled. A completed value may itself be an ordinary error Result. Failure/cancellation reasons remain opaque -lazy trees; cleanup is a list of IO-v2 problem values. Both returned handles are +lazy trees; cleanup is a list of IO problem values. Both returned handles are owned, including an empty cleanup list. Python boundary failures raise `TricuError` with the native status and diagnostic text. @@ -271,7 +271,7 @@ make concurrent calls through the threaded RTS. Context destruction waits for an active call; subsequent calls return invalid-context. Calls provide no callbacks or reentrant entry. Native IO uses OS process authority and shares process standard streams, environment and other process resources; contexts are not -security isolation. See the [IO-v2 contract](../../test/io-v2/README.md), especially +security isolation. See the [IO contract](../../test/io/README.md), especially standard-stream ownership and per-run lease exclusion. ## Demand and errors @@ -312,7 +312,7 @@ publication, concurrent entry, repeated context teardown, contained exceptions, lazy application/discard, and opaque task diagnostics. Installed-library tests exercise the packaged runtime paths in a new Python process. -The integration target compiles the existing IO-v2 copier and checks repeated +The integration target compiles the existing IO copier and checks repeated 64-KiB binary copies, missing-source and missing-parent Results, transferred-resource cancellation, and descriptor counts on Linux. Its compiler probe runs a cold two-module build through the shared library and through diff --git a/ext/haskell/src/Embedding.hs b/ext/haskell/src/Embedding.hs @@ -13,8 +13,8 @@ import Foreign.C.Types import Runtime.Value (ofList) import qualified Wire import qualified IODriver -import qualified IOV2.Driver as IO -import qualified IOV2.Protocol as Protocol +import qualified IO.Driver as IO +import qualified IO.Protocol as Protocol loadBundle :: Word64 -> Ptr Word8 -> CSize -> Ptr Word8 -> CSize -> Ptr Word64 -> IO Word32 loadBundle ctx ptr len namePtr nameLen out = withContext ctx $ do diff --git a/ext/js/README.md b/ext/js/README.md @@ -40,7 +40,7 @@ Contexts and handles are `bigint` IDs. `number` accepts exact safe JS integers o its outer constructor. Observation/codecs also demand the parts they observe. `loadBundle(bytes, exportName)` verifies a bundle and selects an explicit export. -`runIO(program)` runs the built-in native IO-v2 driver and returns +`runIO(program)` runs the built-in native IO driver and returns `{outcome, value, cleanup}`. Outcome 0/1/2 means completed/failed/cancelled; value and cleanup are independently owned handles. Failure reasons stay lazy. Ordinary error Results can be completion values. Boundary failures throw `TricuError` with diff --git a/flake.nix b/flake.nix @@ -122,23 +122,23 @@ arboricxServerBundle = mkRegistryBundle "arboricx-server" "${./.}/tricu-apps/arboricxServer.tri"; arboricxServerRunner = mkRegistryV2Runner "arboricx-server" arboricxServerBundle; - arboricxStreamingV2Bundle = mkRegistryBundle "arboricx-streaming-v2" "${./.}/tricu-apps/arboricxStreamingV2.tri"; + arboricxStreamingBundle = mkRegistryBundle "arboricx-streaming-v2" "${./.}/tricu-apps/arboricxStreaming.tri"; mkRegistryV2Runner = name: bundle: pkgs.writeShellScriptBin name '' exec ${tricuPackage}/bin/tricu arboricx run \ --module "$(${pkgs.coreutils}/bin/cat ${bundle}/manifest)" --name main \ --store ${bundle}/runtime --io --no-result \ +RTS -N1 -M2048m -RTS ''; - arboricxStreamingV2Runner = mkRegistryV2Runner "arboricx-streaming-v2" arboricxStreamingV2Bundle; - arboricxStreamingV2FaultBundle = mkRegistryBundle "arboricx-streaming-v2-fault" "${./.}/test/io-v2/registry-log-failure.tri"; - arboricxStreamingV2FaultRunner = mkRegistryV2Runner "arboricx-streaming-v2-fault" arboricxStreamingV2FaultBundle; + arboricxStreamingRunner = mkRegistryV2Runner "arboricx-streaming-v2" arboricxStreamingBundle; + arboricxStreamingFaultBundle = mkRegistryBundle "arboricx-streaming-v2-fault" "${./.}/test/io/registry-log-failure.tri"; + arboricxStreamingFaultRunner = mkRegistryV2Runner "arboricx-streaming-v2-fault" arboricxStreamingFaultBundle; mkRegistryV2Test = name: runner: pkgs.runCommand "${name}-test" { nativeBuildInputs = [ pkgs.python3 ]; } '' mkdir -p $out python3 ${./test/arboricx_deployment.py} ${runner}/bin/${name} \ --io-version 2 --handler-policy \ - --fault-runner ${arboricxStreamingV2FaultRunner}/bin/arboricx-streaming-v2-fault \ + --fault-runner ${arboricxStreamingFaultRunner}/bin/arboricx-streaming-v2-fault \ --report $out/report.json python3 ${./test/streaming_protocol.py} ${runner}/bin/${name} \ --io-version 2 > $out/protocol.txt @@ -226,7 +226,7 @@ mkdir -p $out workspace cp ${tricuSource}/lib/base.tri workspace/base.tri cp ${tricuSource}/lib/math/native.tri workspace/native_math.tri - cp ${tricuSource}/lib/io-v2.tri workspace/io_v2.tri + cp ${tricuSource}/lib/io.tri workspace/io.tri cp ${tricuSource}/demos/python-filecheck/worker.tri workspace/worker.tri tricu arboricx compile --file workspace/worker.tri --store build-store \ --output $out/worker.arboricx --compile-timeout 120 +RTS -N1 -M1024m -RTS @@ -379,7 +379,7 @@ --tricu ${tricuPackage}/bin/tricu \ --library ${tricuHaskell}/lib/libtricu-haskell${pkgs.stdenv.hostPlatform.extensions.sharedLibrary} \ --directory $out/programs - python3 ${tricuSource}/test/io_v2_generation.py ${tricuPackage}/bin/tricu \ + python3 ${tricuSource}/test/io_generation.py ${tricuPackage}/bin/tricu \ --kit ${tricuSource}/bootstrap/rebuild \ --haskell-library ${tricuHaskell}/lib/libtricu-haskell${pkgs.stdenv.hostPlatform.extensions.sharedLibrary} \ --directory $out/scoped @@ -445,9 +445,9 @@ mkdir -p $out workspace cp ${tricuSource}/lib/base.tri workspace/base.tri cp ${tricuSource}/lib/math/native.tri workspace/native_math.tri - cp ${tricuSource}/lib/io-v2.tri workspace/io_v2.tri + cp ${tricuSource}/lib/io.tri workspace/io.tri cp ${tricuSource}/lib/handlers.tri workspace/handlers.tri - cp ${tricuSource}/demos/io-v2/copy.tri workspace/copy.tri + cp ${tricuSource}/demos/io/copy.tri workspace/copy.tri tricu arboricx compile --file workspace/copy.tri --store copy-store \ --output $out/copy.arboricx --compile-timeout 120 +RTS -N1 -M1024m -RTS library=${self.packages.${system}.tricu-haskell-ffi}/lib/libtricu-haskell${pkgs.stdenv.hostPlatform.extensions.sharedLibrary} @@ -556,14 +556,14 @@ # Current-source storage semantics and allocation guard, compiled with # the packaged stable compiler. Full compiler-pair acceptance is separate. - packages.ioV2StorageTest = pkgs.runCommand "tricu-io-v2-storage-test" { + packages.ioStorageTest = pkgs.runCommand "tricu-io-storage-test" { nativeBuildInputs = [ pkgs.python3 ]; } '' mkdir -p $out export PYTHONDONTWRITEBYTECODE=1 - python3 ${./.}/test/io_v2_compiler_store.py ${tricuPackage}/bin/tricu \ + python3 ${./.}/test/io_compiler_store.py ${tricuPackage}/bin/tricu \ --directory $out/controls > $out/controls.log - python3 ${./.}/test/io_v2_storage_cost.py ${tricuPackage}/bin/tricu \ + python3 ${./.}/test/io_storage_cost.py ${tricuPackage}/bin/tricu \ --directory $out/cost > $out/cost.log ''; @@ -579,6 +579,21 @@ mkdir -p $out ''; + checks.currentIONames = pkgs.runCommand "tricu-current-io-names" { + nativeBuildInputs = [ pkgs.python3 ]; + } '' + python3 ${tricuSource}/test/current_io_names.py + mkdir -p $out + ''; + + packages.libraryFormatting = pkgs.runCommand "tricu-library-formatting" { + nativeBuildInputs = [ pkgs.python3 ]; + } '' + export PYTHONDONTWRITEBYTECODE=1 + python3 ${tricuSource}/test/prepare_formatter.py ${tricuPackage}/bin/tricu --directory $out/formatter + python3 ${tricuSource}/test/library_format.py ${tricuPackage}/bin/tricu --formatter-directory $out/formatter + ''; + checks.runtimeBoundary = pkgs.runCommand "tricu-runtime-source-boundary" {} '' test -f ${runtimeSource}/src/Runtime/Tree.hs for path in Research.hs Eval.hs FileEval.hs Lexer.hs Parser.hs \ @@ -682,8 +697,8 @@ # Opt-in acceptance runs the actual default entrypoint and common v2 gates. packages.arboricxServerTest = mkRegistryV2Test "arboricx-server" arboricxServerRunner; - packages.arboricxStreamingV2Runner = arboricxStreamingV2Runner; - packages.arboricxStreamingV2Test = mkRegistryV2Test "arboricx-streaming-v2" arboricxStreamingV2Runner; + packages.arboricxStreamingRunner = arboricxStreamingRunner; + packages.arboricxStreamingTest = mkRegistryV2Test "arboricx-streaming-v2" arboricxStreamingRunner; packages.arboricxServerBundle = arboricxServerBundle; packages.arboricxServerRunner = arboricxServerRunner; packages.arboricxServer = pkgs.dockerTools.buildImage { diff --git a/lib/arboricx/handlers.tri b/lib/arboricx/handlers.tri @@ -3,15 +3,15 @@ !import conversions as Convert !import arboricx_server as Store !import handlers as H -!import io_v2 as V2 -!import io_v2_storage as Storage +!import io as HostIO +!import io_storage as Storage !import handler_log as Log !import handler_artifacts as Artifacts !export request -!export packageLookupV2 -!export stdoutLogV2 -!export writeLogV2 -!export routerV2 +!export packageLookup +!export stdoutLog +!export writeLog +!export router -- HTTP policy is separate from storage, logging, and the socket lifecycle. -- Outcomes carry a status beside the complete response so logging does not @@ -68,41 +68,41 @@ request lookup method target headers body = H.label "registry-request" <| do H.b _ = H.fail "application-failure" "registry" "route" "Invalid route outcome" -- The captured package service remains independent of contextual artifact routes. -packageLookupV2 root limit target = case Store.packageTarget target of +packageLookup root limit target = case Store.packageTarget target of (t (name, version)) = H.liftIO (Storage.readBytes (Store.pathJoin (Store.pathJoin (Store.pathJoin root "packages") name) version) limit) _ = H.pure (B.err ["invalid-package" "Invalid package name/version"] t) -- Status-only ASCII logging. Ordinary write failures remain Results; publication -- precedes logging and remains visible if the logger fails. -writeLogV2 bytes = do V2.onOk - writer <- V2.stdout - V2.writeAll writer bytes - V2.pure (B.ok t t) -stdoutLogV2 ["info" fields status] = case B.checkContract B.nat? status of - ((t t), _) = H.liftIO (writeLogV2 +writeLog bytes = do HostIO.onOk + writer <- HostIO.stdout + HostIO.writeAll writer bytes + HostIO.pure (B.ok t t) +stdoutLog ["info" fields status] = case B.checkContract B.nat? status of + ((t t), _) = H.liftIO (writeLog (B.append "registry status=" (B.append (Convert.showNumber status) "\n"))) _ = H.pure (B.err "Invalid access-log status" t) -stdoutLogV2 _ = H.pure (B.err "Invalid access-log event" t) +stdoutLog _ = H.pure (B.err "Invalid access-log event" t) -- A supervised child contains request interpretation/logger failure without -- weakening native fail-fast semantics or retaining completed task tickets. -- Cancellation remains cancellation; a failure becomes a redacted HTTP response. -- The listener owns sockets and framing. -routerV2 root limit sink method target headers body = V2.scope (_ : do V2.require - spawned <- V2.spawn 1 1 [] (_ : H.runIOV2 - (request (packageLookupV2 root limit) method target headers body +router root limit sink method target headers body = HostIO.scope (_ : do HostIO.require + spawned <- HostIO.spawn 1 1 [] (_ : H.runIO + (request (packageLookup root limit) method target headers body |> Log.withContext [["method" method] ["target" target]] - |> Artifacts.withFilesystemV2 root limit + |> Artifacts.withFilesystem root limit |> Log.withSink sink)) case spawned of - [task ticket] = do V2.require - outcome <- V2.join ticket + [task ticket] = do HostIO.require + outcome <- HostIO.join ticket case outcome of - ["completed" response] = V2.pure response - ["failed" _ _] = do V2.bind - writeLogV2 "registry request failed\n" - V2.pure (HTTP.errorResponse 500 "Internal Server Error\n") - ["cancelled" reason _] = V2.cancelled reason - _ = V2.fail "Invalid registry task outcome" - _ = V2.fail "Invalid registry spawn reply") + ["completed" response] = HostIO.pure response + ["failed" _ _] = do HostIO.bind + writeLog "registry request failed\n" + HostIO.pure (HTTP.errorResponse 500 "Internal Server Error\n") + ["cancelled" reason _] = HostIO.cancelled reason + _ = HostIO.fail "Invalid registry task outcome" + _ = HostIO.fail "Invalid registry spawn reply") diff --git a/lib/arboricx/streaming-v2.tri b/lib/arboricx/streaming-v2.tri @@ -1,158 +0,0 @@ -!import base as B -!import native_math as N -!import io_v2 as IO -!import io_v2_http as Transport -!import io_v2_artifacts as Stream -!import http as HTTP -!import handlers as H -!import handler_artifacts as Artifacts -!import handler_log as Log -!import arboricx_handlers as Registry -!import arboricx_server as Store -!import conversions as Convert -!export pump -!export connection -!export serve -!export serveCount - -pump _ 0 _ = H.pure (B.ok t t) -pump client remaining initial = let - limit = N.minimum remaining 4096 - consume (unused, (reversed, rest)) = case reversed of - [] = H.bind (H.liftIO (IO.readSome client limit)) (reply : case reply of - ((t t), (("data", chunk), t)) = pump client remaining chunk - _ = H.pure (B.err "Unexpected end of request body" t)) - _ = H.bind (Stream.write (B.reverse reversed)) (reply : case reply of - ((t t), (t, t)) = pump client (N.subtract remaining (N.subtract limit unused)) rest - _ = H.pure reply) - consume _ = H.fail "invalid-response" "artifact-stream" "buffer" "Invalid body state" - in consume (HTTP.takeBodyBytes initial limit []) - -logged sink status response = H.bind - (Log.withSink sink (Log.withContext [["method" "POST"] ["target" "/_arboricx/bundle"]] (Log.info status))) - (reply : case reply of - ((t t), (t, t)) = H.pure response - _ = H.pure (HTTP.errorResponse 500 "Internal Server Error\n")) -upload root sink client count initial = H.bind - (Artifacts.publishStreamV2 root count (H.label "streaming-upload" (pump client count initial))) - (reply : case reply of - ((t t), (digest, t)) = logged sink 201 (HTTP.createdResponse digest) - (t, (_, t)) = logged sink 400 (HTTP.badRequestResponse "Upload failed\n") - _ = H.fail "invalid-response" "artifact-stream" "publish" "Invalid upload Result") --- Supervised request interpretation retains the socket in its connection owner; --- borrowing does not extend lifetime. Join before responding, so cleanup failure --- cannot falsely acknowledge success. Cancellation is never rendered as HTTP 500. -response root sink client count initial = IO.scope (_ : IO.require - (IO.spawn 1 1 [] (_ : H.runIOV2 (upload root sink client count initial))) (spawned : case spawned of - [_ ticket] = IO.require (IO.join ticket) (outcome : case outcome of - ["completed" value] = IO.pure value - ["failed" _ _] = IO.pure (HTTP.errorResponse 500 "Internal Server Error\n") - ["cancelled" reason _] = IO.cancelled reason - _ = IO.fail "Invalid upload task outcome") - _ = IO.fail "Invalid upload spawn reply")) -boundedResponse root limit sink client count initial = case N.lte count limit of - (t t) = IO.bind (response root sink client count initial) (Transport.respond client "POST") - _ = Transport.respond client "POST" (HTTP.errorResponse 413 "Request body too large\n") -framed root limit sink client fields initial = case HTTP.contentLength fields of - ((t t), (t, _)) = boundedResponse root limit sink client 0 initial - ((t t), ((t count), _)) = boundedResponse root limit sink client count initial - (t, (status, message)) = Transport.respond client "POST" (HTTP.errorResponse status message) - _ = Transport.respond client "POST" (HTTP.badRequestResponse "Bad Request\n") -headers root limit sink client version raw = case HTTP.parseHeaders raw of - ((t t), (fields, initial)) = case HTTP.validateRequest "POST" "/_arboricx/bundle" version fields of - ((t t), _) = framed root limit sink client fields initial - (t, (status, message)) = Transport.respond client "POST" (HTTP.errorResponse status message) - _ = Transport.respond client "POST" (HTTP.badRequestResponse "Bad Request\n") - _ = Transport.respond client "" (HTTP.badRequestResponse "Bad Request\n") --- Log before committing headers. Supervision contains logger failures without --- letting a post-header transfer failure generate a second HTTP response. -downloadLog sink method target status = IO.scope (_ : IO.require - (IO.spawn 1 1 [] (_ : H.runIOV2 - (Log.withSink sink (Log.withContext [["method" method] ["target" target]] (Log.info status))))) - (spawned : case spawned of - [_ ticket] = IO.require (IO.join ticket) (outcome : case outcome of - ["completed" ((t t), (t, t))] = IO.pure (t t) - ["cancelled" reason _] = IO.cancelled reason - _ = IO.pure t) - _ = IO.fail "Invalid download logger task")) -downloadError sink client method target status message = IO.bind - (downloadLog sink method target status) (logged : case logged of - (t t) = Transport.respond client method (HTTP.errorResponse status message) - _ = Transport.respond client method (HTTP.errorResponse 500 "Internal Server Error\n")) -prepareDownload owner root limit digest = do IO.onOk - reader <- IO.openReader owner (Store.bundleObjectPath root digest) - snapshot <- IO.snapshotBytes reader (Store.pathJoin root "tmp") limit digest - IO.close reader - IO.pure (B.ok snapshot t) -sendDownload sink client method target [reader size] = IO.bind - (downloadLog sink method target 200) (logged : case logged of - (t t) = do IO.bind - Transport.respond client method (HTTP.buildResponse 200 - [(B.pair "Content-Type" Store.bundleContentType) - (B.pair "Content-Length" (Convert.showNumber size)) - (B.pair "Connection" "close")] []) - case method of - "HEAD" = IO.pure t - _ = IO.require (IO.transferBytes reader client size) (_ : IO.pure t) - _ = Transport.respond client method (HTTP.errorResponse 500 "Internal Server Error\n")) -sendDownload _ _ _ _ _ = IO.fail "Invalid snapshot reply" -download root limit sink client method target digest = case B.checkContract Store.bundleHashC digest of - ((t t), (checked, t)) = IO.scope (owner : IO.bind - (prepareDownload owner root limit checked) (reply : case reply of - ((t t), (snapshot, t)) = sendDownload sink client method target snapshot - _ = downloadError sink client method target 404 "Artifact unavailable\n")) - (t, (message, _)) = downloadError sink client method target 400 message - _ = IO.fail "Invalid digest contract reply" -downloadRoute root limit sink client method target fields bytes = case HTTP.routerMethod method of - "GET" = case Store.stripPrefix Store.bundleHashPrefix target of - (t digest) = download root limit sink client method target digest - _ = IO.bind (Registry.routerV2 root limit sink "GET" target fields bytes) (Transport.respond client method) - routed = IO.bind (Registry.routerV2 root limit sink routed target fields bytes) (Transport.respond client method) - -line root limit sink client ((t t), (("POST", ("/_arboricx/bundle", version)), raw)) = - headers root limit sink client version raw -line root limit sink client parsed = Transport.handleWith (downloadRoute root limit sink) client parsed -connection root limit sink client = IO.bind (Transport.recvHeaders client) (reply : case reply of - ((t t), (raw, _)) = line root limit sink client (HTTP.parseRequestLine raw) - (t, (431, _)) = Transport.respond client "" (HTTP.errorResponse 431 "Request Header Fields Too Large\n") - _ = Transport.respond client "" (HTTP.badRequestResponse "Bad Request\n")) - -worker root limit sink [client] = connection root limit sink client -worker _ _ _ _ = IO.fail "Invalid connection handoff" -settle ticket = IO.require (IO.join ticket) (outcome : case outcome of - ["completed" _] = IO.pure t - ["failed" _ _] = IO.bind (Registry.writeLogV2 "registry connection failed\n") (_ : IO.pure t) - ["cancelled" reason _] = IO.cancelled reason - _ = IO.fail "Invalid connection task outcome") -drain [] = IO.pure t -drain [ticket | rest] = IO.bind (settle ticket) (_ : drain rest) -drain _ = IO.fail "Invalid connection tickets" -next (t count) = B.just (N.predecessor count) -next _ = t --- FIFO bounded admission: consume the oldest ticket before accepting beyond the --- configured window. No discarded tickets, detached tasks or unbounded backlog in --- the language. A slow oldest connection can delay admission; no deadline promise. -admit _ _ _ _ _ _ (t 0) tickets = drain tickets -admit root limit sink owner listener window remaining tickets = case N.gte (B.length tickets) window of - (t t) = case tickets of - [ticket | rest] = IO.bind (settle ticket) (_ : admit root limit sink owner listener window remaining rest) - _ = IO.fail "Invalid admission window" - _ = IO.require (IO.acceptTCP listener owner) (client : IO.require - (IO.spawn 1 1 [client] (worker root limit sink)) (spawned : case spawned of - [_ ticket] = admit root limit sink owner listener window (next remaining) (B.append tickets [ticket]) - _ = IO.fail "Invalid connection spawn reply")) -start root limit sink address window remaining ready = case B.checkContract B.nat? limit of - ((t t), _) = startWindow root limit sink address window remaining ready - _ = IO.fail "Invalid artifact byte limit" -startWindow root limit sink address window remaining ready = case B.checkContract B.nat? window of - ((t t), _) = case B.and? (N.gte window 1) (N.lte window 256) of - (t t) = IO.scope (owner : do IO.require - H.runIOV2 (Artifacts.prepareFilesystemV2 root) - listener <- IO.listenTCP owner address 128 - IO.bind (ready listener) (_ : admit root limit sink owner listener window remaining [])) - _ = IO.fail "Connection window must be between 1 and 256" - _ = IO.fail "Invalid connection window" -serve root limit sink address window ready = start root limit sink address window t ready -serveCount root limit sink address window count ready = case B.checkContract B.nat? count of - ((t t), _) = start root limit sink address window (B.just count) ready - _ = IO.fail "Invalid connection count" diff --git a/lib/arboricx/streaming.tri b/lib/arboricx/streaming.tri @@ -1,4 +1,158 @@ -!export * from arboricx_streaming_v2 +!import base as B +!import native_math as N +!import io as IO +!import io_http as Transport +!import io_artifacts as Stream +!import http as HTTP +!import handlers as H +!import handler_artifacts as Artifacts +!import handler_log as Log +!import arboricx_handlers as Registry +!import arboricx_server as Store +!import conversions as Convert +!export pump +!export connection +!export serve +!export serveCount --- Canonical streaming registry composition: scoped TCP, source-owned publication, --- FIFO admission and consumable joins. No v1 scheduling or grant-policy branch. +pump _ 0 _ = H.pure (B.ok t t) +pump client remaining initial = let + limit = N.minimum remaining 4096 + consume (unused, (reversed, rest)) = case reversed of + [] = H.bind (H.liftIO (IO.readSome client limit)) (reply : case reply of + ((t t), (("data", chunk), t)) = pump client remaining chunk + _ = H.pure (B.err "Unexpected end of request body" t)) + _ = H.bind (Stream.write (B.reverse reversed)) (reply : case reply of + ((t t), (t, t)) = pump client (N.subtract remaining (N.subtract limit unused)) rest + _ = H.pure reply) + consume _ = H.fail "invalid-response" "artifact-stream" "buffer" "Invalid body state" + in consume (HTTP.takeBodyBytes initial limit []) + +logged sink status response = H.bind + (Log.withSink sink (Log.withContext [["method" "POST"] ["target" "/_arboricx/bundle"]] (Log.info status))) + (reply : case reply of + ((t t), (t, t)) = H.pure response + _ = H.pure (HTTP.errorResponse 500 "Internal Server Error\n")) +upload root sink client count initial = H.bind + (Artifacts.publishStream root count (H.label "streaming-upload" (pump client count initial))) + (reply : case reply of + ((t t), (digest, t)) = logged sink 201 (HTTP.createdResponse digest) + (t, (_, t)) = logged sink 400 (HTTP.badRequestResponse "Upload failed\n") + _ = H.fail "invalid-response" "artifact-stream" "publish" "Invalid upload Result") +-- Supervised request interpretation retains the socket in its connection owner; +-- borrowing does not extend lifetime. Join before responding, so cleanup failure +-- cannot falsely acknowledge success. Cancellation is never rendered as HTTP 500. +response root sink client count initial = IO.scope (_ : IO.require + (IO.spawn 1 1 [] (_ : H.runIO (upload root sink client count initial))) (spawned : case spawned of + [_ ticket] = IO.require (IO.join ticket) (outcome : case outcome of + ["completed" value] = IO.pure value + ["failed" _ _] = IO.pure (HTTP.errorResponse 500 "Internal Server Error\n") + ["cancelled" reason _] = IO.cancelled reason + _ = IO.fail "Invalid upload task outcome") + _ = IO.fail "Invalid upload spawn reply")) +boundedResponse root limit sink client count initial = case N.lte count limit of + (t t) = IO.bind (response root sink client count initial) (Transport.respond client "POST") + _ = Transport.respond client "POST" (HTTP.errorResponse 413 "Request body too large\n") +framed root limit sink client fields initial = case HTTP.contentLength fields of + ((t t), (t, _)) = boundedResponse root limit sink client 0 initial + ((t t), ((t count), _)) = boundedResponse root limit sink client count initial + (t, (status, message)) = Transport.respond client "POST" (HTTP.errorResponse status message) + _ = Transport.respond client "POST" (HTTP.badRequestResponse "Bad Request\n") +headers root limit sink client version raw = case HTTP.parseHeaders raw of + ((t t), (fields, initial)) = case HTTP.validateRequest "POST" "/_arboricx/bundle" version fields of + ((t t), _) = framed root limit sink client fields initial + (t, (status, message)) = Transport.respond client "POST" (HTTP.errorResponse status message) + _ = Transport.respond client "POST" (HTTP.badRequestResponse "Bad Request\n") + _ = Transport.respond client "" (HTTP.badRequestResponse "Bad Request\n") +-- Log before committing headers. Supervision contains logger failures without +-- letting a post-header transfer failure generate a second HTTP response. +downloadLog sink method target status = IO.scope (_ : IO.require + (IO.spawn 1 1 [] (_ : H.runIO + (Log.withSink sink (Log.withContext [["method" method] ["target" target]] (Log.info status))))) + (spawned : case spawned of + [_ ticket] = IO.require (IO.join ticket) (outcome : case outcome of + ["completed" ((t t), (t, t))] = IO.pure (t t) + ["cancelled" reason _] = IO.cancelled reason + _ = IO.pure t) + _ = IO.fail "Invalid download logger task")) +downloadError sink client method target status message = IO.bind + (downloadLog sink method target status) (logged : case logged of + (t t) = Transport.respond client method (HTTP.errorResponse status message) + _ = Transport.respond client method (HTTP.errorResponse 500 "Internal Server Error\n")) +prepareDownload owner root limit digest = do IO.onOk + reader <- IO.openReader owner (Store.bundleObjectPath root digest) + snapshot <- IO.snapshotBytes reader (Store.pathJoin root "tmp") limit digest + IO.close reader + IO.pure (B.ok snapshot t) +sendDownload sink client method target [reader size] = IO.bind + (downloadLog sink method target 200) (logged : case logged of + (t t) = do IO.bind + Transport.respond client method (HTTP.buildResponse 200 + [(B.pair "Content-Type" Store.bundleContentType) + (B.pair "Content-Length" (Convert.showNumber size)) + (B.pair "Connection" "close")] []) + case method of + "HEAD" = IO.pure t + _ = IO.require (IO.transferBytes reader client size) (_ : IO.pure t) + _ = Transport.respond client method (HTTP.errorResponse 500 "Internal Server Error\n")) +sendDownload _ _ _ _ _ = IO.fail "Invalid snapshot reply" +download root limit sink client method target digest = case B.checkContract Store.bundleHashC digest of + ((t t), (checked, t)) = IO.scope (owner : IO.bind + (prepareDownload owner root limit checked) (reply : case reply of + ((t t), (snapshot, t)) = sendDownload sink client method target snapshot + _ = downloadError sink client method target 404 "Artifact unavailable\n")) + (t, (message, _)) = downloadError sink client method target 400 message + _ = IO.fail "Invalid digest contract reply" +downloadRoute root limit sink client method target fields bytes = case HTTP.routerMethod method of + "GET" = case Store.stripPrefix Store.bundleHashPrefix target of + (t digest) = download root limit sink client method target digest + _ = IO.bind (Registry.router root limit sink "GET" target fields bytes) (Transport.respond client method) + routed = IO.bind (Registry.router root limit sink routed target fields bytes) (Transport.respond client method) + +line root limit sink client ((t t), (("POST", ("/_arboricx/bundle", version)), raw)) = + headers root limit sink client version raw +line root limit sink client parsed = Transport.handleWith (downloadRoute root limit sink) client parsed +connection root limit sink client = IO.bind (Transport.recvHeaders client) (reply : case reply of + ((t t), (raw, _)) = line root limit sink client (HTTP.parseRequestLine raw) + (t, (431, _)) = Transport.respond client "" (HTTP.errorResponse 431 "Request Header Fields Too Large\n") + _ = Transport.respond client "" (HTTP.badRequestResponse "Bad Request\n")) + +worker root limit sink [client] = connection root limit sink client +worker _ _ _ _ = IO.fail "Invalid connection handoff" +settle ticket = IO.require (IO.join ticket) (outcome : case outcome of + ["completed" _] = IO.pure t + ["failed" _ _] = IO.bind (Registry.writeLog "registry connection failed\n") (_ : IO.pure t) + ["cancelled" reason _] = IO.cancelled reason + _ = IO.fail "Invalid connection task outcome") +drain [] = IO.pure t +drain [ticket | rest] = IO.bind (settle ticket) (_ : drain rest) +drain _ = IO.fail "Invalid connection tickets" +next (t count) = B.just (N.predecessor count) +next _ = t +-- FIFO bounded admission: consume the oldest ticket before accepting beyond the +-- configured window. No discarded tickets, detached tasks or unbounded backlog in +-- the language. A slow oldest connection can delay admission; no deadline promise. +admit _ _ _ _ _ _ (t 0) tickets = drain tickets +admit root limit sink owner listener window remaining tickets = case N.gte (B.length tickets) window of + (t t) = case tickets of + [ticket | rest] = IO.bind (settle ticket) (_ : admit root limit sink owner listener window remaining rest) + _ = IO.fail "Invalid admission window" + _ = IO.require (IO.acceptTCP listener owner) (client : IO.require + (IO.spawn 1 1 [client] (worker root limit sink)) (spawned : case spawned of + [_ ticket] = admit root limit sink owner listener window (next remaining) (B.append tickets [ticket]) + _ = IO.fail "Invalid connection spawn reply")) +start root limit sink address window remaining ready = case B.checkContract B.nat? limit of + ((t t), _) = startWindow root limit sink address window remaining ready + _ = IO.fail "Invalid artifact byte limit" +startWindow root limit sink address window remaining ready = case B.checkContract B.nat? window of + ((t t), _) = case B.and? (N.gte window 1) (N.lte window 256) of + (t t) = IO.scope (owner : do IO.require + H.runIO (Artifacts.prepareFilesystem root) + listener <- IO.listenTCP owner address 128 + IO.bind (ready listener) (_ : admit root limit sink owner listener window remaining [])) + _ = IO.fail "Connection window must be between 1 and 256" + _ = IO.fail "Invalid connection window" +serve root limit sink address window ready = start root limit sink address window t ready +serveCount root limit sink address window count ready = case B.checkContract B.nat? count of + ((t t), _) = start root limit sink address window (B.just count) ready + _ = IO.fail "Invalid connection count" diff --git a/lib/handlers.tri b/lib/handlers.tri @@ -1,5 +1,5 @@ !import base as B -!import io_v2 as V2 +!import io as HostIO !export pure !export bind !export request @@ -16,9 +16,8 @@ !export collect !export view !export diagnostic -!export scopeV2 -!export spawnV2 -!export runIOV2 +!export scope +!export spawn -- Request/response effects. Handler callbacks return computations yielding one -- reply. liftIO bridges host actions into this protocol. @@ -38,8 +37,8 @@ liftIO action = request "host.io" "run" action -- Boundary constructors snapshot the actual lexical environment during -- normalization, including when invoked inside a handler callback. -- Child frames start empty; immutable routing and label values are inherited. -scopeV2 body = B.pair 107 body -spawnV2 supervised keep handles body = B.pair 108 [supervised keep handles body] +scope body = B.pair 107 body +spawn supervised keep handles body = B.pair 108 [supervised keep handles body] -- Diagnostics are data: kind, service, operation, detail, outer-to-inner labels. diagnostic kind service operation detail labels = [kind service operation detail labels] @@ -118,27 +117,26 @@ runPure action = case view action of (diagnostic "unhandled-request" service operation argument labels) t _ = B.err (diagnostic "invalid-view" t t t []) t --- Handler failures become terminal v2 outcomes. Ordinary service Results remain +-- Handler failures become terminal outcomes. Ordinary service Results remain -- values. Labels/continuations stay lazy; only the label list spine is checked. -runHostV2 current = case current of - (100, value) = V2.pure value - (103, problem) = V2.fail problem +runHost current = case current of + (100, value) = HostIO.pure value + (103, problem) = HostIO.fail problem (106, [["host.io" "run" action] labels next]) = case B.checkContract (B.listOf B.anyC) labels of - ((t t), _) = V2.bind action (reply : runHostV2 (next reply)) - _ = V2.fail (diagnostic "invalid-view" t t t []) - (106, [[service operation argument] labels next]) = V2.fail + ((t t), _) = HostIO.bind action (reply : runHost (next reply)) + _ = HostIO.fail (diagnostic "invalid-view" t t t []) + (106, [[service operation argument] labels next]) = HostIO.fail (diagnostic "unhandled-request" service operation argument labels) (107, [labels body next]) = case B.checkContract (B.listOf B.anyC) labels of - ((t t), _) = V2.bind (V2.scope (owner : runHostV2 (body owner))) - (reply : runHostV2 (next reply)) - _ = V2.fail (diagnostic "invalid-view" t t t []) + ((t t), _) = HostIO.bind (HostIO.scope (owner : runHost (body owner))) + (reply : runHost (next reply)) + _ = HostIO.fail (diagnostic "invalid-view" t t t []) (108, [supervised keep handles labels body next]) = case B.checkContract (B.listOf B.anyC) labels of - ((t t), _) = V2.bind (V2.spawn supervised keep handles (fresh : runHostV2 (body fresh))) - (reply : runHostV2 (next reply)) - _ = V2.fail (diagnostic "invalid-view" t t t []) - _ = V2.fail (diagnostic "invalid-view" t t t []) -runIOV2 action = runHostV2 (view action) -runIO = runIOV2 + ((t t), _) = HostIO.bind (HostIO.spawn supervised keep handles (fresh : runHost (body fresh))) + (reply : runHost (next reply)) + _ = HostIO.fail (diagnostic "invalid-view" t t t []) + _ = HostIO.fail (diagnostic "invalid-view" t t t []) +runIO action = runHost (view action) -- Collection threads its accumulator through a boundary transformer. -- Inner handlers run first. Other residual requests are relayed to outer diff --git a/lib/handlers/artifacts.tri b/lib/handlers/artifacts.tri @@ -1,17 +1,17 @@ !import base as B !import handlers as H -!import io_v2 as V2 -!import io_v2_storage as Storage -!import io_v2_artifacts as Stream +!import io as HostIO +!import io_storage as Storage +!import io_artifacts as Stream !import arboricx_server as Store !export load !export loadFrom !export publish !export publishTo -!export withFilesystemV2 -!export withFilesystemForV2 -!export prepareFilesystemV2 -!export publishStreamV2 +!export withFilesystem +!export withFilesystemFor +!export prepareFilesystem +!export publishStream -- Domain Results always have empty remainders. Callers map them to HTTP statuses. -- Invalid digests are ordinary input failures; @@ -63,58 +63,58 @@ publishReplyC value{((t t), (digest, t))} rest = case B.checkContract Store.bund publishReplyC value{(t, (message, t))} rest = B.contractOk value rest publishReplyC _ rest = B.contractErr "expected artifact-publication Result with empty remainder" rest --- Native v2 adapter for the domain interface. Limits count bytes; heap usage +-- Native adapter for the domain interface. Limits count bytes; heap usage -- requires a separate bound. Roots/paths follow OS authority. Verify and sync -- every immutable winner, preserving corrupt winners for diagnosis. Loads -- authenticate the exact bytes returned to the caller. -loadV2 root limit digest = do V2.onOk +loadNative root limit digest = do HostIO.onOk bytes <- Storage.readBytes (Store.bundleObjectPath root digest) limit actual <- Storage.hashBytes limit bytes case B.equal? actual digest of - (t t) = V2.pure (B.ok bytes t) - _ = V2.pure (B.err ["integrity-failed" "artifacts/load" "digest mismatch" "unknown"] t) + (t t) = HostIO.pure (B.ok bytes t) + _ = HostIO.pure (B.err ["integrity-failed" "artifacts/load" "digest mismatch" "unknown"] t) -publishV2 root limit bytes = do V2.onOk +publishNative root limit bytes = do HostIO.onOk digest <- Storage.hashBytes limit bytes case B.checkContract Store.bundleHashC digest of - ((t t), (checked, t)) = let directory = Store.objectDir root (Store.hashShard checked) in do V2.onOk - V2.ensureDirectory directory - V2.syncDirectory (Store.pathJoin root "objects") + ((t t), (checked, t)) = let directory = Store.objectDir root (Store.hashShard checked) in do HostIO.onOk + HostIO.ensureDirectory directory + HostIO.syncDirectory (Store.pathJoin root "objects") Storage.publishBytes directory (Store.bundleObjectPath root checked) 0 limit bytes - V2.pure (B.ok checked t) - _ = V2.fail "Invalid artifact digest reply" + HostIO.pure (B.ok checked t) + _ = HostIO.fail "Invalid artifact digest reply" -filesystemV2 root limit service "load" digest = checkedDigest service digest - (checked : H.liftIO (loadV2 root limit checked)) -filesystemV2 root limit service "publish" bytes = H.checkWith bytesC bytes +filesystem root limit service "load" digest = checkedDigest service digest + (checked : H.liftIO (loadNative root limit checked)) +filesystem root limit service "publish" bytes = H.checkWith bytesC bytes (message : H.fail "invalid-request" service "publish" message) (original : H.fail "invalid-contract" service "publish" original) - (checked : H.liftIO (publishV2 root limit checked)) -filesystemV2 _ _ service operation payload = H.forward service operation payload -withFilesystemForV2 service root limit action = H.handle service (filesystemV2 root limit service) action -withFilesystemV2 root limit action = withFilesystemForV2 "artifacts" root limit action + (checked : H.liftIO (publishNative root limit checked)) +filesystem _ _ service operation payload = H.forward service operation payload +withFilesystemFor service root limit action = H.handle service (filesystem root limit service) action +withFilesystem root limit action = withFilesystemFor "artifacts" root limit action -- Parent-first initialization; the configured root's parent must already exist. -- Directory-entry synchronization follows host filesystem guarantees. -ensureV2 path = do V2.onOk - V2.ensureDirectory path - V2.syncDirectory (Storage.parentPath path) -prepareFilesystemV2 root = H.liftIO (do V2.onOk - ensureV2 root - ensureV2 (Store.pathJoin root "tmp") - ensureV2 (Store.pathJoin root "objects") - ensureV2 (Store.pathJoin root "aliases") - ensureV2 (Store.pathJoin (Store.pathJoin root "aliases") "names") - ensureV2 (Store.pathJoin (Store.pathJoin root "aliases") "packages") - ensureV2 (Store.pathJoin root "manifests") - V2.pure (B.ok t t)) +ensure path = do HostIO.onOk + HostIO.ensureDirectory path + HostIO.syncDirectory (Storage.parentPath path) +prepareFilesystem root = H.liftIO (do HostIO.onOk + ensure root + ensure (Store.pathJoin root "tmp") + ensure (Store.pathJoin root "objects") + ensure (Store.pathJoin root "aliases") + ensure (Store.pathJoin (Store.pathJoin root "aliases") "names") + ensure (Store.pathJoin (Store.pathJoin root "aliases") "packages") + ensure (Store.pathJoin root "manifests") + HostIO.pure (B.ok t t)) -- Bounded producer, existing registry layout. The shard is chosen only after -- hashing the complete stream incrementally. Temporary and object directories -- must share a filesystem. -streamDestination root digest = H.liftIO (do V2.onOk - V2.ensureDirectory (Store.objectDir root (Store.hashShard digest)) - V2.syncDirectory (Store.pathJoin root "objects") - V2.pure (B.ok (Store.bundleObjectPath root digest) t)) -publishStreamV2 root expected producer = Stream.publishWith +streamDestination root digest = H.liftIO (do HostIO.onOk + HostIO.ensureDirectory (Store.objectDir root (Store.hashShard digest)) + HostIO.syncDirectory (Store.pathJoin root "objects") + HostIO.pure (B.ok (Store.bundleObjectPath root digest) t)) +publishStream root expected producer = Stream.publishWith (Store.pathJoin root "tmp") expected (streamDestination root) producer diff --git a/lib/handlers/upload.tri b/lib/handlers/upload.tri @@ -1,4 +1,4 @@ -!export * from io_v2_artifacts +!export * from io_artifacts -- Source-owned exact-length producers over scoped staging and digest resources. -- publish takes a staging directory; publishWith makes destination policy explicit. diff --git a/lib/io-v2.tri b/lib/io-v2.tri @@ -1,193 +0,0 @@ -!import base as B -!import native_math as N -!export * - --- Structured IO with named, versioned facilities. --- Native execution uses OS process authority. -io action = B.pair "tricuIO" (B.pair 2 action) -pure value = B.pair 0 value -bind action next = B.pair 1 (B.pair action next) -fail diagnostic = B.pair 2 diagnostic -scope body = B.pair 3 body -currentScope = B.pair 4 t - --- Flags are canonical 0/1. Callback receives fresh handles. --- Success is [task joinTicket]; non-result children receive t in the ticket slot. -spawn supervised keep handles body = B.pair 5 - (B.pair supervised (B.pair keep (B.pair handles body))) - -join ticket = B.pair 6 ticket -discard ticket = B.pair 7 ticket -cancel task reason = B.pair 8 (B.pair task reason) -cancelled reason = B.pair 9 reason - --- Named, independently versioned host facilities; core sequencing stays separate. -request group revision operation arguments = B.pair 10 - (B.pair group (B.pair revision (B.pair operation arguments))) - -capabilities = request "runtime" 1 "capabilities" t -inspect action = request "runtime" 1 "inspect" action - -stdin = request "standard" 1 "input" t -stdout = request "standard" 1 "output" t -stderr = request "standard" 1 "error" t - -openReader owner path = request "file" 1 "open-reader" (B.pair owner path) -openWriter owner path = request "file" 1 "open-writer" (B.pair owner path) -openAppender owner path = request "file" 1 "open-appender" (B.pair owner path) -openUpdate owner path = request "file" 1 "open-update" (B.pair owner path) - -seekFile handle offset = request "file" 1 "seek" (B.pair handle offset) -filePosition handle = request "file" 1 "position" handle -syncFile handle = request "file" 1 "sync" handle - -syncDirectory path = request "directory" 1 "sync" path - -fileSize handle = request "file" 1 "size" handle -resizeFile handle size = request "file" 1 "resize" (B.pair handle size) - -statPath path = request "path" 1 "stat" path -lstatPath path = request "path" 1 "lstat" path -removeFile path = request "path" 1 "remove-file" path -renamePath source destination = request "path" 1 "rename" (B.pair source destination) - -createDirectory path = request "directory" 1 "create" path -removeDirectory path = request "directory" 1 "remove" path -openDirectory owner path = request "directory" 1 "open" (B.pair owner path) -nextDirectory directory = request "directory" 1 "next" directory - -ipv4 octets port = ["ipv4" octets port] -ipv6 words scope port = ["ipv6" words scope port] - -resolveTCP host family port limit = request "resolver" 1 "resolve" [host family port limit] - -listenTCP owner address backlog = request "tcp" 1 "listen" (B.pair owner (B.pair address backlog)) -connectTCP owner address = request "tcp" 1 "connect" (B.pair owner address) -acceptTCP listener owner = request "tcp" 1 "accept" (B.pair listener owner) - -localAddress socket = request "tcp" 1 "local-address" socket -peerAddress socket = request "tcp" 1 "peer-address" socket -shutdownWrite socket = request "tcp" 1 "shutdown-write" socket - --- Read exactly one authenticated frame from the current cursor. --- Temporary disk <= size; the caller supplies a per-operation limit. -readIndexedTree reader directory limit size digest = request "indexed-tree" 1 "read" - [reader directory limit size digest] - --- Canonical native encoding avoids expanded tree/byte lists. The writer checks --- the size and expected digest before sending. Retry decisions require confirmed --- progress; a failed write can have partial or uncertain effects. -hashIndexedTree limit tree = request "indexed-tree" 1 "hash" (B.pair limit tree) - -writeIndexedTree writer limit digest tree = request "indexed-tree" 1 "write" - [writer limit digest tree] - --- Exact canonical-byte comparison consumes precisely one frame. -verifyIndexedTree reader limit tree = request "indexed-tree" 1 "verify" [reader limit tree] - --- Stateless bounded codecs: decode returns [text carry], with carry <=3 octets. --- Set final=1 at EOF to reject incomplete suffixes. NUL and BOM are valid scalars; --- source-file BOM rejection belongs to library/application policy. -encodeUTF8 text = request "utf8" 1 "encode" text -decodeUTF8 final bytes = request "utf8" 1 "decode" [final bytes] - -readSome reader limit = request "stream" 1 "read" (B.pair reader limit) -writeSome writer bytes = request "stream" 1 "write" (B.pair writer bytes) --- Whole-stream SHA-256 authentication into an anonymous read-only file. --- Result: [reader size]. Scope-owned; disk <= limit, native chunks <=4096. --- An EOF probe may consume one byte beyond the limit on failure. -snapshotBytes reader directory limit digest = request "stream" 1 "snapshot" - [reader directory limit digest] --- Exact native transfer from the current cursor. Failure can follow partial --- writes; callers close the destination rather than retrying uncertain progress. -transferBytes reader writer count = request "stream" 1 "transfer" [reader writer count] - -close handle = request "resource" 1 "close" handle - -openStage owner directory = request "stage" 1 "open" (B.pair owner directory) -sealStage stage = request "stage" 1 "seal" stage -publishNew stage destination = request "stage" 1 "publish" (B.pair stage (B.pair 0 destination)) -publishReplace stage destination = request "stage" 1 "publish" (B.pair stage (B.pair 1 destination)) - -openDigest owner algorithm = request "digest" 1 "open" (B.pair owner algorithm) -updateDigest digest bytes = request "digest" 1 "update" (B.pair digest bytes) -finishDigest digest = request "digest" 1 "finish" digest - -onOk action next = bind action (reply : case reply of - ((t t), (value, t)) = next value - (t, (_, t)) = pure reply - _ = fail "Invalid operation Result") - --- Nonrecursive initialization for caller-ordered store directory creation. --- stat follows an existing symlink and accepts it when its target is a directory. -ensureDirectory path = bind (createDirectory path) (reply : case reply of - (t, (["already-exists" _ _ _], t)) = onOk (statPath path) (status : case status of - ["directory"] = pure (B.ok t t) - _ = pure (B.err ["not-directory" "directory/ensure" "" "none"] t)) - _ = pure reply) - --- Explicit sequential fallback policy, separate from native connect/resolve. --- Stop on the first success; preserve the last ordinary failure unchanged. -connectFirstTCP owner addresses = connectCandidates owner addresses - (B.err ["not-found" "tcp/connect-first" "No endpoint candidates" "none"] t) -connectCandidates _ [] previous = pure previous -connectCandidates owner [address | rest] _ = bind (connectTCP owner address) (reply : case reply of - ((t t), (_, t)) = pure reply - (t, (_, t)) = connectCandidates owner rest reply - _ = fail "Invalid connection Result") -connectCandidates _ _ _ = pure (B.err ["invalid-argument" "tcp/connect-first" "Invalid endpoint list" "none"] t) - --- Turn an explicitly selected operation failure into terminal task failure. --- Ordinary Results elsewhere remain values. -require action next = bind action (reply : case reply of - ((t t), (value, t)) = next value - (t, (problem, t)) = fail problem - _ = fail "Invalid operation Result") - --- Whole-chunk convenience over the native positive-progress contract. Failure --- stops immediately, preserving partial or uncertain progress for the caller. -dropWritten 0 bytes = bytes -dropWritten count [_ | rest] = dropWritten (N.predecessor count) rest -dropWritten _ _ = [] --- Failure carries confirmed progress BEFORE the failing writeSome, plus its --- unchanged diagnostic (whose own progress can be unknown). Composite progress --- includes earlier successful writes even when the last operation reports "none". -writeAll writer bytes = let rec - walk accepted remaining = case remaining of - [] = pure (B.ok t t) - _ = bind (writeSome writer remaining) (reply : case reply of - -- Native writeSome validates at most 4096 input bytes before writing. - -- A full-bound acknowledgement therefore exhausts this entire chunk. - ((t t), (4096, t)) = pure (B.ok t t) - ((t t), (count, t)) = walk (N.add accepted count) (dropWritten count remaining) - (t, (problem, t)) = pure (B.err ["write-all" accepted problem] t) - _ = fail "Invalid operation Result") - in walk 0 bytes - --- Sequential bounded pumping, without acquiring or closing either endpoint. --- Errors retain writeAll's confirmed prefix and original uncertain progress. -copyStream reader writer = do onOk - received <- readSome reader 4096 - case received of - ("eof", t) = pure (B.ok t t) - ("data", bytes) = do onOk - writeAll writer bytes - copyStream reader writer - _ = fail "Invalid read reply" - --- Relative waits and native monotonic observations. Scheduler latency can extend --- waits; yield offers a scheduling opportunity with scheduler-selected ordering. -monotonicNanoseconds = request "clock" 1 "monotonic" t -sleepMilliseconds milliseconds = request "clock" 1 "sleep" milliseconds -yield = request "scheduler" 1 "yield" t - --- Returns Result [task joinTicket stdinWriter stdoutReader stderrReader]. --- cwd: ["inherit"] or ["at" path]; env: ["inherit"] or ["replace" [[key value] ...]]. --- Normal scope exit waits. Close stdin for EOF; drain stdout/stderr concurrently. --- Join completion holds ["exit" code] or ["signal" number]. -spawnProcess executable arguments directory environment graceMilliseconds = - request "process" 1 "spawn" [executable arguments directory environment graceMilliseconds] - --- Read-only host context. Environment query returns ["missing"] or ["value" text]; empty and absent remain distinct. -workingDirectory = request "process" 1 "working-directory" t -environmentVariable name = request "process" 1 "environment-variable" name diff --git a/lib/io-v2/artifacts.tri b/lib/io-v2/artifacts.tri @@ -1,90 +0,0 @@ -!import base as B -!import native_math as N -!import http as HTTP -!import io_v2 as IO -!import io_v2_storage as Storage -!import handlers as H -!import arboricx_server as Store -!export write -!export publish -!export publishWith - --- Source-owned upload policy. Only writes observed by this boundary advance --- its byte count; producer acknowledgements report producer status. -write bytes = H.request "artifact-stream" "write" bytes -onOk action next = H.bind (H.liftIO action) (reply : case reply of - ((t t), (value, t)) = next value - (t, (_, t)) = H.pure reply - _ = H.fail "invalid-response" "artifact-stream" "host" "Invalid host Result") - -hashReader reader digest remaining = IO.onOk (IO.readSome reader 4096) (reply : case reply of - ("eof", t) = case remaining of - 0 = IO.finishDigest digest - _ = IO.pure (B.err "Existing artifact length mismatch" t) - ("data", bytes) = let count = HTTP.httpBodyLength bytes in case N.lte count remaining of - (t t) = IO.onOk (IO.updateDigest digest bytes) - (_ : hashReader reader digest (N.subtract remaining count)) - _ = IO.pure (B.err "Existing artifact length mismatch" t) - _ = IO.fail "Invalid read reply") - --- Accept an existing name only after verifying its complete contents and length --- independently with fresh reader/digest resources. -verifyWinner destination expected wanted = IO.scope (owner : do IO.onOk - reader <- IO.openReader owner destination - digest <- IO.openDigest owner "sha256" - actual <- hashReader reader digest expected - case B.equal? actual wanted of - (t t) = do IO.onOk - IO.syncFile reader - IO.syncDirectory (Storage.parentPath destination) - IO.pure (B.ok t t) - _ = IO.pure (B.err "Existing artifact digest mismatch" t)) - --- Destination policy runs only after exact-length completion and sealing. It --- returns a handler Result path and can create a digest-derived shard. Native --- publication remains generic and rejects cross-filesystem moves without copying. -finish destination expected stage digest = onOk (IO.finishDigest digest) (hash : - case B.checkContract Store.bundleHashC hash of - ((t t), (checked, t)) = onOk (IO.sealStage stage) (_ : - H.bind (destination checked) (reply : case reply of - ((t t), (path, t)) = onOk (IO.publishNew stage path) (outcome : case outcome of - "published" = H.pure (B.ok checked t) - "exists" = onOk (verifyWinner path expected checked) (_ : H.pure (B.ok checked t)) - _ = H.fail "invalid-response" "artifact-stream" "publish" "Invalid publication reply") - (t, (_, t)) = H.pure reply - _ = H.fail "invalid-response" "artifact-stream" "destination" "Expected destination Result")) - _ = H.fail "invalid-response" "artifact-stream" "digest" "Invalid digest reply") - -publish directory expected producer = publishWith directory expected - (digest : H.pure (B.ok (B.append directory (B.pair 47 digest)) t)) producer -publishWith directory expected destination producer = case B.checkContract B.nat? expected of - ((t t), (count, t)) = case N.lte count 67108864 of - (t t) = H.scopeV2 (owner : onOk (IO.openStage owner directory) (stage : - onOk (IO.openDigest owner "sha256") (digest : let rec - walk current remaining = case current of - (100, reply) = case reply of - ((t t), (t, t)) = case remaining of - 0 = finish destination count stage digest - _ = H.pure (B.err "Incomplete artifact stream" t) - (t, (_, t)) = H.pure reply - _ = H.fail "invalid-response" "artifact-stream" "producer" "Expected Result unit" - (103, [kind service operation detail labels]) = B.pair 105 - (B.pair labels (H.fail kind service operation detail)) - (106, [["artifact-stream" "write" bytes] labels next]) = - onOk (IO.writeAll stage bytes) (_ : case bytes of - [] = H.pure (B.err "Empty artifact chunk" t) - _ = let length = HTTP.httpBodyLength bytes in case N.lte length remaining of - (t t) = onOk (IO.updateDigest digest bytes) - (_ : walk (next (B.ok t t)) (N.subtract remaining length)) - _ = H.pure (B.err "Artifact exceeds declared length" t)) - (106, [[service operation argument] labels next]) = H.bind - (B.pair 105 (B.pair labels (H.request service operation argument))) - (reply : walk (next reply) remaining) - (107, [labels body next]) = B.pair 105 (B.pair labels - (H.fail "producer-boundary" "artifact-stream" "publish" "Producer must be sequential")) - (108, [supervised keep handles labels body next]) = B.pair 105 (B.pair labels - (H.fail "producer-boundary" "artifact-stream" "publish" "Producer must be sequential")) - _ = H.fail "invalid-view" "artifact-stream" "publish" "Invalid producer view" - in walk (H.view producer) count))) - _ = H.pure (B.err "Artifact length exceeds limit" t) - _ = H.pure (B.err "Invalid artifact length" t) diff --git a/lib/io-v2/http-transport.tri b/lib/io-v2/http-transport.tri @@ -1,61 +0,0 @@ -!import base as B -!import native_math as N -!import http as HTTP -!import io_v2 as IO -!export recvHeaders -!export readBody -!export respond -!export handle -!export handleWith - --- Reuse pure HTTP grammar/limits. The header cap covers the entire accumulated --- receive buffer, including prefetched body bytes, exactly as in the v1 reader. -receiveHeaders client buffer count = IO.bind (IO.readSome client 4096) (reply : case reply of - ((t t), (("data", chunk), t)) = let - next = B.append buffer chunk - size = N.add count (HTTP.httpBodyLength chunk) - in case N.lte size HTTP.maxHeaderBytes of - (t t) = case B.contains? HTTP.crlfcrlf next of - (t t) = IO.pure (B.ok next t) - _ = receiveHeaders client next size - _ = IO.pure (B.err 431 t) - _ = IO.pure (B.err 400 t)) -recvHeaders client = receiveHeaders client [] 0 - -readState _ (0, (reversed, rest)) = IO.pure (B.ok (B.reverse reversed) rest) -readState client (remaining, (reversed, _)) = IO.bind - (IO.readSome client (HTTP.recvChunkMax4096 remaining)) (reply : case reply of - ((t t), (("data", chunk), t)) = readState client (HTTP.takeBodyBytes chunk remaining reversed) - _ = IO.pure (B.err 400 "Unexpected end of request body\n")) -readState _ _ = IO.pure HTTP.httpBadRequest -readBody client fields initial = case HTTP.contentLength fields of - ((t t), (t, _)) = IO.pure (B.ok [] initial) - ((t t), ((t count), _)) = readState client (HTTP.takeBodyBytes initial count []) - failure = IO.pure failure - --- The connection's owning scope closes it even on partial write/failure. Do not --- retry uncertain writes or let a cleanup failure turn into a second response. -send client bytes = case HTTP.takeBodyBytes bytes 4096 [] of - (_, ([], _)) = IO.pure t - (_, (reversed, rest)) = IO.require (IO.writeAll client (B.reverse reversed)) (_ : send client rest) - _ = IO.fail "Invalid response byte list" -respond client method response = send client (HTTP.responseForMethod method response) -body router client method target fields ((t t), (bytes, _)) = - router client method target fields bytes -body _ client method _ _ (t, (status, message)) = respond client method (HTTP.errorResponse status message) -body _ client method _ _ _ = respond client method (HTTP.badRequestResponse "Bad Request\n") -fields router client method target version raw = case HTTP.parseHeaders raw of - ((t t), (headers, initial)) = case HTTP.validateRequest method target version headers of - ((t t), _) = IO.bind (readBody client headers initial) (body router client method target headers) - (t, (status, message)) = respond client method (HTTP.errorResponse status message) - _ = respond client method (HTTP.badRequestResponse "Bad Request\n") - _ = respond client "" (HTTP.badRequestResponse "Bad Request\n") -handleWith router client ((t t), ((method, (target, version)), raw)) = fields router client method target version raw -handleWith _ client _ = respond client "" (HTTP.badRequestResponse "Bad Request\n") - --- Response callbacks run only after the same request grammar/body checks. They --- may send a bounded native body; framing errors use the ordinary byte response. -handle router client parsed = handleWith - (socket method target headers bytes : IO.bind - (router (HTTP.routerMethod method) target headers bytes) (respond socket method)) - client parsed diff --git a/lib/io-v2/objects.tri b/lib/io-v2/objects.tri @@ -1,85 +0,0 @@ -!import base as B -!import native_math as N -!import io_v2 as IO -!export putTree -!export getTree -!export validDigest - --- Source-owned indexed-object policy. The store root must already exist; its --- creation/parent durability belongs to the caller. Root paths use OS resolution; --- callers must account for symlinks, renames and external mutable writers. -hexDigit value = case value of - 48 = t t - 49 = t t - 50 = t t - 51 = t t - 52 = t t - 53 = t t - 54 = t t - 55 = t t - 56 = t t - 57 = t t - 97 = t t - 98 = t t - 99 = t t - 100 = t t - 101 = t t - 102 = t t - _ = t - -validDigest value = let rec - walk 0 [] = t t - walk 0 _ = t - walk count [c | rest] = case hexDigit c of - (t t) = walk (N.predecessor count) rest - _ = t - walk _ _ = t - in walk 64 value - -shard [a b c | _] = [a b c] -shard _ = [] -objects root = B.append root "/objects" -shardPath root digest = B.append (objects root) (B.pair 47 (shard digest)) -objectPath root digest = B.append (shardPath root digest) (B.pair 47 digest) - --- Verify every winner, including a new publication, byte-for-byte and through --- EOF. Sync the verified open inode and its containing directory before returning --- a digest that callers may publish in a manifest/cache/alias. Failed verification --- leaves the winner intact. Durability follows OS/filesystem synchronization --- guarantees; external writers can mutate paths concurrently. -verify owner root limit digest tree = do IO.onOk - reader <- IO.openReader owner (objectPath root digest) - IO.verifyIndexedTree reader limit tree - end <- IO.readSome reader 1 - case end of - ("eof", t) = do IO.onOk - IO.syncFile reader - IO.syncDirectory (shardPath root digest) - IO.pure (B.ok digest t) - _ = IO.pure (B.err ["invalid-data" "objects/verify" "trailing bytes" "unknown"] t) - -putTree root limit tree = IO.scope (owner : do IO.onOk - IO.syncDirectory root - info <- IO.hashIndexedTree limit tree - case info of - [size digest] = do IO.onOk - IO.ensureDirectory (objects root) - IO.syncDirectory root - IO.ensureDirectory (shardPath root digest) - IO.syncDirectory (objects root) - stage <- IO.openStage owner (shardPath root digest) - IO.writeIndexedTree stage limit digest tree - IO.sealStage stage - status <- IO.publishNew stage (objectPath root digest) - case status of - "published" = verify owner root limit digest tree - "exists" = verify owner root limit digest tree - _ = IO.fail "Invalid publication reply" - _ = IO.fail "Invalid indexed hash reply") - -getTree root limit digest = case validDigest digest of - (t t) = IO.scope (owner : do IO.onOk - reader <- IO.openReader owner (objectPath root digest) - size <- IO.fileSize reader - IO.readIndexedTree reader root limit size digest) - _ = IO.pure (B.err ["invalid-argument" "objects/get" "invalid CAS digest" "none"] t) diff --git a/lib/io-v2/storage.tri b/lib/io-v2/storage.tri @@ -1,172 +0,0 @@ -!import base as B -!import native_math as N -!import io_v2 as IO -!export readBytes -!export hashBytes -!export publishBytes -!export readTree -!export publishTree -!export parentPath - --- Caller-selected limits and directories. Whole byte lists are resident source --- values; transport chunks have a fixed bound. Paths follow OS authority. -problem op detail = ["invalid-data" op detail "unknown"] -checkedLimit limit next = case B.isNat? limit of - (t t) = case N.lte limit 9223372036854775807 of - (t t) = next limit - _ = IO.pure (B.err ["invalid-bound" "storage/limit" "invalid byte limit" "none"] t) - _ = IO.pure (B.err ["invalid-bound" "storage/limit" "invalid byte limit" "none"] t) --- A fixed composition supplies 4096 structural steps without a per-byte --- numeric countdown. Each step preserves the proper-list failure sentinel; --- reaching the bound leaves the suffix for the next chunk to validate. -chunkEnd rest = [[] rest] -chunkStep next [] = [[] []] -chunkStep next [c | rest] = case next rest of - [prefix after] = [(B.pair c prefix) after] - _ = [] -chunkStep _ _ = [] -doubleStep step next = step (step next) -chunkSteps = chunkStep - |> doubleStep |> doubleStep |> doubleStep |> doubleStep - |> doubleStep |> doubleStep |> doubleStep |> doubleStep - |> doubleStep |> doubleStep |> doubleStep |> doubleStep -chunk bytes = chunkSteps chunkEnd bytes --- Only host-produced proper chunks, or prefixes checked by chunk, reach here. --- Count their cells directly, preserving the public base.length contract. -chunkLength [] = 0 -chunkLength [_ | rest] = N.successor (chunkLength rest) -chunkLength _ = 0 -flatten chunks = B.foldr (suffix chunk : B.append chunk suffix) [] (B.reverse chunks) -readCount 0 = 1 -readCount remaining = case N.lte remaining 4096 of - (t t) = remaining - _ = 4096 -readLoop reader remaining chunks = do IO.onOk - received <- IO.readSome reader (readCount remaining) - case received of - ("eof", t) = IO.pure (B.ok (flatten chunks) t) - ("data", bytes) = let count = chunkLength bytes in case N.lte count remaining of - (t t) = readLoop reader (N.subtract remaining count) (B.pair bytes chunks) - _ = IO.pure (B.err ["invalid-bound" "storage/read" "file exceeds byte limit" "unknown"] t) - _ = IO.fail "Invalid storage read reply" --- May consume one excess byte in the EOF probe at the budget boundary. -readBytes path limit = checkedLimit limit (count : IO.scope (owner : do IO.onOk - reader <- IO.openReader owner path - readLoop reader count [])) - -hashLoop digest remaining bytes = case chunk bytes of - [[] []] = IO.finishDigest digest - [part rest] = case N.lte 4096 remaining of - -- A short final chunk may leave unused budget; its empty suffix completes - -- the digest. Every non-final chunk consumes exactly 4096 bytes. - (t t) = do IO.onOk - IO.updateDigest digest part - hashLoop digest (N.subtract remaining 4096) rest - _ = let count = chunkLength part in case N.lte count remaining of - (t t) = do IO.onOk - IO.updateDigest digest part - hashLoop digest (N.subtract remaining count) rest - _ = IO.pure (B.err ["invalid-bound" "storage/hash" "bytes exceed limit" "none"] t) - _ = IO.pure (B.err ["invalid-argument" "storage/hash" "improper byte list" "none"] t) -hashBytes limit bytes = checkedLimit limit (count : IO.scope (owner : do IO.onOk - digest <- IO.openDigest owner "sha256" - hashLoop digest count bytes)) -writeLoop writer bytes = case chunk bytes of - [[] []] = IO.pure (B.ok t t) - [part rest] = do IO.onOk - IO.writeAll writer part - writeLoop writer rest - _ = IO.fail "Invalid preflighted byte list" - --- Compare only the received prefix, returning the unconsumed expected suffix. --- Both inputs have already passed their byte/proper-list validation boundary. -verifyPrefix [] remaining = [remaining] -verifyPrefix [byte | bytes] [expected | rest] = case B.equal? byte expected of - (t t) = verifyPrefix bytes rest - _ = [] -verifyPrefix _ _ = [] -verifyBytes reader remaining = do IO.onOk - received <- IO.readSome reader 4096 - case received of - ("eof", t) = case remaining of - [] = IO.pure (B.ok t t) - _ = IO.pure (B.err (problem "storage/verify" "different bytes") t) - ("data", bytes) = case verifyPrefix bytes remaining of - [rest] = verifyBytes reader rest - _ = IO.pure (B.err (problem "storage/verify" "different bytes") t) - _ = IO.fail "Invalid verification read reply" -verified owner directory destination verify created = do IO.onOk - reader <- IO.openReader owner destination - verify reader - IO.syncFile reader - IO.syncDirectory directory - IO.pure (B.ok created t) --- Immutable names verify every winner, through EOF, before syncing its opened --- inode and directory. Mismatches remain intact for diagnosis. Mutable --- replacement acknowledges its own stage publication; a concurrent writer can --- legitimately replace the alias immediately afterward. -publishBytes directory destination replace limit bytes = case replace of - 0 = do IO.onOk - hashBytes limit bytes - IO.scope (owner : do IO.onOk - stage <- IO.openStage owner directory - writeLoop stage bytes - IO.sealStage stage - status <- IO.publishNew stage destination - case status of - "published" = verified owner directory destination (reader : verifyBytes reader bytes) (t t) - "exists" = verified owner directory destination (reader : verifyBytes reader bytes) t - _ = IO.fail "Invalid publication reply") - 1 = do IO.onOk - hashBytes limit bytes - IO.scope (owner : do IO.onOk - stage <- IO.openStage owner directory - writeLoop stage bytes - IO.sealStage stage - IO.publishReplace stage destination - IO.pure (B.ok (t t) t)) - _ = IO.pure (B.err ["invalid-argument" "storage/publish" "invalid replacement flag" "none"] t) - -verifyTree reader limit tree = do IO.onOk - IO.verifyIndexedTree reader limit tree - end <- IO.readSome reader 1 - case end of - ("eof", t) = IO.pure (B.ok t t) - _ = IO.pure (B.err (problem "storage/verify" "trailing bytes") t) -publishTree directory destination limit expected tree = do IO.onOk - info <- IO.hashIndexedTree limit tree - case info of - [_ actual] = case B.equal? actual expected of - (t t) = IO.scope (owner : do IO.onOk - stage <- IO.openStage owner directory - IO.writeIndexedTree stage limit expected tree - IO.sealStage stage - status <- IO.publishNew stage destination - case status of - "published" = verified owner directory destination (reader : verifyTree reader limit tree) (t t) - "exists" = verified owner directory destination (reader : verifyTree reader limit tree) t - _ = IO.fail "Invalid publication reply") - _ = IO.pure (B.err ["integrity-failed" "storage/publish" "digest mismatch" "none"] t) - _ = IO.fail "Invalid indexed hash reply" -readTree directory path limit expected = IO.scope (owner : do IO.onOk - reader <- IO.openReader owner path - size <- IO.fileSize reader - IO.readIndexedTree reader directory limit size expected) - --- POSIX lexical parent, retaining separators and literal '..' components. --- Callers pass complete paths to the native path validators for OS resolution. -slashes [] = t t -slashes [47 | rest] = slashes rest -slashes _ = t -trimSeparators [47 | rest] = trimSeparators rest -trimSeparators rest = rest -parentReverse [] = "." -parentReverse [47 | rest] = case slashes rest of - (t t) = B.pair 47 (B.reverse rest) - _ = B.reverse rest -parentReverse [_ | rest] = parentReverse rest -parentReverse _ = "." -parentPath [] = "." -parentPath path = case trimSeparators (B.reverse path) of - [] = path - rest = parentReverse rest diff --git a/lib/io-v2/text.tri b/lib/io-v2/text.tri @@ -1,153 +0,0 @@ -!import base as B -!import native_math as N -!import io_v2 as IO -!export readSource -!export readText -!export encodeText -!export writeText -!export lineCursor -!export readLine - -problem operation detail progress = ["invalid-data" operation detail progress] --- base.foldr supplies the accumulator first. -flatten chunks = B.foldr (suffix chunk : B.append chunk suffix) [] (B.reverse chunks) - -checkedLimit limit next = case B.checkContract B.nat? limit of - ((t t), (count, t)) = case N.lte count 9223372036854775807 of - (t t) = next count - _ = IO.pure (B.err ["invalid-bound" "text/limit" "invalid text byte limit" "none"] t) - _ = IO.pure (B.err ["invalid-bound" "text/limit" "invalid text byte limit" "none"] t) - --- Split a bounded prefix; native encode validates its scalars before producing --- bytes. [] signals an improper character spine. -split 0 rest = [[] rest] -split _ [] = [[] []] -split count [c | rest] = case split (N.predecessor count) rest of - [prefix after] = [(B.pair c prefix) after] - _ = [] -split _ _ = [] - -encodeLoop remaining text chunks = case split 1024 text of - [[] []] = IO.pure (B.ok (flatten chunks) t) - [part rest] = do IO.onOk - bytes <- IO.encodeUTF8 part - case N.lte (B.length bytes) remaining of - (t t) = encodeLoop (N.subtract remaining (B.length bytes)) rest (B.pair bytes chunks) - _ = IO.pure (B.err ["invalid-bound" "text/encode" "encoded text exceeds byte limit" "none"] t) - _ = IO.pure (B.err (problem "text/encode" "improper scalar list" "none") t) -encodeText limit text = checkedLimit limit (remaining : encodeLoop remaining text []) - --- Force the small acknowledged-byte counter before the next request, without --- retaining arithmetic that depends on previously emitted chunks. -forceCount t next = next t -forceCount (t bit rest) next = case bit of - t = forceCount rest next - (t t) = forceCount rest next - _ = IO.fail "Invalid text counter" -forceCount _ _ = IO.fail "Invalid text counter" -writeFailure confirmed cause = IO.pure (B.err ["write-text" confirmed cause] t) -writeLoop writer text confirmed = forceCount confirmed (_ : case split 1024 text of - [[] []] = IO.pure (B.ok t t) - [part rest] = IO.bind (IO.encodeUTF8 part) (encoded : case encoded of - ((t t), (bytes, t)) = IO.bind (IO.writeAll writer bytes) (written : case written of - ((t t), (t, t)) = writeLoop writer rest (N.add confirmed (B.length bytes)) - (t, (cause, t)) = writeFailure confirmed cause - _ = IO.fail "Invalid text write reply") - (t, (cause, t)) = writeFailure confirmed cause - _ = IO.fail "Invalid UTF-8 encode reply") - _ = writeFailure confirmed (problem "text/write" "improper scalar list" "none")) -writeText writer text = writeLoop writer text 0 - --- Whole-source convenience retaining the exact received octets and decoded --- scalars. Source policy rejects an initial BOM. A one-byte EOF probe detects --- excess input at the budget boundary. Callers serialize use of this cursor --- across the complete operation; native leases cover individual reads. --- Composite failure retains confirmed input --- bytes (before any uncertain failing read) plus the unchanged native problem. -readFailure consumed cause = IO.pure (B.err ["read-source" consumed cause] t) -readCount 0 = 1 -readCount remaining = case N.lte remaining 4093 of - (t t) = remaining - _ = 4093 --- Native reads produce proper byte lists. Counting that spine needs no --- transforming collection contract; raw octets still reach strict UTF-8 decode. -sourceChunkLength [] = 0 -sourceChunkLength [_ | rest] = N.successor (sourceChunkLength rest) -sourceChunkLength _ = 0 -readLoop reader limit remaining carry first rawChunks textChunks = - IO.bind (IO.readSome reader (readCount remaining)) (received : case received of - ((t t), (("eof", t), t)) = IO.bind (IO.decodeUTF8 1 carry) (decoded : case decoded of - ((t t), ([[] []], t)) = IO.pure (B.ok (B.pair (flatten rawChunks) (flatten textChunks)) t) - (t, (cause, t)) = readFailure (N.subtract limit remaining) cause - _ = IO.fail "Invalid UTF-8 final reply") - ((t t), (("data", bytes), t)) = let count = sourceChunkLength bytes in - case N.lte count remaining of - (t t) = IO.bind (IO.decodeUTF8 0 (B.append carry bytes)) (decoded : case decoded of - ((t t), ([text pending], t)) = case first of - 1 = case text of - [] = readLoop reader limit (N.subtract remaining count) pending 1 (B.pair bytes rawChunks) textChunks - [65279 | _] = readFailure (N.add (N.subtract limit remaining) count) - (problem "text/read-source" "initial source BOM is not permitted" "none") - _ = readLoop reader limit (N.subtract remaining count) pending 0 (B.pair bytes rawChunks) (B.pair text textChunks) - _ = readLoop reader limit (N.subtract remaining count) pending 0 (B.pair bytes rawChunks) (B.pair text textChunks) - (t, (cause, t)) = readFailure (N.add (N.subtract limit remaining) count) cause - _ = IO.fail "Invalid UTF-8 decode reply") - _ = readFailure (N.add (N.subtract limit remaining) count) - ["invalid-bound" "text/read-source" "source exceeds byte limit" "none"] - (t, (cause, t)) = readFailure (N.subtract limit remaining) cause - _ = IO.fail "Invalid source read reply") -readSource reader limit = checkedLimit limit (count : readLoop reader count count [] 1 [] []) - --- Single-consumer source cursor represented as a source value. Thread the --- returned cursor forward, serialize its use with all reads on the underlying --- reader, and retire it on read failure. Read-ahead is bounded to 4093 bytes. -lineCursor reader = [reader [] 0] - --- The byte budget counts line content; LF delimits lines. CR, BOM and NUL --- remain content. --- Scan before decoding: malformed bytes after LF belong to the NEXT line. -lineSplit [] = [[] [] 0] -lineSplit [10 | rest] = [[] rest 1] -lineSplit [byte | rest] = case lineSplit rest of - [prefix after ended] = [(B.pair byte prefix) after ended] - _ = [] -lineSplit _ = [] -lineFailure consumed cause = IO.pure (B.err ["read-line" consumed cause] t) -lineReady reader after ending chunks = IO.pure - (B.ok [["line" (flatten chunks) ending] [reader after 0]] t) -lineEOF reader consumed carry chunks = IO.bind (IO.decodeUTF8 1 carry) (decoded : case decoded of - ((t t), ([[] []], t)) = case consumed of - 0 = IO.pure (B.ok [["eof"] [reader [] 1]] t) - _ = IO.pure (B.ok [["line" (flatten chunks) "eof"] [reader [] 1]] t) - (t, (cause, t)) = lineFailure consumed cause - _ = IO.fail "Invalid line final UTF-8 reply") -lineLoop reader limit remaining carry chunks buffered = forceCount remaining (_ : case buffered of - [] = IO.bind (IO.readSome reader 4093) (received : case received of - ((t t), (("eof", t), t)) = lineEOF reader (N.subtract limit remaining) carry chunks - ((t t), (("data", bytes), t)) = lineLoop reader limit remaining carry chunks bytes - (t, (cause, t)) = lineFailure (N.subtract limit remaining) cause - _ = IO.fail "Invalid line read reply") - _ = case lineSplit buffered of - [part after ended] = let count = B.length part in - case N.lte count remaining of - (t t) = IO.bind (IO.decodeUTF8 ended (B.append carry part)) (decoded : case decoded of - ((t t), ([text pending], t)) = case ended of - 1 = lineReady reader after "lf" (B.pair text chunks) - _ = lineLoop reader limit (N.subtract remaining count) pending (B.pair text chunks) [] - (t, (cause, t)) = lineFailure (N.add (N.subtract limit remaining) count) cause - _ = IO.fail "Invalid line UTF-8 reply") - _ = lineFailure (N.add (N.subtract limit remaining) count) - ["invalid-bound" "text/read-line" "line exceeds byte limit" "none"] - _ = IO.fail "Invalid line cursor bytes") -readLine cursor limit = checkedLimit limit (count : case cursor of - [reader buffered 0] = lineLoop reader count count [] [] buffered - [reader [] 1] = IO.pure (B.ok [["eof"] cursor] t) - _ = IO.pure (B.err ["invalid-argument" "text/read-line" "invalid line cursor" "none"] t)) - --- Generic bounded whole-text convenience preserving an initial BOM as content. --- It uses the shared incremental decoder with this text policy. -readText reader limit = checkedLimit limit (count : IO.onOk - (readLoop reader count count [] 0 [] []) - (received : case received of - (_, text) = IO.pure (B.ok text t) - _ = IO.fail "Invalid text read result")) diff --git a/lib/io.tri b/lib/io.tri @@ -1,4 +1,193 @@ -!export * from io_v2 +!import base as B +!import native_math as N +!export * --- Canonical application IO is protocol 2. Scoped ownership and OS process --- authority replace the retired grant, environment and mutable-state actions. +-- Structured IO with named, versioned facilities. +-- Native execution uses OS process authority. +io action = B.pair "tricuIO" (B.pair 2 action) +pure value = B.pair 0 value +bind action next = B.pair 1 (B.pair action next) +fail diagnostic = B.pair 2 diagnostic +scope body = B.pair 3 body +currentScope = B.pair 4 t + +-- Flags are canonical 0/1. Callback receives fresh handles. +-- Success is [task joinTicket]; non-result children receive t in the ticket slot. +spawn supervised keep handles body = B.pair 5 + (B.pair supervised (B.pair keep (B.pair handles body))) + +join ticket = B.pair 6 ticket +discard ticket = B.pair 7 ticket +cancel task reason = B.pair 8 (B.pair task reason) +cancelled reason = B.pair 9 reason + +-- Named, independently versioned host facilities; core sequencing stays separate. +request group revision operation arguments = B.pair 10 + (B.pair group (B.pair revision (B.pair operation arguments))) + +capabilities = request "runtime" 1 "capabilities" t +inspect action = request "runtime" 1 "inspect" action + +stdin = request "standard" 1 "input" t +stdout = request "standard" 1 "output" t +stderr = request "standard" 1 "error" t + +openReader owner path = request "file" 1 "open-reader" (B.pair owner path) +openWriter owner path = request "file" 1 "open-writer" (B.pair owner path) +openAppender owner path = request "file" 1 "open-appender" (B.pair owner path) +openUpdate owner path = request "file" 1 "open-update" (B.pair owner path) + +seekFile handle offset = request "file" 1 "seek" (B.pair handle offset) +filePosition handle = request "file" 1 "position" handle +syncFile handle = request "file" 1 "sync" handle + +syncDirectory path = request "directory" 1 "sync" path + +fileSize handle = request "file" 1 "size" handle +resizeFile handle size = request "file" 1 "resize" (B.pair handle size) + +statPath path = request "path" 1 "stat" path +lstatPath path = request "path" 1 "lstat" path +removeFile path = request "path" 1 "remove-file" path +renamePath source destination = request "path" 1 "rename" (B.pair source destination) + +createDirectory path = request "directory" 1 "create" path +removeDirectory path = request "directory" 1 "remove" path +openDirectory owner path = request "directory" 1 "open" (B.pair owner path) +nextDirectory directory = request "directory" 1 "next" directory + +ipv4 octets port = ["ipv4" octets port] +ipv6 words scope port = ["ipv6" words scope port] + +resolveTCP host family port limit = request "resolver" 1 "resolve" [host family port limit] + +listenTCP owner address backlog = request "tcp" 1 "listen" (B.pair owner (B.pair address backlog)) +connectTCP owner address = request "tcp" 1 "connect" (B.pair owner address) +acceptTCP listener owner = request "tcp" 1 "accept" (B.pair listener owner) + +localAddress socket = request "tcp" 1 "local-address" socket +peerAddress socket = request "tcp" 1 "peer-address" socket +shutdownWrite socket = request "tcp" 1 "shutdown-write" socket + +-- Read exactly one authenticated frame from the current cursor. +-- Temporary disk <= size; the caller supplies a per-operation limit. +readIndexedTree reader directory limit size digest = request "indexed-tree" 1 "read" + [reader directory limit size digest] + +-- Canonical native encoding avoids expanded tree/byte lists. The writer checks +-- the size and expected digest before sending. Retry decisions require confirmed +-- progress; a failed write can have partial or uncertain effects. +hashIndexedTree limit tree = request "indexed-tree" 1 "hash" (B.pair limit tree) + +writeIndexedTree writer limit digest tree = request "indexed-tree" 1 "write" + [writer limit digest tree] + +-- Exact canonical-byte comparison consumes precisely one frame. +verifyIndexedTree reader limit tree = request "indexed-tree" 1 "verify" [reader limit tree] + +-- Stateless bounded codecs: decode returns [text carry], with carry <=3 octets. +-- Set final=1 at EOF to reject incomplete suffixes. NUL and BOM are valid scalars; +-- source-file BOM rejection belongs to library/application policy. +encodeUTF8 text = request "utf8" 1 "encode" text +decodeUTF8 final bytes = request "utf8" 1 "decode" [final bytes] + +readSome reader limit = request "stream" 1 "read" (B.pair reader limit) +writeSome writer bytes = request "stream" 1 "write" (B.pair writer bytes) +-- Whole-stream SHA-256 authentication into an anonymous read-only file. +-- Result: [reader size]. Scope-owned; disk <= limit, native chunks <=4096. +-- An EOF probe may consume one byte beyond the limit on failure. +snapshotBytes reader directory limit digest = request "stream" 1 "snapshot" + [reader directory limit digest] +-- Exact native transfer from the current cursor. Failure can follow partial +-- writes; callers close the destination rather than retrying uncertain progress. +transferBytes reader writer count = request "stream" 1 "transfer" [reader writer count] + +close handle = request "resource" 1 "close" handle + +openStage owner directory = request "stage" 1 "open" (B.pair owner directory) +sealStage stage = request "stage" 1 "seal" stage +publishNew stage destination = request "stage" 1 "publish" (B.pair stage (B.pair 0 destination)) +publishReplace stage destination = request "stage" 1 "publish" (B.pair stage (B.pair 1 destination)) + +openDigest owner algorithm = request "digest" 1 "open" (B.pair owner algorithm) +updateDigest digest bytes = request "digest" 1 "update" (B.pair digest bytes) +finishDigest digest = request "digest" 1 "finish" digest + +onOk action next = bind action (reply : case reply of + ((t t), (value, t)) = next value + (t, (_, t)) = pure reply + _ = fail "Invalid operation Result") + +-- Nonrecursive initialization for caller-ordered store directory creation. +-- stat follows an existing symlink and accepts it when its target is a directory. +ensureDirectory path = bind (createDirectory path) (reply : case reply of + (t, (["already-exists" _ _ _], t)) = onOk (statPath path) (status : case status of + ["directory"] = pure (B.ok t t) + _ = pure (B.err ["not-directory" "directory/ensure" "" "none"] t)) + _ = pure reply) + +-- Explicit sequential fallback policy, separate from native connect/resolve. +-- Stop on the first success; preserve the last ordinary failure unchanged. +connectFirstTCP owner addresses = connectCandidates owner addresses + (B.err ["not-found" "tcp/connect-first" "No endpoint candidates" "none"] t) +connectCandidates _ [] previous = pure previous +connectCandidates owner [address | rest] _ = bind (connectTCP owner address) (reply : case reply of + ((t t), (_, t)) = pure reply + (t, (_, t)) = connectCandidates owner rest reply + _ = fail "Invalid connection Result") +connectCandidates _ _ _ = pure (B.err ["invalid-argument" "tcp/connect-first" "Invalid endpoint list" "none"] t) + +-- Turn an explicitly selected operation failure into terminal task failure. +-- Ordinary Results elsewhere remain values. +require action next = bind action (reply : case reply of + ((t t), (value, t)) = next value + (t, (problem, t)) = fail problem + _ = fail "Invalid operation Result") + +-- Whole-chunk convenience over the native positive-progress contract. Failure +-- stops immediately, preserving partial or uncertain progress for the caller. +dropWritten 0 bytes = bytes +dropWritten count [_ | rest] = dropWritten (N.predecessor count) rest +dropWritten _ _ = [] +-- Failure carries confirmed progress BEFORE the failing writeSome, plus its +-- unchanged diagnostic (whose own progress can be unknown). Composite progress +-- includes earlier successful writes even when the last operation reports "none". +writeAll writer bytes = let rec + walk accepted remaining = case remaining of + [] = pure (B.ok t t) + _ = bind (writeSome writer remaining) (reply : case reply of + -- Native writeSome validates at most 4096 input bytes before writing. + -- A full-bound acknowledgement therefore exhausts this entire chunk. + ((t t), (4096, t)) = pure (B.ok t t) + ((t t), (count, t)) = walk (N.add accepted count) (dropWritten count remaining) + (t, (problem, t)) = pure (B.err ["write-all" accepted problem] t) + _ = fail "Invalid operation Result") + in walk 0 bytes + +-- Sequential bounded pumping, without acquiring or closing either endpoint. +-- Errors retain writeAll's confirmed prefix and original uncertain progress. +copyStream reader writer = do onOk + received <- readSome reader 4096 + case received of + ("eof", t) = pure (B.ok t t) + ("data", bytes) = do onOk + writeAll writer bytes + copyStream reader writer + _ = fail "Invalid read reply" + +-- Relative waits and native monotonic observations. Scheduler latency can extend +-- waits; yield offers a scheduling opportunity with scheduler-selected ordering. +monotonicNanoseconds = request "clock" 1 "monotonic" t +sleepMilliseconds milliseconds = request "clock" 1 "sleep" milliseconds +yield = request "scheduler" 1 "yield" t + +-- Returns Result [task joinTicket stdinWriter stdoutReader stderrReader]. +-- cwd: ["inherit"] or ["at" path]; env: ["inherit"] or ["replace" [[key value] ...]]. +-- Normal scope exit waits. Close stdin for EOF; drain stdout/stderr concurrently. +-- Join completion holds ["exit" code] or ["signal" number]. +spawnProcess executable arguments directory environment graceMilliseconds = + request "process" 1 "spawn" [executable arguments directory environment graceMilliseconds] + +-- Read-only host context. Environment query returns ["missing"] or ["value" text]; empty and absent remain distinct. +workingDirectory = request "process" 1 "working-directory" t +environmentVariable name = request "process" 1 "environment-variable" name diff --git a/lib/io/artifacts.tri b/lib/io/artifacts.tri @@ -0,0 +1,90 @@ +!import base as B +!import native_math as N +!import http as HTTP +!import io as IO +!import io_storage as Storage +!import handlers as H +!import arboricx_server as Store +!export write +!export publish +!export publishWith + +-- Source-owned upload policy. Only writes observed by this boundary advance +-- its byte count; producer acknowledgements report producer status. +write bytes = H.request "artifact-stream" "write" bytes +onOk action next = H.bind (H.liftIO action) (reply : case reply of + ((t t), (value, t)) = next value + (t, (_, t)) = H.pure reply + _ = H.fail "invalid-response" "artifact-stream" "host" "Invalid host Result") + +hashReader reader digest remaining = IO.onOk (IO.readSome reader 4096) (reply : case reply of + ("eof", t) = case remaining of + 0 = IO.finishDigest digest + _ = IO.pure (B.err "Existing artifact length mismatch" t) + ("data", bytes) = let count = HTTP.httpBodyLength bytes in case N.lte count remaining of + (t t) = IO.onOk (IO.updateDigest digest bytes) + (_ : hashReader reader digest (N.subtract remaining count)) + _ = IO.pure (B.err "Existing artifact length mismatch" t) + _ = IO.fail "Invalid read reply") + +-- Accept an existing name only after verifying its complete contents and length +-- independently with fresh reader/digest resources. +verifyWinner destination expected wanted = IO.scope (owner : do IO.onOk + reader <- IO.openReader owner destination + digest <- IO.openDigest owner "sha256" + actual <- hashReader reader digest expected + case B.equal? actual wanted of + (t t) = do IO.onOk + IO.syncFile reader + IO.syncDirectory (Storage.parentPath destination) + IO.pure (B.ok t t) + _ = IO.pure (B.err "Existing artifact digest mismatch" t)) + +-- Destination policy runs only after exact-length completion and sealing. It +-- returns a handler Result path and can create a digest-derived shard. Native +-- publication remains generic and rejects cross-filesystem moves without copying. +finish destination expected stage digest = onOk (IO.finishDigest digest) (hash : + case B.checkContract Store.bundleHashC hash of + ((t t), (checked, t)) = onOk (IO.sealStage stage) (_ : + H.bind (destination checked) (reply : case reply of + ((t t), (path, t)) = onOk (IO.publishNew stage path) (outcome : case outcome of + "published" = H.pure (B.ok checked t) + "exists" = onOk (verifyWinner path expected checked) (_ : H.pure (B.ok checked t)) + _ = H.fail "invalid-response" "artifact-stream" "publish" "Invalid publication reply") + (t, (_, t)) = H.pure reply + _ = H.fail "invalid-response" "artifact-stream" "destination" "Expected destination Result")) + _ = H.fail "invalid-response" "artifact-stream" "digest" "Invalid digest reply") + +publish directory expected producer = publishWith directory expected + (digest : H.pure (B.ok (B.append directory (B.pair 47 digest)) t)) producer +publishWith directory expected destination producer = case B.checkContract B.nat? expected of + ((t t), (count, t)) = case N.lte count 67108864 of + (t t) = H.scope (owner : onOk (IO.openStage owner directory) (stage : + onOk (IO.openDigest owner "sha256") (digest : let rec + walk current remaining = case current of + (100, reply) = case reply of + ((t t), (t, t)) = case remaining of + 0 = finish destination count stage digest + _ = H.pure (B.err "Incomplete artifact stream" t) + (t, (_, t)) = H.pure reply + _ = H.fail "invalid-response" "artifact-stream" "producer" "Expected Result unit" + (103, [kind service operation detail labels]) = B.pair 105 + (B.pair labels (H.fail kind service operation detail)) + (106, [["artifact-stream" "write" bytes] labels next]) = + onOk (IO.writeAll stage bytes) (_ : case bytes of + [] = H.pure (B.err "Empty artifact chunk" t) + _ = let length = HTTP.httpBodyLength bytes in case N.lte length remaining of + (t t) = onOk (IO.updateDigest digest bytes) + (_ : walk (next (B.ok t t)) (N.subtract remaining length)) + _ = H.pure (B.err "Artifact exceeds declared length" t)) + (106, [[service operation argument] labels next]) = H.bind + (B.pair 105 (B.pair labels (H.request service operation argument))) + (reply : walk (next reply) remaining) + (107, [labels body next]) = B.pair 105 (B.pair labels + (H.fail "producer-boundary" "artifact-stream" "publish" "Producer must be sequential")) + (108, [supervised keep handles labels body next]) = B.pair 105 (B.pair labels + (H.fail "producer-boundary" "artifact-stream" "publish" "Producer must be sequential")) + _ = H.fail "invalid-view" "artifact-stream" "publish" "Invalid producer view" + in walk (H.view producer) count))) + _ = H.pure (B.err "Artifact length exceeds limit" t) + _ = H.pure (B.err "Invalid artifact length" t) diff --git a/lib/io/http-transport.tri b/lib/io/http-transport.tri @@ -0,0 +1,61 @@ +!import base as B +!import native_math as N +!import http as HTTP +!import io as IO +!export recvHeaders +!export readBody +!export respond +!export handle +!export handleWith + +-- Reuse pure HTTP grammar/limits. The header cap covers the entire accumulated +-- receive buffer, including prefetched body bytes, exactly as in the v1 reader. +receiveHeaders client buffer count = IO.bind (IO.readSome client 4096) (reply : case reply of + ((t t), (("data", chunk), t)) = let + next = B.append buffer chunk + size = N.add count (HTTP.httpBodyLength chunk) + in case N.lte size HTTP.maxHeaderBytes of + (t t) = case B.contains? HTTP.crlfcrlf next of + (t t) = IO.pure (B.ok next t) + _ = receiveHeaders client next size + _ = IO.pure (B.err 431 t) + _ = IO.pure (B.err 400 t)) +recvHeaders client = receiveHeaders client [] 0 + +readState _ (0, (reversed, rest)) = IO.pure (B.ok (B.reverse reversed) rest) +readState client (remaining, (reversed, _)) = IO.bind + (IO.readSome client (HTTP.recvChunkMax4096 remaining)) (reply : case reply of + ((t t), (("data", chunk), t)) = readState client (HTTP.takeBodyBytes chunk remaining reversed) + _ = IO.pure (B.err 400 "Unexpected end of request body\n")) +readState _ _ = IO.pure HTTP.httpBadRequest +readBody client fields initial = case HTTP.contentLength fields of + ((t t), (t, _)) = IO.pure (B.ok [] initial) + ((t t), ((t count), _)) = readState client (HTTP.takeBodyBytes initial count []) + failure = IO.pure failure + +-- The connection's owning scope closes it even on partial write/failure. Do not +-- retry uncertain writes or let a cleanup failure turn into a second response. +send client bytes = case HTTP.takeBodyBytes bytes 4096 [] of + (_, ([], _)) = IO.pure t + (_, (reversed, rest)) = IO.require (IO.writeAll client (B.reverse reversed)) (_ : send client rest) + _ = IO.fail "Invalid response byte list" +respond client method response = send client (HTTP.responseForMethod method response) +body router client method target fields ((t t), (bytes, _)) = + router client method target fields bytes +body _ client method _ _ (t, (status, message)) = respond client method (HTTP.errorResponse status message) +body _ client method _ _ _ = respond client method (HTTP.badRequestResponse "Bad Request\n") +fields router client method target version raw = case HTTP.parseHeaders raw of + ((t t), (headers, initial)) = case HTTP.validateRequest method target version headers of + ((t t), _) = IO.bind (readBody client headers initial) (body router client method target headers) + (t, (status, message)) = respond client method (HTTP.errorResponse status message) + _ = respond client method (HTTP.badRequestResponse "Bad Request\n") + _ = respond client "" (HTTP.badRequestResponse "Bad Request\n") +handleWith router client ((t t), ((method, (target, version)), raw)) = fields router client method target version raw +handleWith _ client _ = respond client "" (HTTP.badRequestResponse "Bad Request\n") + +-- Response callbacks run only after the same request grammar/body checks. They +-- may send a bounded native body; framing errors use the ordinary byte response. +handle router client parsed = handleWith + (socket method target headers bytes : IO.bind + (router (HTTP.routerMethod method) target headers bytes) (respond socket method)) + client parsed diff --git a/lib/io/objects.tri b/lib/io/objects.tri @@ -0,0 +1,85 @@ +!import base as B +!import native_math as N +!import io as IO +!export putTree +!export getTree +!export validDigest + +-- Source-owned indexed-object policy. The store root must already exist; its +-- creation/parent durability belongs to the caller. Root paths use OS resolution; +-- callers must account for symlinks, renames and external mutable writers. +hexDigit value = case value of + 48 = t t + 49 = t t + 50 = t t + 51 = t t + 52 = t t + 53 = t t + 54 = t t + 55 = t t + 56 = t t + 57 = t t + 97 = t t + 98 = t t + 99 = t t + 100 = t t + 101 = t t + 102 = t t + _ = t + +validDigest value = let rec + walk 0 [] = t t + walk 0 _ = t + walk count [c | rest] = case hexDigit c of + (t t) = walk (N.predecessor count) rest + _ = t + walk _ _ = t + in walk 64 value + +shard [a b c | _] = [a b c] +shard _ = [] +objects root = B.append root "/objects" +shardPath root digest = B.append (objects root) (B.pair 47 (shard digest)) +objectPath root digest = B.append (shardPath root digest) (B.pair 47 digest) + +-- Verify every winner, including a new publication, byte-for-byte and through +-- EOF. Sync the verified open inode and its containing directory before returning +-- a digest that callers may publish in a manifest/cache/alias. Failed verification +-- leaves the winner intact. Durability follows OS/filesystem synchronization +-- guarantees; external writers can mutate paths concurrently. +verify owner root limit digest tree = do IO.onOk + reader <- IO.openReader owner (objectPath root digest) + IO.verifyIndexedTree reader limit tree + end <- IO.readSome reader 1 + case end of + ("eof", t) = do IO.onOk + IO.syncFile reader + IO.syncDirectory (shardPath root digest) + IO.pure (B.ok digest t) + _ = IO.pure (B.err ["invalid-data" "objects/verify" "trailing bytes" "unknown"] t) + +putTree root limit tree = IO.scope (owner : do IO.onOk + IO.syncDirectory root + info <- IO.hashIndexedTree limit tree + case info of + [size digest] = do IO.onOk + IO.ensureDirectory (objects root) + IO.syncDirectory root + IO.ensureDirectory (shardPath root digest) + IO.syncDirectory (objects root) + stage <- IO.openStage owner (shardPath root digest) + IO.writeIndexedTree stage limit digest tree + IO.sealStage stage + status <- IO.publishNew stage (objectPath root digest) + case status of + "published" = verify owner root limit digest tree + "exists" = verify owner root limit digest tree + _ = IO.fail "Invalid publication reply" + _ = IO.fail "Invalid indexed hash reply") + +getTree root limit digest = case validDigest digest of + (t t) = IO.scope (owner : do IO.onOk + reader <- IO.openReader owner (objectPath root digest) + size <- IO.fileSize reader + IO.readIndexedTree reader root limit size digest) + _ = IO.pure (B.err ["invalid-argument" "objects/get" "invalid CAS digest" "none"] t) diff --git a/lib/io/storage.tri b/lib/io/storage.tri @@ -0,0 +1,172 @@ +!import base as B +!import native_math as N +!import io as IO +!export readBytes +!export hashBytes +!export publishBytes +!export readTree +!export publishTree +!export parentPath + +-- Caller-selected limits and directories. Whole byte lists are resident source +-- values; transport chunks have a fixed bound. Paths follow OS authority. +problem op detail = ["invalid-data" op detail "unknown"] +checkedLimit limit next = case B.isNat? limit of + (t t) = case N.lte limit 9223372036854775807 of + (t t) = next limit + _ = IO.pure (B.err ["invalid-bound" "storage/limit" "invalid byte limit" "none"] t) + _ = IO.pure (B.err ["invalid-bound" "storage/limit" "invalid byte limit" "none"] t) +-- A fixed composition supplies 4096 structural steps without a per-byte +-- numeric countdown. Each step preserves the proper-list failure sentinel; +-- reaching the bound leaves the suffix for the next chunk to validate. +chunkEnd rest = [[] rest] +chunkStep next [] = [[] []] +chunkStep next [c | rest] = case next rest of + [prefix after] = [(B.pair c prefix) after] + _ = [] +chunkStep _ _ = [] +doubleStep step next = step (step next) +chunkSteps = chunkStep + |> doubleStep |> doubleStep |> doubleStep |> doubleStep + |> doubleStep |> doubleStep |> doubleStep |> doubleStep + |> doubleStep |> doubleStep |> doubleStep |> doubleStep +chunk bytes = chunkSteps chunkEnd bytes +-- Only host-produced proper chunks, or prefixes checked by chunk, reach here. +-- Count their cells directly, preserving the public base.length contract. +chunkLength [] = 0 +chunkLength [_ | rest] = N.successor (chunkLength rest) +chunkLength _ = 0 +flatten chunks = B.foldr (suffix chunk : B.append chunk suffix) [] (B.reverse chunks) +readCount 0 = 1 +readCount remaining = case N.lte remaining 4096 of + (t t) = remaining + _ = 4096 +readLoop reader remaining chunks = do IO.onOk + received <- IO.readSome reader (readCount remaining) + case received of + ("eof", t) = IO.pure (B.ok (flatten chunks) t) + ("data", bytes) = let count = chunkLength bytes in case N.lte count remaining of + (t t) = readLoop reader (N.subtract remaining count) (B.pair bytes chunks) + _ = IO.pure (B.err ["invalid-bound" "storage/read" "file exceeds byte limit" "unknown"] t) + _ = IO.fail "Invalid storage read reply" +-- May consume one excess byte in the EOF probe at the budget boundary. +readBytes path limit = checkedLimit limit (count : IO.scope (owner : do IO.onOk + reader <- IO.openReader owner path + readLoop reader count [])) + +hashLoop digest remaining bytes = case chunk bytes of + [[] []] = IO.finishDigest digest + [part rest] = case N.lte 4096 remaining of + -- A short final chunk may leave unused budget; its empty suffix completes + -- the digest. Every non-final chunk consumes exactly 4096 bytes. + (t t) = do IO.onOk + IO.updateDigest digest part + hashLoop digest (N.subtract remaining 4096) rest + _ = let count = chunkLength part in case N.lte count remaining of + (t t) = do IO.onOk + IO.updateDigest digest part + hashLoop digest (N.subtract remaining count) rest + _ = IO.pure (B.err ["invalid-bound" "storage/hash" "bytes exceed limit" "none"] t) + _ = IO.pure (B.err ["invalid-argument" "storage/hash" "improper byte list" "none"] t) +hashBytes limit bytes = checkedLimit limit (count : IO.scope (owner : do IO.onOk + digest <- IO.openDigest owner "sha256" + hashLoop digest count bytes)) +writeLoop writer bytes = case chunk bytes of + [[] []] = IO.pure (B.ok t t) + [part rest] = do IO.onOk + IO.writeAll writer part + writeLoop writer rest + _ = IO.fail "Invalid preflighted byte list" + +-- Compare only the received prefix, returning the unconsumed expected suffix. +-- Both inputs have already passed their byte/proper-list validation boundary. +verifyPrefix [] remaining = [remaining] +verifyPrefix [byte | bytes] [expected | rest] = case B.equal? byte expected of + (t t) = verifyPrefix bytes rest + _ = [] +verifyPrefix _ _ = [] +verifyBytes reader remaining = do IO.onOk + received <- IO.readSome reader 4096 + case received of + ("eof", t) = case remaining of + [] = IO.pure (B.ok t t) + _ = IO.pure (B.err (problem "storage/verify" "different bytes") t) + ("data", bytes) = case verifyPrefix bytes remaining of + [rest] = verifyBytes reader rest + _ = IO.pure (B.err (problem "storage/verify" "different bytes") t) + _ = IO.fail "Invalid verification read reply" +verified owner directory destination verify created = do IO.onOk + reader <- IO.openReader owner destination + verify reader + IO.syncFile reader + IO.syncDirectory directory + IO.pure (B.ok created t) +-- Immutable names verify every winner, through EOF, before syncing its opened +-- inode and directory. Mismatches remain intact for diagnosis. Mutable +-- replacement acknowledges its own stage publication; a concurrent writer can +-- legitimately replace the alias immediately afterward. +publishBytes directory destination replace limit bytes = case replace of + 0 = do IO.onOk + hashBytes limit bytes + IO.scope (owner : do IO.onOk + stage <- IO.openStage owner directory + writeLoop stage bytes + IO.sealStage stage + status <- IO.publishNew stage destination + case status of + "published" = verified owner directory destination (reader : verifyBytes reader bytes) (t t) + "exists" = verified owner directory destination (reader : verifyBytes reader bytes) t + _ = IO.fail "Invalid publication reply") + 1 = do IO.onOk + hashBytes limit bytes + IO.scope (owner : do IO.onOk + stage <- IO.openStage owner directory + writeLoop stage bytes + IO.sealStage stage + IO.publishReplace stage destination + IO.pure (B.ok (t t) t)) + _ = IO.pure (B.err ["invalid-argument" "storage/publish" "invalid replacement flag" "none"] t) + +verifyTree reader limit tree = do IO.onOk + IO.verifyIndexedTree reader limit tree + end <- IO.readSome reader 1 + case end of + ("eof", t) = IO.pure (B.ok t t) + _ = IO.pure (B.err (problem "storage/verify" "trailing bytes") t) +publishTree directory destination limit expected tree = do IO.onOk + info <- IO.hashIndexedTree limit tree + case info of + [_ actual] = case B.equal? actual expected of + (t t) = IO.scope (owner : do IO.onOk + stage <- IO.openStage owner directory + IO.writeIndexedTree stage limit expected tree + IO.sealStage stage + status <- IO.publishNew stage destination + case status of + "published" = verified owner directory destination (reader : verifyTree reader limit tree) (t t) + "exists" = verified owner directory destination (reader : verifyTree reader limit tree) t + _ = IO.fail "Invalid publication reply") + _ = IO.pure (B.err ["integrity-failed" "storage/publish" "digest mismatch" "none"] t) + _ = IO.fail "Invalid indexed hash reply" +readTree directory path limit expected = IO.scope (owner : do IO.onOk + reader <- IO.openReader owner path + size <- IO.fileSize reader + IO.readIndexedTree reader directory limit size expected) + +-- POSIX lexical parent, retaining separators and literal '..' components. +-- Callers pass complete paths to the native path validators for OS resolution. +slashes [] = t t +slashes [47 | rest] = slashes rest +slashes _ = t +trimSeparators [47 | rest] = trimSeparators rest +trimSeparators rest = rest +parentReverse [] = "." +parentReverse [47 | rest] = case slashes rest of + (t t) = B.pair 47 (B.reverse rest) + _ = B.reverse rest +parentReverse [_ | rest] = parentReverse rest +parentReverse _ = "." +parentPath [] = "." +parentPath path = case trimSeparators (B.reverse path) of + [] = path + rest = parentReverse rest diff --git a/lib/io/text.tri b/lib/io/text.tri @@ -0,0 +1,153 @@ +!import base as B +!import native_math as N +!import io as IO +!export readSource +!export readText +!export encodeText +!export writeText +!export lineCursor +!export readLine + +problem operation detail progress = ["invalid-data" operation detail progress] +-- base.foldr supplies the accumulator first. +flatten chunks = B.foldr (suffix chunk : B.append chunk suffix) [] (B.reverse chunks) + +checkedLimit limit next = case B.checkContract B.nat? limit of + ((t t), (count, t)) = case N.lte count 9223372036854775807 of + (t t) = next count + _ = IO.pure (B.err ["invalid-bound" "text/limit" "invalid text byte limit" "none"] t) + _ = IO.pure (B.err ["invalid-bound" "text/limit" "invalid text byte limit" "none"] t) + +-- Split a bounded prefix; native encode validates its scalars before producing +-- bytes. [] signals an improper character spine. +split 0 rest = [[] rest] +split _ [] = [[] []] +split count [c | rest] = case split (N.predecessor count) rest of + [prefix after] = [(B.pair c prefix) after] + _ = [] +split _ _ = [] + +encodeLoop remaining text chunks = case split 1024 text of + [[] []] = IO.pure (B.ok (flatten chunks) t) + [part rest] = do IO.onOk + bytes <- IO.encodeUTF8 part + case N.lte (B.length bytes) remaining of + (t t) = encodeLoop (N.subtract remaining (B.length bytes)) rest (B.pair bytes chunks) + _ = IO.pure (B.err ["invalid-bound" "text/encode" "encoded text exceeds byte limit" "none"] t) + _ = IO.pure (B.err (problem "text/encode" "improper scalar list" "none") t) +encodeText limit text = checkedLimit limit (remaining : encodeLoop remaining text []) + +-- Force the small acknowledged-byte counter before the next request, without +-- retaining arithmetic that depends on previously emitted chunks. +forceCount t next = next t +forceCount (t bit rest) next = case bit of + t = forceCount rest next + (t t) = forceCount rest next + _ = IO.fail "Invalid text counter" +forceCount _ _ = IO.fail "Invalid text counter" +writeFailure confirmed cause = IO.pure (B.err ["write-text" confirmed cause] t) +writeLoop writer text confirmed = forceCount confirmed (_ : case split 1024 text of + [[] []] = IO.pure (B.ok t t) + [part rest] = IO.bind (IO.encodeUTF8 part) (encoded : case encoded of + ((t t), (bytes, t)) = IO.bind (IO.writeAll writer bytes) (written : case written of + ((t t), (t, t)) = writeLoop writer rest (N.add confirmed (B.length bytes)) + (t, (cause, t)) = writeFailure confirmed cause + _ = IO.fail "Invalid text write reply") + (t, (cause, t)) = writeFailure confirmed cause + _ = IO.fail "Invalid UTF-8 encode reply") + _ = writeFailure confirmed (problem "text/write" "improper scalar list" "none")) +writeText writer text = writeLoop writer text 0 + +-- Whole-source convenience retaining the exact received octets and decoded +-- scalars. Source policy rejects an initial BOM. A one-byte EOF probe detects +-- excess input at the budget boundary. Callers serialize use of this cursor +-- across the complete operation; native leases cover individual reads. +-- Composite failure retains confirmed input +-- bytes (before any uncertain failing read) plus the unchanged native problem. +readFailure consumed cause = IO.pure (B.err ["read-source" consumed cause] t) +readCount 0 = 1 +readCount remaining = case N.lte remaining 4093 of + (t t) = remaining + _ = 4093 +-- Native reads produce proper byte lists. Counting that spine needs no +-- transforming collection contract; raw octets still reach strict UTF-8 decode. +sourceChunkLength [] = 0 +sourceChunkLength [_ | rest] = N.successor (sourceChunkLength rest) +sourceChunkLength _ = 0 +readLoop reader limit remaining carry first rawChunks textChunks = + IO.bind (IO.readSome reader (readCount remaining)) (received : case received of + ((t t), (("eof", t), t)) = IO.bind (IO.decodeUTF8 1 carry) (decoded : case decoded of + ((t t), ([[] []], t)) = IO.pure (B.ok (B.pair (flatten rawChunks) (flatten textChunks)) t) + (t, (cause, t)) = readFailure (N.subtract limit remaining) cause + _ = IO.fail "Invalid UTF-8 final reply") + ((t t), (("data", bytes), t)) = let count = sourceChunkLength bytes in + case N.lte count remaining of + (t t) = IO.bind (IO.decodeUTF8 0 (B.append carry bytes)) (decoded : case decoded of + ((t t), ([text pending], t)) = case first of + 1 = case text of + [] = readLoop reader limit (N.subtract remaining count) pending 1 (B.pair bytes rawChunks) textChunks + [65279 | _] = readFailure (N.add (N.subtract limit remaining) count) + (problem "text/read-source" "initial source BOM is not permitted" "none") + _ = readLoop reader limit (N.subtract remaining count) pending 0 (B.pair bytes rawChunks) (B.pair text textChunks) + _ = readLoop reader limit (N.subtract remaining count) pending 0 (B.pair bytes rawChunks) (B.pair text textChunks) + (t, (cause, t)) = readFailure (N.add (N.subtract limit remaining) count) cause + _ = IO.fail "Invalid UTF-8 decode reply") + _ = readFailure (N.add (N.subtract limit remaining) count) + ["invalid-bound" "text/read-source" "source exceeds byte limit" "none"] + (t, (cause, t)) = readFailure (N.subtract limit remaining) cause + _ = IO.fail "Invalid source read reply") +readSource reader limit = checkedLimit limit (count : readLoop reader count count [] 1 [] []) + +-- Single-consumer source cursor represented as a source value. Thread the +-- returned cursor forward, serialize its use with all reads on the underlying +-- reader, and retire it on read failure. Read-ahead is bounded to 4093 bytes. +lineCursor reader = [reader [] 0] + +-- The byte budget counts line content; LF delimits lines. CR, BOM and NUL +-- remain content. +-- Scan before decoding: malformed bytes after LF belong to the NEXT line. +lineSplit [] = [[] [] 0] +lineSplit [10 | rest] = [[] rest 1] +lineSplit [byte | rest] = case lineSplit rest of + [prefix after ended] = [(B.pair byte prefix) after ended] + _ = [] +lineSplit _ = [] +lineFailure consumed cause = IO.pure (B.err ["read-line" consumed cause] t) +lineReady reader after ending chunks = IO.pure + (B.ok [["line" (flatten chunks) ending] [reader after 0]] t) +lineEOF reader consumed carry chunks = IO.bind (IO.decodeUTF8 1 carry) (decoded : case decoded of + ((t t), ([[] []], t)) = case consumed of + 0 = IO.pure (B.ok [["eof"] [reader [] 1]] t) + _ = IO.pure (B.ok [["line" (flatten chunks) "eof"] [reader [] 1]] t) + (t, (cause, t)) = lineFailure consumed cause + _ = IO.fail "Invalid line final UTF-8 reply") +lineLoop reader limit remaining carry chunks buffered = forceCount remaining (_ : case buffered of + [] = IO.bind (IO.readSome reader 4093) (received : case received of + ((t t), (("eof", t), t)) = lineEOF reader (N.subtract limit remaining) carry chunks + ((t t), (("data", bytes), t)) = lineLoop reader limit remaining carry chunks bytes + (t, (cause, t)) = lineFailure (N.subtract limit remaining) cause + _ = IO.fail "Invalid line read reply") + _ = case lineSplit buffered of + [part after ended] = let count = B.length part in + case N.lte count remaining of + (t t) = IO.bind (IO.decodeUTF8 ended (B.append carry part)) (decoded : case decoded of + ((t t), ([text pending], t)) = case ended of + 1 = lineReady reader after "lf" (B.pair text chunks) + _ = lineLoop reader limit (N.subtract remaining count) pending (B.pair text chunks) [] + (t, (cause, t)) = lineFailure (N.add (N.subtract limit remaining) count) cause + _ = IO.fail "Invalid line UTF-8 reply") + _ = lineFailure (N.add (N.subtract limit remaining) count) + ["invalid-bound" "text/read-line" "line exceeds byte limit" "none"] + _ = IO.fail "Invalid line cursor bytes") +readLine cursor limit = checkedLimit limit (count : case cursor of + [reader buffered 0] = lineLoop reader count count [] [] buffered + [reader [] 1] = IO.pure (B.ok [["eof"] cursor] t) + _ = IO.pure (B.err ["invalid-argument" "text/read-line" "invalid line cursor" "none"] t)) + +-- Generic bounded whole-text convenience preserving an initial BOM as content. +-- It uses the shared incremental decoder with this text policy. +readText reader limit = checkedLimit limit (count : IO.onOk + (readLoop reader count count [] 0 [] []) + (received : case received of + (_, text) = IO.pure (B.ok text t) + _ = IO.fail "Invalid text read result")) diff --git a/lib/packages/io-v2.tri b/lib/packages/io-v2.tri @@ -1,17 +0,0 @@ -!import io_v2 as I -!import io_v2_storage as Storage -!import io_v2_text as Text -!export io -!export bind -!export readBytes -!export sha256Hex -!export putStr -!export abortIO --- Explicit native-v2 package candidate. The published v1 wrapper stays separate. --- Whole-value reads/hashes require a caller-selected budget; no runtime grants. -io = I.io -bind = I.bind -readBytes path limit = Storage.readBytes path limit -sha256Hex bytes limit = Storage.hashBytes limit bytes -putStr text = I.require I.stdout (writer : I.require (Text.writeText writer text) I.pure) -abortIO = I.fail diff --git a/lib/packages/io.tri b/lib/packages/io.tri @@ -1,13 +1,13 @@ -!import io_v2 as I -!import io_v2_storage as Storage -!import io_v2_text as Text +!import io as I +!import io_storage as Storage +!import io_text as Text !export io !export bind !export readBytes !export sha256Hex !export putStr !export abortIO --- Packaged IO v2 facade. Reads and hashes take an explicit byte budget. +-- Packaged IO facade. Reads and hashes take an explicit byte budget. io = I.io bind = I.bind readBytes path limit = Storage.readBytes path limit diff --git a/lib/socket.tri b/lib/socket.tri @@ -1,4 +1,4 @@ -!import io_v2 as IO +!import io as IO !export * -- Scoped TCP facilities. Owner arguments and Result replies are explicit; diff --git a/lib/source/export-cache.tri b/lib/source/export-cache.tri @@ -1,7 +1,7 @@ !import prelude * !import compiler_io * -!import io_v2 as RuntimeIO -!import io_v2_text as Text +!import io as RuntimeIO +!import io_text as Text !import source_object * !import source_module * !import source_lock * diff --git a/lib/source/io-v2.tri b/lib/source/io-v2.tri @@ -1,51 +0,0 @@ -!import base as B -!import io_v2 as IO -!import io_v2_storage as Storage -!import io_v2_text as Text -!export * - --- Compiler storage policy over IO v2, imported explicitly as compiler_io. --- Explicit application policy: resident source/metadata bytes and encoded trees --- are limited to 64 MiB. Heap use, expanded-tree size and CPU work require --- separate resource controls. -byteLimit = 67108864 -version = 2 -io = IO.io -pure = IO.pure -bind = IO.bind -abortIO = IO.fail -thenIO first second = bind first (_ : second) -mapIO action f = bind action (value : pure (f value)) -void action = bind action (_ : pure t) -onOk action next = bind action (reply : B.matchResult (_ _ : pure reply) next reply) -onOk_ action next = IO.onOk action next -onResult action failed succeeded = bind action (reply : B.matchResult failed succeeded reply) -onResult_ action failed succeeded = onResult action (problem _ : failed problem) (value _ : succeeded value) - -readFile path = Storage.readBytes path byteLimit -sha256Hex bytes = Storage.hashBytes byteLimit bytes -readSourceFile path = IO.scope (owner : do IO.onOk - reader <- IO.openReader owner path - Text.readSource reader byteLimit) -encodeSourceText text = Text.encodeText byteLimit text --- Compiler callers create directories in parent-first order. A missing ancestor --- produces an error. Sync the parent entry before use. -createDirectory path = do IO.onOk - IO.ensureDirectory path - IO.syncDirectory (Storage.parentPath path) -fileExists path = bind (IO.statPath path) (reply : case reply of - ((t t), (["file" _], t)) = pure (B.ok (t t) t) - ((t t), (_, t)) = pure (B.ok t t) - (t, (["not-found" _ _ _], t)) = pure (B.ok t t) - _ = pure reply) -writeBytesIfAbsent path bytes = Storage.publishBytes (Storage.parentPath path) path 0 byteLimit bytes -atomicWriteBytes path bytes = Storage.publishBytes (Storage.parentPath path) path 1 byteLimit bytes -hashIndexedTree tree = do IO.onOk - info <- IO.hashIndexedTree byteLimit tree - case info of - [_ digest] = pure (B.ok digest t) - _ = IO.fail "Invalid indexed hash reply" -writeIndexedTreeIfAbsent path digest tree = - Storage.publishTree (Storage.parentPath path) path byteLimit digest tree -readIndexedTree path digest = - Storage.readTree (Storage.parentPath path) path byteLimit digest diff --git a/lib/source/io.tri b/lib/source/io.tri @@ -0,0 +1,51 @@ +!import base as B +!import io as IO +!import io_storage as Storage +!import io_text as Text +!export * + +-- Compiler storage policy over IO, imported explicitly as compiler_io. +-- Explicit application policy: resident source/metadata bytes and encoded trees +-- are limited to 64 MiB. Heap use, expanded-tree size and CPU work require +-- separate resource controls. +byteLimit = 67108864 +version = 2 +io = IO.io +pure = IO.pure +bind = IO.bind +abortIO = IO.fail +thenIO first second = bind first (_ : second) +mapIO action f = bind action (value : pure (f value)) +void action = bind action (_ : pure t) +onOk action next = bind action (reply : B.matchResult (_ _ : pure reply) next reply) +onOk_ action next = IO.onOk action next +onResult action failed succeeded = bind action (reply : B.matchResult failed succeeded reply) +onResult_ action failed succeeded = onResult action (problem _ : failed problem) (value _ : succeeded value) + +readFile path = Storage.readBytes path byteLimit +sha256Hex bytes = Storage.hashBytes byteLimit bytes +readSourceFile path = IO.scope (owner : do IO.onOk + reader <- IO.openReader owner path + Text.readSource reader byteLimit) +encodeSourceText text = Text.encodeText byteLimit text +-- Compiler callers create directories in parent-first order. A missing ancestor +-- produces an error. Sync the parent entry before use. +createDirectory path = do IO.onOk + IO.ensureDirectory path + IO.syncDirectory (Storage.parentPath path) +fileExists path = bind (IO.statPath path) (reply : case reply of + ((t t), (["file" _], t)) = pure (B.ok (t t) t) + ((t t), (_, t)) = pure (B.ok t t) + (t, (["not-found" _ _ _], t)) = pure (B.ok t t) + _ = pure reply) +writeBytesIfAbsent path bytes = Storage.publishBytes (Storage.parentPath path) path 0 byteLimit bytes +atomicWriteBytes path bytes = Storage.publishBytes (Storage.parentPath path) path 1 byteLimit bytes +hashIndexedTree tree = do IO.onOk + info <- IO.hashIndexedTree byteLimit tree + case info of + [_ digest] = pure (B.ok digest t) + _ = IO.fail "Invalid indexed hash reply" +writeIndexedTreeIfAbsent path digest tree = + Storage.publishTree (Storage.parentPath path) path byteLimit digest tree +readIndexedTree path digest = + Storage.readTree (Storage.parentPath path) path byteLimit digest diff --git a/src/IO/ByteStream.hs b/src/IO/ByteStream.hs @@ -0,0 +1,84 @@ +{-# LANGUAGE BangPatterns #-} +-- Native bulk bytes; source owns paths, digest selection and response policy. +module IO.ByteStream (snapshot, snapshotWith, transfer) where + +import qualified IO.Resources as R +import Control.Exception +import Control.Monad (unless, when) +import Crypto.Hash (Context, SHA256, hashInit, hashUpdate, hashFinalize) +import qualified Data.ByteString as BS +import Data.IORef +import System.Directory (removeFile) +import System.IO + +-- A whole-stream SHA-256 snapshot, including an EOF probe at the byte budget. +-- Register before consuming input. The exposed file has no write/resize method; +-- only the private handle can prepare it, before its token is returned. +snapshot :: R.Runtime -> R.Scope -> R.Token -> FilePath -> Integer -> String + -> IO (Either R.Problem (R.Token, Integer)) +snapshot = snapshotWith hClose + +snapshotWith :: (Handle -> IO ()) -> R.Runtime -> R.Scope -> R.Token -> FilePath + -> Integer -> String -> IO (Either R.Problem (R.Token, Integer)) +snapshotWith release runtime scope reader directory limit expected + | limit < 0 || limit > R.positionLimit = pure (Left (fault "invalid-bound" "invalid byte limit" "none")) + | length expected /= 64 || any (`notElem` ("0123456789abcdef" :: String)) expected = + pure (Left (fault "invalid-argument" "expected lowercase SHA-256" "none")) + | null directory = pure (Left (fault "invalid-argument" "empty snapshot directory" "none")) + | otherwise = R.withReaderLease runtime scope reader (\receive -> mask $ \restore -> do + slot <- newIORef Nothing + acquired <- R.acquire runtime scope $ do + (path,h) <- openBinaryTempFile directory ".tricu-bytes-" + (do + removeFile path + hSetBuffering h NoBuffering + writeIORef slot (Just h) + pure (R.File (R.FileOps (Just (BS.hGetSome h)) Nothing + (Just (hSeek h AbsoluteSeek)) (Just (hTell h)) (hFileSize h) + Nothing Nothing (release h)))) `onException` hClose h + token <- either R.resourceFault pure acquired + Just h <- readIORef slot + -- A lease protects the registered spool throughout preparation. Exceptions + -- leave it to scope settlement, retaining cancellation/cleanup precedence. + result <- restore $ R.withReaderLease runtime scope token $ \_ -> do + let copy !count !context = do + allowInterrupt + bytes <- receive (fromInteger (min (toInteger R.chunkLimit) (limit-count+1))) + if BS.null bytes then pure (count,context) else do + let nextCount = count + toInteger (BS.length bytes) + when (nextCount > limit) (R.resourceFault (fault "invalid-bound" "stream exceeds byte limit" "unknown")) + BS.hPut h bytes + next <- evaluate (hashUpdate context bytes) + copy nextCount next + (count,context) <- copy 0 (hashInit :: Context SHA256) + unless (show (hashFinalize context) == expected) + (R.resourceFault (fault "integrity-failed" "SHA-256 mismatch" "unknown")) + hSeek h AbsoluteSeek 0 + pure count + case result of + Right count -> pure (token,count) + Left primary -> do + closed <- R.closeResource runtime scope token + R.resourceFault $ case closed of + Right () -> primary + Left cleanup -> primary { R.detail = take 240 (R.detail primary) ++ "; cleanup: " ++ take 240 (show cleanup) }) + where fault category detail progress = R.Problem category "snapshot" detail progress + +-- Exact byte count, starting at the current cursor; no EOF probe. Both leases +-- cover the transfer. A failure may follow partial writes: callers close rather +-- than retrying or emitting a second response. No encoded byte trees are built. +transfer :: R.Runtime -> R.Scope -> R.Token -> R.Token -> Integer -> IO (Either R.Problem ()) +transfer runtime scope reader writer count + | count < 0 || count > R.positionLimit = pure (Left (R.Problem "invalid-bound" "transfer" "invalid byte count" "none")) + | otherwise = R.withReaderLease runtime scope reader (\receive -> + R.withWriterLease runtime scope writer (\send -> do + let sendAll bytes = unless (BS.null bytes) $ do + n <- send bytes + sendAll (BS.drop n bytes) + copy !remaining = when (remaining > 0) $ do + allowInterrupt + bytes <- receive (fromInteger (min remaining (toInteger R.chunkLimit))) + when (BS.null bytes) (R.resourceFault (R.Problem "invalid-data" "transfer" "unexpected EOF" "unknown")) + sendAll bytes + copy (remaining - toInteger (BS.length bytes)) + copy count) >>= either R.resourceFault pure) diff --git a/src/IO/Clock.hs b/src/IO/Clock.hs @@ -0,0 +1,28 @@ +-- Native monotonic observations and interruptible relative waits. No wall-clock +-- epoch, deadline scheduler, race combinator or ordering guarantee is implied. +module IO.Clock (monotonicNanoseconds, sleepMilliseconds, sleepWith, sleepLimit) where + +import Control.Concurrent (threadDelay) +import GHC.Clock (getMonotonicTimeNSec) + +sleepLimit :: Integer +sleepLimit = 9223372036854775807 + +monotonicNanoseconds :: IO Integer +monotonicNanoseconds = toInteger <$> getMonotonicTimeNSec + +-- Driver validates canonical [0,sleepLimit] milliseconds before entry. Keep each +-- host delay <= one second: no Int overflow even on a narrower host, and no giant +-- conversion to microseconds. Delays may run late; cancellation interrupts them. +-- Injectable waiter supports deterministic chunk/overflow tests, not fault flags. +sleepWith :: (Int -> IO ()) -> Integer -> IO () +sleepWith wait = go + where + go 0 = pure () + go remaining = do + let chunk = min remaining 1000 + wait (fromInteger (chunk * 1000)) + go (remaining - chunk) + +sleepMilliseconds :: Integer -> IO () +sleepMilliseconds = sleepWith threadDelay diff --git a/src/IO/Codec.hs b/src/IO/Codec.hs @@ -0,0 +1,112 @@ +{-# LANGUAGE ScopedTypeVariables #-} +-- Format-specific authenticated transport, independent of store paths/publication. +module IO.Codec (hashIndexed, writeIndexed, verifyIndexed, readIndexed, readIndexedWith, sectionLimit, manifestLimit) where + +import qualified IO.Resources as R +import ContentStore.Snapshot +import ContentStore.Indexed (indexedTreeDomain, decodeIndexedSnapshot) +import ContentStore.Object (Domain(..), hashToText, validateObjectHash) +import Wire (Bundle, buildBundleShared, bundleEncodedLength, emitBundle) +import Crypto.Hash (Context, SHA256, hashInit, hashUpdate, hashFinalize) +import qualified Data.ByteString as BS +import Data.Text.Encoding (encodeUtf8) +import Control.Monad (unless) +import Runtime (T) +import Control.Exception +import Data.IORef +import qualified Data.Text as Text +import System.IO (Handle, hClose) + +sectionLimit, manifestLimit :: Int +sectionLimit = 64 +manifestLimit = 65536 + +readIndexed :: R.Runtime -> R.Scope -> R.Token -> FilePath -> Integer -> Integer -> String + -> IO (Either R.Problem T) +readIndexed = readIndexedWith hClose + +-- Injectable release for lifecycle/fault controls, not a production fault flag. +readIndexedWith :: (Handle -> IO ()) -> R.Runtime -> R.Scope -> R.Token -> FilePath + -> Integer -> Integer -> String -> IO (Either R.Problem T) +readIndexedWith release runtime scope reader directory limit size digest + | limit < 0 || size < 0 || size > limit || limit > R.positionLimit = + pure (Left (R.Problem "invalid-bound" "read" "invalid snapshot size limit" "none")) + | Left _ <- validateObjectHash expected = + pure (Left (R.Problem "invalid-argument" "read" "expected 64 lowercase hexadecimal digits" "none")) + | null directory = pure (Left (R.Problem "invalid-argument" "read" "empty snapshot directory" "none")) + | otherwise = R.withReaderLease runtime scope reader $ \receive -> + withAuthenticatedSnapshotUsing lifetime directory limit size indexedTreeDomain expected receive $ \snapshot -> do + decoded <- decodeIndexedSnapshot sectionLimit manifestLimit snapshot + either (R.resourceFault . malformed) pure decoded + where + expected = Text.pack digest + malformed message = R.Problem "invalid-data" "read" (take 512 message) "unknown" + -- Register the anonymous spool before consuming input. A terminal exception + -- leaves its entry for structured scope settlement, preserving the primary + -- fault/cancellation and collecting any cleanup diagnostics there. + lifetime allocate consume = R.withTemporary runtime scope "read" + (do handle <- allocate; pure (handle, release handle)) + (\handle -> consume handle `catch` (R.resourceFault . snapshotProblem)) + >>= either R.resourceFault pure + snapshotProblem e = case e of + SnapshotDigestMismatch -> R.Problem "integrity-failed" "read" "CAS digest mismatch" "unknown" + InvalidSnapshotBound -> R.Problem "invalid-bound" "read" "invalid snapshot size limit" "none" + InvalidSnapshotDigest -> R.Problem "invalid-argument" "read" "invalid digest" "none" + SnapshotTruncated -> malformed "incomplete indexed frame" + SnapshotReaderExceededBound -> malformed "reader exceeded frame bound" + InvalidSnapshotRange -> malformed "invalid snapshot range" + +-- Prepare/hash a resident DAG, not an encoded byte list. Encoding is regenerated +-- during writing: sharing a lazy byte-chunk list across passes would retain the +-- entire encoding. Application policy still selects sharding and publication. +prepareIndexed :: Integer -> T -> IO (Either R.Problem (Bundle, Integer, String)) +prepareIndexed limit tree + | limit < 0 || limit > R.positionLimit = pure (Left (R.Problem "invalid-bound" "hash" "invalid encoded size limit" "none")) + | otherwise = do + bundle <- buildBundleShared [("root", tree)] + let size = bundleEncodedLength bundle + if size > limit then pure (Left (R.Problem "invalid-bound" "hash" "encoded tree exceeds limit" "none")) + else do + let Domain domain = indexedTreeDomain + context <- newIORef (hashUpdate (hashInit :: Context SHA256) (encodeUtf8 domain <> BS.singleton 0)) + emitBundle R.chunkLimit bundle $ \bytes -> do + previous <- readIORef context + next <- evaluate (hashUpdate previous bytes) + writeIORef context next + finished <- readIORef context + digest <- evaluate (hashToText (hashFinalize finished)) + pure (Right (bundle, size, Text.unpack digest)) + +hashIndexed :: Integer -> T -> IO (Either R.Problem (Integer, String)) +hashIndexed limit tree = fmap (fmap (\(_,size,digest) -> (size,digest))) (prepareIndexed limit tree) + +writeIndexed :: R.Runtime -> R.Scope -> R.Token -> Integer -> String -> T + -> IO (Either R.Problem (Integer, String)) +writeIndexed runtime scope writer limit expected tree + | limit < 0 || limit > R.positionLimit = pure (Left (R.Problem "invalid-bound" "write" "invalid encoded size limit" "none")) + | Left _ <- validateObjectHash (Text.pack expected) = pure (Left (R.Problem "invalid-argument" "write" "expected 64 lowercase hexadecimal digits" "none")) + | otherwise = R.withWriterLease runtime scope writer $ \send -> do + (bundle,size,digest) <- prepareIndexed limit tree >>= either R.resourceFault pure + unless (digest == expected) (R.resourceFault (R.Problem "integrity-failed" "write" "CAS digest mismatch" "none")) + let allBytes bytes = unless (BS.null bytes) $ do + count <- send bytes + allBytes (BS.drop count bytes) + emitBundle R.chunkLimit bundle allBytes + pure (size,digest) + +-- Compare the exact canonical bytes, not merely their digest. This consumes one +-- known-size frame without an EOF probe; source policy must check whole-file EOF +-- when verifying an immutable store winner. No untrusted bytes are decoded. +verifyIndexed :: R.Runtime -> R.Scope -> R.Token -> Integer -> T + -> IO (Either R.Problem (Integer, String)) +verifyIndexed runtime scope reader limit tree + | limit < 0 || limit > R.positionLimit = pure (Left (R.Problem "invalid-bound" "verify" "invalid encoded size limit" "none")) + | otherwise = R.withReaderLease runtime scope reader $ \receive -> do + (bundle,size,digest) <- prepareIndexed limit tree >>= either R.resourceFault pure + let compareBytes expected = unless (BS.null expected) $ do + actual <- receive (BS.length expected) + unless (not (BS.null actual) && actual == BS.take (BS.length actual) expected) + (R.resourceFault (R.Problem "integrity-failed" "verify" "indexed frame differs from canonical tree" "unknown")) + compareBytes (BS.drop (BS.length actual) expected) + emitBundle R.chunkLimit bundle compareBytes + pure (size,digest) diff --git a/src/IO/Context.hs b/src/IO/Context.hs @@ -0,0 +1,26 @@ +{-# LANGUAGE ScopedTypeVariables #-} +-- Read-only host context; no global cwd/environment mutation or implicit state. +module IO.Context (workingDirectory, environmentVariable, boundedText) where +import qualified IO.Resources as R +import Control.Exception +import qualified System.Directory as D +import qualified System.Environment as E + +-- Bound the returned text; the host library may materialize it before this check. +boundedText :: String -> String -> Either R.Problem String +boundedText operation value + | length (take 4097 value) > 4096 = Left (R.Problem "invalid-bound" operation "host context text exceeds scalar limit" "none") + | any (\c -> c == '\0' || (c >= '\xD800' && c <= '\xDFFF')) value = + Left (R.Problem "invalid-data" operation "host context is not NUL-free Unicode scalar text" "none") + | otherwise = Right value + +workingDirectory :: IO (Either R.Problem FilePath) +workingDirectory = (boundedText "working-directory" <$> D.getCurrentDirectory) + `catch` (\(e :: IOException) -> pure (Left (R.ioProblem "working-directory" e))) + +environmentVariable :: String -> IO (Either R.Problem (Maybe String)) +environmentVariable name = case boundedText "environment-variable" name of + Left problem -> pure (Left problem) + Right checked | null checked || '=' `elem` checked -> pure (Left (R.Problem "invalid-argument" "environment-variable" "" "none")) + Right checked -> (E.lookupEnv checked >>= pure . traverse (boundedText "environment-variable")) + `catch` (\(e :: IOException) -> pure (Left (R.ioProblem "environment-variable" e))) diff --git a/src/IO/Descriptor.hs b/src/IO/Descriptor.hs @@ -0,0 +1,29 @@ +-- Owned raw descriptors: a failed close must never be retried against a number +-- the OS may already have recycled. No public descriptors or unrestricted FFI. +module IO.Descriptor (closeOnce, releaseOnce, duplicateSync) where + +import Control.Exception (bracketOnError, mask_) +import Control.Monad (when) +import Data.IORef +import GHC.IO.Handle (hDuplicate) +import System.IO (Handle, hClose) +import System.Posix.IO (closeFd, handleToFd) +import System.Posix.Types (Fd) +import System.Posix.Unistd (fileSynchronise) + +closeOnce :: Fd -> IO (IO ()) +closeOnce fd = releaseOnce (closeFd fd) + +-- Injectable action for close-failure/cancellation controls. +releaseOnce :: IO () -> IO (IO ()) +releaseOnce action = do + owned <- newIORef True + pure $ mask_ $ do + close <- atomicModifyIORef' owned (\old -> (False,old)) + when close action + +duplicateSync :: Handle -> IO (IO (), IO ()) +duplicateSync handle = mask_ $ do + fd <- bracketOnError (hDuplicate handle) hClose handleToFd + release <- closeOnce fd + pure (fileSynchronise fd, release) diff --git a/src/IO/Digest.hs b/src/IO/Digest.hs @@ -0,0 +1,30 @@ +-- Scoped SHA-256 state, deliberately independent of writers and publication. +module IO.Digest (openDigest) where + +import qualified IO.Resources as R +import Crypto.Hash (Context, SHA256, Digest, hashInit, hashUpdate, hashFinalize) +import Data.ByteArray (convert) +import qualified Data.ByteString.Base16 as Hex +import qualified Data.ByteString.Char8 as BC +import Data.IORef +import Control.Exception (evaluate, mask_) + +openDigest :: R.Runtime -> R.Scope -> String -> IO (Either R.Problem R.Token) +openDigest runtime scope algorithm + | algorithm /= "sha256" = pure (Left (R.Problem "unsupported" "digest-open" (take 128 algorithm) "none")) + | otherwise = R.acquire runtime scope $ do + state <- newIORef (Just (hashInit :: Context SHA256)) + let active op = readIORef state >>= maybe + (R.resourceFault (R.Problem "invalid-state" op "digest already finished" "none")) pure + update bytes = mask_ $ do + context <- active "digest-update" + -- Force the new context before storing it: no chain of old chunks. + next <- evaluate (hashUpdate context bytes) + writeIORef state (Just next) + finish = mask_ $ do + context <- active "digest-finish" + let encoded = Hex.encode (convert (hashFinalize context :: Digest SHA256)) + _ <- evaluate (BC.length encoded) + writeIORef state Nothing + pure (BC.unpack encoded) + pure (R.Digest update finish (writeIORef state Nothing)) diff --git a/src/IO/Driver.hs b/src/IO/Driver.hs @@ -0,0 +1,467 @@ +{-# LANGUAGE ScopedTypeVariables #-} +-- Native structured tasks, resource ownership and scoped operations. +module IO.Driver (Outcome(..), run, runDetailed, runInRuntime, canonicalNat, decodeChunk) where + +import Runtime (T(..), apply, ofNumber, ofString, ofList, ofBytes, toString) +import qualified IO.Resources as R +import qualified IO.Protocol as P +import IO.Protocol (canonicalNat, problemTree) +import qualified IO.Tasks as S +import qualified IO.Staging as Stage +import qualified IO.Digest as Digest +import qualified IO.Filesystem as FS +import qualified IO.Network as TCP +import qualified IO.Codec as Codec +import qualified IO.ByteStream as ByteStream +import qualified IO.UTF8 as UTF8 +import qualified IO.Resolver as Resolver +import qualified IO.Clock as Clock +import qualified IO.Process as Process +import qualified IO.Context as Context +import qualified Control.Concurrent as Concurrent +import IO.Tasks (Outcome(..)) +import qualified Data.ByteString as BS + + +decodeChunk :: T -> Either String BS.ByteString +decodeChunk = fmap BS.pack . walk R.chunkLimit + where + walk _ Leaf = Right [] + walk 0 _ = Left "chunk exceeds bound" + walk n (Fork value rest) = case canonicalNat 8 value of + Just byte -> (fromIntegral byte :) <$> walk (n-1) rest + _ -> Left "noncanonical octet" + walk _ _ = Left "improper chunk" + +ok, err :: T -> T +ok value = Fork (Stem Leaf) (Fork value Leaf) +err value = Fork Leaf (Fork value Leaf) +reply :: (a -> T) -> Either R.Problem a -> T +reply encode = either (err . problemTree) (ok . encode) +bad :: String -> T +bad op = err (problemTree (R.Problem "invalid-argument" op "" "none")) +-- Only host-generated operation Results pass here. Successful payloads and +-- arbitrary user failure diagnostics remain opaque; no error is inferred from +-- a pure computation's value. Keep category/detail/progress exactly intact. +qualifyError :: String -> T -> T +qualifyError operation result = case result of + Fork Leaf (Fork (Fork category (Fork _ rest)) Leaf) -> + err (Fork category (Fork (ofString operation) rest)) + _ -> result + +protocol :: Outcome +protocol = protocolFailure "invalid IO computation" +protocolFailure :: String -> Outcome +protocolFailure detail = Failed (problemTree (R.Problem "protocol-error" "core/decode" detail "none")) [] + +tokenTree :: R.Token -> T +tokenTree (R.Token identity key) = ofList [ofString "io.v2.resource", ofNumber identity, ofNumber key] +decodeToken :: T -> Maybe R.Token +decodeToken (Fork tag (Fork runId (Fork key Leaf))) + | tag == ofString "io.v2.resource" = R.Token <$> canonicalNat 64 runId <*> canonicalNat 64 key +decodeToken _ = Nothing + +-- Scope references also carry a run identity, supplied by a private root token. +-- Use a separate marker so a resource cannot be passed as a scope. +scopeTree :: R.Token -> R.Scope -> T +scopeTree (R.Token identity _) scope = ofList [ofString "io.v2.scope", ofNumber identity, ofNumber scope] +decodeScope :: R.Token -> T -> Maybe R.Scope +decodeScope (R.Token identity _) (Fork tag (Fork runId (Fork key Leaf))) + | tag == ofString "io.v2.scope", Just identity == canonicalNat 64 runId = canonicalNat 64 key +decodeScope _ _ = Nothing + +runDetailed :: T -> IO Outcome +runDetailed action = R.newRuntime >>= \runtime -> runInRuntime runtime action + +-- One execution owns this runtime's root and always closes it. Exposed for +-- embedding/backend tests; a closed runtime cannot be reused for acquisitions. +runInRuntime :: R.Runtime -> T -> IO Outcome +runInRuntime runtime action = do + identity <- R.runtimeIdentity runtime + scheduler <- S.newScheduler runtime + group <- S.rootGroup scheduler + S.runGroup group (loop runtime (R.Token identity 0) group action []) + +run :: T -> IO (Either String T) +run action = do + outcome <- runDetailed action + pure $ case outcome of + Completed value -> Right value + Cancelled _ cleanup -> Left ("IO task cancelled (reason not forced; " ++ show (length cleanup) ++ " cleanup diagnostics)") + Failed _ cleanup -> Left ("IO task failed (diagnostic payload not forced; " ++ show (length cleanup) ++ " cleanup diagnostics)") + +loop :: R.Runtime -> R.Token -> S.Group -> T -> [T] -> IO Outcome +loop runtime marker group action frames = S.checkpoint group >> step + where + scope = S.scopeOf group + step = case P.decodeAction action of + Left detail -> pure (protocolFailure detail) + Right (P.Core code payload) -> dispatch code payload $ case lookup code + [(5,"spawn"),(6,"join"),(7,"discard"),(8,"cancel")] of + Just name -> done . qualifyError ("core/" ++ name) + Nothing -> done + Right (P.Request facility revision operation arguments) -> + case P.resolve facility revision operation of + Just code -> dispatch code arguments (done . qualifyError (facility ++ "/" ++ operation)) + Nothing -> done (reply id (Left (R.Problem "unsupported" + (facility ++ "/" ++ operation) ("group revision " ++ show revision) "none"))) + dispatch code payload finish = case code of + 0 -> finish payload + 1 -> case payload of + Fork first next -> loop runtime marker group first (next:frames) + _ -> pure protocol + 2 -> pure (Failed payload []) + 3 -> do + result <- S.nested group (\child -> loop runtime marker child + (apply payload (scopeTree marker (S.scopeOf child))) []) + case result of Completed value -> finish value; failure -> pure failure + 4 -> finish (scopeTree marker scope) + 5 -> case payload of + Fork supervised (Fork keep (Fork handles callback)) -> + case (canonicalNat 1 supervised, canonicalNat 1 keep, decodeHandles handles) of + (Just contain, Just retain, Just tokens) | contain <= 1 && retain <= 1 -> do + result <- S.spawn group (contain == 1) (retain == 1) tokens (\child fresh -> + loop runtime marker child (apply callback (ofList (map tokenTree fresh))) []) + finish (reply (spawnReply (retain == 1)) result) + _ -> finish (bad "spawn") + _ -> finish (bad "spawn") + 6 -> case decodeTask "io.v2.join" payload of + Just ref -> S.join group ref >>= finish . reply outcomeTree + _ -> finish (bad "join") + 7 -> case decodeTask "io.v2.join" payload of + Just ref -> S.discard group ref >>= finish . reply (const Leaf) + _ -> finish (bad "discard") + 8 -> case payload of + Fork handle reason -> case decodeTask "io.v2.task" handle of + Just ref -> S.cancel group ref reason >>= finish . reply (const Leaf) + _ -> finish (bad "cancel") + _ -> finish (bad "cancel") + 9 -> pure (Cancelled payload []) + 20 -> open finish R.openReader "open-reader" payload + 21 -> open finish R.openWriter "open-writer" payload + 22 -> case payload of + Fork handle count -> case (decodeToken handle, canonicalNat 16 count) of + (Just token, Just n) -> R.readSome runtime scope token (fromInteger n) >>= finish . reply readValue + _ -> finish (bad "read") + _ -> finish (bad "read") + 23 -> case payload of + Fork handle bytes -> case (decodeToken handle, decodeChunk bytes) of + (Just token, Right chunk) -> R.writeSome runtime scope token chunk >>= finish . reply (ofNumber . toInteger) + _ -> finish (bad "write") + _ -> finish (bad "write") + 24 -> case decodeToken payload of + Just token -> R.closeResource runtime scope token >>= finish . reply (const Leaf) + _ -> finish (bad "close") + 25 -> standard finish R.StandardInput payload + 26 -> standard finish R.StandardOutput payload + 27 -> standard finish R.StandardError payload + 50 -> open finish R.openAppender "open-appender" payload + 51 -> open finish R.openUpdate "open-update" payload + 52 -> positionOperation finish R.seekFile "seek" payload + 53 -> unary finish "position" R.filePosition ofNumber payload + 54 -> unary finish "size" R.fileSize ofNumber payload + 55 -> positionOperation finish R.resizeFile "resize" payload + 30 -> open finish Stage.openStage "stage-open" payload + 31 -> unary finish "seal" R.sealStage (const Leaf) payload + 32 -> case payload of + Fork handle (Fork mode path) -> case (decodeToken handle, canonicalNat 1 mode, canonicalText path) of + (Just token, Just replace, Just destination) | replace <= 1 -> + R.publishStage runtime scope token (replace == 1) destination >>= finish . reply ofString + _ -> finish (bad "publish") + _ -> finish (bad "publish") + 40 -> open finish Digest.openDigest "digest-open" payload + 41 -> case payload of + Fork handle bytes -> case (decodeToken handle, decodeChunk bytes) of + (Just token, Right chunk) -> R.updateDigest runtime scope token chunk >>= finish . reply (const Leaf) + _ -> finish (bad "digest-update") + _ -> finish (bad "digest-update") + 42 -> unary finish "digest-finish" R.finishDigest ofString payload + 60 -> pathOperation finish "stat" (FS.statPath True) statusTree payload + 61 -> pathOperation finish "lstat" (FS.statPath False) statusTree payload + 62 -> pathOperation finish "create" FS.createDirectory (const Leaf) payload + 63 -> pathOperation finish "remove" FS.removeDirectory (const Leaf) payload + 64 -> pathOperation finish "remove-file" FS.removeFile (const Leaf) payload + 65 -> case payload of + Fork old new -> case (canonicalText old, canonicalText new) of + (Just source, Just target) -> FS.renamePath source target >>= finish . reply (const Leaf) + _ -> finish (bad "rename") + _ -> finish (bad "rename") + 66 -> open finish FS.openDirectory "open" payload + 67 -> unary finish "next" R.nextDirectory + (maybe (Fork (ofString "eof") Leaf) (Fork (ofString "entry") . ofString)) payload + 80 -> case payload of + Fork ownerRef (Fork endpoint backlog) -> case + (decodeScope marker ownerRef, decodeEndpoint endpoint, canonicalNat 16 backlog) of + (Just owner, Just target, Just limit) -> owned finish "listen" owner + (TCP.listenTCP runtime owner target (fromInteger limit)) + _ -> finish (bad "listen") + _ -> finish (bad "listen") + 81 -> case payload of + Fork ownerRef endpoint -> case (decodeScope marker ownerRef, decodeEndpoint endpoint) of + (Just owner, Just target) -> owned finish "connect" owner (TCP.connectTCP runtime owner target) + _ -> finish (bad "connect") + _ -> finish (bad "connect") + 82 -> case payload of + Fork handle ownerRef -> case (decodeToken handle, decodeScope marker ownerRef) of + (Just listener, Just owner) -> owned finish "accept" owner + (R.acceptConnection runtime scope listener owner) + _ -> finish (bad "accept") + _ -> finish (bad "accept") + 83 -> unary finish "local-address" (\r s t -> R.socketAddress r s t False) endpointTree payload + 84 -> unary finish "peer-address" (\r s t -> R.socketAddress r s t True) endpointTree payload + 85 -> unary finish "shutdown-write" R.shutdownWrite (const Leaf) payload + 86 -> case payload of + Fork host (Fork family (Fork port (Fork limit Leaf))) -> case + (boundedHost host, resolveFamily family, canonicalNat 16 port, canonicalNat 8 limit) of + (Just name, Just selection, Just service, Just count) -> + Resolver.resolveTCP name selection service (fromInteger count) >>= finish . reply + (\(Resolver.Resolution endpoints truncated) -> ofList + [ofList (map endpointTree endpoints),ofNumber (if truncated then 1 else 0)]) + _ -> finish (bad "resolve") + _ -> finish (bad "resolve") + 87 -> case payload of + Fork reader (Fork directory (Fork limit (Fork size (Fork digest Leaf)))) -> case + (decodeToken reader, canonicalText directory, canonicalNat 63 limit, + canonicalNat 63 size, boundedDigest digest) of + (Just token, Just path, Just maximum, Just count, Just expected) -> + Codec.readIndexed runtime scope token path maximum count expected >>= finish . reply id + _ -> finish (bad "read") + _ -> finish (bad "read") + 88 -> case payload of + Fork limit tree -> case canonicalNat 63 limit of + Just maximum -> Codec.hashIndexed maximum tree >>= finish . reply encodedInfo + _ -> finish (bad "hash") + _ -> finish (bad "hash") + 89 -> case payload of + Fork writer (Fork limit (Fork digest (Fork tree Leaf))) -> case + (decodeToken writer, canonicalNat 63 limit, boundedDigest digest) of + (Just token, Just maximum, Just expected) -> + Codec.writeIndexed runtime scope token maximum expected tree >>= finish . reply encodedInfo + _ -> finish (bad "write") + _ -> finish (bad "write") + 95 -> case payload of + Fork reader (Fork limit (Fork tree Leaf)) -> case (decodeToken reader, canonicalNat 63 limit) of + (Just token, Just maximum) -> Codec.verifyIndexed runtime scope token maximum tree >>= finish . reply encodedInfo + _ -> finish (bad "verify") + _ -> finish (bad "verify") + 56 -> unary finish "sync" R.syncFile (const Leaf) payload + 68 -> pathOperation finish "sync" (FS.syncDirectory runtime scope) (const Leaf) payload + 96 -> case unicodeChunk UTF8.scalarLimit payload of + Just text -> finish (reply ofBytes (UTF8.encode text)) + _ -> finish (bad "encode") + 97 -> case payload of + Fork final (Fork bytes Leaf) -> case (canonicalNat 1 final, decodeChunk bytes) of + (Just end, Right chunk) -> finish (reply (\(text,carry) -> ofList [ofString text,ofBytes carry]) + (UTF8.decode (end == 1) chunk)) + _ -> finish (bad "decode") + _ -> finish (bad "decode") + 102 -> case payload of + Leaf -> Clock.monotonicNanoseconds >>= finish . ok . ofNumber + _ -> finish (bad "monotonic") + 103 -> case canonicalNat 63 payload of + Just milliseconds -> Clock.sleepMilliseconds milliseconds >> finish (ok Leaf) + _ -> finish (bad "sleep") + 104 -> case payload of + Leaf -> Concurrent.yield >> finish (ok Leaf) + _ -> finish (bad "yield") + 106 -> case payload of + Leaf -> Context.workingDirectory >>= finish . reply ofString + _ -> finish (bad "working-directory") + 107 -> case processText payload of + Just name -> Context.environmentVariable name >>= finish . reply + (maybe (ofList [ofString "missing"]) (\value -> ofList [ofString "value", ofString value])) + _ -> finish (bad "environment-variable") + 105 -> case decodeProcess payload of + Just spec -> Process.launch runtime group spec >>= finish . reply + (\(task,pipes) -> ofList ([taskTree "io.v2.task" task, taskTree "io.v2.join" task] ++ map tokenTree pipes)) + Nothing -> finish (bad "spawn") + 108 -> case payload of + Fork reader (Fork directory (Fork limit (Fork digest Leaf))) -> case + (decodeToken reader, canonicalText directory, canonicalNat 63 limit, boundedDigest digest) of + (Just token, Just path, Just maximum, Just expected) -> + ByteStream.snapshot runtime scope token path maximum expected >>= finish . reply + (\(snapshot,size) -> ofList [tokenTree snapshot,ofNumber size]) + _ -> finish (bad "snapshot") + _ -> finish (bad "snapshot") + 109 -> case payload of + Fork reader (Fork writer (Fork count Leaf)) -> case + (decodeToken reader, decodeToken writer, canonicalNat 63 count) of + (Just input, Just output, Just size) -> + ByteStream.transfer runtime scope input output size >>= finish . reply (const Leaf) + _ -> finish (bad "transfer") + _ -> finish (bad "transfer") + 100 -> case payload of + Leaf -> finish (ok P.capabilities) + _ -> finish (bad "capabilities") + 101 -> finish (reply id (P.inspectAction payload)) + _ -> pure protocol + encodedInfo (size,digest) = ofList [ofNumber size, ofString digest] + done value = case frames of + [] -> pure (Completed value) + next:rest -> loop runtime marker group (apply next value) rest + owned finish name owner action = do + allowed <- R.isAncestor runtime scope owner + if allowed then action >>= finish . reply tokenTree else finish (bad name) + pathOperation finish name operation encode payload = case canonicalText payload of + Just path -> operation path >>= finish . reply encode + _ -> finish (bad name) + statusTree status = case status of + FS.Regular size -> ofList [ofString "file",ofNumber size] + FS.Directory -> ofList [ofString "directory"] + FS.Symlink -> ofList [ofString "symlink"] + FS.Other -> ofList [ofString "other"] + positionOperation finish operation name payload = case payload of + Fork handle value -> case (decodeToken handle, canonicalNat 63 value) of + (Just token, Just position) -> operation runtime scope token position >>= finish . reply (const Leaf) + _ -> finish (bad name) + _ -> finish (bad name) + standard finish stream payload = case payload of + Leaf -> R.standardStream runtime scope stream >>= finish . reply tokenTree + _ -> finish (bad "standard-stream") + readValue bytes | BS.null bytes = Fork (ofString "eof") Leaf + | otherwise = Fork (ofString "data") (ofBytes bytes) + unary finish name operation encode value = case decodeToken value of + Just token -> operation runtime scope token >>= finish . reply encode + _ -> finish (bad name) + open finish opener name value = case value of + Fork scopeRef path -> case (decodeScope marker scopeRef, canonicalText path) of + (Just owner, Just filename) -> do + allowed <- R.isAncestor runtime scope owner + if allowed then opener runtime owner filename >>= finish . reply tokenTree else finish (bad name) + _ -> finish (bad name) + _ -> finish (bad name) + +-- Transfer lists are bounded separately from binary chunks. Excess tails are +-- not forced; the bound is shared with capability reporting. +decodeHandles :: T -> Maybe [R.Token] +decodeHandles = walk P.transferLimit + where + walk _ Leaf = Just [] + walk 0 _ = Nothing + walk n (Fork h rest) = (:) <$> decodeToken h <*> walk (n-1) rest + walk _ _ = Nothing + +taskTree :: String -> S.TaskRef -> T +taskTree tag (S.TaskRef identity key) = ofList [ofString tag, ofNumber identity, ofNumber key] +decodeTask :: String -> T -> Maybe S.TaskRef +decodeTask expected (Fork tag (Fork identity (Fork key Leaf))) + | tag == ofString expected = S.TaskRef <$> canonicalNat 64 identity <*> canonicalNat 64 key +decodeTask _ _ = Nothing +spawnReply :: Bool -> S.TaskRef -> T +spawnReply keep ref = ofList [taskTree "io.v2.task" ref, if keep then taskTree "io.v2.join" ref else Leaf] +outcomeTree :: Outcome -> T +outcomeTree (Completed value) = ofList [ofString "completed", value] +outcomeTree (Failed diagnostic cleanup) = ofList [ofString "failed", diagnostic, ofList (map problemTree cleanup)] +outcomeTree (Cancelled reason cleanup) = ofList [ofString "cancelled", reason, ofList (map problemTree cleanup)] + +canonicalText :: T -> Maybe String +canonicalText value = case toString value of + Right text | '\0' `notElem` text && value == ofString text -> Just text + _ -> Nothing + +-- Endpoint components have fixed lengths; an excess element is not inspected. +decodeEndpoint :: T -> Maybe R.Endpoint +decodeEndpoint (Fork tag (Fork bytes (Fork port Leaf))) + | tag == ofString "ipv4" = do + values <- components 4 8 bytes + p <- canonicalNat 16 port + case values of + [a,b,c,d] -> Just (R.IPv4 (fromInteger a,fromInteger b,fromInteger c,fromInteger d) (fromInteger p)) + _ -> Nothing +decodeEndpoint (Fork tag (Fork words (Fork scope (Fork port Leaf)))) + | tag == ofString "ipv6" = do + values <- components 8 16 words + s <- canonicalNat 32 scope + p <- canonicalNat 16 port + case values of + [a,b,c,d,e,f,g,h] -> Just (R.IPv6 + (fromInteger a,fromInteger b,fromInteger c,fromInteger d,fromInteger e,fromInteger f,fromInteger g,fromInteger h) + (fromInteger s) (fromInteger p)) + _ -> Nothing +decodeEndpoint _ = Nothing +components :: Int -> Int -> T -> Maybe [Integer] +components 0 _ Leaf = Just [] +components 0 _ _ = Nothing +components n bits (Fork first rest) = (:) <$> canonicalNat bits first <*> components (n-1) bits rest +components _ _ _ = Nothing +endpointTree :: R.Endpoint -> T +endpointTree (R.IPv4 (a,b,c,d) p) = ofList [ofString "ipv4",numbers [a,b,c,d],ofNumber (toInteger p)] +endpointTree (R.IPv6 (a,b,c,d,e,f,g,h) s p) = ofList + [ofString "ipv6",numbers [a,b,c,d,e,f,g,h],ofNumber (toInteger s),ofNumber (toInteger p)] +numbers :: Integral a => [a] -> T +numbers = ofList . map (ofNumber . toInteger) + +-- Exactly 64 lowercase hex digits; reject an excess spine without forcing it. +boundedDigest :: T -> Maybe String +boundedDigest = walk (64 :: Int) + where + walk 0 Leaf = Just [] + walk 0 _ = Nothing + walk n (Fork c rest) = do + code <- canonicalNat 7 c + if (code >= 48 && code <= 57) || (code >= 97 && code <= 102) + then (toEnum (fromInteger code) :) <$> walk (n-1) rest + else Nothing + walk _ _ = Nothing + +-- Text codecs accept NUL, unlike paths. Unicode surrogate code points and +-- noncanonical scalar naturals are rejected before conversion to host Char. +unicodeChunk :: Int -> T -> Maybe String +unicodeChunk _ Leaf = Just [] +unicodeChunk 0 _ = Nothing +unicodeChunk n (Fork c rest) = do + scalar <- canonicalNat 21 c + if scalar <= 0x10ffff && not (scalar >= 0xd800 && scalar <= 0xdfff) + then (toEnum (fromInteger scalar) :) <$> unicodeChunk (n-1) rest + else Nothing +unicodeChunk _ _ = Nothing + +resolveFamily :: T -> Maybe Resolver.Family +resolveFamily value + | value == ofString "ipv4" = Just Resolver.IPv4Only + | value == ofString "ipv6" = Just Resolver.IPv6Only + | value == ofString "any" = Just Resolver.AnyFamily + | otherwise = Nothing +boundedHost :: T -> Maybe String +boundedHost = walk Resolver.hostLimit + where walk _ Leaf = Just [] + walk 0 _ = Nothing + walk n (Fork first rest) = do + code <- canonicalNat 7 first + (toEnum (fromInteger code):) <$> walk (n-1) rest + walk _ _ = Nothing + +-- Bounded before constructing host strings/lists; an excess element is inert. +decodeProcess :: T -> Maybe Process.Spec +decodeProcess (Fork executable (Fork arguments (Fork directory (Fork environment (Fork grace Leaf))))) = do + exe <- processText executable + args <- boundedList 256 processText arguments + cwd <- case directory of + Fork tag Leaf | tag == ofString "inherit" -> Just Nothing + Fork tag (Fork path Leaf) | tag == ofString "at" -> Just <$> processText path + _ -> Nothing + env <- case environment of + Fork tag Leaf | tag == ofString "inherit" -> Just Nothing + Fork tag (Fork entries Leaf) | tag == ofString "replace" -> Just <$> boundedList 256 entry entries + _ -> Nothing + duration <- canonicalNat 63 grace + let spec = Process.Spec exe args cwd env duration + if Process.validSpec spec then Just spec else Nothing + where + entry (Fork key (Fork value Leaf)) = (,) <$> processText key <*> processText value + entry _ = Nothing +decodeProcess _ = Nothing + +boundedList :: Int -> (T -> Maybe a) -> T -> Maybe [a] +boundedList _ _ Leaf = Just [] +boundedList 0 _ _ = Nothing +boundedList n decode (Fork value rest) = (:) <$> decode value <*> boundedList (n-1) decode rest +boundedList _ _ _ = Nothing + +processText :: T -> Maybe String +processText = boundedList 4096 scalar + where + scalar value = do + n <- canonicalNat 21 value + if n > 0 && n <= 1114111 && not (n >= 55296 && n <= 57343) + then Just (toEnum (fromInteger n)) else Nothing diff --git a/src/IO/Filesystem.hs b/src/IO/Filesystem.hs @@ -0,0 +1,109 @@ +{-# LANGUAGE ScopedTypeVariables #-} +-- Path operations are observations/mutations, not authorization or transactions. +-- Directory cursors never materialize a listing or retain previously returned names. +module IO.Filesystem + ( Status(..), statPath, createDirectory, removeDirectory, removeFile, renamePath + , syncDirectory + , openDirectory, directoryBackend, nameLimit + ) where +import qualified IO.Descriptor as Descriptor +import System.Posix.IO (openFd, OpenMode(ReadOnly), defaultFileFlags, nonBlock) +import System.Posix.Unistd (fileSynchronise) +import qualified IO.Resources as R +import qualified System.Posix.Files as F +import qualified System.Posix.Directory as D +import Control.Exception +import Control.Monad (when, unless) +import Data.IORef +import Data.Char (ord) + +data Status = Regular Integer | Directory | Symlink | Other deriving (Eq, Show) +nameLimit :: Int +nameLimit = 4096 + +pathOperation :: String -> Bool -> [FilePath] -> IO a -> IO (Either R.Problem a) +pathOperation op mutating paths action + | any (\p -> null p || '\0' `elem` p) paths = + pure (Left (R.Problem "invalid-argument" op "empty or NUL-containing path" "none")) + | otherwise = (Right <$> action) `catch` \(e :: IOException) -> do + let problem = R.ioProblem op e + pure (Left (problem {R.progress=if mutating then "unknown" else "none"})) + +-- Following the final symlink is an explicit choice; intermediate components +-- always follow the host path resolver. Neither form pins later path operations. +statPath :: Bool -> FilePath -> IO (Either R.Problem Status) +statPath follow path = pathOperation op False [path] $ do + status <- (if follow then F.getFileStatus else F.getSymbolicLinkStatus) path + if F.isRegularFile status then do + let size = toInteger (F.fileSize status) + if size >= 0 && size <= R.positionLimit then pure (Regular size) + else ioError (userError "file size outside supported range") + else pure (if F.isDirectory status then Directory + else if F.isSymbolicLink status then Symlink else Other) + where op = if follow then "stat" else "lstat" + +createDirectory, removeDirectory, removeFile :: FilePath -> IO (Either R.Problem ()) +createDirectory path = pathOperation "create" True [path] (D.createDirectory path 0o777) +removeDirectory path = pathOperation "remove" True [path] (D.removeDirectory path) +removeFile path = pathOperation "remove-file" True [path] (F.removeLink path) +renamePath :: FilePath -> FilePath -> IO (Either R.Problem ()) +renamePath old new = pathOperation "rename" True [old,new] (F.rename old new) + +-- Synchronize an opened directory, not an unchecked path after verification. +-- Nonblocking open avoids hanging on a FIFO supplied where a directory belongs. +syncDirectory :: R.Runtime -> R.Scope -> FilePath -> IO (Either R.Problem ()) +syncDirectory runtime scope path + | null path || '\0' `elem` path = pure (Left (R.Problem "invalid-argument" "sync" "empty or NUL-containing path" "none")) + | otherwise = R.withTemporary runtime scope "sync" (do + fd <- openFd path ReadOnly (defaultFileFlags {nonBlock=True}) + release <- Descriptor.closeOnce fd + pure (fd,release)) $ \fd -> do + status <- F.getFdStatus fd + unless (F.isDirectory status) (R.resourceFault (R.Problem "not-directory" "sync" "path is not a directory" "none")) + fileSynchronise fd + +openDirectory :: R.Runtime -> R.Scope -> FilePath -> IO (Either R.Problem R.Token) +openDirectory runtime scope path + | null path || '\0' `elem` path = pure (Left (R.Problem "invalid-argument" "open" "empty or NUL-containing path" "none")) + | otherwise = R.acquire runtime scope $ + bracketOnError (D.openDirStream path) D.closeDirStream $ \stream -> + directoryBackend (D.readDirStream stream) (D.closeDirStream stream) + +data Cursor = Active | Ended | Poisoned +-- Injectable backend callbacks exercise failure/cancellation at the actual cursor +-- boundary. The registry supplies the lease; these callbacks are not standalone +-- concurrent iterators. An uncertain advance cannot be implicitly retried. +directoryBackend :: IO FilePath -> IO () -> IO R.Backend +directoryBackend readName close = do + state <- newIORef Active + owned <- newIORef True + let fault category detail = R.resourceFault (R.Problem category "next" detail "unknown") + validName = walk nameLimit + where + walk _ [] = pure () + walk 0 _ = fault "invalid-name" "directory name exceeds scalar limit" + walk n (c:rest) + | c == '/' || c == '\0' = fault "invalid-name" "invalid directory component" + | ord c > 0x10ffff || (ord c >= 0xd800 && ord c <= 0xdfff) = fault "invalid-encoding" "directory name is not Unicode scalar text" + | otherwise = walk (n-1) rest + advance budget = do + name <- readName + case name of + "" -> writeIORef state Ended >> pure Nothing + "." -> skip budget + ".." -> skip budget + _ -> validName name >> pure (Just name) + skip 0 = fault "invalid-state" "directory dot-entry scan exceeded bound" + skip n = advance (n-1) + next = mask $ \restore -> do + phase <- readIORef state + case phase of + Ended -> pure Nothing + Poisoned -> fault "invalid-state" "directory cursor failed; reopen explicitly" + Active -> restore (advance (2 :: Int)) `onException` writeIORef state Poisoned + release = mask_ $ do + -- closedir owns a raw pointer: never retry an uncertain close against a + -- potentially freed DIR*. Registry teardown can revisit async failures. + wasOwned <- atomicModifyIORef' owned (\old -> (False,old)) + when wasOwned close + pure (R.Directory next release) diff --git a/src/IO/Network.hs b/src/IO/Network.hs @@ -0,0 +1,97 @@ +{-# LANGUAGE ScopedTypeVariables #-} +-- TCP transports: numeric IPv4/IPv6 endpoints, no implicit resolver/retry policy. +module IO.Network (listenTCP, connectTCP, duplexBackend, backlogLimit) where +import qualified IO.Resources as R +import qualified Network.Socket as N +import qualified Network.Socket.ByteString as B +import qualified Data.ByteString as BS +import Control.Exception +import Control.Monad (when, unless) +import Data.IORef +import GHC.IO.Exception (ioe_errno) +import Foreign.C.Error + +backlogLimit :: Int +backlogLimit = 4096 +address (R.IPv4 host port) = N.SockAddrInet (fromIntegral port) (N.tupleToHostAddress host) +address (R.IPv6 host scope port) = N.SockAddrInet6 (fromIntegral port) 0 (N.tupleToHostAddress6 host) scope +family (R.IPv4 _ _) = N.AF_INET +family (R.IPv6 _ _ _) = N.AF_INET6 +port (R.IPv4 _ p) = p +port (R.IPv6 _ _ p) = p +endpoint (N.SockAddrInet p h) = pure (R.IPv4 (N.hostAddressToTuple h) (fromIntegral p)) +endpoint (N.SockAddrInet6 p _ h scope) = pure (R.IPv6 (N.hostAddress6ToTuple h) scope (fromIntegral p)) +endpoint _ = ioError (userError "unexpected TCP address family") + +networkIO :: String -> IO a -> IO a +networkIO op action = action `catch` \(e :: IOException) -> do + let generic = R.ioProblem op e + category = case ioe_errno e of + Just code -> maybe (R.category generic) id (lookup (Errno code) + [(eCONNREFUSED,"connection-refused"),(eCONNRESET,"connection-reset"), + (eCONNABORTED,"connection-aborted"),(eADDRINUSE,"address-in-use"), + (eADDRNOTAVAIL,"address-unavailable"),(eNETUNREACH,"network-unreachable"), + (eHOSTUNREACH,"host-unreachable"),(ePIPE,"broken-pipe"),(eTIMEDOUT,"timed-out")]) + _ -> R.category generic + R.resourceFault (generic {R.category=category, + R.progress=if op `elem` ["local-address","peer-address"] then "none" else "unknown"}) + +listenTCP :: R.Runtime -> R.Scope -> R.Endpoint -> Int -> IO (Either R.Problem R.Token) +listenTCP runtime owner target backlog + | backlog <= 0 || backlog > backlogLimit = pure (Left (R.Problem "invalid-bound" "listen" "" "none")) + | otherwise = R.acquire runtime owner $ networkIO "listen" $ + bracketOnError (N.socket (family target) N.Stream N.defaultProtocol) N.close $ \socket -> do + N.setSocketOption socket N.ReuseAddr 1 + when (family target == N.AF_INET6) (N.setSocketOption socket N.IPv6Only 1) + N.bind socket (address target) + N.listen socket backlog + let accept = networkIO "accept" $ + bracketOnError (fst <$> N.accept socket) N.close duplexBackend + pure (R.Listener (R.ListenerOps accept + (networkIO "local-address" (N.getSocketName socket >>= endpoint)) (N.close socket))) + +connectTCP :: R.Runtime -> R.Scope -> R.Endpoint -> IO (Either R.Problem R.Token) +connectTCP runtime owner target + | port target == 0 = pure (Left (R.Problem "invalid-bound" "connect" "zero peer port" "none")) + | otherwise = R.acquire runtime owner $ networkIO "connect" $ + bracketOnError (N.socket (family target) N.Stream N.defaultProtocol) N.close $ \socket -> do + N.connect socket (address target) + duplexBackend socket + +data Direction = Open | Ended | Poisoned +-- The registry supplies separate read/write leases; direction state is not +-- synchronized across unrelated sockets, aliases or host IO outside this runtime. +duplexBackend :: N.Socket -> IO R.Backend +duplexBackend socket = do + reading <- newIORef Open + writing <- newIORef Open + let invalid op = R.resourceFault (R.Problem "invalid-state" op "socket direction failed; close explicitly" "unknown") + receive count = mask $ \restore -> do + phase <- readIORef reading + case phase of + Ended -> pure BS.empty + Poisoned -> invalid "read" + Open -> (do + bytes <- restore (networkIO "read" (B.recv socket count)) + when (BS.null bytes) (writeIORef reading Ended) + pure bytes) `onException` writeIORef reading Poisoned + send bytes = mask $ \restore -> do + phase <- readIORef writing + case phase of + Ended -> R.resourceFault (R.Problem "invalid-state" "write" "write direction shut down" "none") + Poisoned -> invalid "write" + Open -> (do + count <- restore (networkIO "write" (B.send socket bytes)) + unless (count > 0 && count <= BS.length bytes) + (ioError (userError "invalid socket write progress")) + pure count) `onException` writeIORef writing Poisoned + shutdown = mask $ \restore -> do + phase <- readIORef writing + case phase of + Ended -> pure () + Poisoned -> invalid "shutdown-write" + Open -> (restore (networkIO "shutdown-write" (N.shutdown socket N.ShutdownSend)) >> + writeIORef writing Ended) `onException` writeIORef writing Poisoned + pure (R.Duplex (R.DuplexOps receive send shutdown + (networkIO "local-address" (N.getSocketName socket >>= endpoint)) + (networkIO "peer-address" (N.getPeerName socket >>= endpoint)) (N.close socket))) diff --git a/src/IO/Process.hs b/src/IO/Process.hs @@ -0,0 +1,173 @@ +{-# LANGUAGE ScopedTypeVariables #-} +-- Direct-child processes, supervised by the existing structured task machinery. +-- No shell expansion, detached waiter, implicit output collection or job control. +module IO.Process (Spec(..), launch, validSpec, TerminationOps(..), terminateWith, cleanupAll) where + +import qualified IO.Resources as R +import qualified IO.Tasks as S +import qualified IO.Clock as Clock +import Runtime (T(..), ofList, ofString, ofNumber) +import Control.Concurrent (threadDelay) +import Control.Concurrent.STM +import Control.Exception +import Control.Monad +import Data.IORef +import Data.List (nub) +import qualified Data.ByteString as BS +import System.Exit +import System.IO +import qualified System.Process as P +import System.Posix.Signals (signalProcess, sigKILL) + +-- Nothing explicitly selects inherited cwd/environment. Just [] is an empty env. +-- Limits count Unicode scalars, not OS-encoded bytes; exec may impose smaller limits. +data Spec = Spec FilePath [String] (Maybe FilePath) (Maybe [(String,String)]) Integer + deriving Show + +validSpec :: Spec -> Bool +validSpec (Spec executable arguments directory environment grace) = + not (null executable) && maybe True (not . null) directory && + length arguments <= 256 && length variables <= 256 && + all validText strings && sum (map length strings) <= 65536 && + all (\(key,_) -> not (null key) && '=' `notElem` key) variables && + length (nub (map fst variables)) == length variables && + grace >= 0 && grace <= 9223372036854775807 + where + variables = maybe [] id environment + strings = executable : arguments ++ maybe [] (:[]) directory ++ concatMap (\(k,v) -> [k,v]) variables + validText s = length s <= 4096 && all (\c -> c /= '\0' && not (c >= '\xD800' && c <= '\xDFFF')) s + +-- Returns one ordinary task/join reference and parent-owned stdin/stdout/stderr. +-- The private process handle is owned by the monitor child, never source-visible. +-- Launch failures consume the internal join ticket before returning an error. +launch :: R.Runtime -> S.Group -> Spec -> IO (Either R.Problem (S.TaskRef, [R.Token])) +launch runtime parent spec + | not (validSpec spec) = pure (Left (R.Problem "invalid-argument" "process/spawn" "" "none")) + | otherwise = mask $ \restore -> do + ready <- newEmptyTMVarIO + spawned <- S.spawn parent False True [] (\child _ -> monitor ready child) + case spawned of + Left problem -> pure (Left problem) + Right task -> do + result <- restore (atomically (takeTMVar ready)) + case result of + Right pipes -> pure (Right (task,pipes)) + Left problem -> do + -- Failure is not acknowledged until the launched process is reaped. + settled <- restore (S.join parent task) + case settled of + Right (S.Completed _) -> pure (Left problem) + _ -> S.checkpoint parent >> R.resourceFault problem + where + Spec executable arguments directory environment grace = spec + monitor ready child = do + slot <- newIORef Nothing + acquired <- R.acquire runtime (S.scopeOf child) $ do + (input,output,errors,process) <- P.createProcess (P.proc executable arguments) + { P.cwd = directory, P.env = environment, P.std_in = P.CreatePipe + , P.std_out = P.CreatePipe, P.std_err = P.CreatePipe, P.close_fds = True } + let handles = [h | Just h <- [input,output,errors]] + raw <- newIORef handles + let release = cleanupAll [releaseRaw raw, terminateAndReap process grace] + writeIORef slot (Just (process,raw,handles)) + pure (R.Internal release) + case acquired of + Left problem -> atomically (putTMVar ready (Left problem)) >> pure (S.Completed Leaf) + Right _ -> do + Just (process,raw,handles) <- readIORef slot + case handles of + [input,output,errors] -> do + pipes <- install raw [] [(True,input),(False,output),(False,errors)] + case pipes of + Left problem -> atomically (putTMVar ready (Left problem)) >> pure (S.Completed Leaf) + Right tokens -> do + atomically (putTMVar ready (Right tokens)) + -- This wait is body work, not a protected release callback. + status <- awaitExit process + pure (S.Completed (exitTree status)) + _ -> ioError (userError "process backend omitted requested pipes") + install _ installed [] = pure (Right (reverse installed)) + install raw installed ((writing,h):rest) = do + result <- R.acquire runtime (S.scopeOf parent) $ do + hSetBinaryMode h True + hSetBuffering h NoBuffering + poisoned <- newIORef False + let guarded action = mask $ \restore -> do + failed <- readIORef poisoned + if failed then R.resourceFault (R.Problem "invalid-state" "process/pipe" "prior operation failed; progress is uncertain" "unknown") + else restore action `onException` writeIORef poisoned True + -- Publication is masked by acquire; thereafter registry release owns h. + modifyIORef' raw (filter (/= h)) + pure $ if writing + then R.Writer (\bytes -> guarded (BS.hPut h bytes >> pure (BS.length bytes))) (hClose h) + else R.Reader (\count -> guarded (BS.hGetSome h count)) (hClose h) + case result of + Right token -> install raw (token:installed) rest + Left problem -> do + cleanupAll [R.closeResource runtime (S.scopeOf parent) token >>= either R.resourceFault pure | token <- installed] + pure (Left problem) + +exitTree :: ExitCode -> T +exitTree ExitSuccess = ofList [ofString "exit", ofNumber 0] +exitTree (ExitFailure code) + | code < 0 = ofList [ofString "signal", ofNumber (negate (toInteger code))] + | otherwise = ofList [ofString "exit", ofNumber (toInteger code)] + +releaseRaw :: IORef [Handle] -> IO () +releaseRaw raw = atomicModifyIORef' raw (\handles -> ([],handles)) >>= cleanupAll . map hClose + +-- Attempt all releases, preserving the first exception. Resource settlement +-- reports failure rather than silently acknowledging a partial cleanup. +cleanupAll :: [IO ()] -> IO () +cleanupAll actions = do + outcomes <- mapM try actions + case [e | Left (e :: SomeException) <- outcomes] of + [] -> pure () + [e] -> throwIO e + e:rest -> R.resourceFault (R.Problem "cleanup-failed" "process/release" + (take 240 (displayException e) ++ "; additional cleanup errors omitted: " ++ show (length rest)) "unknown") + +-- Injectable boundary for independent escalation/error-order tests. The owner +-- must settle the sole wait body before entering this release protocol. +data TerminationOps = TerminationOps + { checkReaped :: IO Bool, requestTerminate :: IO (), forceTerminate :: IO () + , reap :: IO (), clock :: IO Integer, pause :: IO () } + +terminateWith :: TerminationOps -> Integer -> IO () +terminateWith ops grace = do + exited <- checkReaped ops + unless exited $ do + start <- clock ops + let wait remaining previous = do + done <- checkReaped ops + unless done $ do + now <- clock ops + -- Host samples are Word64. Account for wrap between nearby polls, + -- rather than constructing an unreachable absolute deadline. + let elapsed = (now - previous) `mod` 18446744073709551616 + if elapsed < remaining then pause ops >> wait (remaining-elapsed) now + else cleanupAll [forceTerminate ops, reap ops] + cleanupAll [requestTerminate ops, wait (grace*1000000) start] + +terminateAndReap :: P.ProcessHandle -> Integer -> IO () +terminateAndReap process = terminateWith TerminationOps + { checkReaped = maybe False (const True) <$> P.getProcessExitCode process + , requestTerminate = P.terminateProcess process + , forceTerminate = do + -- No other reaper remains: an unreaped child retains its PID. Never + -- expose this handle for concurrent public wait/getPid operations. + pid <- P.getPid process + case pid of Just identity -> signalProcess sigKILL identity; Nothing -> pure () + , reap = void (awaitExit process) + , clock = Clock.monotonicNanoseconds + , pause = threadDelay 10000 } + +-- waitForProcess may block an RTS capability in a foreign wait (and cannot be +-- relied on as a cancellation checkpoint). WNOHANG polling keeps the sole reaper +-- in the task body and is interruptible without a detached host waiter. +awaitExit :: P.ProcessHandle -> IO ExitCode +awaitExit process = do + status <- P.getProcessExitCode process + case status of + Just result -> pure result + Nothing -> threadDelay 10000 >> awaitExit process diff --git a/src/IO/Protocol.hs b/src/IO/Protocol.hs @@ -0,0 +1,82 @@ +-- Shared native request catalogue and shallow, non-executing inspection. +-- Dispatch numbers below are PRIVATE implementation selectors. +module IO.Protocol + ( Action(..), decodeAction, canonicalNat, resolve, capabilities, inspectAction + , problemTree, transferLimit + ) where + +import Runtime (T(..), ofList, ofString, ofNumber) +import qualified IO.Resources as R +import qualified IO.Filesystem as FS +import qualified IO.Network as TCP +import qualified IO.Codec as Codec +import qualified IO.UTF8 as UTF8 +import qualified IO.Resolver as Resolver +import qualified IO.Clock as Clock +import Data.List (find) +import IO.Wire (Action(..), canonicalNat, decodeAction) + +-- Only implemented revisions/operations are advertised. Unsupported names are +-- not promises about a future group's argument schema or API. +catalogue :: [(String, [(String, Integer)])] +catalogue = + [ ("runtime", [("capabilities",100), ("inspect",101)]) + , ("standard", [("input",25), ("output",26), ("error",27)]) + , ("file", [("open-reader",20), ("open-writer",21), ("open-appender",50), ("open-update",51), ("seek",52), ("position",53), ("size",54), ("resize",55), ("sync",56)]) + , ("path", [("stat",60), ("lstat",61), ("remove-file",64), ("rename",65)]) + , ("directory", [("create",62), ("remove",63), ("open",66), ("next",67), ("sync",68)]) + , ("tcp", [("listen",80), ("connect",81), ("accept",82), ("local-address",83), ("peer-address",84), ("shutdown-write",85)]) + , ("resolver", [("resolve",86)]) + , ("indexed-tree", [("read",87), ("hash",88), ("write",89), ("verify",95)]) + , ("utf8", [("encode",96), ("decode",97)]) + , ("stream", [("read",22), ("write",23), ("snapshot",108), ("transfer",109)]) + , ("resource", [("close",24)]) + , ("stage", [("open",30), ("seal",31), ("publish",32)]) + , ("digest", [("open",40), ("update",41), ("finish",42)]) + , ("clock", [("monotonic",102), ("sleep",103)]) + , ("scheduler", [("yield",104)]) + , ("process", [("spawn",105), ("working-directory",106), ("environment-variable",107)]) + ] + +resolve :: String -> Integer -> String -> Maybe Integer +resolve group 1 operation = lookup group catalogue >>= lookup operation +resolve _ _ _ = Nothing + +core :: [(Integer, String)] +core = zip [0..9] ["pure", "bind", "fail", "scope", "current-scope", "spawn", "join", "discard", "cancel", "cancelled"] + +transferLimit :: Int +transferLimit = 256 + +problemTree :: R.Problem -> T +problemTree p = ofList (map ofString [R.category p, R.operation p, R.detail p, R.progress p]) + +-- Association rows keep metadata extensible without making it authority. +capabilities :: T +capabilities = ofList + [ row "schema" (ofNumber 1) + , row "io-version" (ofNumber 2) + , row "backend" (ofString "haskell-native") + , row "authority" (ofString "os-process") + , row "core" (strings (map snd core)) + , row "groups" (ofList [ofList [ofString g, ofNumber 1, strings (map fst ops)] | (g,ops) <- catalogue]) + , row "limits" (ofList [row "chunk-bytes" (ofNumber (toInteger R.chunkLimit)), row "transfer-handles" (ofNumber (toInteger transferLimit)), row "file-offset-max" (ofNumber R.positionLimit), row "directory-name-scalars" (ofNumber (toInteger FS.nameLimit)), row "tcp-backlog" (ofNumber (toInteger TCP.backlogLimit)), row "resolver-host-characters" (ofNumber (toInteger Resolver.hostLimit)), row "resolver-addresses" (ofNumber (toInteger Resolver.addressLimit)), row "indexed-sections" (ofNumber (toInteger Codec.sectionLimit)), row "indexed-manifest-bytes" (ofNumber (toInteger Codec.manifestLimit)), row "utf8-encode-scalars" (ofNumber (toInteger UTF8.scalarLimit)), row "sleep-milliseconds-max" (ofNumber Clock.sleepLimit), row "process-arguments" (ofNumber 256), row "process-environment-entries" (ofNumber 256), row "process-text-scalars" (ofNumber 4096), row "process-total-scalars" (ofNumber 65536), row "process-grace-milliseconds-max" (ofNumber 9223372036854775807)]) + , row "tcp-address-families" (strings ["ipv4","ipv6"]) + , row "digest-algorithms" (strings ["sha256"]) + , row "restrictions" (strings ["action-boundary-cancellation", "no-hard-deadlines", "stage-same-filesystem", "stage-no-copy-fallback", "stage-private-namespace", "publish-new-requires-hard-links", "no-crash-recovery", "borrowed-standard-streams", "standard-stream-leases-per-run", "file-single-cursor-lease", "append-no-position-or-resize","file-host-handle-locking", "directory-unsorted-non-snapshot", "filesystem-host-encoding", "directory-failure-poisons-cursor", "tcp-numeric-addresses", "tcp-ipv6-only-listeners", "tcp-direction-leases", "tcp-failure-poisons-direction", "resolver-host-order-and-buffering", "resolver-may-delay-cancellation", "indexed-authenticated-snapshot", "indexed-caller-frame-and-disk-limit", "indexed-dag-resident", "indexed-writer-preflight", "indexed-budget-not-heap-quota", "sync-host-filesystem-guarantees", "utf8-strict-scalars", "clock-host-monotonic-word64", "sleep-may-run-late", "yield-no-ordering-guarantee", "process-direct-child-only", "process-normal-exit-joins", "process-explicit-pipe-draining"]) + ] + where row key value = ofList [ofString key, value] + strings = ofList . map ofString + +-- This is classification of ONE header, not executable validation, recursive +-- traversal, argument validation, handle lookup, or prediction of success. +-- Payloads, callbacks and continuations are never included in the result. +inspectAction :: T -> Either R.Problem T +inspectAction value = case decodeAction value of + Left detail -> Left (R.Problem "invalid-argument" "inspect" detail "none") + Right (Core code _) -> case find ((== code) . fst) core of + Just (_,label) -> Right (ofList [ofString "core", ofString label]) + Nothing -> error "decodeAction returned unknown core" + Right (Request group revision operation _) -> Right (ofList + [ofString "facility", ofString group, ofNumber revision, ofString operation, + ofString (case resolve group revision operation of Just _ -> "available"; _ -> "unsupported")]) diff --git a/src/IO/Resolver.hs b/src/IO/Resolver.hs @@ -0,0 +1,58 @@ +{-# LANGUAGE ScopedTypeVariables #-} +-- Explicit host name resolution, not an implicit part of connection acquisition. +-- getaddrinfo may buffer more results and delay cancellation; only the published +-- endpoint prefix is bounded here. No worker is abandoned on interruption. +module IO.Resolver (Family(..), Resolution(..), resolveTCP, resolveWith, hostLimit, addressLimit, validHost) where +import qualified IO.Resources as R +import qualified Network.Socket as N +import Control.Exception + +hostLimit, addressLimit :: Int +hostLimit = 253 +addressLimit = 64 +data Family = IPv4Only | IPv6Only | AnyFamily deriving (Eq, Show) +data Resolution = Resolution [R.Endpoint] Bool deriving (Eq, Show) +validHost :: String -> Bool +validHost [] = False +validHost host = walk hostLimit host + where walk _ [] = True + walk 0 _ = False + walk n (c:rest) = ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || + (c >= '0' && c <= '9') || c == '-' || c == '.') && walk (n-1) rest + +resolveTCP :: String -> Family -> Integer -> Int -> IO (Either R.Problem Resolution) +resolveTCP host family port limit = resolveWith host port limit $ do + let af = case family of IPv4Only -> N.AF_INET; IPv6Only -> N.AF_INET6; AnyFamily -> N.AF_UNSPEC + hints = N.defaultHints {N.addrFamily=af, N.addrSocketType=N.Stream, + N.addrFlags=[N.AI_NUMERICSERV]} + infos <- N.getAddrInfo (Just hints) (Just host) (Just (show port)) + pure [endpoint | info <- infos, Just endpoint <- [convert (N.addrAddress info)]] + where convert (N.SockAddrInet p h) = Just (R.IPv4 (N.hostAddressToTuple h) (fromIntegral p)) + convert (N.SockAddrInet6 p _ h scope) = Just (R.IPv6 (N.hostAddress6ToTuple h) scope (fromIntegral p)) + convert _ = Nothing + +-- Injected query supports deterministic failure/bound tests without public DNS. +resolveWith :: String -> Integer -> Int -> IO [R.Endpoint] -> IO (Either R.Problem Resolution) +resolveWith host port limit query + | not (validHost host) = pure (Left (R.Problem "invalid-argument" "resolve" "expected bounded ASCII host name" "none")) + | port < 0 || port > 65535 || limit <= 0 || limit > addressLimit = + pure (Left (R.Problem "invalid-bound" "resolve" "" "none")) + | otherwise = (query >>= bounded limit [] >>= pure . Right) `catch` \(e :: IOException) -> + pure (Left (R.Problem "resolution-failed" "resolve" (take 512 (displayException e)) "none")) + where + bounded 0 acc rest = do + truncated <- evaluate (not (null rest)) + finish acc truncated + bounded _ acc [] = finish acc False + bounded n acc (first:rest) = forceEndpoint first `seq` bounded (n-1) (first:acc) rest + finish acc truncated = do + let prefix = reverse acc + -- Neither a lazy split nor endpoint projections may retain the discarded + -- host-result tail/AddrInfo records after returning the bounded reply. + _ <- evaluate (foldr seq () prefix) + pure (Resolution prefix truncated) + +forceEndpoint :: R.Endpoint -> () +forceEndpoint (R.IPv4 (a,b,c,d) p) = a `seq` b `seq` c `seq` d `seq` p `seq` () +forceEndpoint (R.IPv6 (a,b,c,d,e,f,g,h) scope p) = + a `seq` b `seq` c `seq` d `seq` e `seq` f `seq` g `seq` h `seq` scope `seq` p `seq` () diff --git a/src/IO/Resources.hs b/src/IO/Resources.hs @@ -0,0 +1,612 @@ +{-# LANGUAGE ScopedTypeVariables #-} +-- Scoped native resources. No Tree interpretation or application policy here. +-- Resource kinds share lifetime/lease rules; task supervision is in IO.Tasks. +module IO.Resources + ( Runtime, Scope, Token(..), Backend(..), FileOps(..), DuplexOps(..), ListenerOps(..), Endpoint(..), Problem(..), newRuntime, rootScope + , childScope, closeScope, acquire, openReader, openWriter, readSome, writeSome + , withTemporary, syncFile + , withReaderLease, withWriterLease, withInternalResource, closeInternalResource + , closeResource, liveCounts, chunkLimit, runtimeIdentity, isAncestor, combineErrors + , beginCloseScopeSTM, reserveChildSTM + , openAppender, openUpdate, seekFile, filePosition, fileSize, resizeFile, positionLimit + , nextDirectory, acceptConnection, shutdownWrite, socketAddress + , StandardStream(..), newRuntimeWithStreams, standardStream + , sealStage, publishStage, updateDigest, finishDigest, resourceFault, ioProblem + ) where + +import qualified IO.Descriptor as Descriptor +import Control.Concurrent.MVar +import Control.Concurrent.STM +import Control.Exception +import Control.Monad (foldM, unless, when) +import qualified Data.ByteString as BS +import qualified Data.Map.Strict as M +import qualified Data.Set as S +import Data.List (foldl') +import Data.IORef +import Data.Bits ((.&.), (.|.), complement) +import Data.Word (Word8, Word16, Word32) +import Text.Read (readMaybe) +import Data.Unique (newUnique, hashUnique) +import System.IO +import System.IO.Error + +-- IDs never recur within a run; the run identity rejects cross-run handles. +type Scope = Integer +data Token = Token !Integer !Integer deriving (Eq, Show) +data Problem = Problem { category :: String, operation :: String, detail :: String + , progress :: String } deriving (Eq, Show) +data Backend = Reader (Int -> IO BS.ByteString) (IO ()) + | Writer (BS.ByteString -> IO Int) (IO ()) + | Staged (BS.ByteString -> IO Int) (IO ()) (Bool -> FilePath -> IO String) (IO ()) + | Digest (BS.ByteString -> IO ()) (IO String) (IO ()) + | Inherited Backend + | File FileOps + | Directory (IO (Maybe String)) (IO ()) + | Duplex DuplexOps + | Listener ListenerOps + | Internal (IO ()) + +data Endpoint = IPv4 (Word8,Word8,Word8,Word8) Word16 + | IPv6 (Word16,Word16,Word16,Word16,Word16,Word16,Word16,Word16) Word32 Word16 + deriving (Eq, Show) +data DuplexOps = DuplexOps + { duplexRead :: Int -> IO BS.ByteString + , duplexWrite :: BS.ByteString -> IO Int + , duplexShutdownWrite :: IO () + , duplexLocal :: IO Endpoint + , duplexPeer :: IO Endpoint + , duplexRelease :: IO () + } +data ListenerOps = ListenerOps + { listenerAccept :: IO Backend + , listenerLocal :: IO Endpoint + , listenerRelease :: IO () + } +-- Bit 0 leases the read direction, bit 1 the write direction. Non-duplex +-- operations always lease both. Close/handoff require BOTH directions idle. +data Access = Exclusive | ReadDirection | WriteDirection +leaseBits :: Access -> Backend -> Word8 +leaseBits ReadDirection (Duplex _) = 1 +leaseBits WriteDirection (Duplex _) = 2 +leaseBits _ _ = 3 +-- File cursor operations and byte IO share ONE registry lease. Optional methods +-- express access mode, not separate handles/cursors or independent lifetimes. +data FileOps = FileOps + { fileReadAction :: Maybe (Int -> IO BS.ByteString) + , fileWriteAction :: Maybe (BS.ByteString -> IO Int) + , fileSeekAction :: Maybe (Integer -> IO ()) + , filePositionAction :: Maybe (IO Integer) + , fileSizeAction :: IO Integer + , fileResizeAction :: Maybe (Integer -> IO ()) + -- Acquire (synchronization action, once-only release); registered before use. + , fileSyncActions :: Maybe (IO (IO (), IO ())) + , fileReleaseAction :: IO () + } +positionLimit :: Integer +positionLimit = 2^(63 :: Int)-1 +-- Backend state errors are ordinary operation failures; unrelated synchronous +-- faults remain terminal task faults, not a catch-all recoverable Result. +newtype ResourceFault = ResourceFault Problem deriving Show +instance Exception ResourceFault +resourceFault :: Problem -> IO a +resourceFault = throwIO . ResourceFault +data Resource = Resource !Scope Backend (TVar Word8) (TVar Bool) (MVar ()) +data ScopeState = ScopeState (Maybe Scope) !Bool !Int +-- Scope Bool is admission, not permission for existing leases to complete. +data State = State !Integer (M.Map Scope ScopeState) (M.Map Integer Resource) +data Runtime = Runtime !Integer (TVar State) + +chunkLimit :: Int +chunkLimit = 4096 +rootScope :: Scope +rootScope = 0 +-- Three fixed root entries exist for the run, including during parent joining. +-- No lazy acquisition into a closing root and no per-lookup aliases/history. +-- These are borrowed process handles: no mode/buffering changes or OS close. +data StandardStream = StandardInput | StandardOutput | StandardError deriving (Enum) +newRuntime :: IO Runtime +newRuntime = newRuntimeWithStreams stdin stdout stderr +newRuntimeWithStreams :: Handle -> Handle -> Handle -> IO Runtime +newRuntimeWithStreams input output diagnostic = do + identity <- toInteger . hashUnique <$> newUnique + let writer h = do + failed <- newIORef False + pure $ Writer (\bytes -> mask $ \restore -> do + poisoned <- readIORef failed + when poisoned (resourceFault (Problem "invalid-state" "write" + "inherited writer failed; progress is uncertain" "unknown")) + -- A failed flush can leave host-buffered bytes. Never implicitly retry + -- them with the next write. The embedding still owns that buffer. + restore (BS.hPut h bytes >> hFlush h) `onException` writeIORef failed True + pure (BS.length bytes)) (pure ()) + out <- writer output + err <- writer diagnostic + let backends = [Reader (BS.hGetSome input) (pure ()), out, err] + entries <- mapM (\backend -> Resource rootScope (Inherited backend) + <$> newTVarIO 0 <*> newTVarIO False <*> newMVar ()) backends + Runtime identity <$> newTVarIO (State 4 (M.singleton 0 (ScopeState Nothing True 0)) + (M.fromList (zip [1..3] entries))) + +standardStream :: Runtime -> Scope -> StandardStream -> IO (Either Problem Token) +standardStream (Runtime identity state) current stream = atomically $ do + snapshot <- readTVar state + let token = Token identity (1 + toInteger (fromEnum stream)) + -- Resolve validation here, not in a lazy result retaining the registry snapshot. + case lookupResource identity current token snapshot of + Left fault -> pure (Left fault) + Right _ -> token `seq` pure (Right token) + +internal :: Backend -> Bool +internal (Internal _) = True +internal _ = False + +inherited :: Backend -> Bool +inherited (Inherited _) = True +inherited _ = False +usable :: Backend -> Backend +usable (Inherited backend) = backend +usable backend = backend + +problem :: String -> String -> Problem +problem cat op = Problem cat op "" "none" +ioProblem :: String -> IOException -> Problem +ioProblem op e = Problem cat op (take 512 (show e)) (if op `elem` ["write", "read", "seek", "resize", "next", "close", "sync"] then "unknown" else "none") + where cat | isDoesNotExistError e = "not-found" + | isPermissionError e = "os-permission-denied" + | isAlreadyExistsError e = "already-exists" + | otherwise = "io-error" + +-- Scheduler transactions can close admission before joining children, without +-- revoking their ancestor borrowing or closing their own open scopes. Resource +-- release is a separate step, performed only after task settlement. +beginCloseScopeSTM :: Runtime -> Scope -> STM () +beginCloseScopeSTM (Runtime _ state) scope = modifyTVar' state $ \(State n scopes resources) -> + State n (M.adjust (\(ScopeState p _ count) -> ScopeState p False count) scope scopes) resources + +childScope :: Runtime -> Scope -> IO (Either Problem Scope) +childScope runtime parent = atomically $ fmap (fmap fst) (reserveChildSTM runtime parent []) + +-- Low-level reservation, NOT a public spawn operation. A scheduler must combine +-- this transaction with task registration and opening a prestarted worker's gate. +-- Thread creation must precede commitment: a failed fork must not move resources. +-- STM rollback then covers the resource registry AND task publication together. +-- +-- Only resources owned by the attaching parent move; borrowed ancestor resources +-- stay borrowed. Every token is checked before any mutation. Rekeying invalidates +-- all old aliases, with no tombstones, and preserves original acquisition order +-- independently of the caller's handle-list order. Returned tokens follow input +-- order. No pending lease or explicit close may cross the handoff boundary. +reserveChildSTM :: Runtime -> Scope -> [Token] -> STM (Either Problem (Scope, [Token])) +reserveChildSTM (Runtime identity state) parent tokens = do + snapshot@(State next scopes resources) <- readTVar state + case M.lookup parent scopes of + Just (ScopeState _ True _) -> + if S.size (S.fromList [(run, key) | Token run key <- tokens]) /= length tokens + then pure (Left (problem "duplicate-handle" "transfer")) + else do + checked <- mapM (validate snapshot) tokens + case sequence checked of + Left e -> pure (Left e) + Right entries -> do + let ordered = M.toAscList (M.fromList entries) + moved = zip ordered [next+1..] + replacements = M.fromList [(old, new) | ((old, _), new) <- moved] + removeOld = foldr (M.delete . fst) resources entries + insertMoved acc ((_, Resource _ backend busy closing gate), key) = + M.insert key (Resource next backend busy closing gate) acc + finalResources = foldl' insertMoved removeOld moved + fresh (Token _ old) = Token identity (replacements M.! old) + freshTokens = map fresh tokens + -- Do not let lazy output tokens retain the moved backend records + -- through the replacement-map construction after the scope closes. + foldr (\token rest -> token `seq` rest) () freshTokens `seq` pure () + writeTVar state (State (next+1+toInteger (length entries)) + (M.insert next (ScopeState (Just parent) True 0) scopes) finalResources) + pure (Right (next, freshTokens)) + _ -> pure (Left (problem "closed-scope" "scope")) + where + validate snapshot token@(Token _ key) = case lookupResource identity parent token snapshot of + Left e -> pure (Left e) + Right resource@(Resource owner backend busy closing _) -> do + occupied <- readTVar busy + dead <- readTVar closing + pure $ if owner /= parent then Left (problem "not-owner" "transfer") + else if inherited backend then Left (problem "borrowed-resource" "transfer") + else if internal backend then Left (problem "internal-resource" "transfer") + else if dead then Left (problem "closed-handle" "transfer") + else if occupied /= 0 then Left (problem "busy" "transfer") + else Right (key, resource) + +release :: Backend -> IO () +release (Internal done) = done +release (Duplex methods) = duplexRelease methods +release (Listener methods) = listenerRelease methods +release (Directory _ done) = done +release (File methods) = fileReleaseAction methods +release (Inherited _) = pure () +release (Reader _ done) = done +release (Writer _ done) = done +release (Staged _ _ _ done) = done +release (Digest _ _ done) = done + +-- Acquisition is masked through registry publication, like bracket acquisition. +-- Backend acquisition must be exception-safe internally. Admission remains counted +-- until publication/failure, so scope closure cannot miss an in-flight acquisition. +acquire :: Runtime -> Scope -> IO Backend -> IO (Either Problem Token) +acquire (Runtime identity state) owner create = mask_ $ do + admitted <- atomically $ do + State next scopes resources <- readTVar state + case M.lookup owner scopes of + Just (ScopeState parent True pending) -> do + writeTVar state (State next (M.insert owner (ScopeState parent True (pending+1)) scopes) resources) + pure True + _ -> pure False + if not admitted then pure (Left (problem "closed-scope" "open")) else do + let leave = atomically $ modifyTVar' state $ \(State n scopes resources) -> + State n (M.adjust (\(ScopeState p a count) -> ScopeState p a (count-1)) owner scopes) resources + result <- try (bracketOnError create release $ \backend -> do + busy <- newTVarIO 0 + closing <- newTVarIO False + gate <- newMVar () + atomically $ do + State next scopes resources <- readTVar state + writeTVar state (State (next+1) scopes (M.insert next (Resource owner backend busy closing gate) resources)) + pure (Token identity next)) `finally` leave + case result of + Right value -> pure (Right value) + Left (exception :: SomeException) + | Just (ResourceFault fault) <- fromException exception -> pure (Left fault) + | Just e <- fromException exception -> pure (Left (ioProblem "open" e)) + | otherwise -> throwIO exception + +openReader, openWriter, openAppender, openUpdate :: Runtime -> Scope -> FilePath -> IO (Either Problem Token) +openReader = openFileResource ReadMode +openWriter = openFileResource WriteMode +openAppender = openFileResource AppendMode +openUpdate = openFileResource ReadWriteMode + +openFileResource :: IOMode -> Runtime -> Scope -> FilePath -> IO (Either Problem Token) +openFileResource mode runtime scope path = acquire runtime scope $ + bracketOnError (openBinaryFile path mode) hClose $ \h -> do + hSetBuffering h NoBuffering + let readable = mode == ReadMode || mode == ReadWriteMode + writable = mode /= ReadMode + positioned = mode /= AppendMode + optional allowed action = if allowed then Just action else Nothing + seekable action = do + supported <- hIsSeekable h + unless supported (resourceFault (Problem "unsupported" "file" "file is not seekable" "none")) + action + resize size = seekable $ do + -- Synchronize/discard any read-ahead before changing length. Resizing + -- preserves the logical cursor, including a position beyond new EOF. + position <- hTell h + hSeek h AbsoluteSeek position + hSetFileSize h size + pure (File (FileOps + (optional readable (BS.hGetSome h)) + (optional writable (\bytes -> BS.hPut h bytes >> pure (BS.length bytes))) + (optional positioned (\position -> seekable (hSeek h AbsoluteSeek position))) + (optional positioned (seekable (hTell h))) + (hFileSize h) + (optional (writable && positioned) resize) + (Just $ do + -- Synchronize any read-ahead while preserving the logical cursor. Append + -- needs only flushing; the duplicate is used for fsync, never seeking. + when readable (seekable (hTell h >>= hSeek h AbsoluteSeek)) + when writable (hFlush h) + Descriptor.duplicateSync h) + (hClose h))) + +-- Validating bounds precedes backend IO. Query replies are checked too, so a +-- backend cannot inject a negative/out-of-range natural into the tree protocol. +fileOperation :: Runtime -> Scope -> Token -> String -> (FileOps -> Maybe (IO a)) -> IO (Either Problem a) +fileOperation runtime scope token op select = withLease runtime scope token op isFile $ \backend -> case backend of + File methods -> case select methods of + Just action -> action + Nothing -> resourceFault (Problem "unsupported" op "operation unavailable in this file mode" "none") + _ -> error "validated file kind" + where isFile (File _) = True + isFile _ = False + +syncFile :: Runtime -> Scope -> Token -> IO (Either Problem ()) +syncFile runtime scope token = fileOperation runtime scope token "sync" $ \methods -> + fmap (\allocate -> withTemporary runtime scope "sync" allocate id >>= either resourceFault pure) + (fileSyncActions methods) + +seekFile, resizeFile :: Runtime -> Scope -> Token -> Integer -> IO (Either Problem ()) +seekFile runtime scope token position + | position < 0 || position > positionLimit = pure (Left (problem "invalid-bound" "seek")) + | otherwise = fileOperation runtime scope token "seek" (fmap ($ position) . fileSeekAction) +resizeFile runtime scope token size + | size < 0 || size > positionLimit = pure (Left (problem "invalid-bound" "resize")) + | otherwise = fileOperation runtime scope token "resize" (fmap ($ size) . fileResizeAction) + +filePosition, fileSize :: Runtime -> Scope -> Token -> IO (Either Problem Integer) +filePosition runtime scope token = fileOperation runtime scope token "position" + (fmap checkedPosition . filePositionAction) +fileSize runtime scope token = fileOperation runtime scope token "size" + (Just . checkedPosition . fileSizeAction) +checkedPosition :: IO Integer -> IO Integer +checkedPosition action = do + value <- action + unless (value >= 0 && value <= positionLimit) + (resourceFault (Problem "unsupported" "file" "position outside supported range" "none")) + pure value + +inside :: Scope -> Scope -> M.Map Scope ScopeState -> Bool +inside current owner scopes = case M.lookup current scopes of + Nothing -> False + Just (ScopeState parent _ _) -> current == owner || maybe False (\p -> inside p owner scopes) parent + +lookupResource :: Integer -> Scope -> Token -> State -> Either Problem Resource +lookupResource identity current (Token run key) (State _ scopes resources) + | run /= identity = Left (problem "stale-handle" "resource") + | otherwise = case M.lookup key resources of + Nothing -> Left (problem "stale-handle" "resource") + Just resource@(Resource owner _ _ _ _) + | inside current owner scopes -> Right resource + | otherwise -> Left (problem "out-of-scope" "resource") + +withLease :: Runtime -> Scope -> Token -> String -> (Backend -> Bool) -> (Backend -> IO a) -> IO (Either Problem a) +withLease = withAccess Exclusive + +-- Host-owned temporary resources are registered for lexical cleanup but are not +-- public handles. Even a guessed registry key cannot read, close or transfer one. +-- Their active operation still has a lease, so scope release waits for it. +withInternalResource :: Runtime -> Scope -> Token -> String -> IO a -> IO (Either Problem a) +withInternalResource runtime scope token op action = withLease runtime scope token op internal (const action) + +closeInternalResource :: Runtime -> Scope -> Token -> IO (Either Problem ()) +closeInternalResource runtime@(Runtime identity state) scope token = do + found <- atomically (lookupResource identity scope token <$> readTVar state) + case found of + Left p -> pure (Left p) + Right (Resource _ backend _ _ _) | internal backend -> closeResourceWith True runtime scope token + _ -> pure (Left (problem "wrong-kind" "close")) + +-- Register transient host-owned resources before use. Ordinary failures retain +-- primary diagnostics plus bounded cleanup detail; terminal faults leave cleanup +-- to lexical settlement. Allocation must itself be exception-safe until return. +withTemporary :: Runtime -> Scope -> String -> IO (a, IO ()) -> (a -> IO b) -> IO (Either Problem b) +withTemporary runtime scope op allocate use = mask $ \restore -> do + slot <- newIORef Nothing + acquired <- acquire runtime scope $ do + (value, done) <- allocate + writeIORef slot (Just value) + pure (Internal done) + case acquired of + Left p -> pure (Left p) + Right token -> do + Just value <- readIORef slot + result <- restore (withInternalResource runtime scope token op (use value)) + closed <- closeInternalResource runtime scope token + pure $ case (result,closed) of + (Right value, Right ()) -> Right value + (Right _, Left cleanup) -> Left cleanup + (Left primary, Right ()) -> Left primary + (Left primary, Left cleanup) -> Left primary { detail = take 240 (detail primary) ++ + "; cleanup: " ++ take 240 (category cleanup ++ ": " ++ detail cleanup) } + +withAccess :: Access -> Runtime -> Scope -> Token -> String -> (Backend -> Bool) -> (Backend -> IO a) -> IO (Either Problem a) +withAccess access (Runtime identity state) current token op rightKind use = mask $ \restore -> do + lease <- atomically $ do + snapshot <- readTVar state + case lookupResource identity current token snapshot of + Left e -> pure (Left e) + Right resource@(Resource _ backend busy closing _) -> do + dead <- readTVar closing + occupied <- readTVar busy + if dead then pure (Left (problem "closed-handle" op)) + else if not (rightKind (usable backend)) then pure (Left (problem "wrong-kind" op)) + else if occupied .&. leaseBits access (usable backend) /= 0 then pure (Left (problem "busy" op)) + else writeTVar busy (occupied .|. leaseBits access (usable backend)) >> pure (Right resource) + case lease of + Left e -> pure (Left e) + Right (Resource _ backend busy _ _) -> do + result <- try (restore (use (usable backend))) `finally` atomically (modifyTVar' busy (.&. complement (leaseBits access (usable backend)))) + case result of + Right value -> pure (Right value) + Left (exception :: SomeException) + | Just (ResourceFault p) <- fromException exception -> pure (Left p) + | Just e <- fromException exception -> pure (Left (ioProblem op e)) + | otherwise -> throwIO exception + +readSome :: Runtime -> Scope -> Token -> Int -> IO (Either Problem BS.ByteString) +readSome runtime scope token count + | count <= 0 || count > chunkLimit = pure (Left (problem "invalid-bound" "read")) + | otherwise = withReaderLease runtime scope token (\readBytes -> readBytes count) + +-- Internal codec transport: retain one directional lease across an entire frame, +-- not one lease per chunk. Internal clients must not retain the supplied reader +-- beyond the callback; no such function is exposed through the source protocol. +withReaderLease :: Runtime -> Scope -> Token -> ((Int -> IO BS.ByteString) -> IO a) -> IO (Either Problem a) +withReaderLease runtime scope token use = withAccess ReadDirection runtime scope token "read" isReader $ \backend -> + let checked readBytes count = do + unless (count > 0 && count <= chunkLimit) (resourceFault (problem "invalid-bound" "read")) + bytes <- readBytes count + unless (BS.length bytes <= count) (ioError (userError "backend exceeded read bound")) + pure bytes + in case backend of + Duplex methods -> use (checked (duplexRead methods)) + Reader readBytes _ -> use (checked readBytes) + File methods | Just readBytes <- fileReadAction methods -> use (checked readBytes) + _ -> error "validated reader kind" + +writeSome :: Runtime -> Scope -> Token -> BS.ByteString -> IO (Either Problem Int) +writeSome runtime scope token bytes + | BS.null bytes || BS.length bytes > chunkLimit = pure (Left (problem "invalid-bound" "write")) + | otherwise = withWriterLease runtime scope token (\writeBytes -> writeBytes bytes) + +-- As for the reader hook, internal clients must not retain the supplied writer. +withWriterLease :: Runtime -> Scope -> Token -> ((BS.ByteString -> IO Int) -> IO a) -> IO (Either Problem a) +withWriterLease runtime scope token use = withAccess WriteDirection runtime scope token "write" isWriter $ \backend -> + let checked writeBytes bytes = do + unless (not (BS.null bytes) && BS.length bytes <= chunkLimit) + (resourceFault (problem "invalid-bound" "write")) + count <- writeBytes bytes + unless (count > 0 && count <= BS.length bytes) (ioError (userError "invalid backend write progress")) + pure count + in case backend of + Duplex methods -> use (checked (duplexWrite methods)) + File methods | Just writeBytes <- fileWriteAction methods -> use (checked writeBytes) + Writer writeBytes _ -> use (checked writeBytes) + Staged writeBytes _ _ _ -> use (checked writeBytes) + _ -> error "validated writer kind" + +isReader, isWriter, isStage, isDigest :: Backend -> Bool +isReader (Duplex _) = True +isReader (File methods) = maybe False (const True) (fileReadAction methods) +isReader (Reader _ _) = True +isReader _ = False +isWriter (Duplex _) = True +isWriter (File methods) = maybe False (const True) (fileWriteAction methods) +isWriter (Writer _ _) = True +isWriter (Staged _ _ _ _) = True +isWriter _ = False +isStage (Staged _ _ _ _) = True +isStage _ = False +isDigest (Digest _ _ _) = True +isDigest _ = False + +acceptConnection :: Runtime -> Scope -> Token -> Scope -> IO (Either Problem Token) +acceptConnection runtime current token owner = do + allowed <- isAncestor runtime current owner + if not allowed then pure (Left (problem "out-of-scope" "accept")) else do + result <- withLease runtime current token "accept" isListener $ \backend -> case backend of + Listener methods -> acquire runtime owner (listenerAccept methods) + _ -> error "validated listener kind" + pure (either Left id result) + where isListener (Listener _) = True + isListener _ = False + +shutdownWrite :: Runtime -> Scope -> Token -> IO (Either Problem ()) +shutdownWrite runtime current token = withAccess WriteDirection runtime current token "shutdown-write" isDuplex $ \backend -> case backend of + Duplex methods -> duplexShutdownWrite methods + _ -> error "validated duplex kind" + where isDuplex (Duplex _) = True + isDuplex _ = False + +socketAddress :: Runtime -> Scope -> Token -> Bool -> IO (Either Problem Endpoint) +socketAddress runtime current token peer = withLease runtime current token op supported $ \backend -> case backend of + Duplex methods -> (if peer then duplexPeer else duplexLocal) methods + Listener methods -> listenerLocal methods + _ -> error "validated socket kind" + where op = if peer then "peer-address" else "local-address" + supported (Duplex _) = True + supported (Listener _) = not peer + supported _ = False + +nextDirectory :: Runtime -> Scope -> Token -> IO (Either Problem (Maybe String)) +nextDirectory runtime scope token = withLease runtime scope token "next" isDirectory $ \backend -> case backend of + Directory next _ -> next + _ -> error "validated directory kind" + where isDirectory (Directory _ _) = True + isDirectory _ = False + +sealStage :: Runtime -> Scope -> Token -> IO (Either Problem ()) +sealStage runtime scope token = withLease runtime scope token "seal" isStage $ \backend -> case backend of + Staged _ seal _ _ -> seal + _ -> error "validated stage kind" +publishStage :: Runtime -> Scope -> Token -> Bool -> FilePath -> IO (Either Problem String) +publishStage runtime scope token replace destination = withLease runtime scope token "publish" isStage $ \backend -> case backend of + Staged _ _ publish _ -> publish replace destination + _ -> error "validated stage kind" +updateDigest :: Runtime -> Scope -> Token -> BS.ByteString -> IO (Either Problem ()) +updateDigest runtime scope token bytes + | BS.null bytes || BS.length bytes > chunkLimit = pure (Left (problem "invalid-bound" "digest-update")) + | otherwise = withLease runtime scope token "digest-update" isDigest $ \backend -> case backend of + Digest update _ _ -> update bytes + _ -> error "validated digest kind" +finishDigest :: Runtime -> Scope -> Token -> IO (Either Problem String) +finishDigest runtime scope token = withLease runtime scope token "digest-finish" isDigest $ \backend -> case backend of + Digest _ finish _ -> finish + _ -> error "validated digest kind" + +-- The close gate serializes explicit close and teardown without holding the +-- registry lock during IO. Mark closing before waiting for leases. If interrupted +-- while waiting, a subsequent scope teardown can finish the close. +closeResource :: Runtime -> Scope -> Token -> IO (Either Problem ()) +closeResource = closeResourceWith False + +-- Only lexical settlement may revoke inherited references, never the OS handles. +closeResourceWith :: Bool -> Runtime -> Scope -> Token -> IO (Either Problem ()) +closeResourceWith settling runtime@(Runtime identity state) current token = mask_ $ do + found <- atomically $ lookupResource identity current token <$> readTVar state + case found of + Left e -> pure (Left e) + Right (Resource _ backend _ _ _) | internal backend && not settling -> + pure (Left (problem "internal-resource" "close")) + Right (Resource _ backend _ _ _) | inherited backend && not settling -> + pure (Left (problem "borrowed-resource" "close")) + Right (Resource _ _ _ _ gate) -> withMVar gate $ \_ -> do + -- Claim closing in the SAME transaction as token validation. Otherwise a + -- transfer can rekey the entry between lookup and closeEntry, allowing an + -- old alias to close the new owner's backend and leave a dead live entry. + fresh <- atomically $ do + result <- lookupResource identity current token <$> readTVar state + case result of + Right (Resource _ _ _ closing _) -> writeTVar closing True + Left _ -> pure () + pure result + case fresh of + Left e -> pure (Left e) + Right resource -> closeEntry runtime token resource + +closeEntry :: Runtime -> Token -> Resource -> IO (Either Problem ()) +closeEntry (Runtime _ state) (Token _ key) (Resource _ backend busy closing _) = do + atomically (writeTVar closing True) + atomically (readTVar busy >>= check . (== 0)) + result <- try (release backend) + case result of + Left (exception :: SomeException) | Just (_ :: SomeAsyncException) <- fromException exception -> throwIO exception + _ -> do + atomically $ modifyTVar' state $ \(State n scopes resources) -> State n scopes (M.delete key resources) + pure $ case result of + Right () -> Right () + Left exception -> Left $ case fromException exception of + Just e -> ioProblem "close" e + Nothing -> Problem "backend-fault" "close" (take 512 (displayException exception)) "unknown" + +-- IO.Tasks must settle children before entering resource teardown. Close any +-- remaining nested resource scopes before this scope, in reverse acquisition order. +closeScope :: Runtime -> Scope -> IO [Problem] +closeScope runtime@(Runtime identity state) scope = mask_ $ do + atomically (beginCloseScopeSTM runtime scope) + children <- atomically $ do + State _ scopes _ <- readTVar state + case M.lookup scope scopes of + Just (ScopeState _ _ pending) -> check (pending == 0) + Nothing -> pure () + pure [key | (key, ScopeState parent _ _) <- M.toDescList scopes, parent == Just scope] + childErrors <- foldM (\errors child -> combineErrors errors <$> closeScope runtime child) [] children + keys <- atomically $ do + State _ _ resources <- readTVar state + pure [key | (key, Resource owner _ _ _ _) <- M.toDescList resources, owner == scope] + errors <- foldM (\previous key -> do + result <- closeResourceWith True runtime scope (Token identity key) + pure (combineErrors previous (either (:[]) (const []) result))) childErrors keys + atomically $ modifyTVar' state $ \(State n scopes resources) -> State n (M.delete scope scopes) resources + pure errors + +-- Keep bounded diagnostics while preserving an explicit count of omissions. +combineErrors :: [Problem] -> [Problem] -> [Problem] +combineErrors left right = + let allErrors = left ++ right + actual = filter ((/= "cleanup-omitted") . category) allErrors + prior = sum [maybe 0 id (readMaybe (detail e) :: Maybe Integer) | e <- allErrors, category e == "cleanup-omitted"] + omitted = prior + toInteger (max 0 (length actual - 16)) + in take 16 actual ++ [Problem "cleanup-omitted" "close" (show omitted) "none" | omitted > 0] + +liveCounts :: Runtime -> IO (Int, Int) +liveCounts (Runtime _ state) = atomically $ do + State _ scopes resources <- readTVar state + pure (M.size scopes, M.size resources) + +runtimeIdentity :: Runtime -> IO Integer +runtimeIdentity (Runtime identity _) = pure identity +isAncestor :: Runtime -> Scope -> Scope -> IO Bool +isAncestor (Runtime _ state) current owner = atomically $ do + State _ scopes _ <- readTVar state + pure (inside current owner scopes) diff --git a/src/IO/Staging.hs b/src/IO/Staging.hs @@ -0,0 +1,149 @@ +{-# LANGUAGE ScopedTypeVariables #-} +-- Same-filesystem staged publication, including cross-directory destinations. No digest, length, HTTP or artifact policy. +module IO.Staging (openStage, openStageWith, StageOps(..), nativeOps) where + +import qualified IO.Resources as R +import qualified Data.ByteString as BS +import Control.Exception +import Control.Monad (unless, when) +import Data.List (isInfixOf) +import Data.IORef +import System.Directory (canonicalizePath, removeFile, renameFile) +import System.FilePath (takeDirectory, takeFileName, (</>)) +import System.IO +import System.IO.Error +import System.Posix.Files (createLink) +import System.Posix.IO (openFd, closeFd, handleToFd, defaultFileFlags, OpenMode(ReadOnly)) +import System.Posix.Types (Fd) +import System.Posix.Unistd (fileSynchronise) + +-- Injectable OS dependencies, not production fault flags. The native backend +-- has exactly the same transitions as deterministic failure/cancellation tests. +data StageOps = StageOps + { putBytes :: Handle -> BS.ByteString -> IO () + , syncFile :: Fd -> IO () + , closeDescriptor :: Fd -> IO () + , replaceName :: FilePath -> FilePath -> IO () + , linkName :: FilePath -> FilePath -> IO () + , removeName :: FilePath -> IO () + , openDirectory :: FilePath -> IO Fd + , syncDirectory :: Fd -> IO () + } +nativeOps :: StageOps +nativeOps = StageOps BS.hPut fileSynchronise closeFd renameFile createLink removeFile + (\path -> openFd path ReadOnly defaultFileFlags) fileSynchronise + +temporaryMarker :: String +temporaryMarker = ".tricu-stage-" + +data Phase = Writing | Sealed | Failed | Published deriving Eq +data Owned = OwnHandle Handle | OwnDescriptor Fd | Released +newtype CleanupFault = CleanupFault String deriving Show +instance Exception CleanupFault + +openStage :: R.Runtime -> R.Scope -> FilePath -> IO (Either R.Problem R.Token) +openStage = openStageWith nativeOps +openStageWith :: StageOps -> R.Runtime -> R.Scope -> FilePath -> IO (Either R.Problem R.Token) +openStageWith _ _ _ "" = pure (Left (R.Problem "invalid-argument" "stage-open" "empty directory" "none")) +openStageWith ops runtime scope directory = R.acquire runtime scope $ mask_ $ do + canonical <- canonicalizePath directory + (path, handle) <- openBinaryTempFile canonical temporaryMarker + -- No user evaluation between allocation and installing cleanup bookkeeping. + phase <- newIORef Writing + owned <- newIORef (OwnHandle handle) + privateName <- newIORef True + let removePrivate = do + exists <- readIORef privateName + when exists $ do + removeName ops path `catch` (\e -> unless (isDoesNotExistError e) (throwIO (e :: IOException))) + writeIORef privateName False + closeOwned = do + current <- readIORef owned + case current of + Released -> pure () + OwnHandle h -> hClose h >> writeIORef owned Released + OwnDescriptor fd -> do + -- A failed close can already have closed the descriptor. Never + -- retry a numeric descriptor that the OS may have reused. + writeIORef owned Released + closeDescriptor ops fd + cleanup = mask_ $ do + closed <- try closeOwned + removed <- try removePrivate + let failures = [e | Left e <- [closed, removed]] :: [SomeException] + asynchronous e = case fromException e :: Maybe SomeAsyncException of + Just _ -> True + Nothing -> False + -- Preserve interruption so the registry retains the closing entry for + -- later settlement, but still attempt private-name removal here. + case filter asynchronous failures of + first:_ -> throwIO first + [] -> case (closed, removed) of + (Left first, Left second) -> case fromException first of + Just ioe -> throwIO (ioeSetErrorString ioe + (take 240 (ioeGetErrorString ioe) ++ "; removal: " ++ take 240 (displayException second))) + Nothing -> throwIO (CleanupFault + (take 240 (displayException first) ++ "; removal: " ++ take 240 (displayException second))) + (Left first, _) -> throwIO first + (_, Left second) -> throwIO second + _ -> pure () + require expected operation = do + actual <- readIORef phase + unless (actual == expected) (R.resourceFault (R.Problem "invalid-state" operation "stage is not in the required phase" "none")) + poison = writeIORef phase Failed + write bytes = do + require Writing "write" + (putBytes ops handle bytes >> pure (BS.length bytes)) `onException` poison + seal = mask_ $ do + require Writing "seal" + (do + hFlush handle + fd <- handleToFd handle + writeIORef owned (OwnDescriptor fd) + -- On sync failure/cancellation the scope still owns this descriptor. + -- Its later release cannot mask the primary operation failure. + syncFile ops fd + closeOwned + writeIORef phase Sealed) `onException` poison + publish replace destination = mask_ $ do + require Sealed "publish" + progress <- newIORef "not-published" + let action = do + targetDirectory <- canonicalizePath (takeDirectory destination) + let name = takeFileName destination + unless (name `notElem` ["", ".", ".."] && not (temporaryMarker `isInfixOf` name)) $ + R.resourceFault (R.Problem "invalid-destination" "publish" "destination must name a file outside the private staging namespace" "not-published") + let target = targetDirectory </> name + -- Syscall failure can be ambiguous on remote filesystems. Never + -- turn it into permission to retry or delete the destination. + writeIORef progress "unknown" + linked <- if replace then replaceName ops path target >> pure True + else (linkName ops path target >> pure True) `catch` + (\e -> if isAlreadyExistsError e then pure False else throwIO (e :: IOException)) + if not linked then pure "exists" else do + writeIORef progress "published" + writeIORef phase Published + if replace then writeIORef privateName False else removePrivate + -- These are atomic namespace syscalls, never copy fallbacks. + -- Synchronize destination visibility before source-name removal durability; + -- distinct directories both need synchronization. Failure remains + -- published and cleanup cannot remove the destination. Do not open + -- a second descriptor until the first has been settled. + let syncParent directoryPath = do + fd <- openDirectory ops directoryPath + writeIORef owned (OwnDescriptor fd) + syncDirectory ops fd + closeOwned + syncParent targetDirectory + when (targetDirectory /= canonical) (syncParent canonical) + pure "published" + action `catch` (\(e :: IOException) -> do + poison + visibility <- readIORef progress + R.resourceFault ((R.ioProblem "publish" e) { R.progress = visibility })) + `onException` (do + state <- readIORef phase + -- In particular, cancellation after link/rename never rolls back + -- visibility. Cleanup only ever touches our private temporary name. + unless (state == Published) poison) + (hSetBuffering handle NoBuffering >> pure (R.Staged write seal publish cleanup)) `onException` cleanup diff --git a/src/IO/Tasks.hs b/src/IO/Tasks.hs @@ -0,0 +1,323 @@ +{-# LANGUAGE ScopedTypeVariables #-} +-- Structured native task groups. Registry transactions are the linearization +-- points; ThreadKilled only wakes a body, never carries the cancellation reason. +module IO.Tasks + ( Outcome(..), Scheduler, Group, TaskRef(..), newScheduler, rootGroup + , scopeOf, runGroup, nested, spawn, join, discard, cancel, checkpoint, liveCounts + ) where + +import Control.Concurrent hiding (yield) +import Control.Concurrent.STM +import Control.Exception +import Control.Monad (forM_, void) +import qualified Data.Map.Strict as M +import Runtime (T(..), ofString, ofList) +import qualified IO.Resources as R + +data Outcome = Completed T | Failed T [R.Problem] | Cancelled T [R.Problem] deriving Show +data Phase = Open | Joining | Releasing | Closed deriving Eq +data Scheduler = Scheduler R.Runtime (TVar (M.Map Integer Task)) + (TVar (M.Map Integer Task)) (TVar (M.Map Integer [Integer])) +data Group = Group + { groupScheduler :: Scheduler + , scopeOf :: R.Scope + , taskIdentity :: Integer + , groupParent :: Maybe (Group, Bool) + , groupPhase :: TVar Phase + , groupReason :: TVar (Maybe Outcome) + , groupChildren :: TVar (M.Map Integer Task) + , groupCleanup :: TVar [R.Problem] + , groupLexical :: TVar (Maybe Group) + } +data Task = Task Group Group (TVar (Maybe Outcome)) +data TaskRef = TaskRef !Integer !Integer deriving (Eq, Show) +data Interrupted = Interrupted deriving Show +instance Exception Interrupted + +schedulerOf :: Group -> Scheduler +schedulerOf = groupScheduler +runtimeOf :: Scheduler -> R.Runtime +runtimeOf (Scheduler runtime _ _ _) = runtime + +newScheduler :: R.Runtime -> IO Scheduler +newScheduler runtime = Scheduler runtime <$> newTVarIO M.empty <*> newTVarIO M.empty <*> newTVarIO M.empty +newGroup :: Scheduler -> R.Scope -> Integer -> Maybe (Group, Bool) -> STM Group +newGroup scheduler scope task parent = Group scheduler scope task parent + <$> newTVar Open <*> newTVar Nothing <*> newTVar M.empty <*> newTVar [] <*> newTVar Nothing +rootGroup :: Scheduler -> IO Group +rootGroup scheduler = atomically (newGroup scheduler R.rootScope 0 Nothing) + +problem :: String -> String -> R.Problem +problem category operation = R.Problem category operation "" "none" +failedException :: SomeException -> Outcome +failedException e = Failed (ofString (take 512 (displayException e))) [] + +-- First recorded stop wins. Failure propagates across lexical groups and +-- uncontained child boundaries, but never across preinstalled supervision. +-- All siblings receive sticky requests in this same transaction; a slow OS +-- interruption of one worker cannot prevent cancellation of the others. +stop :: Group -> Outcome -> STM () +stop Group{ groupScheduler = scheduler, scopeOf = scope, groupParent = parent, + groupPhase = phase, groupReason = reason, groupChildren = children, groupLexical = lexical } outcome = do + state <- readTVar phase + previous <- readTVar reason + case (state, previous) of + (Closed, _) -> pure () + (_, Just _) -> pure () + _ -> do + writeTVar reason (Just (withoutCleanup outcome)) + R.beginCloseScopeSTM (runtimeOf scheduler) scope + inner <- readTVar lexical + forM_ inner (\child -> stop child (Cancelled (ofString "owning group stopped") [])) + active <- readTVar children + forM_ (M.elems active) $ \(Task _ child _) -> + stop child (Cancelled (ofString "owning group stopped") []) + case (outcome, parent) of + (Failed _ _, Just (outer, True)) -> stop outer (withoutCleanup outcome) + _ -> pure () + +checkpoint :: Group -> IO () +checkpoint Group{ groupReason = reason } = atomically $ do + stopped <- readTVar reason + case stopped of Nothing -> pure (); Just _ -> throwSTM Interrupted + +-- Propagation carries only the primary. Cleanup travels once, at settlement, +-- rather than being duplicated along early fail-fast notifications. +withoutCleanup :: Outcome -> Outcome +withoutCleanup (Failed diagnostic _) = Failed diagnostic [] +withoutCleanup (Cancelled reason _) = Cancelled reason [] +withoutCleanup completed = completed +cleanupOf :: Outcome -> [R.Problem] +cleanupOf (Failed _ errors) = errors +cleanupOf (Cancelled _ errors) = errors +cleanupOf _ = [] + +attach :: Outcome -> [R.Problem] -> Outcome +attach (Failed diagnostic previous) errors = Failed diagnostic (R.combineErrors previous errors) +attach (Cancelled reason previous) errors = Cancelled reason (R.combineErrors previous errors) +attach completed [] = completed +attach (Completed _) (first:rest) = Failed (ofList (map ofString [R.category first, R.operation first, R.detail first, R.progress first])) rest + +-- The controller never executes user code. Its body runs in a separate worker, +-- so cancellation cannot land inside the resource-release protocol. Lexical +-- nesting may interrupt a controller from outside; the exception boundary then +-- cancels/joins its entire subtree before rethrowing that host interruption. +-- Repeated external host interruption/process death is not a cleanup guarantee. +runGroup :: Group -> IO Outcome -> IO Outcome +runGroup group@Group{ groupScheduler = scheduler, scopeOf = scope, taskIdentity = taskId, + groupPhase = phase, groupReason = reason, groupChildren = children, + groupCleanup = childErrors, groupLexical = lexical } body = mask $ \restore -> do + bodyDone <- newEmptyTMVarIO + launched <- try $ forkIOWithUnmask $ \unmask -> do + result <- try (unmask (checkpoint group >> body >>= evaluate)) + atomically (putTMVar bodyDone result) + worker <- case launched of + Left (exception :: SomeException) -> atomically (putTMVar bodyDone (Left exception)) >> pure Nothing + Right tid -> pure (Just tid) + let interruptBody = do + -- A lexical controller is host teardown, not user work. Its sticky stop + -- was set by stop; let it settle naturally rather than injecting an + -- exception into its release protocol. Only interrupt actual body work. + inner <- readTVarIO lexical + case inner of Nothing -> forM_ worker (\tid -> throwTo tid ThreadKilled); Just _ -> pure () + closeAdmission = atomically $ do + writeTVar phase Joining + R.beginCloseScopeSTM (runtimeOf scheduler) scope + joinChildren = do + let Scheduler _ _ _ waits = scheduler + atomically $ do + active <- readTVar children + graph <- readTVar waits + let targets = M.keys active + if any (reaches graph taskId []) targets + then stop group (Failed (ofString "task join cycle") []) + else modifyTVar' waits (M.insert taskId targets) + atomically (readTVar children >>= check . M.null) + `finally` atomically (modifyTVar' waits (M.delete taskId)) + release = do + atomically (writeTVar phase Releasing) + R.closeScope (runtimeOf scheduler) scope + finish outcome errors = atomically $ do + prior <- readTVar reason + descendants <- readTVar childErrors + let diagnostics = R.combineErrors (cleanupOf outcome) (R.combineErrors descendants errors) + final = attach (maybe (withoutCleanup outcome) id prior) diagnostics + case final of Failed _ _ -> stop group final; _ -> pure () + writeTVar childErrors (cleanupOf final) + writeTVar phase Closed + let Scheduler _ _ tickets _ = scheduler + modifyTVar' tickets (M.filter (\(Task owner _ _) -> scopeOf owner /= scope)) + pure final + execute = do + signal <- restore $ atomically $ do + requested <- readTVar reason + case requested of + Just _ -> pure Nothing + Nothing -> Just <$> readTMVar bodyDone + result <- case signal of + Just value -> pure value + Nothing -> interruptBody >> atomically (readTMVar bodyDone) + let outcome = either failedException id result + atomically $ case outcome of Completed _ -> pure (); _ -> stop group outcome + closeAdmission + joinChildren + errors <- release + finish outcome errors + abort (exception :: SomeException) = do + atomically (stop group (Cancelled (ofString "host interruption") [])) + interruptBody + void (atomically (readTMVar bodyDone)) + closeAdmission + joinChildren + errors <- release + void (finish (Cancelled (ofString "host interruption") []) errors) + throwIO exception + execute `catch` abort + +nested :: Group -> (Group -> IO Outcome) -> IO Outcome +nested parent@Group{ groupScheduler = scheduler, scopeOf = scope, taskIdentity = task, + groupCleanup = parentErrors, groupLexical = lexical } body = mask $ \restore -> do + allocated <- atomically $ do + checkpointSTM parent + reserved <- R.reserveChildSTM (runtimeOf scheduler) scope [] + case reserved of + Left e -> pure (Left e) + Right (childScope, _) -> do + child <- newGroup scheduler childScope task (Just (parent, True)) + writeTVar lexical (Just child) + pure (Right child) + case allocated of + Left e -> pure (Failed (ofString (R.category e)) []) + Right child@Group{ groupCleanup = diagnostics } -> do + result <- runGroup child (restore (body child)) `finally` atomically (do + errors <- readTVar diagnostics + modifyTVar' parentErrors (\previous -> R.combineErrors previous errors) + writeTVar lexical Nothing) + pure (withoutCleanup result) + +checkpointSTM :: Group -> STM () +checkpointSTM Group{ groupPhase = phase, groupReason = reason } = do + state <- readTVar phase + stopped <- readTVar reason + case (state, stopped) of + (Open, Nothing) -> pure () + _ -> throwSTM Interrupted + +-- A worker exists but cannot run before atomic resource transfer AND task +-- publication. Fork failure leaves the registry untouched. Failed validation +-- opens the gate with Nothing and joins that unused worker before returning. +spawn :: Group -> Bool -> Bool -> [R.Token] -> (Group -> [R.Token] -> IO Outcome) + -> IO (Either R.Problem TaskRef) +spawn owner@Group{ groupScheduler = scheduler, scopeOf = scope, + groupChildren = children, groupCleanup = ownerErrors } supervised keep tokens body = mask_ $ do + gate <- newEmptyTMVarIO + retired <- newEmptyMVar + let Scheduler runtime active tickets _ = scheduler + _ <- forkIOWithUnmask $ \unmask -> (do + entry <- atomically (takeTMVar gate) + case entry of + Nothing -> pure () + Just (task@(Task _ child completed), fresh) -> do + outcome <- unmask (runGroup child (body child fresh)) `catch` (pure . failedException) + atomically $ do + writeTVar completed (Just outcome) + if supervised then pure () else + modifyTVar' ownerErrors (\previous -> R.combineErrors previous (cleanupOf outcome)) + modifyTVar' children (M.delete (scopeOf child)) + modifyTVar' active (M.delete (scopeOf child)) + -- runGroup already propagated ordinary faults before release. This + -- also covers an unexpected controller fault at its outer boundary. + case outcome of Failed _ _ | not supervised -> stop owner outcome; _ -> pure () + task `seq` pure ()) `finally` putMVar retired () + let abandon = atomically (putTMVar gate Nothing) >> takeMVar retired + result <- (atomically $ do + checkpointSTM owner + reserved <- R.reserveChildSTM runtime scope tokens + case reserved of + Left e -> pure (Left e) + Right (childScope, fresh) -> do + child <- newGroup scheduler childScope childScope (Just (owner, not supervised)) + completed <- newTVar Nothing + let task = Task owner child completed + modifyTVar' children (M.insert childScope task) + modifyTVar' active (M.insert childScope task) + if keep then modifyTVar' tickets (M.insert childScope task) else pure () + putTMVar gate (Just (task, fresh)) + pure (Right childScope)) `onException` abandon + case result of + Left e -> abandon >> pure (Left e) + Right key -> do + identity <- R.runtimeIdentity runtime + pure (Right (TaskRef identity key)) + +within :: Group -> Group -> Bool +within current@Group{ groupParent = parent } owner = + scopeOf current == scopeOf owner || maybe False (\(outer, _) -> within outer owner) parent + +-- Registry lookup must remain inside the claiming transaction. Only the run +-- identity check is IO (it reads an immutable field). +checked :: Group -> TaskRef -> TVar (M.Map Integer Task) -> IO (STM (Either R.Problem Task)) +checked current (TaskRef run key) table = do + identity <- R.runtimeIdentity (runtimeOf (schedulerOf current)) + pure $ if identity /= run then pure (Left (problem "stale-task" "task")) else do + entries <- readTVar table + pure $ case M.lookup key entries of + Just task@(Task owner _ _) | within current owner -> Right task + Just _ -> Left (problem "out-of-scope" "task") + Nothing -> Left (problem "stale-task" "task") + +-- Includes implicit scope joining as well as explicit ticket waits. Lexical +-- controllers share their body's task identity, so nested scopes cannot hide a +-- cycle. Edges are removed when a wait settles, never retained as history. +reaches :: M.Map Integer [Integer] -> Integer -> [Integer] -> Integer -> Bool +reaches graph wanted seen target + | target == wanted = True + | target `elem` seen = False + | otherwise = any (reaches graph wanted (target:seen)) (M.findWithDefault [] target graph) + +join :: Group -> TaskRef -> IO (Either R.Problem Outcome) +join current@Group{ groupScheduler = Scheduler _ _ tickets waits, taskIdentity = taskId } ref@(TaskRef _ key) = mask $ \restore -> do + lookupEntry <- checked current ref tickets + claimed <- atomically $ do + checkpointSTM current + entry <- lookupEntry + graph <- readTVar waits + case entry of + Right task@(Task _ target _) | within current target || reaches graph taskId [] key -> pure (Left (problem "join-cycle" "join")) + | otherwise -> do + modifyTVar' tickets (M.delete key) + modifyTVar' waits (M.insert taskId [key]) + pure (Right task) + Left e -> pure (Left e) + case claimed of + Left e -> pure (Left e) + Right (Task _ _ completed) -> + (Right <$> restore (atomically (readTVar completed >>= maybe retry pure))) + `finally` atomically (modifyTVar' waits (M.delete taskId)) + +discard :: Group -> TaskRef -> IO (Either R.Problem ()) +discard current ref@(TaskRef _ key) = do + let Scheduler _ _ tickets _ = schedulerOf current + lookupEntry <- checked current ref tickets + atomically $ do + checkpointSTM current + entry <- lookupEntry + case entry of Left e -> pure (Left e); Right _ -> modifyTVar' tickets (M.delete key) >> pure (Right ()) + +cancel :: Group -> TaskRef -> T -> IO (Either R.Problem ()) +cancel current ref why = do + let Scheduler _ active _ _ = schedulerOf current + lookupEntry <- checked current ref active + atomically $ do + checkpointSTM current + entry <- lookupEntry + case entry of + Left e -> pure (Left e) + Right (Task _ child@Group{ groupPhase = phase } _) -> do + state <- readTVar phase + if state == Closed then pure (Left (problem "completed-task" "cancel")) + else stop child (Cancelled why []) >> pure (Right ()) + +liveCounts :: Scheduler -> IO (Int, Int, Int) +liveCounts (Scheduler _ active tickets waits) = atomically $ (,,) + <$> (M.size <$> readTVar active) <*> (M.size <$> readTVar tickets) <*> (M.size <$> readTVar waits) diff --git a/src/IO/UTF8.hs b/src/IO/UTF8.hs @@ -0,0 +1,63 @@ +{-# LANGUAGE BangPatterns #-} +-- Stateless bounded Unicode transport. Source owns the <=3-byte carry between +-- calls, text/source policy and sequencing; no shared mutable decoder context. +module IO.UTF8 (encode, decode, scalarLimit) where + +import qualified IO.Resources as R +import qualified Data.ByteString as BS +import qualified Data.Text as Text +import Data.Text.Encoding (encodeUtf8) +import Data.Char (chr, ord) +import Data.Bits ((.&.)) +import Data.List (foldl') + +scalarLimit :: Int +scalarLimit = R.chunkLimit `div` 4 + +fault op message = R.Problem "invalid-data" op message "none" + +encode :: String -> Either R.Problem BS.ByteString +encode text = encodeUtf8 . Text.pack <$> checked scalarLimit text + where + checked _ [] = Right [] + checked 0 _ = Left (R.Problem "invalid-bound" "encode" "Unicode chunk exceeds scalar limit" "none") + checked n (c:rest) + | ord c >= 0xd800 && ord c <= 0xdfff = Left (fault "encode" "surrogate is not a Unicode scalar") + | otherwise = (c:) <$> checked (n-1) rest + +-- Strict UTF-8, including overlong/surrogate/range controls. A nonfinal valid +-- incomplete suffix is copied (not a slice retaining the input buffer). Invalid +-- prefixes fail immediately, even when incomplete. No partial output on error. +decode :: Bool -> BS.ByteString -> Either R.Problem (String, BS.ByteString) +decode final bytes + | BS.length bytes > R.chunkLimit = Left (R.Problem "invalid-bound" "decode" "UTF-8 chunk exceeds byte limit" "none") + | otherwise = walk 0 [] + where + end = BS.length bytes + at i = fromIntegral (BS.index bytes i) :: Int + bad offset why = Left (fault "decode" (why ++ " at byte " ++ show offset)) + walk !offset chars + | offset == end = Right (reverse chars, BS.empty) + | otherwise = + let lead = at offset + emit code count = let !c = chr code in walk (offset + count) (c:chars) + sequenceOf count low high mask = + let valid i = let c = at (offset+i) + in c >= (if i == 1 then low else 0x80) + && c <= (if i == 1 then high else 0xbf) + available = min (count-1) (end-offset-1) + in if not (all valid [1..available]) then bad offset "invalid UTF-8 sequence" + else if end-offset < count then + if final then bad offset "incomplete UTF-8 sequence" + else Right (reverse chars, BS.copy (BS.drop offset bytes)) + else emit (foldl' (\code i -> code*64 + (at (offset+i) .&. 0x3f)) + (lead .&. mask) [1..count-1]) count + in if lead <= 0x7f then emit lead 1 + else if lead >= 0xc2 && lead <= 0xdf then sequenceOf 2 0x80 0xbf 0x1f + else if lead == 0xe0 then sequenceOf 3 0xa0 0xbf 0x0f + else if lead == 0xed then sequenceOf 3 0x80 0x9f 0x0f + else if lead >= 0xe1 && lead <= 0xef then sequenceOf 3 0x80 0xbf 0x0f + else if lead == 0xf0 then sequenceOf 4 0x90 0xbf 0x07 + else if lead == 0xf4 then sequenceOf 4 0x80 0x8f 0x07 + else if lead >= 0xf1 && lead <= 0xf3 then sequenceOf 4 0x80 0xbf 0x07 + else bad offset "invalid UTF-8 leading byte" diff --git a/src/IO/Wire.hs b/src/IO/Wire.hs @@ -0,0 +1,45 @@ +-- Pure IO wire decoding, shared by native and browser embeddings. No resource, +-- scheduler, host effects or facility resolution imports. Payloads are borrowed +-- lazy trees: decoding never traverses arguments, callbacks or continuations. +module IO.Wire (Action(..), canonicalNat, decodeAction) where + +import Runtime.Tree (T(..)) + +canonicalNat :: Int -> T -> Maybe Integer +canonicalNat _ Leaf = Just 0 +canonicalNat 0 _ = Nothing +canonicalNat _ (Fork Leaf Leaf) = Nothing +canonicalNat depth (Fork Leaf rest) = (2 *) <$> canonicalNat (depth-1) rest +canonicalNat depth (Fork (Stem Leaf) rest) = (\n -> 2*n+1) <$> canonicalNat (depth-1) rest +canonicalNat _ _ = Nothing + +-- Names are nonempty ASCII identifiers, at most 64 characters. Do not inspect +-- an excess element or tail, or demand arguments while resolving a header. +name :: T -> Maybe String +name value = do + text <- walk (64 :: Int) value + case text of c:_ | c >= 'a' && c <= 'z' -> Just text; _ -> Nothing + where + walk _ Leaf = Just [] + walk 0 _ = Nothing + walk n (Fork c rest) = do + code <- canonicalNat 7 c + if (code >= 97 && code <= 122) || (code >= 48 && code <= 57) || code == 45 + then (toEnum (fromInteger code) :) <$> walk (n-1) rest + else Nothing + walk _ _ = Nothing + +data Action = Core Integer T | Request String Integer String T + +decodeAction :: T -> Either String Action +decodeAction (Fork tag payload) = case canonicalNat 8 tag of + Just n | n <= 9 -> Right (Core n payload) + Just 10 -> case payload of + Fork group (Fork revision (Fork operation arguments)) -> + case (name group, canonicalNat 16 revision, name operation) of + (Just g, Just v, Just op) | v > 0 -> Right (Request g v op arguments) + _ -> Left "invalid facility header" + _ -> Left "invalid facility header" + _ -> Left "unknown computation constructor" +decodeAction _ = Left "invalid computation shape" + diff --git a/src/IODriver.hs b/src/IODriver.hs @@ -3,13 +3,13 @@ module IODriver (checkIOSentinel, runIO) where import Runtime (T(..), ofString) -import qualified IOV2.Driver as V2 +import qualified IO.Driver as HostIO checkIOSentinel :: T -> Either String (Integer, T) checkIOSentinel tree = case tree of Fork sentinel (Fork version action) | sentinel /= ofString "tricuIO" -> Left "sentinel mismatch (expected canonical tricuIO)" - | otherwise -> case V2.canonicalNat 16 version of + | otherwise -> case HostIO.canonicalNat 16 version of Just v -> Right (v, action) Nothing -> Left "invalid IO envelope version" _ -> Left "root is not an IO sentinel pair" @@ -17,5 +17,5 @@ checkIOSentinel tree = case tree of runIO :: T -> IO (Either String T) runIO tree = case checkIOSentinel tree of Left problem -> pure (Left problem) - Right (2, action) -> V2.run action + Right (2, action) -> HostIO.run action Right _ -> pure (Left "Unsupported tricuIO version") diff --git a/src/IOV2/ByteStream.hs b/src/IOV2/ByteStream.hs @@ -1,84 +0,0 @@ -{-# LANGUAGE BangPatterns #-} --- Native bulk bytes; source owns paths, digest selection and response policy. -module IOV2.ByteStream (snapshot, snapshotWith, transfer) where - -import qualified IOV2.Resources as R -import Control.Exception -import Control.Monad (unless, when) -import Crypto.Hash (Context, SHA256, hashInit, hashUpdate, hashFinalize) -import qualified Data.ByteString as BS -import Data.IORef -import System.Directory (removeFile) -import System.IO - --- A whole-stream SHA-256 snapshot, including an EOF probe at the byte budget. --- Register before consuming input. The exposed file has no write/resize method; --- only the private handle can prepare it, before its token is returned. -snapshot :: R.Runtime -> R.Scope -> R.Token -> FilePath -> Integer -> String - -> IO (Either R.Problem (R.Token, Integer)) -snapshot = snapshotWith hClose - -snapshotWith :: (Handle -> IO ()) -> R.Runtime -> R.Scope -> R.Token -> FilePath - -> Integer -> String -> IO (Either R.Problem (R.Token, Integer)) -snapshotWith release runtime scope reader directory limit expected - | limit < 0 || limit > R.positionLimit = pure (Left (fault "invalid-bound" "invalid byte limit" "none")) - | length expected /= 64 || any (`notElem` ("0123456789abcdef" :: String)) expected = - pure (Left (fault "invalid-argument" "expected lowercase SHA-256" "none")) - | null directory = pure (Left (fault "invalid-argument" "empty snapshot directory" "none")) - | otherwise = R.withReaderLease runtime scope reader (\receive -> mask $ \restore -> do - slot <- newIORef Nothing - acquired <- R.acquire runtime scope $ do - (path,h) <- openBinaryTempFile directory ".tricu-bytes-" - (do - removeFile path - hSetBuffering h NoBuffering - writeIORef slot (Just h) - pure (R.File (R.FileOps (Just (BS.hGetSome h)) Nothing - (Just (hSeek h AbsoluteSeek)) (Just (hTell h)) (hFileSize h) - Nothing Nothing (release h)))) `onException` hClose h - token <- either R.resourceFault pure acquired - Just h <- readIORef slot - -- A lease protects the registered spool throughout preparation. Exceptions - -- leave it to scope settlement, retaining cancellation/cleanup precedence. - result <- restore $ R.withReaderLease runtime scope token $ \_ -> do - let copy !count !context = do - allowInterrupt - bytes <- receive (fromInteger (min (toInteger R.chunkLimit) (limit-count+1))) - if BS.null bytes then pure (count,context) else do - let nextCount = count + toInteger (BS.length bytes) - when (nextCount > limit) (R.resourceFault (fault "invalid-bound" "stream exceeds byte limit" "unknown")) - BS.hPut h bytes - next <- evaluate (hashUpdate context bytes) - copy nextCount next - (count,context) <- copy 0 (hashInit :: Context SHA256) - unless (show (hashFinalize context) == expected) - (R.resourceFault (fault "integrity-failed" "SHA-256 mismatch" "unknown")) - hSeek h AbsoluteSeek 0 - pure count - case result of - Right count -> pure (token,count) - Left primary -> do - closed <- R.closeResource runtime scope token - R.resourceFault $ case closed of - Right () -> primary - Left cleanup -> primary { R.detail = take 240 (R.detail primary) ++ "; cleanup: " ++ take 240 (show cleanup) }) - where fault category detail progress = R.Problem category "snapshot" detail progress - --- Exact byte count, starting at the current cursor; no EOF probe. Both leases --- cover the transfer. A failure may follow partial writes: callers close rather --- than retrying or emitting a second response. No encoded byte trees are built. -transfer :: R.Runtime -> R.Scope -> R.Token -> R.Token -> Integer -> IO (Either R.Problem ()) -transfer runtime scope reader writer count - | count < 0 || count > R.positionLimit = pure (Left (R.Problem "invalid-bound" "transfer" "invalid byte count" "none")) - | otherwise = R.withReaderLease runtime scope reader (\receive -> - R.withWriterLease runtime scope writer (\send -> do - let sendAll bytes = unless (BS.null bytes) $ do - n <- send bytes - sendAll (BS.drop n bytes) - copy !remaining = when (remaining > 0) $ do - allowInterrupt - bytes <- receive (fromInteger (min remaining (toInteger R.chunkLimit))) - when (BS.null bytes) (R.resourceFault (R.Problem "invalid-data" "transfer" "unexpected EOF" "unknown")) - sendAll bytes - copy (remaining - toInteger (BS.length bytes)) - copy count) >>= either R.resourceFault pure) diff --git a/src/IOV2/Clock.hs b/src/IOV2/Clock.hs @@ -1,28 +0,0 @@ --- Native monotonic observations and interruptible relative waits. No wall-clock --- epoch, deadline scheduler, race combinator or ordering guarantee is implied. -module IOV2.Clock (monotonicNanoseconds, sleepMilliseconds, sleepWith, sleepLimit) where - -import Control.Concurrent (threadDelay) -import GHC.Clock (getMonotonicTimeNSec) - -sleepLimit :: Integer -sleepLimit = 9223372036854775807 - -monotonicNanoseconds :: IO Integer -monotonicNanoseconds = toInteger <$> getMonotonicTimeNSec - --- Driver validates canonical [0,sleepLimit] milliseconds before entry. Keep each --- host delay <= one second: no Int overflow even on a narrower host, and no giant --- conversion to microseconds. Delays may run late; cancellation interrupts them. --- Injectable waiter supports deterministic chunk/overflow tests, not fault flags. -sleepWith :: (Int -> IO ()) -> Integer -> IO () -sleepWith wait = go - where - go 0 = pure () - go remaining = do - let chunk = min remaining 1000 - wait (fromInteger (chunk * 1000)) - go (remaining - chunk) - -sleepMilliseconds :: Integer -> IO () -sleepMilliseconds = sleepWith threadDelay diff --git a/src/IOV2/Codec.hs b/src/IOV2/Codec.hs @@ -1,112 +0,0 @@ -{-# LANGUAGE ScopedTypeVariables #-} --- Format-specific authenticated transport, independent of store paths/publication. -module IOV2.Codec (hashIndexed, writeIndexed, verifyIndexed, readIndexed, readIndexedWith, sectionLimit, manifestLimit) where - -import qualified IOV2.Resources as R -import ContentStore.Snapshot -import ContentStore.Indexed (indexedTreeDomain, decodeIndexedSnapshot) -import ContentStore.Object (Domain(..), hashToText, validateObjectHash) -import Wire (Bundle, buildBundleShared, bundleEncodedLength, emitBundle) -import Crypto.Hash (Context, SHA256, hashInit, hashUpdate, hashFinalize) -import qualified Data.ByteString as BS -import Data.Text.Encoding (encodeUtf8) -import Control.Monad (unless) -import Runtime (T) -import Control.Exception -import Data.IORef -import qualified Data.Text as Text -import System.IO (Handle, hClose) - -sectionLimit, manifestLimit :: Int -sectionLimit = 64 -manifestLimit = 65536 - -readIndexed :: R.Runtime -> R.Scope -> R.Token -> FilePath -> Integer -> Integer -> String - -> IO (Either R.Problem T) -readIndexed = readIndexedWith hClose - --- Injectable release for lifecycle/fault controls, not a production fault flag. -readIndexedWith :: (Handle -> IO ()) -> R.Runtime -> R.Scope -> R.Token -> FilePath - -> Integer -> Integer -> String -> IO (Either R.Problem T) -readIndexedWith release runtime scope reader directory limit size digest - | limit < 0 || size < 0 || size > limit || limit > R.positionLimit = - pure (Left (R.Problem "invalid-bound" "read" "invalid snapshot size limit" "none")) - | Left _ <- validateObjectHash expected = - pure (Left (R.Problem "invalid-argument" "read" "expected 64 lowercase hexadecimal digits" "none")) - | null directory = pure (Left (R.Problem "invalid-argument" "read" "empty snapshot directory" "none")) - | otherwise = R.withReaderLease runtime scope reader $ \receive -> - withAuthenticatedSnapshotUsing lifetime directory limit size indexedTreeDomain expected receive $ \snapshot -> do - decoded <- decodeIndexedSnapshot sectionLimit manifestLimit snapshot - either (R.resourceFault . malformed) pure decoded - where - expected = Text.pack digest - malformed message = R.Problem "invalid-data" "read" (take 512 message) "unknown" - -- Register the anonymous spool before consuming input. A terminal exception - -- leaves its entry for structured scope settlement, preserving the primary - -- fault/cancellation and collecting any cleanup diagnostics there. - lifetime allocate consume = R.withTemporary runtime scope "read" - (do handle <- allocate; pure (handle, release handle)) - (\handle -> consume handle `catch` (R.resourceFault . snapshotProblem)) - >>= either R.resourceFault pure - snapshotProblem e = case e of - SnapshotDigestMismatch -> R.Problem "integrity-failed" "read" "CAS digest mismatch" "unknown" - InvalidSnapshotBound -> R.Problem "invalid-bound" "read" "invalid snapshot size limit" "none" - InvalidSnapshotDigest -> R.Problem "invalid-argument" "read" "invalid digest" "none" - SnapshotTruncated -> malformed "incomplete indexed frame" - SnapshotReaderExceededBound -> malformed "reader exceeded frame bound" - InvalidSnapshotRange -> malformed "invalid snapshot range" - --- Prepare/hash a resident DAG, not an encoded byte list. Encoding is regenerated --- during writing: sharing a lazy byte-chunk list across passes would retain the --- entire encoding. Application policy still selects sharding and publication. -prepareIndexed :: Integer -> T -> IO (Either R.Problem (Bundle, Integer, String)) -prepareIndexed limit tree - | limit < 0 || limit > R.positionLimit = pure (Left (R.Problem "invalid-bound" "hash" "invalid encoded size limit" "none")) - | otherwise = do - bundle <- buildBundleShared [("root", tree)] - let size = bundleEncodedLength bundle - if size > limit then pure (Left (R.Problem "invalid-bound" "hash" "encoded tree exceeds limit" "none")) - else do - let Domain domain = indexedTreeDomain - context <- newIORef (hashUpdate (hashInit :: Context SHA256) (encodeUtf8 domain <> BS.singleton 0)) - emitBundle R.chunkLimit bundle $ \bytes -> do - previous <- readIORef context - next <- evaluate (hashUpdate previous bytes) - writeIORef context next - finished <- readIORef context - digest <- evaluate (hashToText (hashFinalize finished)) - pure (Right (bundle, size, Text.unpack digest)) - -hashIndexed :: Integer -> T -> IO (Either R.Problem (Integer, String)) -hashIndexed limit tree = fmap (fmap (\(_,size,digest) -> (size,digest))) (prepareIndexed limit tree) - -writeIndexed :: R.Runtime -> R.Scope -> R.Token -> Integer -> String -> T - -> IO (Either R.Problem (Integer, String)) -writeIndexed runtime scope writer limit expected tree - | limit < 0 || limit > R.positionLimit = pure (Left (R.Problem "invalid-bound" "write" "invalid encoded size limit" "none")) - | Left _ <- validateObjectHash (Text.pack expected) = pure (Left (R.Problem "invalid-argument" "write" "expected 64 lowercase hexadecimal digits" "none")) - | otherwise = R.withWriterLease runtime scope writer $ \send -> do - (bundle,size,digest) <- prepareIndexed limit tree >>= either R.resourceFault pure - unless (digest == expected) (R.resourceFault (R.Problem "integrity-failed" "write" "CAS digest mismatch" "none")) - let allBytes bytes = unless (BS.null bytes) $ do - count <- send bytes - allBytes (BS.drop count bytes) - emitBundle R.chunkLimit bundle allBytes - pure (size,digest) - --- Compare the exact canonical bytes, not merely their digest. This consumes one --- known-size frame without an EOF probe; source policy must check whole-file EOF --- when verifying an immutable store winner. No untrusted bytes are decoded. -verifyIndexed :: R.Runtime -> R.Scope -> R.Token -> Integer -> T - -> IO (Either R.Problem (Integer, String)) -verifyIndexed runtime scope reader limit tree - | limit < 0 || limit > R.positionLimit = pure (Left (R.Problem "invalid-bound" "verify" "invalid encoded size limit" "none")) - | otherwise = R.withReaderLease runtime scope reader $ \receive -> do - (bundle,size,digest) <- prepareIndexed limit tree >>= either R.resourceFault pure - let compareBytes expected = unless (BS.null expected) $ do - actual <- receive (BS.length expected) - unless (not (BS.null actual) && actual == BS.take (BS.length actual) expected) - (R.resourceFault (R.Problem "integrity-failed" "verify" "indexed frame differs from canonical tree" "unknown")) - compareBytes (BS.drop (BS.length actual) expected) - emitBundle R.chunkLimit bundle compareBytes - pure (size,digest) diff --git a/src/IOV2/Context.hs b/src/IOV2/Context.hs @@ -1,26 +0,0 @@ -{-# LANGUAGE ScopedTypeVariables #-} --- Read-only host context; no global cwd/environment mutation or implicit state. -module IOV2.Context (workingDirectory, environmentVariable, boundedText) where -import qualified IOV2.Resources as R -import Control.Exception -import qualified System.Directory as D -import qualified System.Environment as E - --- Bound the returned text; the host library may materialize it before this check. -boundedText :: String -> String -> Either R.Problem String -boundedText operation value - | length (take 4097 value) > 4096 = Left (R.Problem "invalid-bound" operation "host context text exceeds scalar limit" "none") - | any (\c -> c == '\0' || (c >= '\xD800' && c <= '\xDFFF')) value = - Left (R.Problem "invalid-data" operation "host context is not NUL-free Unicode scalar text" "none") - | otherwise = Right value - -workingDirectory :: IO (Either R.Problem FilePath) -workingDirectory = (boundedText "working-directory" <$> D.getCurrentDirectory) - `catch` (\(e :: IOException) -> pure (Left (R.ioProblem "working-directory" e))) - -environmentVariable :: String -> IO (Either R.Problem (Maybe String)) -environmentVariable name = case boundedText "environment-variable" name of - Left problem -> pure (Left problem) - Right checked | null checked || '=' `elem` checked -> pure (Left (R.Problem "invalid-argument" "environment-variable" "" "none")) - Right checked -> (E.lookupEnv checked >>= pure . traverse (boundedText "environment-variable")) - `catch` (\(e :: IOException) -> pure (Left (R.ioProblem "environment-variable" e))) diff --git a/src/IOV2/Descriptor.hs b/src/IOV2/Descriptor.hs @@ -1,29 +0,0 @@ --- Owned raw descriptors: a failed close must never be retried against a number --- the OS may already have recycled. No public descriptors or unrestricted FFI. -module IOV2.Descriptor (closeOnce, releaseOnce, duplicateSync) where - -import Control.Exception (bracketOnError, mask_) -import Control.Monad (when) -import Data.IORef -import GHC.IO.Handle (hDuplicate) -import System.IO (Handle, hClose) -import System.Posix.IO (closeFd, handleToFd) -import System.Posix.Types (Fd) -import System.Posix.Unistd (fileSynchronise) - -closeOnce :: Fd -> IO (IO ()) -closeOnce fd = releaseOnce (closeFd fd) - --- Injectable action for close-failure/cancellation controls. -releaseOnce :: IO () -> IO (IO ()) -releaseOnce action = do - owned <- newIORef True - pure $ mask_ $ do - close <- atomicModifyIORef' owned (\old -> (False,old)) - when close action - -duplicateSync :: Handle -> IO (IO (), IO ()) -duplicateSync handle = mask_ $ do - fd <- bracketOnError (hDuplicate handle) hClose handleToFd - release <- closeOnce fd - pure (fileSynchronise fd, release) diff --git a/src/IOV2/Digest.hs b/src/IOV2/Digest.hs @@ -1,30 +0,0 @@ --- Scoped SHA-256 state, deliberately independent of writers and publication. -module IOV2.Digest (openDigest) where - -import qualified IOV2.Resources as R -import Crypto.Hash (Context, SHA256, Digest, hashInit, hashUpdate, hashFinalize) -import Data.ByteArray (convert) -import qualified Data.ByteString.Base16 as Hex -import qualified Data.ByteString.Char8 as BC -import Data.IORef -import Control.Exception (evaluate, mask_) - -openDigest :: R.Runtime -> R.Scope -> String -> IO (Either R.Problem R.Token) -openDigest runtime scope algorithm - | algorithm /= "sha256" = pure (Left (R.Problem "unsupported" "digest-open" (take 128 algorithm) "none")) - | otherwise = R.acquire runtime scope $ do - state <- newIORef (Just (hashInit :: Context SHA256)) - let active op = readIORef state >>= maybe - (R.resourceFault (R.Problem "invalid-state" op "digest already finished" "none")) pure - update bytes = mask_ $ do - context <- active "digest-update" - -- Force the new context before storing it: no chain of old chunks. - next <- evaluate (hashUpdate context bytes) - writeIORef state (Just next) - finish = mask_ $ do - context <- active "digest-finish" - let encoded = Hex.encode (convert (hashFinalize context :: Digest SHA256)) - _ <- evaluate (BC.length encoded) - writeIORef state Nothing - pure (BC.unpack encoded) - pure (R.Digest update finish (writeIORef state Nothing)) diff --git a/src/IOV2/Driver.hs b/src/IOV2/Driver.hs @@ -1,467 +0,0 @@ -{-# LANGUAGE ScopedTypeVariables #-} --- Native v2 structured tasks, resource ownership and scoped operations. -module IOV2.Driver (Outcome(..), run, runDetailed, runInRuntime, canonicalNat, decodeChunk) where - -import Runtime (T(..), apply, ofNumber, ofString, ofList, ofBytes, toString) -import qualified IOV2.Resources as R -import qualified IOV2.Protocol as P -import IOV2.Protocol (canonicalNat, problemTree) -import qualified IOV2.Tasks as S -import qualified IOV2.Staging as Stage -import qualified IOV2.Digest as Digest -import qualified IOV2.Filesystem as FS -import qualified IOV2.Network as TCP -import qualified IOV2.Codec as Codec -import qualified IOV2.ByteStream as ByteStream -import qualified IOV2.UTF8 as UTF8 -import qualified IOV2.Resolver as Resolver -import qualified IOV2.Clock as Clock -import qualified IOV2.Process as Process -import qualified IOV2.Context as Context -import qualified Control.Concurrent as Concurrent -import IOV2.Tasks (Outcome(..)) -import qualified Data.ByteString as BS - - -decodeChunk :: T -> Either String BS.ByteString -decodeChunk = fmap BS.pack . walk R.chunkLimit - where - walk _ Leaf = Right [] - walk 0 _ = Left "chunk exceeds bound" - walk n (Fork value rest) = case canonicalNat 8 value of - Just byte -> (fromIntegral byte :) <$> walk (n-1) rest - _ -> Left "noncanonical octet" - walk _ _ = Left "improper chunk" - -ok, err :: T -> T -ok value = Fork (Stem Leaf) (Fork value Leaf) -err value = Fork Leaf (Fork value Leaf) -reply :: (a -> T) -> Either R.Problem a -> T -reply encode = either (err . problemTree) (ok . encode) -bad :: String -> T -bad op = err (problemTree (R.Problem "invalid-argument" op "" "none")) --- Only host-generated operation Results pass here. Successful payloads and --- arbitrary user failure diagnostics remain opaque; no error is inferred from --- a pure computation's value. Keep category/detail/progress exactly intact. -qualifyError :: String -> T -> T -qualifyError operation result = case result of - Fork Leaf (Fork (Fork category (Fork _ rest)) Leaf) -> - err (Fork category (Fork (ofString operation) rest)) - _ -> result - -protocol :: Outcome -protocol = protocolFailure "invalid v2 computation" -protocolFailure :: String -> Outcome -protocolFailure detail = Failed (problemTree (R.Problem "protocol-error" "core/decode" detail "none")) [] - -tokenTree :: R.Token -> T -tokenTree (R.Token identity key) = ofList [ofString "io.v2.resource", ofNumber identity, ofNumber key] -decodeToken :: T -> Maybe R.Token -decodeToken (Fork tag (Fork runId (Fork key Leaf))) - | tag == ofString "io.v2.resource" = R.Token <$> canonicalNat 64 runId <*> canonicalNat 64 key -decodeToken _ = Nothing - --- Scope references also carry a run identity, supplied by a private root token. --- Use a separate marker so a resource cannot be passed as a scope. -scopeTree :: R.Token -> R.Scope -> T -scopeTree (R.Token identity _) scope = ofList [ofString "io.v2.scope", ofNumber identity, ofNumber scope] -decodeScope :: R.Token -> T -> Maybe R.Scope -decodeScope (R.Token identity _) (Fork tag (Fork runId (Fork key Leaf))) - | tag == ofString "io.v2.scope", Just identity == canonicalNat 64 runId = canonicalNat 64 key -decodeScope _ _ = Nothing - -runDetailed :: T -> IO Outcome -runDetailed action = R.newRuntime >>= \runtime -> runInRuntime runtime action - --- One execution owns this runtime's root and always closes it. Exposed for --- embedding/backend tests; a closed runtime cannot be reused for acquisitions. -runInRuntime :: R.Runtime -> T -> IO Outcome -runInRuntime runtime action = do - identity <- R.runtimeIdentity runtime - scheduler <- S.newScheduler runtime - group <- S.rootGroup scheduler - S.runGroup group (loop runtime (R.Token identity 0) group action []) - -run :: T -> IO (Either String T) -run action = do - outcome <- runDetailed action - pure $ case outcome of - Completed value -> Right value - Cancelled _ cleanup -> Left ("IO v2 task cancelled (reason not forced; " ++ show (length cleanup) ++ " cleanup diagnostics)") - Failed _ cleanup -> Left ("IO v2 task failed (diagnostic payload not forced; " ++ show (length cleanup) ++ " cleanup diagnostics)") - -loop :: R.Runtime -> R.Token -> S.Group -> T -> [T] -> IO Outcome -loop runtime marker group action frames = S.checkpoint group >> step - where - scope = S.scopeOf group - step = case P.decodeAction action of - Left detail -> pure (protocolFailure detail) - Right (P.Core code payload) -> dispatch code payload $ case lookup code - [(5,"spawn"),(6,"join"),(7,"discard"),(8,"cancel")] of - Just name -> done . qualifyError ("core/" ++ name) - Nothing -> done - Right (P.Request facility revision operation arguments) -> - case P.resolve facility revision operation of - Just code -> dispatch code arguments (done . qualifyError (facility ++ "/" ++ operation)) - Nothing -> done (reply id (Left (R.Problem "unsupported" - (facility ++ "/" ++ operation) ("group revision " ++ show revision) "none"))) - dispatch code payload finish = case code of - 0 -> finish payload - 1 -> case payload of - Fork first next -> loop runtime marker group first (next:frames) - _ -> pure protocol - 2 -> pure (Failed payload []) - 3 -> do - result <- S.nested group (\child -> loop runtime marker child - (apply payload (scopeTree marker (S.scopeOf child))) []) - case result of Completed value -> finish value; failure -> pure failure - 4 -> finish (scopeTree marker scope) - 5 -> case payload of - Fork supervised (Fork keep (Fork handles callback)) -> - case (canonicalNat 1 supervised, canonicalNat 1 keep, decodeHandles handles) of - (Just contain, Just retain, Just tokens) | contain <= 1 && retain <= 1 -> do - result <- S.spawn group (contain == 1) (retain == 1) tokens (\child fresh -> - loop runtime marker child (apply callback (ofList (map tokenTree fresh))) []) - finish (reply (spawnReply (retain == 1)) result) - _ -> finish (bad "spawn") - _ -> finish (bad "spawn") - 6 -> case decodeTask "io.v2.join" payload of - Just ref -> S.join group ref >>= finish . reply outcomeTree - _ -> finish (bad "join") - 7 -> case decodeTask "io.v2.join" payload of - Just ref -> S.discard group ref >>= finish . reply (const Leaf) - _ -> finish (bad "discard") - 8 -> case payload of - Fork handle reason -> case decodeTask "io.v2.task" handle of - Just ref -> S.cancel group ref reason >>= finish . reply (const Leaf) - _ -> finish (bad "cancel") - _ -> finish (bad "cancel") - 9 -> pure (Cancelled payload []) - 20 -> open finish R.openReader "open-reader" payload - 21 -> open finish R.openWriter "open-writer" payload - 22 -> case payload of - Fork handle count -> case (decodeToken handle, canonicalNat 16 count) of - (Just token, Just n) -> R.readSome runtime scope token (fromInteger n) >>= finish . reply readValue - _ -> finish (bad "read") - _ -> finish (bad "read") - 23 -> case payload of - Fork handle bytes -> case (decodeToken handle, decodeChunk bytes) of - (Just token, Right chunk) -> R.writeSome runtime scope token chunk >>= finish . reply (ofNumber . toInteger) - _ -> finish (bad "write") - _ -> finish (bad "write") - 24 -> case decodeToken payload of - Just token -> R.closeResource runtime scope token >>= finish . reply (const Leaf) - _ -> finish (bad "close") - 25 -> standard finish R.StandardInput payload - 26 -> standard finish R.StandardOutput payload - 27 -> standard finish R.StandardError payload - 50 -> open finish R.openAppender "open-appender" payload - 51 -> open finish R.openUpdate "open-update" payload - 52 -> positionOperation finish R.seekFile "seek" payload - 53 -> unary finish "position" R.filePosition ofNumber payload - 54 -> unary finish "size" R.fileSize ofNumber payload - 55 -> positionOperation finish R.resizeFile "resize" payload - 30 -> open finish Stage.openStage "stage-open" payload - 31 -> unary finish "seal" R.sealStage (const Leaf) payload - 32 -> case payload of - Fork handle (Fork mode path) -> case (decodeToken handle, canonicalNat 1 mode, canonicalText path) of - (Just token, Just replace, Just destination) | replace <= 1 -> - R.publishStage runtime scope token (replace == 1) destination >>= finish . reply ofString - _ -> finish (bad "publish") - _ -> finish (bad "publish") - 40 -> open finish Digest.openDigest "digest-open" payload - 41 -> case payload of - Fork handle bytes -> case (decodeToken handle, decodeChunk bytes) of - (Just token, Right chunk) -> R.updateDigest runtime scope token chunk >>= finish . reply (const Leaf) - _ -> finish (bad "digest-update") - _ -> finish (bad "digest-update") - 42 -> unary finish "digest-finish" R.finishDigest ofString payload - 60 -> pathOperation finish "stat" (FS.statPath True) statusTree payload - 61 -> pathOperation finish "lstat" (FS.statPath False) statusTree payload - 62 -> pathOperation finish "create" FS.createDirectory (const Leaf) payload - 63 -> pathOperation finish "remove" FS.removeDirectory (const Leaf) payload - 64 -> pathOperation finish "remove-file" FS.removeFile (const Leaf) payload - 65 -> case payload of - Fork old new -> case (canonicalText old, canonicalText new) of - (Just source, Just target) -> FS.renamePath source target >>= finish . reply (const Leaf) - _ -> finish (bad "rename") - _ -> finish (bad "rename") - 66 -> open finish FS.openDirectory "open" payload - 67 -> unary finish "next" R.nextDirectory - (maybe (Fork (ofString "eof") Leaf) (Fork (ofString "entry") . ofString)) payload - 80 -> case payload of - Fork ownerRef (Fork endpoint backlog) -> case - (decodeScope marker ownerRef, decodeEndpoint endpoint, canonicalNat 16 backlog) of - (Just owner, Just target, Just limit) -> owned finish "listen" owner - (TCP.listenTCP runtime owner target (fromInteger limit)) - _ -> finish (bad "listen") - _ -> finish (bad "listen") - 81 -> case payload of - Fork ownerRef endpoint -> case (decodeScope marker ownerRef, decodeEndpoint endpoint) of - (Just owner, Just target) -> owned finish "connect" owner (TCP.connectTCP runtime owner target) - _ -> finish (bad "connect") - _ -> finish (bad "connect") - 82 -> case payload of - Fork handle ownerRef -> case (decodeToken handle, decodeScope marker ownerRef) of - (Just listener, Just owner) -> owned finish "accept" owner - (R.acceptConnection runtime scope listener owner) - _ -> finish (bad "accept") - _ -> finish (bad "accept") - 83 -> unary finish "local-address" (\r s t -> R.socketAddress r s t False) endpointTree payload - 84 -> unary finish "peer-address" (\r s t -> R.socketAddress r s t True) endpointTree payload - 85 -> unary finish "shutdown-write" R.shutdownWrite (const Leaf) payload - 86 -> case payload of - Fork host (Fork family (Fork port (Fork limit Leaf))) -> case - (boundedHost host, resolveFamily family, canonicalNat 16 port, canonicalNat 8 limit) of - (Just name, Just selection, Just service, Just count) -> - Resolver.resolveTCP name selection service (fromInteger count) >>= finish . reply - (\(Resolver.Resolution endpoints truncated) -> ofList - [ofList (map endpointTree endpoints),ofNumber (if truncated then 1 else 0)]) - _ -> finish (bad "resolve") - _ -> finish (bad "resolve") - 87 -> case payload of - Fork reader (Fork directory (Fork limit (Fork size (Fork digest Leaf)))) -> case - (decodeToken reader, canonicalText directory, canonicalNat 63 limit, - canonicalNat 63 size, boundedDigest digest) of - (Just token, Just path, Just maximum, Just count, Just expected) -> - Codec.readIndexed runtime scope token path maximum count expected >>= finish . reply id - _ -> finish (bad "read") - _ -> finish (bad "read") - 88 -> case payload of - Fork limit tree -> case canonicalNat 63 limit of - Just maximum -> Codec.hashIndexed maximum tree >>= finish . reply encodedInfo - _ -> finish (bad "hash") - _ -> finish (bad "hash") - 89 -> case payload of - Fork writer (Fork limit (Fork digest (Fork tree Leaf))) -> case - (decodeToken writer, canonicalNat 63 limit, boundedDigest digest) of - (Just token, Just maximum, Just expected) -> - Codec.writeIndexed runtime scope token maximum expected tree >>= finish . reply encodedInfo - _ -> finish (bad "write") - _ -> finish (bad "write") - 95 -> case payload of - Fork reader (Fork limit (Fork tree Leaf)) -> case (decodeToken reader, canonicalNat 63 limit) of - (Just token, Just maximum) -> Codec.verifyIndexed runtime scope token maximum tree >>= finish . reply encodedInfo - _ -> finish (bad "verify") - _ -> finish (bad "verify") - 56 -> unary finish "sync" R.syncFile (const Leaf) payload - 68 -> pathOperation finish "sync" (FS.syncDirectory runtime scope) (const Leaf) payload - 96 -> case unicodeChunk UTF8.scalarLimit payload of - Just text -> finish (reply ofBytes (UTF8.encode text)) - _ -> finish (bad "encode") - 97 -> case payload of - Fork final (Fork bytes Leaf) -> case (canonicalNat 1 final, decodeChunk bytes) of - (Just end, Right chunk) -> finish (reply (\(text,carry) -> ofList [ofString text,ofBytes carry]) - (UTF8.decode (end == 1) chunk)) - _ -> finish (bad "decode") - _ -> finish (bad "decode") - 102 -> case payload of - Leaf -> Clock.monotonicNanoseconds >>= finish . ok . ofNumber - _ -> finish (bad "monotonic") - 103 -> case canonicalNat 63 payload of - Just milliseconds -> Clock.sleepMilliseconds milliseconds >> finish (ok Leaf) - _ -> finish (bad "sleep") - 104 -> case payload of - Leaf -> Concurrent.yield >> finish (ok Leaf) - _ -> finish (bad "yield") - 106 -> case payload of - Leaf -> Context.workingDirectory >>= finish . reply ofString - _ -> finish (bad "working-directory") - 107 -> case processText payload of - Just name -> Context.environmentVariable name >>= finish . reply - (maybe (ofList [ofString "missing"]) (\value -> ofList [ofString "value", ofString value])) - _ -> finish (bad "environment-variable") - 105 -> case decodeProcess payload of - Just spec -> Process.launch runtime group spec >>= finish . reply - (\(task,pipes) -> ofList ([taskTree "io.v2.task" task, taskTree "io.v2.join" task] ++ map tokenTree pipes)) - Nothing -> finish (bad "spawn") - 108 -> case payload of - Fork reader (Fork directory (Fork limit (Fork digest Leaf))) -> case - (decodeToken reader, canonicalText directory, canonicalNat 63 limit, boundedDigest digest) of - (Just token, Just path, Just maximum, Just expected) -> - ByteStream.snapshot runtime scope token path maximum expected >>= finish . reply - (\(snapshot,size) -> ofList [tokenTree snapshot,ofNumber size]) - _ -> finish (bad "snapshot") - _ -> finish (bad "snapshot") - 109 -> case payload of - Fork reader (Fork writer (Fork count Leaf)) -> case - (decodeToken reader, decodeToken writer, canonicalNat 63 count) of - (Just input, Just output, Just size) -> - ByteStream.transfer runtime scope input output size >>= finish . reply (const Leaf) - _ -> finish (bad "transfer") - _ -> finish (bad "transfer") - 100 -> case payload of - Leaf -> finish (ok P.capabilities) - _ -> finish (bad "capabilities") - 101 -> finish (reply id (P.inspectAction payload)) - _ -> pure protocol - encodedInfo (size,digest) = ofList [ofNumber size, ofString digest] - done value = case frames of - [] -> pure (Completed value) - next:rest -> loop runtime marker group (apply next value) rest - owned finish name owner action = do - allowed <- R.isAncestor runtime scope owner - if allowed then action >>= finish . reply tokenTree else finish (bad name) - pathOperation finish name operation encode payload = case canonicalText payload of - Just path -> operation path >>= finish . reply encode - _ -> finish (bad name) - statusTree status = case status of - FS.Regular size -> ofList [ofString "file",ofNumber size] - FS.Directory -> ofList [ofString "directory"] - FS.Symlink -> ofList [ofString "symlink"] - FS.Other -> ofList [ofString "other"] - positionOperation finish operation name payload = case payload of - Fork handle value -> case (decodeToken handle, canonicalNat 63 value) of - (Just token, Just position) -> operation runtime scope token position >>= finish . reply (const Leaf) - _ -> finish (bad name) - _ -> finish (bad name) - standard finish stream payload = case payload of - Leaf -> R.standardStream runtime scope stream >>= finish . reply tokenTree - _ -> finish (bad "standard-stream") - readValue bytes | BS.null bytes = Fork (ofString "eof") Leaf - | otherwise = Fork (ofString "data") (ofBytes bytes) - unary finish name operation encode value = case decodeToken value of - Just token -> operation runtime scope token >>= finish . reply encode - _ -> finish (bad name) - open finish opener name value = case value of - Fork scopeRef path -> case (decodeScope marker scopeRef, canonicalText path) of - (Just owner, Just filename) -> do - allowed <- R.isAncestor runtime scope owner - if allowed then opener runtime owner filename >>= finish . reply tokenTree else finish (bad name) - _ -> finish (bad name) - _ -> finish (bad name) - --- Transfer lists are bounded separately from binary chunks. Excess tails are --- not forced; the bound is shared with capability reporting. -decodeHandles :: T -> Maybe [R.Token] -decodeHandles = walk P.transferLimit - where - walk _ Leaf = Just [] - walk 0 _ = Nothing - walk n (Fork h rest) = (:) <$> decodeToken h <*> walk (n-1) rest - walk _ _ = Nothing - -taskTree :: String -> S.TaskRef -> T -taskTree tag (S.TaskRef identity key) = ofList [ofString tag, ofNumber identity, ofNumber key] -decodeTask :: String -> T -> Maybe S.TaskRef -decodeTask expected (Fork tag (Fork identity (Fork key Leaf))) - | tag == ofString expected = S.TaskRef <$> canonicalNat 64 identity <*> canonicalNat 64 key -decodeTask _ _ = Nothing -spawnReply :: Bool -> S.TaskRef -> T -spawnReply keep ref = ofList [taskTree "io.v2.task" ref, if keep then taskTree "io.v2.join" ref else Leaf] -outcomeTree :: Outcome -> T -outcomeTree (Completed value) = ofList [ofString "completed", value] -outcomeTree (Failed diagnostic cleanup) = ofList [ofString "failed", diagnostic, ofList (map problemTree cleanup)] -outcomeTree (Cancelled reason cleanup) = ofList [ofString "cancelled", reason, ofList (map problemTree cleanup)] - -canonicalText :: T -> Maybe String -canonicalText value = case toString value of - Right text | '\0' `notElem` text && value == ofString text -> Just text - _ -> Nothing - --- Endpoint components have fixed lengths; an excess element is not inspected. -decodeEndpoint :: T -> Maybe R.Endpoint -decodeEndpoint (Fork tag (Fork bytes (Fork port Leaf))) - | tag == ofString "ipv4" = do - values <- components 4 8 bytes - p <- canonicalNat 16 port - case values of - [a,b,c,d] -> Just (R.IPv4 (fromInteger a,fromInteger b,fromInteger c,fromInteger d) (fromInteger p)) - _ -> Nothing -decodeEndpoint (Fork tag (Fork words (Fork scope (Fork port Leaf)))) - | tag == ofString "ipv6" = do - values <- components 8 16 words - s <- canonicalNat 32 scope - p <- canonicalNat 16 port - case values of - [a,b,c,d,e,f,g,h] -> Just (R.IPv6 - (fromInteger a,fromInteger b,fromInteger c,fromInteger d,fromInteger e,fromInteger f,fromInteger g,fromInteger h) - (fromInteger s) (fromInteger p)) - _ -> Nothing -decodeEndpoint _ = Nothing -components :: Int -> Int -> T -> Maybe [Integer] -components 0 _ Leaf = Just [] -components 0 _ _ = Nothing -components n bits (Fork first rest) = (:) <$> canonicalNat bits first <*> components (n-1) bits rest -components _ _ _ = Nothing -endpointTree :: R.Endpoint -> T -endpointTree (R.IPv4 (a,b,c,d) p) = ofList [ofString "ipv4",numbers [a,b,c,d],ofNumber (toInteger p)] -endpointTree (R.IPv6 (a,b,c,d,e,f,g,h) s p) = ofList - [ofString "ipv6",numbers [a,b,c,d,e,f,g,h],ofNumber (toInteger s),ofNumber (toInteger p)] -numbers :: Integral a => [a] -> T -numbers = ofList . map (ofNumber . toInteger) - --- Exactly 64 lowercase hex digits; reject an excess spine without forcing it. -boundedDigest :: T -> Maybe String -boundedDigest = walk (64 :: Int) - where - walk 0 Leaf = Just [] - walk 0 _ = Nothing - walk n (Fork c rest) = do - code <- canonicalNat 7 c - if (code >= 48 && code <= 57) || (code >= 97 && code <= 102) - then (toEnum (fromInteger code) :) <$> walk (n-1) rest - else Nothing - walk _ _ = Nothing - --- Text codecs accept NUL, unlike paths. Unicode surrogate code points and --- noncanonical scalar naturals are rejected before conversion to host Char. -unicodeChunk :: Int -> T -> Maybe String -unicodeChunk _ Leaf = Just [] -unicodeChunk 0 _ = Nothing -unicodeChunk n (Fork c rest) = do - scalar <- canonicalNat 21 c - if scalar <= 0x10ffff && not (scalar >= 0xd800 && scalar <= 0xdfff) - then (toEnum (fromInteger scalar) :) <$> unicodeChunk (n-1) rest - else Nothing -unicodeChunk _ _ = Nothing - -resolveFamily :: T -> Maybe Resolver.Family -resolveFamily value - | value == ofString "ipv4" = Just Resolver.IPv4Only - | value == ofString "ipv6" = Just Resolver.IPv6Only - | value == ofString "any" = Just Resolver.AnyFamily - | otherwise = Nothing -boundedHost :: T -> Maybe String -boundedHost = walk Resolver.hostLimit - where walk _ Leaf = Just [] - walk 0 _ = Nothing - walk n (Fork first rest) = do - code <- canonicalNat 7 first - (toEnum (fromInteger code):) <$> walk (n-1) rest - walk _ _ = Nothing - --- Bounded before constructing host strings/lists; an excess element is inert. -decodeProcess :: T -> Maybe Process.Spec -decodeProcess (Fork executable (Fork arguments (Fork directory (Fork environment (Fork grace Leaf))))) = do - exe <- processText executable - args <- boundedList 256 processText arguments - cwd <- case directory of - Fork tag Leaf | tag == ofString "inherit" -> Just Nothing - Fork tag (Fork path Leaf) | tag == ofString "at" -> Just <$> processText path - _ -> Nothing - env <- case environment of - Fork tag Leaf | tag == ofString "inherit" -> Just Nothing - Fork tag (Fork entries Leaf) | tag == ofString "replace" -> Just <$> boundedList 256 entry entries - _ -> Nothing - duration <- canonicalNat 63 grace - let spec = Process.Spec exe args cwd env duration - if Process.validSpec spec then Just spec else Nothing - where - entry (Fork key (Fork value Leaf)) = (,) <$> processText key <*> processText value - entry _ = Nothing -decodeProcess _ = Nothing - -boundedList :: Int -> (T -> Maybe a) -> T -> Maybe [a] -boundedList _ _ Leaf = Just [] -boundedList 0 _ _ = Nothing -boundedList n decode (Fork value rest) = (:) <$> decode value <*> boundedList (n-1) decode rest -boundedList _ _ _ = Nothing - -processText :: T -> Maybe String -processText = boundedList 4096 scalar - where - scalar value = do - n <- canonicalNat 21 value - if n > 0 && n <= 1114111 && not (n >= 55296 && n <= 57343) - then Just (toEnum (fromInteger n)) else Nothing diff --git a/src/IOV2/Filesystem.hs b/src/IOV2/Filesystem.hs @@ -1,109 +0,0 @@ -{-# LANGUAGE ScopedTypeVariables #-} --- Path operations are observations/mutations, not authorization or transactions. --- Directory cursors never materialize a listing or retain previously returned names. -module IOV2.Filesystem - ( Status(..), statPath, createDirectory, removeDirectory, removeFile, renamePath - , syncDirectory - , openDirectory, directoryBackend, nameLimit - ) where -import qualified IOV2.Descriptor as Descriptor -import System.Posix.IO (openFd, OpenMode(ReadOnly), defaultFileFlags, nonBlock) -import System.Posix.Unistd (fileSynchronise) -import qualified IOV2.Resources as R -import qualified System.Posix.Files as F -import qualified System.Posix.Directory as D -import Control.Exception -import Control.Monad (when, unless) -import Data.IORef -import Data.Char (ord) - -data Status = Regular Integer | Directory | Symlink | Other deriving (Eq, Show) -nameLimit :: Int -nameLimit = 4096 - -pathOperation :: String -> Bool -> [FilePath] -> IO a -> IO (Either R.Problem a) -pathOperation op mutating paths action - | any (\p -> null p || '\0' `elem` p) paths = - pure (Left (R.Problem "invalid-argument" op "empty or NUL-containing path" "none")) - | otherwise = (Right <$> action) `catch` \(e :: IOException) -> do - let problem = R.ioProblem op e - pure (Left (problem {R.progress=if mutating then "unknown" else "none"})) - --- Following the final symlink is an explicit choice; intermediate components --- always follow the host path resolver. Neither form pins later path operations. -statPath :: Bool -> FilePath -> IO (Either R.Problem Status) -statPath follow path = pathOperation op False [path] $ do - status <- (if follow then F.getFileStatus else F.getSymbolicLinkStatus) path - if F.isRegularFile status then do - let size = toInteger (F.fileSize status) - if size >= 0 && size <= R.positionLimit then pure (Regular size) - else ioError (userError "file size outside supported range") - else pure (if F.isDirectory status then Directory - else if F.isSymbolicLink status then Symlink else Other) - where op = if follow then "stat" else "lstat" - -createDirectory, removeDirectory, removeFile :: FilePath -> IO (Either R.Problem ()) -createDirectory path = pathOperation "create" True [path] (D.createDirectory path 0o777) -removeDirectory path = pathOperation "remove" True [path] (D.removeDirectory path) -removeFile path = pathOperation "remove-file" True [path] (F.removeLink path) -renamePath :: FilePath -> FilePath -> IO (Either R.Problem ()) -renamePath old new = pathOperation "rename" True [old,new] (F.rename old new) - --- Synchronize an opened directory, not an unchecked path after verification. --- Nonblocking open avoids hanging on a FIFO supplied where a directory belongs. -syncDirectory :: R.Runtime -> R.Scope -> FilePath -> IO (Either R.Problem ()) -syncDirectory runtime scope path - | null path || '\0' `elem` path = pure (Left (R.Problem "invalid-argument" "sync" "empty or NUL-containing path" "none")) - | otherwise = R.withTemporary runtime scope "sync" (do - fd <- openFd path ReadOnly (defaultFileFlags {nonBlock=True}) - release <- Descriptor.closeOnce fd - pure (fd,release)) $ \fd -> do - status <- F.getFdStatus fd - unless (F.isDirectory status) (R.resourceFault (R.Problem "not-directory" "sync" "path is not a directory" "none")) - fileSynchronise fd - -openDirectory :: R.Runtime -> R.Scope -> FilePath -> IO (Either R.Problem R.Token) -openDirectory runtime scope path - | null path || '\0' `elem` path = pure (Left (R.Problem "invalid-argument" "open" "empty or NUL-containing path" "none")) - | otherwise = R.acquire runtime scope $ - bracketOnError (D.openDirStream path) D.closeDirStream $ \stream -> - directoryBackend (D.readDirStream stream) (D.closeDirStream stream) - -data Cursor = Active | Ended | Poisoned --- Injectable backend callbacks exercise failure/cancellation at the actual cursor --- boundary. The registry supplies the lease; these callbacks are not standalone --- concurrent iterators. An uncertain advance cannot be implicitly retried. -directoryBackend :: IO FilePath -> IO () -> IO R.Backend -directoryBackend readName close = do - state <- newIORef Active - owned <- newIORef True - let fault category detail = R.resourceFault (R.Problem category "next" detail "unknown") - validName = walk nameLimit - where - walk _ [] = pure () - walk 0 _ = fault "invalid-name" "directory name exceeds scalar limit" - walk n (c:rest) - | c == '/' || c == '\0' = fault "invalid-name" "invalid directory component" - | ord c > 0x10ffff || (ord c >= 0xd800 && ord c <= 0xdfff) = fault "invalid-encoding" "directory name is not Unicode scalar text" - | otherwise = walk (n-1) rest - advance budget = do - name <- readName - case name of - "" -> writeIORef state Ended >> pure Nothing - "." -> skip budget - ".." -> skip budget - _ -> validName name >> pure (Just name) - skip 0 = fault "invalid-state" "directory dot-entry scan exceeded bound" - skip n = advance (n-1) - next = mask $ \restore -> do - phase <- readIORef state - case phase of - Ended -> pure Nothing - Poisoned -> fault "invalid-state" "directory cursor failed; reopen explicitly" - Active -> restore (advance (2 :: Int)) `onException` writeIORef state Poisoned - release = mask_ $ do - -- closedir owns a raw pointer: never retry an uncertain close against a - -- potentially freed DIR*. Registry teardown can revisit async failures. - wasOwned <- atomicModifyIORef' owned (\old -> (False,old)) - when wasOwned close - pure (R.Directory next release) diff --git a/src/IOV2/Network.hs b/src/IOV2/Network.hs @@ -1,97 +0,0 @@ -{-# LANGUAGE ScopedTypeVariables #-} --- TCP transports: numeric IPv4/IPv6 endpoints, no implicit resolver/retry policy. -module IOV2.Network (listenTCP, connectTCP, duplexBackend, backlogLimit) where -import qualified IOV2.Resources as R -import qualified Network.Socket as N -import qualified Network.Socket.ByteString as B -import qualified Data.ByteString as BS -import Control.Exception -import Control.Monad (when, unless) -import Data.IORef -import GHC.IO.Exception (ioe_errno) -import Foreign.C.Error - -backlogLimit :: Int -backlogLimit = 4096 -address (R.IPv4 host port) = N.SockAddrInet (fromIntegral port) (N.tupleToHostAddress host) -address (R.IPv6 host scope port) = N.SockAddrInet6 (fromIntegral port) 0 (N.tupleToHostAddress6 host) scope -family (R.IPv4 _ _) = N.AF_INET -family (R.IPv6 _ _ _) = N.AF_INET6 -port (R.IPv4 _ p) = p -port (R.IPv6 _ _ p) = p -endpoint (N.SockAddrInet p h) = pure (R.IPv4 (N.hostAddressToTuple h) (fromIntegral p)) -endpoint (N.SockAddrInet6 p _ h scope) = pure (R.IPv6 (N.hostAddress6ToTuple h) scope (fromIntegral p)) -endpoint _ = ioError (userError "unexpected TCP address family") - -networkIO :: String -> IO a -> IO a -networkIO op action = action `catch` \(e :: IOException) -> do - let generic = R.ioProblem op e - category = case ioe_errno e of - Just code -> maybe (R.category generic) id (lookup (Errno code) - [(eCONNREFUSED,"connection-refused"),(eCONNRESET,"connection-reset"), - (eCONNABORTED,"connection-aborted"),(eADDRINUSE,"address-in-use"), - (eADDRNOTAVAIL,"address-unavailable"),(eNETUNREACH,"network-unreachable"), - (eHOSTUNREACH,"host-unreachable"),(ePIPE,"broken-pipe"),(eTIMEDOUT,"timed-out")]) - _ -> R.category generic - R.resourceFault (generic {R.category=category, - R.progress=if op `elem` ["local-address","peer-address"] then "none" else "unknown"}) - -listenTCP :: R.Runtime -> R.Scope -> R.Endpoint -> Int -> IO (Either R.Problem R.Token) -listenTCP runtime owner target backlog - | backlog <= 0 || backlog > backlogLimit = pure (Left (R.Problem "invalid-bound" "listen" "" "none")) - | otherwise = R.acquire runtime owner $ networkIO "listen" $ - bracketOnError (N.socket (family target) N.Stream N.defaultProtocol) N.close $ \socket -> do - N.setSocketOption socket N.ReuseAddr 1 - when (family target == N.AF_INET6) (N.setSocketOption socket N.IPv6Only 1) - N.bind socket (address target) - N.listen socket backlog - let accept = networkIO "accept" $ - bracketOnError (fst <$> N.accept socket) N.close duplexBackend - pure (R.Listener (R.ListenerOps accept - (networkIO "local-address" (N.getSocketName socket >>= endpoint)) (N.close socket))) - -connectTCP :: R.Runtime -> R.Scope -> R.Endpoint -> IO (Either R.Problem R.Token) -connectTCP runtime owner target - | port target == 0 = pure (Left (R.Problem "invalid-bound" "connect" "zero peer port" "none")) - | otherwise = R.acquire runtime owner $ networkIO "connect" $ - bracketOnError (N.socket (family target) N.Stream N.defaultProtocol) N.close $ \socket -> do - N.connect socket (address target) - duplexBackend socket - -data Direction = Open | Ended | Poisoned --- The registry supplies separate read/write leases; direction state is not --- synchronized across unrelated sockets, aliases or host IO outside this runtime. -duplexBackend :: N.Socket -> IO R.Backend -duplexBackend socket = do - reading <- newIORef Open - writing <- newIORef Open - let invalid op = R.resourceFault (R.Problem "invalid-state" op "socket direction failed; close explicitly" "unknown") - receive count = mask $ \restore -> do - phase <- readIORef reading - case phase of - Ended -> pure BS.empty - Poisoned -> invalid "read" - Open -> (do - bytes <- restore (networkIO "read" (B.recv socket count)) - when (BS.null bytes) (writeIORef reading Ended) - pure bytes) `onException` writeIORef reading Poisoned - send bytes = mask $ \restore -> do - phase <- readIORef writing - case phase of - Ended -> R.resourceFault (R.Problem "invalid-state" "write" "write direction shut down" "none") - Poisoned -> invalid "write" - Open -> (do - count <- restore (networkIO "write" (B.send socket bytes)) - unless (count > 0 && count <= BS.length bytes) - (ioError (userError "invalid socket write progress")) - pure count) `onException` writeIORef writing Poisoned - shutdown = mask $ \restore -> do - phase <- readIORef writing - case phase of - Ended -> pure () - Poisoned -> invalid "shutdown-write" - Open -> (restore (networkIO "shutdown-write" (N.shutdown socket N.ShutdownSend)) >> - writeIORef writing Ended) `onException` writeIORef writing Poisoned - pure (R.Duplex (R.DuplexOps receive send shutdown - (networkIO "local-address" (N.getSocketName socket >>= endpoint)) - (networkIO "peer-address" (N.getPeerName socket >>= endpoint)) (N.close socket))) diff --git a/src/IOV2/Process.hs b/src/IOV2/Process.hs @@ -1,173 +0,0 @@ -{-# LANGUAGE ScopedTypeVariables #-} --- Direct-child processes, supervised by the existing structured task machinery. --- No shell expansion, detached waiter, implicit output collection or job control. -module IOV2.Process (Spec(..), launch, validSpec, TerminationOps(..), terminateWith, cleanupAll) where - -import qualified IOV2.Resources as R -import qualified IOV2.Tasks as S -import qualified IOV2.Clock as Clock -import Runtime (T(..), ofList, ofString, ofNumber) -import Control.Concurrent (threadDelay) -import Control.Concurrent.STM -import Control.Exception -import Control.Monad -import Data.IORef -import Data.List (nub) -import qualified Data.ByteString as BS -import System.Exit -import System.IO -import qualified System.Process as P -import System.Posix.Signals (signalProcess, sigKILL) - --- Nothing explicitly selects inherited cwd/environment. Just [] is an empty env. --- Limits count Unicode scalars, not OS-encoded bytes; exec may impose smaller limits. -data Spec = Spec FilePath [String] (Maybe FilePath) (Maybe [(String,String)]) Integer - deriving Show - -validSpec :: Spec -> Bool -validSpec (Spec executable arguments directory environment grace) = - not (null executable) && maybe True (not . null) directory && - length arguments <= 256 && length variables <= 256 && - all validText strings && sum (map length strings) <= 65536 && - all (\(key,_) -> not (null key) && '=' `notElem` key) variables && - length (nub (map fst variables)) == length variables && - grace >= 0 && grace <= 9223372036854775807 - where - variables = maybe [] id environment - strings = executable : arguments ++ maybe [] (:[]) directory ++ concatMap (\(k,v) -> [k,v]) variables - validText s = length s <= 4096 && all (\c -> c /= '\0' && not (c >= '\xD800' && c <= '\xDFFF')) s - --- Returns one ordinary task/join reference and parent-owned stdin/stdout/stderr. --- The private process handle is owned by the monitor child, never source-visible. --- Launch failures consume the internal join ticket before returning an error. -launch :: R.Runtime -> S.Group -> Spec -> IO (Either R.Problem (S.TaskRef, [R.Token])) -launch runtime parent spec - | not (validSpec spec) = pure (Left (R.Problem "invalid-argument" "process/spawn" "" "none")) - | otherwise = mask $ \restore -> do - ready <- newEmptyTMVarIO - spawned <- S.spawn parent False True [] (\child _ -> monitor ready child) - case spawned of - Left problem -> pure (Left problem) - Right task -> do - result <- restore (atomically (takeTMVar ready)) - case result of - Right pipes -> pure (Right (task,pipes)) - Left problem -> do - -- Failure is not acknowledged until the launched process is reaped. - settled <- restore (S.join parent task) - case settled of - Right (S.Completed _) -> pure (Left problem) - _ -> S.checkpoint parent >> R.resourceFault problem - where - Spec executable arguments directory environment grace = spec - monitor ready child = do - slot <- newIORef Nothing - acquired <- R.acquire runtime (S.scopeOf child) $ do - (input,output,errors,process) <- P.createProcess (P.proc executable arguments) - { P.cwd = directory, P.env = environment, P.std_in = P.CreatePipe - , P.std_out = P.CreatePipe, P.std_err = P.CreatePipe, P.close_fds = True } - let handles = [h | Just h <- [input,output,errors]] - raw <- newIORef handles - let release = cleanupAll [releaseRaw raw, terminateAndReap process grace] - writeIORef slot (Just (process,raw,handles)) - pure (R.Internal release) - case acquired of - Left problem -> atomically (putTMVar ready (Left problem)) >> pure (S.Completed Leaf) - Right _ -> do - Just (process,raw,handles) <- readIORef slot - case handles of - [input,output,errors] -> do - pipes <- install raw [] [(True,input),(False,output),(False,errors)] - case pipes of - Left problem -> atomically (putTMVar ready (Left problem)) >> pure (S.Completed Leaf) - Right tokens -> do - atomically (putTMVar ready (Right tokens)) - -- This wait is body work, not a protected release callback. - status <- awaitExit process - pure (S.Completed (exitTree status)) - _ -> ioError (userError "process backend omitted requested pipes") - install _ installed [] = pure (Right (reverse installed)) - install raw installed ((writing,h):rest) = do - result <- R.acquire runtime (S.scopeOf parent) $ do - hSetBinaryMode h True - hSetBuffering h NoBuffering - poisoned <- newIORef False - let guarded action = mask $ \restore -> do - failed <- readIORef poisoned - if failed then R.resourceFault (R.Problem "invalid-state" "process/pipe" "prior operation failed; progress is uncertain" "unknown") - else restore action `onException` writeIORef poisoned True - -- Publication is masked by acquire; thereafter registry release owns h. - modifyIORef' raw (filter (/= h)) - pure $ if writing - then R.Writer (\bytes -> guarded (BS.hPut h bytes >> pure (BS.length bytes))) (hClose h) - else R.Reader (\count -> guarded (BS.hGetSome h count)) (hClose h) - case result of - Right token -> install raw (token:installed) rest - Left problem -> do - cleanupAll [R.closeResource runtime (S.scopeOf parent) token >>= either R.resourceFault pure | token <- installed] - pure (Left problem) - -exitTree :: ExitCode -> T -exitTree ExitSuccess = ofList [ofString "exit", ofNumber 0] -exitTree (ExitFailure code) - | code < 0 = ofList [ofString "signal", ofNumber (negate (toInteger code))] - | otherwise = ofList [ofString "exit", ofNumber (toInteger code)] - -releaseRaw :: IORef [Handle] -> IO () -releaseRaw raw = atomicModifyIORef' raw (\handles -> ([],handles)) >>= cleanupAll . map hClose - --- Attempt all releases, preserving the first exception. Resource settlement --- reports failure rather than silently acknowledging a partial cleanup. -cleanupAll :: [IO ()] -> IO () -cleanupAll actions = do - outcomes <- mapM try actions - case [e | Left (e :: SomeException) <- outcomes] of - [] -> pure () - [e] -> throwIO e - e:rest -> R.resourceFault (R.Problem "cleanup-failed" "process/release" - (take 240 (displayException e) ++ "; additional cleanup errors omitted: " ++ show (length rest)) "unknown") - --- Injectable boundary for independent escalation/error-order tests. The owner --- must settle the sole wait body before entering this release protocol. -data TerminationOps = TerminationOps - { checkReaped :: IO Bool, requestTerminate :: IO (), forceTerminate :: IO () - , reap :: IO (), clock :: IO Integer, pause :: IO () } - -terminateWith :: TerminationOps -> Integer -> IO () -terminateWith ops grace = do - exited <- checkReaped ops - unless exited $ do - start <- clock ops - let wait remaining previous = do - done <- checkReaped ops - unless done $ do - now <- clock ops - -- Host samples are Word64. Account for wrap between nearby polls, - -- rather than constructing an unreachable absolute deadline. - let elapsed = (now - previous) `mod` 18446744073709551616 - if elapsed < remaining then pause ops >> wait (remaining-elapsed) now - else cleanupAll [forceTerminate ops, reap ops] - cleanupAll [requestTerminate ops, wait (grace*1000000) start] - -terminateAndReap :: P.ProcessHandle -> Integer -> IO () -terminateAndReap process = terminateWith TerminationOps - { checkReaped = maybe False (const True) <$> P.getProcessExitCode process - , requestTerminate = P.terminateProcess process - , forceTerminate = do - -- No other reaper remains: an unreaped child retains its PID. Never - -- expose this handle for concurrent public wait/getPid operations. - pid <- P.getPid process - case pid of Just identity -> signalProcess sigKILL identity; Nothing -> pure () - , reap = void (awaitExit process) - , clock = Clock.monotonicNanoseconds - , pause = threadDelay 10000 } - --- waitForProcess may block an RTS capability in a foreign wait (and cannot be --- relied on as a cancellation checkpoint). WNOHANG polling keeps the sole reaper --- in the task body and is interruptible without a detached host waiter. -awaitExit :: P.ProcessHandle -> IO ExitCode -awaitExit process = do - status <- P.getProcessExitCode process - case status of - Just result -> pure result - Nothing -> threadDelay 10000 >> awaitExit process diff --git a/src/IOV2/Protocol.hs b/src/IOV2/Protocol.hs @@ -1,82 +0,0 @@ --- Shared native v2 request catalogue and shallow, non-executing inspection. --- Dispatch numbers below are PRIVATE implementation selectors. -module IOV2.Protocol - ( Action(..), decodeAction, canonicalNat, resolve, capabilities, inspectAction - , problemTree, transferLimit - ) where - -import Runtime (T(..), ofList, ofString, ofNumber) -import qualified IOV2.Resources as R -import qualified IOV2.Filesystem as FS -import qualified IOV2.Network as TCP -import qualified IOV2.Codec as Codec -import qualified IOV2.UTF8 as UTF8 -import qualified IOV2.Resolver as Resolver -import qualified IOV2.Clock as Clock -import Data.List (find) -import IOV2.Wire (Action(..), canonicalNat, decodeAction) - --- Only implemented revisions/operations are advertised. Unsupported names are --- not promises about a future group's argument schema or API. -catalogue :: [(String, [(String, Integer)])] -catalogue = - [ ("runtime", [("capabilities",100), ("inspect",101)]) - , ("standard", [("input",25), ("output",26), ("error",27)]) - , ("file", [("open-reader",20), ("open-writer",21), ("open-appender",50), ("open-update",51), ("seek",52), ("position",53), ("size",54), ("resize",55), ("sync",56)]) - , ("path", [("stat",60), ("lstat",61), ("remove-file",64), ("rename",65)]) - , ("directory", [("create",62), ("remove",63), ("open",66), ("next",67), ("sync",68)]) - , ("tcp", [("listen",80), ("connect",81), ("accept",82), ("local-address",83), ("peer-address",84), ("shutdown-write",85)]) - , ("resolver", [("resolve",86)]) - , ("indexed-tree", [("read",87), ("hash",88), ("write",89), ("verify",95)]) - , ("utf8", [("encode",96), ("decode",97)]) - , ("stream", [("read",22), ("write",23), ("snapshot",108), ("transfer",109)]) - , ("resource", [("close",24)]) - , ("stage", [("open",30), ("seal",31), ("publish",32)]) - , ("digest", [("open",40), ("update",41), ("finish",42)]) - , ("clock", [("monotonic",102), ("sleep",103)]) - , ("scheduler", [("yield",104)]) - , ("process", [("spawn",105), ("working-directory",106), ("environment-variable",107)]) - ] - -resolve :: String -> Integer -> String -> Maybe Integer -resolve group 1 operation = lookup group catalogue >>= lookup operation -resolve _ _ _ = Nothing - -core :: [(Integer, String)] -core = zip [0..9] ["pure", "bind", "fail", "scope", "current-scope", "spawn", "join", "discard", "cancel", "cancelled"] - -transferLimit :: Int -transferLimit = 256 - -problemTree :: R.Problem -> T -problemTree p = ofList (map ofString [R.category p, R.operation p, R.detail p, R.progress p]) - --- Association rows keep metadata extensible without making it authority. -capabilities :: T -capabilities = ofList - [ row "schema" (ofNumber 1) - , row "io-version" (ofNumber 2) - , row "backend" (ofString "haskell-native") - , row "authority" (ofString "os-process") - , row "core" (strings (map snd core)) - , row "groups" (ofList [ofList [ofString g, ofNumber 1, strings (map fst ops)] | (g,ops) <- catalogue]) - , row "limits" (ofList [row "chunk-bytes" (ofNumber (toInteger R.chunkLimit)), row "transfer-handles" (ofNumber (toInteger transferLimit)), row "file-offset-max" (ofNumber R.positionLimit), row "directory-name-scalars" (ofNumber (toInteger FS.nameLimit)), row "tcp-backlog" (ofNumber (toInteger TCP.backlogLimit)), row "resolver-host-characters" (ofNumber (toInteger Resolver.hostLimit)), row "resolver-addresses" (ofNumber (toInteger Resolver.addressLimit)), row "indexed-sections" (ofNumber (toInteger Codec.sectionLimit)), row "indexed-manifest-bytes" (ofNumber (toInteger Codec.manifestLimit)), row "utf8-encode-scalars" (ofNumber (toInteger UTF8.scalarLimit)), row "sleep-milliseconds-max" (ofNumber Clock.sleepLimit), row "process-arguments" (ofNumber 256), row "process-environment-entries" (ofNumber 256), row "process-text-scalars" (ofNumber 4096), row "process-total-scalars" (ofNumber 65536), row "process-grace-milliseconds-max" (ofNumber 9223372036854775807)]) - , row "tcp-address-families" (strings ["ipv4","ipv6"]) - , row "digest-algorithms" (strings ["sha256"]) - , row "restrictions" (strings ["action-boundary-cancellation", "no-hard-deadlines", "stage-same-filesystem", "stage-no-copy-fallback", "stage-private-namespace", "publish-new-requires-hard-links", "no-crash-recovery", "borrowed-standard-streams", "standard-stream-leases-per-run", "file-single-cursor-lease", "append-no-position-or-resize","file-host-handle-locking", "directory-unsorted-non-snapshot", "filesystem-host-encoding", "directory-failure-poisons-cursor", "tcp-numeric-addresses", "tcp-ipv6-only-listeners", "tcp-direction-leases", "tcp-failure-poisons-direction", "resolver-host-order-and-buffering", "resolver-may-delay-cancellation", "indexed-authenticated-snapshot", "indexed-caller-frame-and-disk-limit", "indexed-dag-resident", "indexed-writer-preflight", "indexed-budget-not-heap-quota", "sync-host-filesystem-guarantees", "utf8-strict-scalars", "clock-host-monotonic-word64", "sleep-may-run-late", "yield-no-ordering-guarantee", "process-direct-child-only", "process-normal-exit-joins", "process-explicit-pipe-draining"]) - ] - where row key value = ofList [ofString key, value] - strings = ofList . map ofString - --- This is classification of ONE header, not executable validation, recursive --- traversal, argument validation, handle lookup, or prediction of success. --- Payloads, callbacks and continuations are never included in the result. -inspectAction :: T -> Either R.Problem T -inspectAction value = case decodeAction value of - Left detail -> Left (R.Problem "invalid-argument" "inspect" detail "none") - Right (Core code _) -> case find ((== code) . fst) core of - Just (_,label) -> Right (ofList [ofString "core", ofString label]) - Nothing -> error "decodeAction returned unknown core" - Right (Request group revision operation _) -> Right (ofList - [ofString "facility", ofString group, ofNumber revision, ofString operation, - ofString (case resolve group revision operation of Just _ -> "available"; _ -> "unsupported")]) diff --git a/src/IOV2/Resolver.hs b/src/IOV2/Resolver.hs @@ -1,58 +0,0 @@ -{-# LANGUAGE ScopedTypeVariables #-} --- Explicit host name resolution, not an implicit part of connection acquisition. --- getaddrinfo may buffer more results and delay cancellation; only the published --- endpoint prefix is bounded here. No worker is abandoned on interruption. -module IOV2.Resolver (Family(..), Resolution(..), resolveTCP, resolveWith, hostLimit, addressLimit, validHost) where -import qualified IOV2.Resources as R -import qualified Network.Socket as N -import Control.Exception - -hostLimit, addressLimit :: Int -hostLimit = 253 -addressLimit = 64 -data Family = IPv4Only | IPv6Only | AnyFamily deriving (Eq, Show) -data Resolution = Resolution [R.Endpoint] Bool deriving (Eq, Show) -validHost :: String -> Bool -validHost [] = False -validHost host = walk hostLimit host - where walk _ [] = True - walk 0 _ = False - walk n (c:rest) = ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || - (c >= '0' && c <= '9') || c == '-' || c == '.') && walk (n-1) rest - -resolveTCP :: String -> Family -> Integer -> Int -> IO (Either R.Problem Resolution) -resolveTCP host family port limit = resolveWith host port limit $ do - let af = case family of IPv4Only -> N.AF_INET; IPv6Only -> N.AF_INET6; AnyFamily -> N.AF_UNSPEC - hints = N.defaultHints {N.addrFamily=af, N.addrSocketType=N.Stream, - N.addrFlags=[N.AI_NUMERICSERV]} - infos <- N.getAddrInfo (Just hints) (Just host) (Just (show port)) - pure [endpoint | info <- infos, Just endpoint <- [convert (N.addrAddress info)]] - where convert (N.SockAddrInet p h) = Just (R.IPv4 (N.hostAddressToTuple h) (fromIntegral p)) - convert (N.SockAddrInet6 p _ h scope) = Just (R.IPv6 (N.hostAddress6ToTuple h) scope (fromIntegral p)) - convert _ = Nothing - --- Injected query supports deterministic failure/bound tests without public DNS. -resolveWith :: String -> Integer -> Int -> IO [R.Endpoint] -> IO (Either R.Problem Resolution) -resolveWith host port limit query - | not (validHost host) = pure (Left (R.Problem "invalid-argument" "resolve" "expected bounded ASCII host name" "none")) - | port < 0 || port > 65535 || limit <= 0 || limit > addressLimit = - pure (Left (R.Problem "invalid-bound" "resolve" "" "none")) - | otherwise = (query >>= bounded limit [] >>= pure . Right) `catch` \(e :: IOException) -> - pure (Left (R.Problem "resolution-failed" "resolve" (take 512 (displayException e)) "none")) - where - bounded 0 acc rest = do - truncated <- evaluate (not (null rest)) - finish acc truncated - bounded _ acc [] = finish acc False - bounded n acc (first:rest) = forceEndpoint first `seq` bounded (n-1) (first:acc) rest - finish acc truncated = do - let prefix = reverse acc - -- Neither a lazy split nor endpoint projections may retain the discarded - -- host-result tail/AddrInfo records after returning the bounded reply. - _ <- evaluate (foldr seq () prefix) - pure (Resolution prefix truncated) - -forceEndpoint :: R.Endpoint -> () -forceEndpoint (R.IPv4 (a,b,c,d) p) = a `seq` b `seq` c `seq` d `seq` p `seq` () -forceEndpoint (R.IPv6 (a,b,c,d,e,f,g,h) scope p) = - a `seq` b `seq` c `seq` d `seq` e `seq` f `seq` g `seq` h `seq` scope `seq` p `seq` () diff --git a/src/IOV2/Resources.hs b/src/IOV2/Resources.hs @@ -1,612 +0,0 @@ -{-# LANGUAGE ScopedTypeVariables #-} --- Scoped native resources. No Tree interpretation or application policy here. --- Resource kinds share lifetime/lease rules; task supervision is in IOV2.Tasks. -module IOV2.Resources - ( Runtime, Scope, Token(..), Backend(..), FileOps(..), DuplexOps(..), ListenerOps(..), Endpoint(..), Problem(..), newRuntime, rootScope - , childScope, closeScope, acquire, openReader, openWriter, readSome, writeSome - , withTemporary, syncFile - , withReaderLease, withWriterLease, withInternalResource, closeInternalResource - , closeResource, liveCounts, chunkLimit, runtimeIdentity, isAncestor, combineErrors - , beginCloseScopeSTM, reserveChildSTM - , openAppender, openUpdate, seekFile, filePosition, fileSize, resizeFile, positionLimit - , nextDirectory, acceptConnection, shutdownWrite, socketAddress - , StandardStream(..), newRuntimeWithStreams, standardStream - , sealStage, publishStage, updateDigest, finishDigest, resourceFault, ioProblem - ) where - -import qualified IOV2.Descriptor as Descriptor -import Control.Concurrent.MVar -import Control.Concurrent.STM -import Control.Exception -import Control.Monad (foldM, unless, when) -import qualified Data.ByteString as BS -import qualified Data.Map.Strict as M -import qualified Data.Set as S -import Data.List (foldl') -import Data.IORef -import Data.Bits ((.&.), (.|.), complement) -import Data.Word (Word8, Word16, Word32) -import Text.Read (readMaybe) -import Data.Unique (newUnique, hashUnique) -import System.IO -import System.IO.Error - --- IDs never recur within a run; the run identity rejects cross-run handles. -type Scope = Integer -data Token = Token !Integer !Integer deriving (Eq, Show) -data Problem = Problem { category :: String, operation :: String, detail :: String - , progress :: String } deriving (Eq, Show) -data Backend = Reader (Int -> IO BS.ByteString) (IO ()) - | Writer (BS.ByteString -> IO Int) (IO ()) - | Staged (BS.ByteString -> IO Int) (IO ()) (Bool -> FilePath -> IO String) (IO ()) - | Digest (BS.ByteString -> IO ()) (IO String) (IO ()) - | Inherited Backend - | File FileOps - | Directory (IO (Maybe String)) (IO ()) - | Duplex DuplexOps - | Listener ListenerOps - | Internal (IO ()) - -data Endpoint = IPv4 (Word8,Word8,Word8,Word8) Word16 - | IPv6 (Word16,Word16,Word16,Word16,Word16,Word16,Word16,Word16) Word32 Word16 - deriving (Eq, Show) -data DuplexOps = DuplexOps - { duplexRead :: Int -> IO BS.ByteString - , duplexWrite :: BS.ByteString -> IO Int - , duplexShutdownWrite :: IO () - , duplexLocal :: IO Endpoint - , duplexPeer :: IO Endpoint - , duplexRelease :: IO () - } -data ListenerOps = ListenerOps - { listenerAccept :: IO Backend - , listenerLocal :: IO Endpoint - , listenerRelease :: IO () - } --- Bit 0 leases the read direction, bit 1 the write direction. Non-duplex --- operations always lease both. Close/handoff require BOTH directions idle. -data Access = Exclusive | ReadDirection | WriteDirection -leaseBits :: Access -> Backend -> Word8 -leaseBits ReadDirection (Duplex _) = 1 -leaseBits WriteDirection (Duplex _) = 2 -leaseBits _ _ = 3 --- File cursor operations and byte IO share ONE registry lease. Optional methods --- express access mode, not separate handles/cursors or independent lifetimes. -data FileOps = FileOps - { fileReadAction :: Maybe (Int -> IO BS.ByteString) - , fileWriteAction :: Maybe (BS.ByteString -> IO Int) - , fileSeekAction :: Maybe (Integer -> IO ()) - , filePositionAction :: Maybe (IO Integer) - , fileSizeAction :: IO Integer - , fileResizeAction :: Maybe (Integer -> IO ()) - -- Acquire (synchronization action, once-only release); registered before use. - , fileSyncActions :: Maybe (IO (IO (), IO ())) - , fileReleaseAction :: IO () - } -positionLimit :: Integer -positionLimit = 2^(63 :: Int)-1 --- Backend state errors are ordinary operation failures; unrelated synchronous --- faults remain terminal task faults, not a catch-all recoverable Result. -newtype ResourceFault = ResourceFault Problem deriving Show -instance Exception ResourceFault -resourceFault :: Problem -> IO a -resourceFault = throwIO . ResourceFault -data Resource = Resource !Scope Backend (TVar Word8) (TVar Bool) (MVar ()) -data ScopeState = ScopeState (Maybe Scope) !Bool !Int --- Scope Bool is admission, not permission for existing leases to complete. -data State = State !Integer (M.Map Scope ScopeState) (M.Map Integer Resource) -data Runtime = Runtime !Integer (TVar State) - -chunkLimit :: Int -chunkLimit = 4096 -rootScope :: Scope -rootScope = 0 --- Three fixed root entries exist for the run, including during parent joining. --- No lazy acquisition into a closing root and no per-lookup aliases/history. --- These are borrowed process handles: no mode/buffering changes or OS close. -data StandardStream = StandardInput | StandardOutput | StandardError deriving (Enum) -newRuntime :: IO Runtime -newRuntime = newRuntimeWithStreams stdin stdout stderr -newRuntimeWithStreams :: Handle -> Handle -> Handle -> IO Runtime -newRuntimeWithStreams input output diagnostic = do - identity <- toInteger . hashUnique <$> newUnique - let writer h = do - failed <- newIORef False - pure $ Writer (\bytes -> mask $ \restore -> do - poisoned <- readIORef failed - when poisoned (resourceFault (Problem "invalid-state" "write" - "inherited writer failed; progress is uncertain" "unknown")) - -- A failed flush can leave host-buffered bytes. Never implicitly retry - -- them with the next v2 write. The embedding still owns that buffer. - restore (BS.hPut h bytes >> hFlush h) `onException` writeIORef failed True - pure (BS.length bytes)) (pure ()) - out <- writer output - err <- writer diagnostic - let backends = [Reader (BS.hGetSome input) (pure ()), out, err] - entries <- mapM (\backend -> Resource rootScope (Inherited backend) - <$> newTVarIO 0 <*> newTVarIO False <*> newMVar ()) backends - Runtime identity <$> newTVarIO (State 4 (M.singleton 0 (ScopeState Nothing True 0)) - (M.fromList (zip [1..3] entries))) - -standardStream :: Runtime -> Scope -> StandardStream -> IO (Either Problem Token) -standardStream (Runtime identity state) current stream = atomically $ do - snapshot <- readTVar state - let token = Token identity (1 + toInteger (fromEnum stream)) - -- Resolve validation here, not in a lazy result retaining the registry snapshot. - case lookupResource identity current token snapshot of - Left fault -> pure (Left fault) - Right _ -> token `seq` pure (Right token) - -internal :: Backend -> Bool -internal (Internal _) = True -internal _ = False - -inherited :: Backend -> Bool -inherited (Inherited _) = True -inherited _ = False -usable :: Backend -> Backend -usable (Inherited backend) = backend -usable backend = backend - -problem :: String -> String -> Problem -problem cat op = Problem cat op "" "none" -ioProblem :: String -> IOException -> Problem -ioProblem op e = Problem cat op (take 512 (show e)) (if op `elem` ["write", "read", "seek", "resize", "next", "close", "sync"] then "unknown" else "none") - where cat | isDoesNotExistError e = "not-found" - | isPermissionError e = "os-permission-denied" - | isAlreadyExistsError e = "already-exists" - | otherwise = "io-error" - --- Scheduler transactions can close admission before joining children, without --- revoking their ancestor borrowing or closing their own open scopes. Resource --- release is a separate step, performed only after task settlement. -beginCloseScopeSTM :: Runtime -> Scope -> STM () -beginCloseScopeSTM (Runtime _ state) scope = modifyTVar' state $ \(State n scopes resources) -> - State n (M.adjust (\(ScopeState p _ count) -> ScopeState p False count) scope scopes) resources - -childScope :: Runtime -> Scope -> IO (Either Problem Scope) -childScope runtime parent = atomically $ fmap (fmap fst) (reserveChildSTM runtime parent []) - --- Low-level reservation, NOT a public spawn operation. A scheduler must combine --- this transaction with task registration and opening a prestarted worker's gate. --- Thread creation must precede commitment: a failed fork must not move resources. --- STM rollback then covers the resource registry AND task publication together. --- --- Only resources owned by the attaching parent move; borrowed ancestor resources --- stay borrowed. Every token is checked before any mutation. Rekeying invalidates --- all old aliases, with no tombstones, and preserves original acquisition order --- independently of the caller's handle-list order. Returned tokens follow input --- order. No pending lease or explicit close may cross the handoff boundary. -reserveChildSTM :: Runtime -> Scope -> [Token] -> STM (Either Problem (Scope, [Token])) -reserveChildSTM (Runtime identity state) parent tokens = do - snapshot@(State next scopes resources) <- readTVar state - case M.lookup parent scopes of - Just (ScopeState _ True _) -> - if S.size (S.fromList [(run, key) | Token run key <- tokens]) /= length tokens - then pure (Left (problem "duplicate-handle" "transfer")) - else do - checked <- mapM (validate snapshot) tokens - case sequence checked of - Left e -> pure (Left e) - Right entries -> do - let ordered = M.toAscList (M.fromList entries) - moved = zip ordered [next+1..] - replacements = M.fromList [(old, new) | ((old, _), new) <- moved] - removeOld = foldr (M.delete . fst) resources entries - insertMoved acc ((_, Resource _ backend busy closing gate), key) = - M.insert key (Resource next backend busy closing gate) acc - finalResources = foldl' insertMoved removeOld moved - fresh (Token _ old) = Token identity (replacements M.! old) - freshTokens = map fresh tokens - -- Do not let lazy output tokens retain the moved backend records - -- through the replacement-map construction after the scope closes. - foldr (\token rest -> token `seq` rest) () freshTokens `seq` pure () - writeTVar state (State (next+1+toInteger (length entries)) - (M.insert next (ScopeState (Just parent) True 0) scopes) finalResources) - pure (Right (next, freshTokens)) - _ -> pure (Left (problem "closed-scope" "scope")) - where - validate snapshot token@(Token _ key) = case lookupResource identity parent token snapshot of - Left e -> pure (Left e) - Right resource@(Resource owner backend busy closing _) -> do - occupied <- readTVar busy - dead <- readTVar closing - pure $ if owner /= parent then Left (problem "not-owner" "transfer") - else if inherited backend then Left (problem "borrowed-resource" "transfer") - else if internal backend then Left (problem "internal-resource" "transfer") - else if dead then Left (problem "closed-handle" "transfer") - else if occupied /= 0 then Left (problem "busy" "transfer") - else Right (key, resource) - -release :: Backend -> IO () -release (Internal done) = done -release (Duplex methods) = duplexRelease methods -release (Listener methods) = listenerRelease methods -release (Directory _ done) = done -release (File methods) = fileReleaseAction methods -release (Inherited _) = pure () -release (Reader _ done) = done -release (Writer _ done) = done -release (Staged _ _ _ done) = done -release (Digest _ _ done) = done - --- Acquisition is masked through registry publication, like bracket acquisition. --- Backend acquisition must be exception-safe internally. Admission remains counted --- until publication/failure, so scope closure cannot miss an in-flight acquisition. -acquire :: Runtime -> Scope -> IO Backend -> IO (Either Problem Token) -acquire (Runtime identity state) owner create = mask_ $ do - admitted <- atomically $ do - State next scopes resources <- readTVar state - case M.lookup owner scopes of - Just (ScopeState parent True pending) -> do - writeTVar state (State next (M.insert owner (ScopeState parent True (pending+1)) scopes) resources) - pure True - _ -> pure False - if not admitted then pure (Left (problem "closed-scope" "open")) else do - let leave = atomically $ modifyTVar' state $ \(State n scopes resources) -> - State n (M.adjust (\(ScopeState p a count) -> ScopeState p a (count-1)) owner scopes) resources - result <- try (bracketOnError create release $ \backend -> do - busy <- newTVarIO 0 - closing <- newTVarIO False - gate <- newMVar () - atomically $ do - State next scopes resources <- readTVar state - writeTVar state (State (next+1) scopes (M.insert next (Resource owner backend busy closing gate) resources)) - pure (Token identity next)) `finally` leave - case result of - Right value -> pure (Right value) - Left (exception :: SomeException) - | Just (ResourceFault fault) <- fromException exception -> pure (Left fault) - | Just e <- fromException exception -> pure (Left (ioProblem "open" e)) - | otherwise -> throwIO exception - -openReader, openWriter, openAppender, openUpdate :: Runtime -> Scope -> FilePath -> IO (Either Problem Token) -openReader = openFileResource ReadMode -openWriter = openFileResource WriteMode -openAppender = openFileResource AppendMode -openUpdate = openFileResource ReadWriteMode - -openFileResource :: IOMode -> Runtime -> Scope -> FilePath -> IO (Either Problem Token) -openFileResource mode runtime scope path = acquire runtime scope $ - bracketOnError (openBinaryFile path mode) hClose $ \h -> do - hSetBuffering h NoBuffering - let readable = mode == ReadMode || mode == ReadWriteMode - writable = mode /= ReadMode - positioned = mode /= AppendMode - optional allowed action = if allowed then Just action else Nothing - seekable action = do - supported <- hIsSeekable h - unless supported (resourceFault (Problem "unsupported" "file" "file is not seekable" "none")) - action - resize size = seekable $ do - -- Synchronize/discard any read-ahead before changing length. Resizing - -- preserves the logical cursor, including a position beyond new EOF. - position <- hTell h - hSeek h AbsoluteSeek position - hSetFileSize h size - pure (File (FileOps - (optional readable (BS.hGetSome h)) - (optional writable (\bytes -> BS.hPut h bytes >> pure (BS.length bytes))) - (optional positioned (\position -> seekable (hSeek h AbsoluteSeek position))) - (optional positioned (seekable (hTell h))) - (hFileSize h) - (optional (writable && positioned) resize) - (Just $ do - -- Synchronize any read-ahead while preserving the logical cursor. Append - -- needs only flushing; the duplicate is used for fsync, never seeking. - when readable (seekable (hTell h >>= hSeek h AbsoluteSeek)) - when writable (hFlush h) - Descriptor.duplicateSync h) - (hClose h))) - --- Validating bounds precedes backend IO. Query replies are checked too, so a --- backend cannot inject a negative/out-of-range natural into the tree protocol. -fileOperation :: Runtime -> Scope -> Token -> String -> (FileOps -> Maybe (IO a)) -> IO (Either Problem a) -fileOperation runtime scope token op select = withLease runtime scope token op isFile $ \backend -> case backend of - File methods -> case select methods of - Just action -> action - Nothing -> resourceFault (Problem "unsupported" op "operation unavailable in this file mode" "none") - _ -> error "validated file kind" - where isFile (File _) = True - isFile _ = False - -syncFile :: Runtime -> Scope -> Token -> IO (Either Problem ()) -syncFile runtime scope token = fileOperation runtime scope token "sync" $ \methods -> - fmap (\allocate -> withTemporary runtime scope "sync" allocate id >>= either resourceFault pure) - (fileSyncActions methods) - -seekFile, resizeFile :: Runtime -> Scope -> Token -> Integer -> IO (Either Problem ()) -seekFile runtime scope token position - | position < 0 || position > positionLimit = pure (Left (problem "invalid-bound" "seek")) - | otherwise = fileOperation runtime scope token "seek" (fmap ($ position) . fileSeekAction) -resizeFile runtime scope token size - | size < 0 || size > positionLimit = pure (Left (problem "invalid-bound" "resize")) - | otherwise = fileOperation runtime scope token "resize" (fmap ($ size) . fileResizeAction) - -filePosition, fileSize :: Runtime -> Scope -> Token -> IO (Either Problem Integer) -filePosition runtime scope token = fileOperation runtime scope token "position" - (fmap checkedPosition . filePositionAction) -fileSize runtime scope token = fileOperation runtime scope token "size" - (Just . checkedPosition . fileSizeAction) -checkedPosition :: IO Integer -> IO Integer -checkedPosition action = do - value <- action - unless (value >= 0 && value <= positionLimit) - (resourceFault (Problem "unsupported" "file" "position outside supported range" "none")) - pure value - -inside :: Scope -> Scope -> M.Map Scope ScopeState -> Bool -inside current owner scopes = case M.lookup current scopes of - Nothing -> False - Just (ScopeState parent _ _) -> current == owner || maybe False (\p -> inside p owner scopes) parent - -lookupResource :: Integer -> Scope -> Token -> State -> Either Problem Resource -lookupResource identity current (Token run key) (State _ scopes resources) - | run /= identity = Left (problem "stale-handle" "resource") - | otherwise = case M.lookup key resources of - Nothing -> Left (problem "stale-handle" "resource") - Just resource@(Resource owner _ _ _ _) - | inside current owner scopes -> Right resource - | otherwise -> Left (problem "out-of-scope" "resource") - -withLease :: Runtime -> Scope -> Token -> String -> (Backend -> Bool) -> (Backend -> IO a) -> IO (Either Problem a) -withLease = withAccess Exclusive - --- Host-owned temporary resources are registered for lexical cleanup but are not --- public handles. Even a guessed registry key cannot read, close or transfer one. --- Their active operation still has a lease, so scope release waits for it. -withInternalResource :: Runtime -> Scope -> Token -> String -> IO a -> IO (Either Problem a) -withInternalResource runtime scope token op action = withLease runtime scope token op internal (const action) - -closeInternalResource :: Runtime -> Scope -> Token -> IO (Either Problem ()) -closeInternalResource runtime@(Runtime identity state) scope token = do - found <- atomically (lookupResource identity scope token <$> readTVar state) - case found of - Left p -> pure (Left p) - Right (Resource _ backend _ _ _) | internal backend -> closeResourceWith True runtime scope token - _ -> pure (Left (problem "wrong-kind" "close")) - --- Register transient host-owned resources before use. Ordinary failures retain --- primary diagnostics plus bounded cleanup detail; terminal faults leave cleanup --- to lexical settlement. Allocation must itself be exception-safe until return. -withTemporary :: Runtime -> Scope -> String -> IO (a, IO ()) -> (a -> IO b) -> IO (Either Problem b) -withTemporary runtime scope op allocate use = mask $ \restore -> do - slot <- newIORef Nothing - acquired <- acquire runtime scope $ do - (value, done) <- allocate - writeIORef slot (Just value) - pure (Internal done) - case acquired of - Left p -> pure (Left p) - Right token -> do - Just value <- readIORef slot - result <- restore (withInternalResource runtime scope token op (use value)) - closed <- closeInternalResource runtime scope token - pure $ case (result,closed) of - (Right value, Right ()) -> Right value - (Right _, Left cleanup) -> Left cleanup - (Left primary, Right ()) -> Left primary - (Left primary, Left cleanup) -> Left primary { detail = take 240 (detail primary) ++ - "; cleanup: " ++ take 240 (category cleanup ++ ": " ++ detail cleanup) } - -withAccess :: Access -> Runtime -> Scope -> Token -> String -> (Backend -> Bool) -> (Backend -> IO a) -> IO (Either Problem a) -withAccess access (Runtime identity state) current token op rightKind use = mask $ \restore -> do - lease <- atomically $ do - snapshot <- readTVar state - case lookupResource identity current token snapshot of - Left e -> pure (Left e) - Right resource@(Resource _ backend busy closing _) -> do - dead <- readTVar closing - occupied <- readTVar busy - if dead then pure (Left (problem "closed-handle" op)) - else if not (rightKind (usable backend)) then pure (Left (problem "wrong-kind" op)) - else if occupied .&. leaseBits access (usable backend) /= 0 then pure (Left (problem "busy" op)) - else writeTVar busy (occupied .|. leaseBits access (usable backend)) >> pure (Right resource) - case lease of - Left e -> pure (Left e) - Right (Resource _ backend busy _ _) -> do - result <- try (restore (use (usable backend))) `finally` atomically (modifyTVar' busy (.&. complement (leaseBits access (usable backend)))) - case result of - Right value -> pure (Right value) - Left (exception :: SomeException) - | Just (ResourceFault p) <- fromException exception -> pure (Left p) - | Just e <- fromException exception -> pure (Left (ioProblem op e)) - | otherwise -> throwIO exception - -readSome :: Runtime -> Scope -> Token -> Int -> IO (Either Problem BS.ByteString) -readSome runtime scope token count - | count <= 0 || count > chunkLimit = pure (Left (problem "invalid-bound" "read")) - | otherwise = withReaderLease runtime scope token (\readBytes -> readBytes count) - --- Internal codec transport: retain one directional lease across an entire frame, --- not one lease per chunk. Internal clients must not retain the supplied reader --- beyond the callback; no such function is exposed through the source protocol. -withReaderLease :: Runtime -> Scope -> Token -> ((Int -> IO BS.ByteString) -> IO a) -> IO (Either Problem a) -withReaderLease runtime scope token use = withAccess ReadDirection runtime scope token "read" isReader $ \backend -> - let checked readBytes count = do - unless (count > 0 && count <= chunkLimit) (resourceFault (problem "invalid-bound" "read")) - bytes <- readBytes count - unless (BS.length bytes <= count) (ioError (userError "backend exceeded read bound")) - pure bytes - in case backend of - Duplex methods -> use (checked (duplexRead methods)) - Reader readBytes _ -> use (checked readBytes) - File methods | Just readBytes <- fileReadAction methods -> use (checked readBytes) - _ -> error "validated reader kind" - -writeSome :: Runtime -> Scope -> Token -> BS.ByteString -> IO (Either Problem Int) -writeSome runtime scope token bytes - | BS.null bytes || BS.length bytes > chunkLimit = pure (Left (problem "invalid-bound" "write")) - | otherwise = withWriterLease runtime scope token (\writeBytes -> writeBytes bytes) - --- As for the reader hook, internal clients must not retain the supplied writer. -withWriterLease :: Runtime -> Scope -> Token -> ((BS.ByteString -> IO Int) -> IO a) -> IO (Either Problem a) -withWriterLease runtime scope token use = withAccess WriteDirection runtime scope token "write" isWriter $ \backend -> - let checked writeBytes bytes = do - unless (not (BS.null bytes) && BS.length bytes <= chunkLimit) - (resourceFault (problem "invalid-bound" "write")) - count <- writeBytes bytes - unless (count > 0 && count <= BS.length bytes) (ioError (userError "invalid backend write progress")) - pure count - in case backend of - Duplex methods -> use (checked (duplexWrite methods)) - File methods | Just writeBytes <- fileWriteAction methods -> use (checked writeBytes) - Writer writeBytes _ -> use (checked writeBytes) - Staged writeBytes _ _ _ -> use (checked writeBytes) - _ -> error "validated writer kind" - -isReader, isWriter, isStage, isDigest :: Backend -> Bool -isReader (Duplex _) = True -isReader (File methods) = maybe False (const True) (fileReadAction methods) -isReader (Reader _ _) = True -isReader _ = False -isWriter (Duplex _) = True -isWriter (File methods) = maybe False (const True) (fileWriteAction methods) -isWriter (Writer _ _) = True -isWriter (Staged _ _ _ _) = True -isWriter _ = False -isStage (Staged _ _ _ _) = True -isStage _ = False -isDigest (Digest _ _ _) = True -isDigest _ = False - -acceptConnection :: Runtime -> Scope -> Token -> Scope -> IO (Either Problem Token) -acceptConnection runtime current token owner = do - allowed <- isAncestor runtime current owner - if not allowed then pure (Left (problem "out-of-scope" "accept")) else do - result <- withLease runtime current token "accept" isListener $ \backend -> case backend of - Listener methods -> acquire runtime owner (listenerAccept methods) - _ -> error "validated listener kind" - pure (either Left id result) - where isListener (Listener _) = True - isListener _ = False - -shutdownWrite :: Runtime -> Scope -> Token -> IO (Either Problem ()) -shutdownWrite runtime current token = withAccess WriteDirection runtime current token "shutdown-write" isDuplex $ \backend -> case backend of - Duplex methods -> duplexShutdownWrite methods - _ -> error "validated duplex kind" - where isDuplex (Duplex _) = True - isDuplex _ = False - -socketAddress :: Runtime -> Scope -> Token -> Bool -> IO (Either Problem Endpoint) -socketAddress runtime current token peer = withLease runtime current token op supported $ \backend -> case backend of - Duplex methods -> (if peer then duplexPeer else duplexLocal) methods - Listener methods -> listenerLocal methods - _ -> error "validated socket kind" - where op = if peer then "peer-address" else "local-address" - supported (Duplex _) = True - supported (Listener _) = not peer - supported _ = False - -nextDirectory :: Runtime -> Scope -> Token -> IO (Either Problem (Maybe String)) -nextDirectory runtime scope token = withLease runtime scope token "next" isDirectory $ \backend -> case backend of - Directory next _ -> next - _ -> error "validated directory kind" - where isDirectory (Directory _ _) = True - isDirectory _ = False - -sealStage :: Runtime -> Scope -> Token -> IO (Either Problem ()) -sealStage runtime scope token = withLease runtime scope token "seal" isStage $ \backend -> case backend of - Staged _ seal _ _ -> seal - _ -> error "validated stage kind" -publishStage :: Runtime -> Scope -> Token -> Bool -> FilePath -> IO (Either Problem String) -publishStage runtime scope token replace destination = withLease runtime scope token "publish" isStage $ \backend -> case backend of - Staged _ _ publish _ -> publish replace destination - _ -> error "validated stage kind" -updateDigest :: Runtime -> Scope -> Token -> BS.ByteString -> IO (Either Problem ()) -updateDigest runtime scope token bytes - | BS.null bytes || BS.length bytes > chunkLimit = pure (Left (problem "invalid-bound" "digest-update")) - | otherwise = withLease runtime scope token "digest-update" isDigest $ \backend -> case backend of - Digest update _ _ -> update bytes - _ -> error "validated digest kind" -finishDigest :: Runtime -> Scope -> Token -> IO (Either Problem String) -finishDigest runtime scope token = withLease runtime scope token "digest-finish" isDigest $ \backend -> case backend of - Digest _ finish _ -> finish - _ -> error "validated digest kind" - --- The close gate serializes explicit close and teardown without holding the --- registry lock during IO. Mark closing before waiting for leases. If interrupted --- while waiting, a subsequent scope teardown can finish the close. -closeResource :: Runtime -> Scope -> Token -> IO (Either Problem ()) -closeResource = closeResourceWith False - --- Only lexical settlement may revoke inherited references, never the OS handles. -closeResourceWith :: Bool -> Runtime -> Scope -> Token -> IO (Either Problem ()) -closeResourceWith settling runtime@(Runtime identity state) current token = mask_ $ do - found <- atomically $ lookupResource identity current token <$> readTVar state - case found of - Left e -> pure (Left e) - Right (Resource _ backend _ _ _) | internal backend && not settling -> - pure (Left (problem "internal-resource" "close")) - Right (Resource _ backend _ _ _) | inherited backend && not settling -> - pure (Left (problem "borrowed-resource" "close")) - Right (Resource _ _ _ _ gate) -> withMVar gate $ \_ -> do - -- Claim closing in the SAME transaction as token validation. Otherwise a - -- transfer can rekey the entry between lookup and closeEntry, allowing an - -- old alias to close the new owner's backend and leave a dead live entry. - fresh <- atomically $ do - result <- lookupResource identity current token <$> readTVar state - case result of - Right (Resource _ _ _ closing _) -> writeTVar closing True - Left _ -> pure () - pure result - case fresh of - Left e -> pure (Left e) - Right resource -> closeEntry runtime token resource - -closeEntry :: Runtime -> Token -> Resource -> IO (Either Problem ()) -closeEntry (Runtime _ state) (Token _ key) (Resource _ backend busy closing _) = do - atomically (writeTVar closing True) - atomically (readTVar busy >>= check . (== 0)) - result <- try (release backend) - case result of - Left (exception :: SomeException) | Just (_ :: SomeAsyncException) <- fromException exception -> throwIO exception - _ -> do - atomically $ modifyTVar' state $ \(State n scopes resources) -> State n scopes (M.delete key resources) - pure $ case result of - Right () -> Right () - Left exception -> Left $ case fromException exception of - Just e -> ioProblem "close" e - Nothing -> Problem "backend-fault" "close" (take 512 (displayException exception)) "unknown" - --- IOV2.Tasks must settle children before entering resource teardown. Close any --- remaining nested resource scopes before this scope, in reverse acquisition order. -closeScope :: Runtime -> Scope -> IO [Problem] -closeScope runtime@(Runtime identity state) scope = mask_ $ do - atomically (beginCloseScopeSTM runtime scope) - children <- atomically $ do - State _ scopes _ <- readTVar state - case M.lookup scope scopes of - Just (ScopeState _ _ pending) -> check (pending == 0) - Nothing -> pure () - pure [key | (key, ScopeState parent _ _) <- M.toDescList scopes, parent == Just scope] - childErrors <- foldM (\errors child -> combineErrors errors <$> closeScope runtime child) [] children - keys <- atomically $ do - State _ _ resources <- readTVar state - pure [key | (key, Resource owner _ _ _ _) <- M.toDescList resources, owner == scope] - errors <- foldM (\previous key -> do - result <- closeResourceWith True runtime scope (Token identity key) - pure (combineErrors previous (either (:[]) (const []) result))) childErrors keys - atomically $ modifyTVar' state $ \(State n scopes resources) -> State n (M.delete scope scopes) resources - pure errors - --- Keep bounded diagnostics while preserving an explicit count of omissions. -combineErrors :: [Problem] -> [Problem] -> [Problem] -combineErrors left right = - let allErrors = left ++ right - actual = filter ((/= "cleanup-omitted") . category) allErrors - prior = sum [maybe 0 id (readMaybe (detail e) :: Maybe Integer) | e <- allErrors, category e == "cleanup-omitted"] - omitted = prior + toInteger (max 0 (length actual - 16)) - in take 16 actual ++ [Problem "cleanup-omitted" "close" (show omitted) "none" | omitted > 0] - -liveCounts :: Runtime -> IO (Int, Int) -liveCounts (Runtime _ state) = atomically $ do - State _ scopes resources <- readTVar state - pure (M.size scopes, M.size resources) - -runtimeIdentity :: Runtime -> IO Integer -runtimeIdentity (Runtime identity _) = pure identity -isAncestor :: Runtime -> Scope -> Scope -> IO Bool -isAncestor (Runtime _ state) current owner = atomically $ do - State _ scopes _ <- readTVar state - pure (inside current owner scopes) diff --git a/src/IOV2/Staging.hs b/src/IOV2/Staging.hs @@ -1,149 +0,0 @@ -{-# LANGUAGE ScopedTypeVariables #-} --- Same-filesystem staged publication, including cross-directory destinations. No digest, length, HTTP or artifact policy. -module IOV2.Staging (openStage, openStageWith, StageOps(..), nativeOps) where - -import qualified IOV2.Resources as R -import qualified Data.ByteString as BS -import Control.Exception -import Control.Monad (unless, when) -import Data.List (isInfixOf) -import Data.IORef -import System.Directory (canonicalizePath, removeFile, renameFile) -import System.FilePath (takeDirectory, takeFileName, (</>)) -import System.IO -import System.IO.Error -import System.Posix.Files (createLink) -import System.Posix.IO (openFd, closeFd, handleToFd, defaultFileFlags, OpenMode(ReadOnly)) -import System.Posix.Types (Fd) -import System.Posix.Unistd (fileSynchronise) - --- Injectable OS dependencies, not production fault flags. The native backend --- has exactly the same transitions as deterministic failure/cancellation tests. -data StageOps = StageOps - { putBytes :: Handle -> BS.ByteString -> IO () - , syncFile :: Fd -> IO () - , closeDescriptor :: Fd -> IO () - , replaceName :: FilePath -> FilePath -> IO () - , linkName :: FilePath -> FilePath -> IO () - , removeName :: FilePath -> IO () - , openDirectory :: FilePath -> IO Fd - , syncDirectory :: Fd -> IO () - } -nativeOps :: StageOps -nativeOps = StageOps BS.hPut fileSynchronise closeFd renameFile createLink removeFile - (\path -> openFd path ReadOnly defaultFileFlags) fileSynchronise - -temporaryMarker :: String -temporaryMarker = ".tricu-stage-" - -data Phase = Writing | Sealed | Failed | Published deriving Eq -data Owned = OwnHandle Handle | OwnDescriptor Fd | Released -newtype CleanupFault = CleanupFault String deriving Show -instance Exception CleanupFault - -openStage :: R.Runtime -> R.Scope -> FilePath -> IO (Either R.Problem R.Token) -openStage = openStageWith nativeOps -openStageWith :: StageOps -> R.Runtime -> R.Scope -> FilePath -> IO (Either R.Problem R.Token) -openStageWith _ _ _ "" = pure (Left (R.Problem "invalid-argument" "stage-open" "empty directory" "none")) -openStageWith ops runtime scope directory = R.acquire runtime scope $ mask_ $ do - canonical <- canonicalizePath directory - (path, handle) <- openBinaryTempFile canonical temporaryMarker - -- No user evaluation between allocation and installing cleanup bookkeeping. - phase <- newIORef Writing - owned <- newIORef (OwnHandle handle) - privateName <- newIORef True - let removePrivate = do - exists <- readIORef privateName - when exists $ do - removeName ops path `catch` (\e -> unless (isDoesNotExistError e) (throwIO (e :: IOException))) - writeIORef privateName False - closeOwned = do - current <- readIORef owned - case current of - Released -> pure () - OwnHandle h -> hClose h >> writeIORef owned Released - OwnDescriptor fd -> do - -- A failed close can already have closed the descriptor. Never - -- retry a numeric descriptor that the OS may have reused. - writeIORef owned Released - closeDescriptor ops fd - cleanup = mask_ $ do - closed <- try closeOwned - removed <- try removePrivate - let failures = [e | Left e <- [closed, removed]] :: [SomeException] - asynchronous e = case fromException e :: Maybe SomeAsyncException of - Just _ -> True - Nothing -> False - -- Preserve interruption so the registry retains the closing entry for - -- later settlement, but still attempt private-name removal here. - case filter asynchronous failures of - first:_ -> throwIO first - [] -> case (closed, removed) of - (Left first, Left second) -> case fromException first of - Just ioe -> throwIO (ioeSetErrorString ioe - (take 240 (ioeGetErrorString ioe) ++ "; removal: " ++ take 240 (displayException second))) - Nothing -> throwIO (CleanupFault - (take 240 (displayException first) ++ "; removal: " ++ take 240 (displayException second))) - (Left first, _) -> throwIO first - (_, Left second) -> throwIO second - _ -> pure () - require expected operation = do - actual <- readIORef phase - unless (actual == expected) (R.resourceFault (R.Problem "invalid-state" operation "stage is not in the required phase" "none")) - poison = writeIORef phase Failed - write bytes = do - require Writing "write" - (putBytes ops handle bytes >> pure (BS.length bytes)) `onException` poison - seal = mask_ $ do - require Writing "seal" - (do - hFlush handle - fd <- handleToFd handle - writeIORef owned (OwnDescriptor fd) - -- On sync failure/cancellation the scope still owns this descriptor. - -- Its later release cannot mask the primary operation failure. - syncFile ops fd - closeOwned - writeIORef phase Sealed) `onException` poison - publish replace destination = mask_ $ do - require Sealed "publish" - progress <- newIORef "not-published" - let action = do - targetDirectory <- canonicalizePath (takeDirectory destination) - let name = takeFileName destination - unless (name `notElem` ["", ".", ".."] && not (temporaryMarker `isInfixOf` name)) $ - R.resourceFault (R.Problem "invalid-destination" "publish" "destination must name a file outside the private staging namespace" "not-published") - let target = targetDirectory </> name - -- Syscall failure can be ambiguous on remote filesystems. Never - -- turn it into permission to retry or delete the destination. - writeIORef progress "unknown" - linked <- if replace then replaceName ops path target >> pure True - else (linkName ops path target >> pure True) `catch` - (\e -> if isAlreadyExistsError e then pure False else throwIO (e :: IOException)) - if not linked then pure "exists" else do - writeIORef progress "published" - writeIORef phase Published - if replace then writeIORef privateName False else removePrivate - -- These are atomic namespace syscalls, never copy fallbacks. - -- Synchronize destination visibility before source-name removal durability; - -- distinct directories both need synchronization. Failure remains - -- published and cleanup cannot remove the destination. Do not open - -- a second descriptor until the first has been settled. - let syncParent directoryPath = do - fd <- openDirectory ops directoryPath - writeIORef owned (OwnDescriptor fd) - syncDirectory ops fd - closeOwned - syncParent targetDirectory - when (targetDirectory /= canonical) (syncParent canonical) - pure "published" - action `catch` (\(e :: IOException) -> do - poison - visibility <- readIORef progress - R.resourceFault ((R.ioProblem "publish" e) { R.progress = visibility })) - `onException` (do - state <- readIORef phase - -- In particular, cancellation after link/rename never rolls back - -- visibility. Cleanup only ever touches our private temporary name. - unless (state == Published) poison) - (hSetBuffering handle NoBuffering >> pure (R.Staged write seal publish cleanup)) `onException` cleanup diff --git a/src/IOV2/Tasks.hs b/src/IOV2/Tasks.hs @@ -1,323 +0,0 @@ -{-# LANGUAGE ScopedTypeVariables #-} --- Structured native task groups. Registry transactions are the linearization --- points; ThreadKilled only wakes a body, never carries the cancellation reason. -module IOV2.Tasks - ( Outcome(..), Scheduler, Group, TaskRef(..), newScheduler, rootGroup - , scopeOf, runGroup, nested, spawn, join, discard, cancel, checkpoint, liveCounts - ) where - -import Control.Concurrent hiding (yield) -import Control.Concurrent.STM -import Control.Exception -import Control.Monad (forM_, void) -import qualified Data.Map.Strict as M -import Runtime (T(..), ofString, ofList) -import qualified IOV2.Resources as R - -data Outcome = Completed T | Failed T [R.Problem] | Cancelled T [R.Problem] deriving Show -data Phase = Open | Joining | Releasing | Closed deriving Eq -data Scheduler = Scheduler R.Runtime (TVar (M.Map Integer Task)) - (TVar (M.Map Integer Task)) (TVar (M.Map Integer [Integer])) -data Group = Group - { groupScheduler :: Scheduler - , scopeOf :: R.Scope - , taskIdentity :: Integer - , groupParent :: Maybe (Group, Bool) - , groupPhase :: TVar Phase - , groupReason :: TVar (Maybe Outcome) - , groupChildren :: TVar (M.Map Integer Task) - , groupCleanup :: TVar [R.Problem] - , groupLexical :: TVar (Maybe Group) - } -data Task = Task Group Group (TVar (Maybe Outcome)) -data TaskRef = TaskRef !Integer !Integer deriving (Eq, Show) -data Interrupted = Interrupted deriving Show -instance Exception Interrupted - -schedulerOf :: Group -> Scheduler -schedulerOf = groupScheduler -runtimeOf :: Scheduler -> R.Runtime -runtimeOf (Scheduler runtime _ _ _) = runtime - -newScheduler :: R.Runtime -> IO Scheduler -newScheduler runtime = Scheduler runtime <$> newTVarIO M.empty <*> newTVarIO M.empty <*> newTVarIO M.empty -newGroup :: Scheduler -> R.Scope -> Integer -> Maybe (Group, Bool) -> STM Group -newGroup scheduler scope task parent = Group scheduler scope task parent - <$> newTVar Open <*> newTVar Nothing <*> newTVar M.empty <*> newTVar [] <*> newTVar Nothing -rootGroup :: Scheduler -> IO Group -rootGroup scheduler = atomically (newGroup scheduler R.rootScope 0 Nothing) - -problem :: String -> String -> R.Problem -problem category operation = R.Problem category operation "" "none" -failedException :: SomeException -> Outcome -failedException e = Failed (ofString (take 512 (displayException e))) [] - --- First recorded stop wins. Failure propagates across lexical groups and --- uncontained child boundaries, but never across preinstalled supervision. --- All siblings receive sticky requests in this same transaction; a slow OS --- interruption of one worker cannot prevent cancellation of the others. -stop :: Group -> Outcome -> STM () -stop Group{ groupScheduler = scheduler, scopeOf = scope, groupParent = parent, - groupPhase = phase, groupReason = reason, groupChildren = children, groupLexical = lexical } outcome = do - state <- readTVar phase - previous <- readTVar reason - case (state, previous) of - (Closed, _) -> pure () - (_, Just _) -> pure () - _ -> do - writeTVar reason (Just (withoutCleanup outcome)) - R.beginCloseScopeSTM (runtimeOf scheduler) scope - inner <- readTVar lexical - forM_ inner (\child -> stop child (Cancelled (ofString "owning group stopped") [])) - active <- readTVar children - forM_ (M.elems active) $ \(Task _ child _) -> - stop child (Cancelled (ofString "owning group stopped") []) - case (outcome, parent) of - (Failed _ _, Just (outer, True)) -> stop outer (withoutCleanup outcome) - _ -> pure () - -checkpoint :: Group -> IO () -checkpoint Group{ groupReason = reason } = atomically $ do - stopped <- readTVar reason - case stopped of Nothing -> pure (); Just _ -> throwSTM Interrupted - --- Propagation carries only the primary. Cleanup travels once, at settlement, --- rather than being duplicated along early fail-fast notifications. -withoutCleanup :: Outcome -> Outcome -withoutCleanup (Failed diagnostic _) = Failed diagnostic [] -withoutCleanup (Cancelled reason _) = Cancelled reason [] -withoutCleanup completed = completed -cleanupOf :: Outcome -> [R.Problem] -cleanupOf (Failed _ errors) = errors -cleanupOf (Cancelled _ errors) = errors -cleanupOf _ = [] - -attach :: Outcome -> [R.Problem] -> Outcome -attach (Failed diagnostic previous) errors = Failed diagnostic (R.combineErrors previous errors) -attach (Cancelled reason previous) errors = Cancelled reason (R.combineErrors previous errors) -attach completed [] = completed -attach (Completed _) (first:rest) = Failed (ofList (map ofString [R.category first, R.operation first, R.detail first, R.progress first])) rest - --- The controller never executes user code. Its body runs in a separate worker, --- so cancellation cannot land inside the resource-release protocol. Lexical --- nesting may interrupt a controller from outside; the exception boundary then --- cancels/joins its entire subtree before rethrowing that host interruption. --- Repeated external host interruption/process death is not a cleanup guarantee. -runGroup :: Group -> IO Outcome -> IO Outcome -runGroup group@Group{ groupScheduler = scheduler, scopeOf = scope, taskIdentity = taskId, - groupPhase = phase, groupReason = reason, groupChildren = children, - groupCleanup = childErrors, groupLexical = lexical } body = mask $ \restore -> do - bodyDone <- newEmptyTMVarIO - launched <- try $ forkIOWithUnmask $ \unmask -> do - result <- try (unmask (checkpoint group >> body >>= evaluate)) - atomically (putTMVar bodyDone result) - worker <- case launched of - Left (exception :: SomeException) -> atomically (putTMVar bodyDone (Left exception)) >> pure Nothing - Right tid -> pure (Just tid) - let interruptBody = do - -- A lexical controller is host teardown, not user work. Its sticky stop - -- was set by stop; let it settle naturally rather than injecting an - -- exception into its release protocol. Only interrupt actual body work. - inner <- readTVarIO lexical - case inner of Nothing -> forM_ worker (\tid -> throwTo tid ThreadKilled); Just _ -> pure () - closeAdmission = atomically $ do - writeTVar phase Joining - R.beginCloseScopeSTM (runtimeOf scheduler) scope - joinChildren = do - let Scheduler _ _ _ waits = scheduler - atomically $ do - active <- readTVar children - graph <- readTVar waits - let targets = M.keys active - if any (reaches graph taskId []) targets - then stop group (Failed (ofString "task join cycle") []) - else modifyTVar' waits (M.insert taskId targets) - atomically (readTVar children >>= check . M.null) - `finally` atomically (modifyTVar' waits (M.delete taskId)) - release = do - atomically (writeTVar phase Releasing) - R.closeScope (runtimeOf scheduler) scope - finish outcome errors = atomically $ do - prior <- readTVar reason - descendants <- readTVar childErrors - let diagnostics = R.combineErrors (cleanupOf outcome) (R.combineErrors descendants errors) - final = attach (maybe (withoutCleanup outcome) id prior) diagnostics - case final of Failed _ _ -> stop group final; _ -> pure () - writeTVar childErrors (cleanupOf final) - writeTVar phase Closed - let Scheduler _ _ tickets _ = scheduler - modifyTVar' tickets (M.filter (\(Task owner _ _) -> scopeOf owner /= scope)) - pure final - execute = do - signal <- restore $ atomically $ do - requested <- readTVar reason - case requested of - Just _ -> pure Nothing - Nothing -> Just <$> readTMVar bodyDone - result <- case signal of - Just value -> pure value - Nothing -> interruptBody >> atomically (readTMVar bodyDone) - let outcome = either failedException id result - atomically $ case outcome of Completed _ -> pure (); _ -> stop group outcome - closeAdmission - joinChildren - errors <- release - finish outcome errors - abort (exception :: SomeException) = do - atomically (stop group (Cancelled (ofString "host interruption") [])) - interruptBody - void (atomically (readTMVar bodyDone)) - closeAdmission - joinChildren - errors <- release - void (finish (Cancelled (ofString "host interruption") []) errors) - throwIO exception - execute `catch` abort - -nested :: Group -> (Group -> IO Outcome) -> IO Outcome -nested parent@Group{ groupScheduler = scheduler, scopeOf = scope, taskIdentity = task, - groupCleanup = parentErrors, groupLexical = lexical } body = mask $ \restore -> do - allocated <- atomically $ do - checkpointSTM parent - reserved <- R.reserveChildSTM (runtimeOf scheduler) scope [] - case reserved of - Left e -> pure (Left e) - Right (childScope, _) -> do - child <- newGroup scheduler childScope task (Just (parent, True)) - writeTVar lexical (Just child) - pure (Right child) - case allocated of - Left e -> pure (Failed (ofString (R.category e)) []) - Right child@Group{ groupCleanup = diagnostics } -> do - result <- runGroup child (restore (body child)) `finally` atomically (do - errors <- readTVar diagnostics - modifyTVar' parentErrors (\previous -> R.combineErrors previous errors) - writeTVar lexical Nothing) - pure (withoutCleanup result) - -checkpointSTM :: Group -> STM () -checkpointSTM Group{ groupPhase = phase, groupReason = reason } = do - state <- readTVar phase - stopped <- readTVar reason - case (state, stopped) of - (Open, Nothing) -> pure () - _ -> throwSTM Interrupted - --- A worker exists but cannot run before atomic resource transfer AND task --- publication. Fork failure leaves the registry untouched. Failed validation --- opens the gate with Nothing and joins that unused worker before returning. -spawn :: Group -> Bool -> Bool -> [R.Token] -> (Group -> [R.Token] -> IO Outcome) - -> IO (Either R.Problem TaskRef) -spawn owner@Group{ groupScheduler = scheduler, scopeOf = scope, - groupChildren = children, groupCleanup = ownerErrors } supervised keep tokens body = mask_ $ do - gate <- newEmptyTMVarIO - retired <- newEmptyMVar - let Scheduler runtime active tickets _ = scheduler - _ <- forkIOWithUnmask $ \unmask -> (do - entry <- atomically (takeTMVar gate) - case entry of - Nothing -> pure () - Just (task@(Task _ child completed), fresh) -> do - outcome <- unmask (runGroup child (body child fresh)) `catch` (pure . failedException) - atomically $ do - writeTVar completed (Just outcome) - if supervised then pure () else - modifyTVar' ownerErrors (\previous -> R.combineErrors previous (cleanupOf outcome)) - modifyTVar' children (M.delete (scopeOf child)) - modifyTVar' active (M.delete (scopeOf child)) - -- runGroup already propagated ordinary faults before release. This - -- also covers an unexpected controller fault at its outer boundary. - case outcome of Failed _ _ | not supervised -> stop owner outcome; _ -> pure () - task `seq` pure ()) `finally` putMVar retired () - let abandon = atomically (putTMVar gate Nothing) >> takeMVar retired - result <- (atomically $ do - checkpointSTM owner - reserved <- R.reserveChildSTM runtime scope tokens - case reserved of - Left e -> pure (Left e) - Right (childScope, fresh) -> do - child <- newGroup scheduler childScope childScope (Just (owner, not supervised)) - completed <- newTVar Nothing - let task = Task owner child completed - modifyTVar' children (M.insert childScope task) - modifyTVar' active (M.insert childScope task) - if keep then modifyTVar' tickets (M.insert childScope task) else pure () - putTMVar gate (Just (task, fresh)) - pure (Right childScope)) `onException` abandon - case result of - Left e -> abandon >> pure (Left e) - Right key -> do - identity <- R.runtimeIdentity runtime - pure (Right (TaskRef identity key)) - -within :: Group -> Group -> Bool -within current@Group{ groupParent = parent } owner = - scopeOf current == scopeOf owner || maybe False (\(outer, _) -> within outer owner) parent - --- Registry lookup must remain inside the claiming transaction. Only the run --- identity check is IO (it reads an immutable field). -checked :: Group -> TaskRef -> TVar (M.Map Integer Task) -> IO (STM (Either R.Problem Task)) -checked current (TaskRef run key) table = do - identity <- R.runtimeIdentity (runtimeOf (schedulerOf current)) - pure $ if identity /= run then pure (Left (problem "stale-task" "task")) else do - entries <- readTVar table - pure $ case M.lookup key entries of - Just task@(Task owner _ _) | within current owner -> Right task - Just _ -> Left (problem "out-of-scope" "task") - Nothing -> Left (problem "stale-task" "task") - --- Includes implicit scope joining as well as explicit ticket waits. Lexical --- controllers share their body's task identity, so nested scopes cannot hide a --- cycle. Edges are removed when a wait settles, never retained as history. -reaches :: M.Map Integer [Integer] -> Integer -> [Integer] -> Integer -> Bool -reaches graph wanted seen target - | target == wanted = True - | target `elem` seen = False - | otherwise = any (reaches graph wanted (target:seen)) (M.findWithDefault [] target graph) - -join :: Group -> TaskRef -> IO (Either R.Problem Outcome) -join current@Group{ groupScheduler = Scheduler _ _ tickets waits, taskIdentity = taskId } ref@(TaskRef _ key) = mask $ \restore -> do - lookupEntry <- checked current ref tickets - claimed <- atomically $ do - checkpointSTM current - entry <- lookupEntry - graph <- readTVar waits - case entry of - Right task@(Task _ target _) | within current target || reaches graph taskId [] key -> pure (Left (problem "join-cycle" "join")) - | otherwise -> do - modifyTVar' tickets (M.delete key) - modifyTVar' waits (M.insert taskId [key]) - pure (Right task) - Left e -> pure (Left e) - case claimed of - Left e -> pure (Left e) - Right (Task _ _ completed) -> - (Right <$> restore (atomically (readTVar completed >>= maybe retry pure))) - `finally` atomically (modifyTVar' waits (M.delete taskId)) - -discard :: Group -> TaskRef -> IO (Either R.Problem ()) -discard current ref@(TaskRef _ key) = do - let Scheduler _ _ tickets _ = schedulerOf current - lookupEntry <- checked current ref tickets - atomically $ do - checkpointSTM current - entry <- lookupEntry - case entry of Left e -> pure (Left e); Right _ -> modifyTVar' tickets (M.delete key) >> pure (Right ()) - -cancel :: Group -> TaskRef -> T -> IO (Either R.Problem ()) -cancel current ref why = do - let Scheduler _ active _ _ = schedulerOf current - lookupEntry <- checked current ref active - atomically $ do - checkpointSTM current - entry <- lookupEntry - case entry of - Left e -> pure (Left e) - Right (Task _ child@Group{ groupPhase = phase } _) -> do - state <- readTVar phase - if state == Closed then pure (Left (problem "completed-task" "cancel")) - else stop child (Cancelled why []) >> pure (Right ()) - -liveCounts :: Scheduler -> IO (Int, Int, Int) -liveCounts (Scheduler _ active tickets waits) = atomically $ (,,) - <$> (M.size <$> readTVar active) <*> (M.size <$> readTVar tickets) <*> (M.size <$> readTVar waits) diff --git a/src/IOV2/UTF8.hs b/src/IOV2/UTF8.hs @@ -1,63 +0,0 @@ -{-# LANGUAGE BangPatterns #-} --- Stateless bounded Unicode transport. Source owns the <=3-byte carry between --- calls, text/source policy and sequencing; no shared mutable decoder context. -module IOV2.UTF8 (encode, decode, scalarLimit) where - -import qualified IOV2.Resources as R -import qualified Data.ByteString as BS -import qualified Data.Text as Text -import Data.Text.Encoding (encodeUtf8) -import Data.Char (chr, ord) -import Data.Bits ((.&.)) -import Data.List (foldl') - -scalarLimit :: Int -scalarLimit = R.chunkLimit `div` 4 - -fault op message = R.Problem "invalid-data" op message "none" - -encode :: String -> Either R.Problem BS.ByteString -encode text = encodeUtf8 . Text.pack <$> checked scalarLimit text - where - checked _ [] = Right [] - checked 0 _ = Left (R.Problem "invalid-bound" "encode" "Unicode chunk exceeds scalar limit" "none") - checked n (c:rest) - | ord c >= 0xd800 && ord c <= 0xdfff = Left (fault "encode" "surrogate is not a Unicode scalar") - | otherwise = (c:) <$> checked (n-1) rest - --- Strict UTF-8, including overlong/surrogate/range controls. A nonfinal valid --- incomplete suffix is copied (not a slice retaining the input buffer). Invalid --- prefixes fail immediately, even when incomplete. No partial output on error. -decode :: Bool -> BS.ByteString -> Either R.Problem (String, BS.ByteString) -decode final bytes - | BS.length bytes > R.chunkLimit = Left (R.Problem "invalid-bound" "decode" "UTF-8 chunk exceeds byte limit" "none") - | otherwise = walk 0 [] - where - end = BS.length bytes - at i = fromIntegral (BS.index bytes i) :: Int - bad offset why = Left (fault "decode" (why ++ " at byte " ++ show offset)) - walk !offset chars - | offset == end = Right (reverse chars, BS.empty) - | otherwise = - let lead = at offset - emit code count = let !c = chr code in walk (offset + count) (c:chars) - sequenceOf count low high mask = - let valid i = let c = at (offset+i) - in c >= (if i == 1 then low else 0x80) - && c <= (if i == 1 then high else 0xbf) - available = min (count-1) (end-offset-1) - in if not (all valid [1..available]) then bad offset "invalid UTF-8 sequence" - else if end-offset < count then - if final then bad offset "incomplete UTF-8 sequence" - else Right (reverse chars, BS.copy (BS.drop offset bytes)) - else emit (foldl' (\code i -> code*64 + (at (offset+i) .&. 0x3f)) - (lead .&. mask) [1..count-1]) count - in if lead <= 0x7f then emit lead 1 - else if lead >= 0xc2 && lead <= 0xdf then sequenceOf 2 0x80 0xbf 0x1f - else if lead == 0xe0 then sequenceOf 3 0xa0 0xbf 0x0f - else if lead == 0xed then sequenceOf 3 0x80 0x9f 0x0f - else if lead >= 0xe1 && lead <= 0xef then sequenceOf 3 0x80 0xbf 0x0f - else if lead == 0xf0 then sequenceOf 4 0x90 0xbf 0x07 - else if lead == 0xf4 then sequenceOf 4 0x80 0x8f 0x07 - else if lead >= 0xf1 && lead <= 0xf3 then sequenceOf 4 0x80 0xbf 0x07 - else bad offset "invalid UTF-8 leading byte" diff --git a/src/IOV2/Wire.hs b/src/IOV2/Wire.hs @@ -1,45 +0,0 @@ --- Pure v2 wire decoding, shared by native and browser embeddings. No resource, --- scheduler, host effects or facility resolution imports. Payloads are borrowed --- lazy trees: decoding never traverses arguments, callbacks or continuations. -module IOV2.Wire (Action(..), canonicalNat, decodeAction) where - -import Runtime.Tree (T(..)) - -canonicalNat :: Int -> T -> Maybe Integer -canonicalNat _ Leaf = Just 0 -canonicalNat 0 _ = Nothing -canonicalNat _ (Fork Leaf Leaf) = Nothing -canonicalNat depth (Fork Leaf rest) = (2 *) <$> canonicalNat (depth-1) rest -canonicalNat depth (Fork (Stem Leaf) rest) = (\n -> 2*n+1) <$> canonicalNat (depth-1) rest -canonicalNat _ _ = Nothing - --- Names are nonempty ASCII identifiers, at most 64 characters. Do not inspect --- an excess element or tail, or demand arguments while resolving a header. -name :: T -> Maybe String -name value = do - text <- walk (64 :: Int) value - case text of c:_ | c >= 'a' && c <= 'z' -> Just text; _ -> Nothing - where - walk _ Leaf = Just [] - walk 0 _ = Nothing - walk n (Fork c rest) = do - code <- canonicalNat 7 c - if (code >= 97 && code <= 122) || (code >= 48 && code <= 57) || code == 45 - then (toEnum (fromInteger code) :) <$> walk (n-1) rest - else Nothing - walk _ _ = Nothing - -data Action = Core Integer T | Request String Integer String T - -decodeAction :: T -> Either String Action -decodeAction (Fork tag payload) = case canonicalNat 8 tag of - Just n | n <= 9 -> Right (Core n payload) - Just 10 -> case payload of - Fork group (Fork revision (Fork operation arguments)) -> - case (name group, canonicalNat 16 revision, name operation) of - (Just g, Just v, Just op) | v > 0 -> Right (Request g v op arguments) - _ -> Left "invalid facility header" - _ -> Left "invalid facility header" - _ -> Left "unknown computation constructor" -decodeAction _ = Left "invalid computation shape" - diff --git a/src/InteractionInspection.hs b/src/InteractionInspection.hs @@ -113,7 +113,7 @@ natural nodes = go 0 1 0 W.BNFork _ _ -> Left "number exceeds 64-bit inspection limit" _ -> Left "invalid number shape" --- V2 and envelope numbers are canonical and independently bounded. +-- HostIO and envelope numbers are canonical and independently bounded. canonicalNatural :: Nodes -> Int -> Word32 -> Either Text Integer canonicalNatural nodes = go where @@ -187,7 +187,7 @@ decode nodes limits view idx = case view of of Left err -> boundedFailure err Right (1, action) -> Decoded "tricuIO v1" "decoded" [("action",Action,action)] - Right (2, action) -> Decoded "tricuIO v2" "decoded" [("action",ActionV2,action)] + Right (2, action) -> Decoded "tricuIO" "decoded" [("action",ActionV2,action)] Right (n, _) -> Decoded ("unsupported tricuIO version " <> tshow n) "unknown" [] Action -> case pair nodes idx of Left _ -> failure "expected action pair (tag, payload)" @@ -216,7 +216,7 @@ decode nodes limits view idx = case view of Right (g,v,op,args) -> Decoded ("Facility " <> g <> "@" <> tshow v <> "/" <> op) "decoded" [("arguments",Opaque "arguments (not validated; backend availability not inferred)",args)] Right n -> case lookup n coreV2 of - Nothing -> Decoded ("unknown IO v2 constructor " <> tshow n) "unknown" [] + Nothing -> Decoded ("unknown IO constructor " <> tshow n) "unknown" [] Just (label,fields) -> case unpackFields payload fields of Left err -> failure (label <> ": " <> err) Right children -> Decoded label "decoded" children diff --git a/src/Main.hs b/src/Main.hs @@ -743,7 +743,7 @@ runEval :: TricuArgs -> IO () runEval opts = do when (evalUnsafeIo opts || evalAllowReadAll opts || evalAllowWriteAll opts || not (null (evalAllowRead opts)) || not (null (evalAllowWrite opts))) $ - die "IO v1 permission grants are not accepted; IO v2 uses OS process authority" + die "IO v1 permission grants are not accepted; IO uses OS process authority" let files = evalFiles opts form = evalFormat opts out = evalOutput opts @@ -901,7 +901,7 @@ runStored :: TricuArgs -> IO () runStored opts = do when (storedUnsafeIo opts || not (null (storedAllowRead opts)) || not (null (storedAllowWrite opts))) $ - die "IO v1 permission grants are not accepted; IO v2 uses OS process authority" + die "IO v1 permission grants are not accepted; IO uses OS process authority" when (storedNoResult opts && (not (storedIo opts) || storedResult opts)) $ die "--no-result requires --io and cannot be combined with --result" unless (storedRepeat opts >= 1 && storedRepeat opts <= 1000) $ diff --git a/test/IOBoundaryTests.hs b/test/IOBoundaryTests.hs @@ -1,5 +1,5 @@ -- The old path-grant sandbox is retired, not silently relaxed. Current resource --- ownership/lease/cancellation controls live in the IOV2 suites; OS permissions +-- ownership/lease/cancellation controls live in the IO suites; OS permissions -- and real TCP/filesystem protocols also have independent CLI acceptance. module IOBoundaryTests (ioBoundaryTests) where diff --git a/test/IOByteStreamTests.hs b/test/IOByteStreamTests.hs @@ -0,0 +1,150 @@ +{-# LANGUAGE ScopedTypeVariables #-} +module IOByteStreamTests (ioByteStreamTests) where + +import qualified IO.ByteStream as B +import qualified IO.Resources as R +import Control.Concurrent +import Control.Exception +import Control.Monad (forM_) +import Crypto.Hash (hash, Digest, SHA256) +import qualified Data.ByteString as BS +import Data.IORef +import System.Directory (listDirectory) +import System.FilePath ((</>)) +import System.IO (hClose) +import System.IO.Temp (withSystemTempDirectory) +import Test.Tasty +import Test.Tasty.HUnit + +sha :: BS.ByteString -> String +sha bytes = show (hash bytes :: Digest SHA256) +category wanted value = case value of + Left p -> R.category p @?= wanted + Right _ -> assertFailure ("expected " ++ wanted) +reader runtime bytes = do + remaining <- newIORef bytes + Right token <- R.acquire runtime R.rootScope (pure (R.Reader (\n -> + atomicModifyIORef' remaining (\rest -> (BS.drop n rest, BS.take n rest))) (pure ()))) + pure token + +ioByteStreamTests = localOption (mkTimeout 30000000) $ testGroup "IO native byte streams" + [ testCase "authenticated binary snapshots and partial writes across chunk boundaries" $ + withSystemTempDirectory "tricu-bytes" $ \directory -> forM_ [0,1,4095,4096,4097,917828,1048576] $ \size -> do + let bytes = BS.pack (take size (cycle [0..255])) + runtime <- R.newRuntime + input <- reader runtime bytes + Right (snapshot,count) <- B.snapshot runtime R.rootScope input directory (toInteger size) (sha bytes) + count @?= toInteger size + listDirectory directory >>= (@?= []) + chunks <- newIORef [] + Right output <- R.acquire runtime R.rootScope (pure (R.Writer (\chunk -> do + let part = BS.take 997 chunk + modifyIORef' chunks (part:) + pure (BS.length part)) (pure ()))) + B.transfer runtime R.rootScope snapshot output count >>= (@?= Right ()) + BS.concat . reverse <$> readIORef chunks >>= (@?= bytes) + R.readSome runtime R.rootScope snapshot 1 >>= (@?= Right BS.empty) + R.seekFile runtime R.rootScope snapshot 0 >>= (@?= Right ()) + R.fileSize runtime R.rootScope snapshot >>= (@?= Right count) + R.writeSome runtime R.rootScope snapshot "x" >>= category "wrong-kind" + R.resizeFile runtime R.rootScope snapshot 0 >>= category "unsupported" + R.closeScope runtime R.rootScope >>= (@?= []) + , testCase "digest and limit failures remove the spool; EOF at the limit is required" $ + withSystemTempDirectory "tricu-bytes" $ \directory -> do + runtime <- R.newRuntime + forM_ [("abc",3,sha "xyz","integrity-failed"),("abcd",3,sha "abc","invalid-bound"),("",0,sha "x","integrity-failed")] $ \(bytes,limit,digest,wanted) -> do + input <- reader runtime bytes + counts <- R.liveCounts runtime + B.snapshot runtime R.rootScope input directory limit digest >>= category wanted + R.liveCounts runtime >>= (@?= counts) + listDirectory directory >>= (@?= []) + input <- reader runtime "abc" + B.snapshot runtime R.rootScope input directory (-1) (sha "abc") >>= category "invalid-bound" + B.snapshot runtime R.rootScope input directory 3 (replicate 64 'Z') >>= category "invalid-argument" + R.readSome runtime R.rootScope input 3 >>= (@?= Right "abc") + R.closeScope runtime R.rootScope >>= (@?= []) + , testCase "source mutation after snapshot cannot change transferred bytes" $ + withSystemTempDirectory "tricu-bytes" $ \directory -> do + runtime <- R.newRuntime + -- Injectable source bytes model in-place changes, independent of host + -- Handle locking of a simultaneously opened file. + source <- newIORef "original" + cursor <- newIORef 0 + Right input <- R.acquire runtime R.rootScope (pure (R.Reader (\n -> do + pos <- readIORef cursor + bytes <- BS.take n . BS.drop pos <$> readIORef source + modifyIORef' cursor (+ BS.length bytes) + pure bytes) (pure ()))) + Right (snapshot,count) <- B.snapshot runtime R.rootScope input directory 100 (sha "original") + writeIORef source "modified" + Right output <- R.openWriter runtime R.rootScope (directory </> "output") + B.transfer runtime R.rootScope snapshot output count >>= (@?= Right ()) + R.closeScope runtime R.rootScope >>= (@?= []) + BS.readFile (directory </> "output") >>= (@?= "original") + , testCase "mutation during copying fails authentication" $ + withSystemTempDirectory "tricu-bytes" $ \directory -> do + runtime <- R.newRuntime + let original = BS.replicate 8192 65 + calls <- newIORef (0 :: Int) + Right input <- R.acquire runtime R.rootScope (pure (R.Reader (\n -> do + call <- atomicModifyIORef' calls (\x -> (x+1,x)) + pure (if call < 2 then BS.replicate n (if call == 0 then 65 else 66) else BS.empty)) (pure ()))) + B.snapshot runtime R.rootScope input directory 8192 (sha original) >>= category "integrity-failed" + listDirectory directory >>= (@?= []) + R.closeScope runtime R.rootScope >>= (@?= []) + , testCase "snapshot cancellation keeps scoped cleanup and releases source lease" $ + withSystemTempDirectory "tricu-bytes" $ \directory -> do + runtime <- R.newRuntime + entered <- newEmptyMVar; block <- newEmptyMVar; done <- newEmptyMVar + Right input <- R.acquire runtime R.rootScope (pure (R.Reader (\_ -> putMVar entered () >> takeMVar block) (pure ()))) + released <- newIORef (0 :: Int) + worker <- forkIO $ (B.snapshotWith (\h -> modifyIORef' released (+1) >> hClose h) + runtime R.rootScope input directory 4096 (sha "") >> pure ()) + `catch` (\(_ :: AsyncException) -> pure ()) `finally` putMVar done () + takeMVar entered + listDirectory directory >>= (@?= []) + killThread worker; takeMVar done + R.closeScope runtime R.rootScope >>= (@?= []) + readIORef released >>= (@?= 1) + , testCase "cleanup failure preserves primary integrity diagnostic" $ + withSystemTempDirectory "tricu-bytes" $ \directory -> do + runtime <- R.newRuntime + input <- reader runtime "bad" + result <- B.snapshotWith (\h -> hClose h >> ioError (userError "injected close")) + runtime R.rootScope input directory 3 (sha "yes") + category "integrity-failed" result + case result of Left p -> assertBool "cleanup recorded" (length (R.detail p) > length ("SHA-256 mismatch" :: String)); _ -> pure () + R.closeScope runtime R.rootScope >>= (@?= []) + , testCase "transfer failure and cancellation release leases without retrying" $ + withSystemTempDirectory "tricu-bytes" $ \directory -> do + runtime <- R.newRuntime + input <- reader runtime "abcdef" + writes <- newIORef (0 :: Int) + Right broken <- R.acquire runtime R.rootScope (pure (R.Writer (\_ -> do + modifyIORef' writes (+1) + ioError (userError "peer disconnected")) (pure ()))) + B.transfer runtime R.rootScope input broken 6 >>= category "io-error" + readIORef writes >>= (@?= 1) + R.closeResource runtime R.rootScope broken >>= (@?= Right ()) + second <- reader runtime "abcdef" + entered <- newEmptyMVar; block <- newEmptyMVar; done <- newEmptyMVar + Right blocked <- R.acquire runtime R.rootScope (pure (R.Writer (\_ -> putMVar entered () >> takeMVar block) (pure ()))) + worker <- forkIO $ (B.transfer runtime R.rootScope second blocked 6 >> pure ()) + `catch` (\(_ :: AsyncException) -> pure ()) `finally` putMVar done () + takeMVar entered + R.readSome runtime R.rootScope second 1 >>= category "busy" + R.writeSome runtime R.rootScope blocked "x" >>= category "busy" + killThread worker; takeMVar done + R.closeResource runtime R.rootScope second >>= (@?= Right ()) + R.closeResource runtime R.rootScope blocked >>= (@?= Right ()) + R.closeScope runtime R.rootScope >>= (@?= []) + , testCase "transfer rejects short sources and wrong-kind targets" $ do + runtime <- R.newRuntime + input <- reader runtime "x" + B.transfer runtime R.rootScope input input 1 >>= category "wrong-kind" + chunks <- newIORef [] + Right output <- R.acquire runtime R.rootScope (pure (R.Writer (\bytes -> modifyIORef' chunks (bytes:) >> pure (BS.length bytes)) (pure ()))) + B.transfer runtime R.rootScope input output 2 >>= category "invalid-data" + readIORef chunks >>= (@?= ["x"]) + R.closeScope runtime R.rootScope >>= (@?= []) + ] diff --git a/test/IOClockTests.hs b/test/IOClockTests.hs @@ -0,0 +1,68 @@ +{-# LANGUAGE ScopedTypeVariables #-} +module IOClockTests (ioClockTests) where + +import qualified IO.Clock as C +import qualified IO.Driver as V +import qualified IO.Protocol as P +import Runtime (T(..), ofNumber, ofString, ofList) +import Control.Concurrent +import Control.Exception +import Control.Monad +import Data.IORef +import System.Timeout (timeout) +import Test.Tasty +import Test.Tasty.HUnit + +request g op payload = Fork (ofNumber 10) (Fork (ofString g) (Fork (ofNumber 1) (Fork (ofString op) payload))) +ok value = Fork (Stem Leaf) (Fork value Leaf) +completed action value = V.runDetailed action >>= \result -> case result of + V.Completed actual -> actual @?= value + _ -> assertFailure "expected completion" + +ioClockTests = localOption (mkTimeout 10000000) $ testGroup "IO clocks/scheduler" + [ testCase "monotonic result is canonical unsigned Word64, not a wall epoch" $ do + before <- C.monotonicNanoseconds + V.runDetailed (request "clock" "monotonic" Leaf) >>= \outcome -> case outcome of + V.Completed (Fork (Stem Leaf) (Fork value Leaf)) -> case V.canonicalNat 64 value of + Just value -> do + after <- C.monotonicNanoseconds + assertBool "sample order" (before <= value && value <= after) + _ -> assertFailure "noncanonical clock value" + _ -> assertFailure "clock failed" + , testCase "zero sleep and yield return ordinary unit Results" $ do + completed (request "clock" "sleep" Leaf) (ok Leaf) + completed (request "scheduler" "yield" Leaf) (ok Leaf) + , testCase "relative sleep does not acknowledge before the requested duration" $ do + before <- C.monotonicNanoseconds + completed (request "clock" "sleep" (ofNumber 20)) (ok Leaf) + after <- C.monotonicNanoseconds + assertBool "early sleep completion" (after-before >= 20000000) + , testCase "wait chunks preserve milliseconds and never overflow host microseconds" $ do + calls <- newIORef [] + C.sleepWith (\n -> modifyIORef' calls (++[n])) 2501 + readIORef calls >>= (@?= [1000000,1000000,501000]) + writeIORef calls [] + C.sleepWith (\n -> modifyIORef' calls (++[n])) 0 + readIORef calls >>= (@?= []) + result <- try (C.sleepWith (\n -> writeIORef calls [n] >> ioError (userError "stop")) 9223372036854775807) :: IO (Either IOException ()) + case result of Left _ -> pure (); _ -> assertFailure "waiter failure ignored" + readIORef calls >>= (@?= [1000000]) + , testCase "maximum relative wait is interruptible rather than an overflowing short wait" $ do + entered <- newEmptyMVar + done <- newEmptyMVar + tid <- forkIO $ (try (putMVar entered () >> C.sleepMilliseconds 9223372036854775807) :: IO (Either SomeException ())) >>= putMVar done + takeMVar entered + threadDelay 20000 + tryReadMVar done >>= \result -> case result of Nothing -> pure (); _ -> assertFailure "wait completed early" + killThread tid + timeout 2000000 (takeMVar done) >>= \result -> case result of + Just (Left _) -> pure () + _ -> assertFailure "wait ignored interruption" + , testCase "invalid clock/scheduler arguments fail ordinarily without truncation" $ + forM_ [("clock","sleep",ofNumber 9223372036854775808), ("clock","sleep",Fork Leaf Leaf), + ("clock","sleep",Stem Leaf), ("clock","monotonic",ofNumber 1), ("scheduler","yield",ofNumber 1)] $ \(g,op,args) -> + completed (request g op args) (Fork Leaf (Fork (ofList (map ofString ["invalid-argument",g++"/"++op,"","none"])) Leaf)) + , testCase "clock inspection is inert even with an undefined duration" $ + P.inspectAction (request "clock" "sleep" (error "duration demanded")) @?= + Right (ofList [ofString "facility",ofString "clock",ofNumber 1,ofString "sleep",ofString "available"]) + ] diff --git a/test/IOContextTests.hs b/test/IOContextTests.hs @@ -0,0 +1,39 @@ +module IOContextTests (ioContextTests) where +import qualified IO.Context as C +import qualified IO.Driver as V +import qualified IO.Resources as R +import qualified IO.Protocol as P +import Runtime (T(..),ofString,ofList,ofNumber) +import System.Directory (getCurrentDirectory) +import System.Environment (lookupEnv) +import Test.Tasty +import Test.Tasty.HUnit + +request op payload = Fork (ofNumber 10) (Fork (ofString "process") (Fork (ofNumber 1) (Fork (ofString op) payload))) +ok value = Fork (Stem Leaf) (Fork value Leaf) +check action expected = V.runDetailed action >>= \outcome -> case outcome of + V.Completed value -> value @?= expected + _ -> assertFailure (show outcome) +ioContextTests = testGroup "IO read-only process context" + [ testCase "cwd is the actual host directory" $ do + expected <- getCurrentDirectory + check (request "working-directory" Leaf) (ok (ofString expected)) + , testCase "environment query is distinct from lexical application configuration" $ do + expected <- lookupEnv "PATH" + actual <- C.environmentVariable "PATH" + actual @?= traverse (C.boundedText "environment-variable") expected + , testCase "invalid names and payloads reject ordinarily" $ do + mapM_ (\name -> C.environmentVariable name >>= \result -> case result of + Left _ -> pure (); _ -> assertFailure "invalid name accepted") ["","A=B","A\0B",replicate 4097 'x'] + let bad op = Fork Leaf (Fork (ofList (map ofString ["invalid-argument","process/"++op,"","none"])) Leaf) + check (request "working-directory" (Stem Leaf)) (bad "working-directory") + check (request "environment-variable" (Stem Leaf)) (bad "environment-variable") + , testCase "result bounds and strict scalars do not truncate or normalize" $ do + C.boundedText "test" "λ\n\xFEFF" @?= Right "λ\n\xFEFF" + C.boundedText "test" (replicate 4096 'x') @?= Right (replicate 4096 'x') + mapM_ (\value -> case C.boundedText "test" value of + Left _ -> pure (); _ -> assertFailure "invalid context text accepted") [replicate 4097 'x',"\xD800","\0"] + , testCase "context inspection does not execute or demand payloads" $ do + P.inspectAction (request "working-directory" (error "demanded")) @?= + Right (ofList [ofString "facility",ofString "process",ofNumber 1,ofString "working-directory",ofString "available"]) + ] diff --git a/test/IOFilesystemTests.hs b/test/IOFilesystemTests.hs @@ -0,0 +1,158 @@ +{-# LANGUAGE ScopedTypeVariables #-} +module IOFilesystemTests (ioFilesystemTests) where +import qualified IO.Filesystem as F +import qualified IO.Resources as R +import qualified System.Posix.Files as P +import qualified System.Posix.Files.ByteString as PB +import qualified Data.ByteString.Char8 as B +import System.IO.Temp (withSystemTempDirectory) +import Control.Concurrent +import Control.Concurrent.STM +import Control.Exception +import Control.Monad +import Data.IORef +import Data.List (sort) +import System.Timeout (timeout) +import Test.Tasty +import Test.Tasty.HUnit + +right :: Show e => Either e a -> IO a +right = either (\e -> assertFailure (show e) >> error "assertion") pure +category name result = case result of + Left p -> R.category p @?= name + _ -> assertFailure ("expected "++name) +fixture body = withSystemTempDirectory "v2-fs" $ \dir -> + bracket R.newRuntime (\r -> void (R.closeScope r 0)) (\r -> body r dir) +allNames r scope token = do + name <- R.nextDirectory r scope token >>= right + case name of Nothing -> pure []; Just value -> (value:) <$> allNames r scope token +wait done = timeout 3000000 (readMVar done) >>= maybe (assertFailure "timeout" >> error "timeout") pure + +ioFilesystemTests = localOption (mkTimeout 30000000) $ testGroup "IO filesystem" + [ testCase "stat/lstat distinguish symlinks, dangling links, directory and file" $ fixture $ \_ dir -> do + B.writeFile (dir++"/file") (B.pack "abc") + P.createSymbolicLink "file" (dir++"/link") + P.createSymbolicLink "absent" (dir++"/dangling") + F.statPath True (dir++"/file") >>= (@?= Right (F.Regular 3)) + F.statPath True dir >>= (@?= Right F.Directory) + F.statPath True (dir++"/link") >>= (@?= Right (F.Regular 3)) + F.statPath False (dir++"/link") >>= (@?= Right F.Symlink) + F.statPath False (dir++"/dangling") >>= (@?= Right F.Symlink) + F.statPath True (dir++"/dangling") >>= category "not-found" + F.statPath True "" >>= category "invalid-argument" + , testCase "strict directory creation and nonrecursive removal retain contents" $ fixture $ \_ dir -> do + F.createDirectory (dir++"/new") >>= (@?= Right ()) + F.createDirectory (dir++"/new") >>= category "already-exists" + B.writeFile (dir++"/new/item") (B.pack "keep") + F.removeDirectory (dir++"/new") >>= \reply -> case reply of + Left p -> R.progress p @?= "unknown" + _ -> assertFailure "removed nonempty directory" + B.readFile (dir++"/new/item") >>= (@?= B.pack "keep") + F.removeFile (dir++"/new/item") >>= (@?= Right ()) + F.removeDirectory (dir++"/new") >>= (@?= Right ()) + F.statPath False (dir++"/new") >>= category "not-found" + , testCase "rename replaces names; unlink removes link rather than its target" $ fixture $ \_ dir -> do + B.writeFile (dir++"/old") (B.pack "new") + B.writeFile (dir++"/target") (B.pack "prior") + F.renamePath (dir++"/old") (dir++"/target") >>= (@?= Right ()) + F.statPath True (dir++"/old") >>= category "not-found" + B.readFile (dir++"/target") >>= (@?= B.pack "new") + P.createSymbolicLink "target" (dir++"/link") + F.removeFile (dir++"/link") >>= (@?= Right ()) + B.readFile (dir++"/target") >>= (@?= B.pack "new") + F.removeFile dir >>= category "io-error" + , testCase "directory is bounded per entry, excludes dots, has sticky EOF and normal lifetime" $ fixture $ \r dir -> do + let names = ["a", "λ", "space name", "line\nname"] + forM_ names $ \name -> B.writeFile (dir++"/"++name) B.empty + d <- F.openDirectory r 0 dir >>= right + allNames r 0 d >>= (\actual -> sort actual @?= sort names) + R.nextDirectory r 0 d >>= (@?= Right Nothing) + R.nextDirectory r 0 d >>= (@?= Right Nothing) + R.readSome r 0 d 1 >>= category "wrong-kind" + R.fileSize r 0 d >>= category "wrong-kind" + R.closeResource r 0 d >>= (@?= Right ()) + R.nextDirectory r 0 d >>= category "stale-handle" + R.liveCounts r >>= (@?= (1,3)) + , testCase "directory cursor follows opened inode across rename and transfer" $ fixture $ \r dir -> do + F.createDirectory (dir++"/old") >>= right + B.writeFile (dir++"/old/item") B.empty + d <- F.openDirectory r 0 (dir++"/old") >>= right + F.renamePath (dir++"/old") (dir++"/new") >>= right + F.createDirectory (dir++"/old") >>= right + (scope, tokens) <- atomically (R.reserveChildSTM r 0 [d]) >>= right + case tokens of + [fresh] -> allNames r scope fresh >>= (@?= ["item"]) + _ -> assertFailure "transfer failed" + R.nextDirectory r 0 d >>= category "stale-handle" + R.closeScope r scope >>= (@?= []) + R.liveCounts r >>= (@?= (1,3)) + , testCase "IO failure poisons cursor without replay and close runs once" $ fixture $ \r _ -> do + calls <- newIORef (0::Int); closed <- newIORef (0::Int) + d <- R.acquire r 0 (F.directoryBackend + (modifyIORef' calls (+1) >> ioError (userError "read fault")) + (modifyIORef' closed (+1))) >>= right + R.nextDirectory r 0 d >>= category "io-error" + R.nextDirectory r 0 d >>= category "invalid-state" + readIORef calls >>= (@?= 1) + R.closeResource r 0 d >>= (@?= Right ()) + R.closeScope r 0 >>= (@?= []) + readIORef closed >>= (@?= 1) + , testCase "failed raw close reports uncertainty and is never retried" $ fixture $ \r _ -> do + calls <- newIORef (0::Int) + d <- R.acquire r 0 (F.directoryBackend (pure "") + (modifyIORef' calls (+1) >> ioError (userError "close fault"))) >>= right + R.closeResource r 0 d >>= \reply -> case reply of + Left p -> R.progress p @?= "unknown" + _ -> assertFailure "close fault disappeared" + R.closeResource r 0 d >>= category "stale-handle" + R.closeScope r 0 >>= (@?= []) + readIORef calls >>= (@?= 1) + , testCase "async close settlement cannot reuse a possibly freed directory pointer" $ fixture $ \r _ -> do + calls <- newIORef (0::Int) + d <- R.acquire r 0 (F.directoryBackend (pure "") + (modifyIORef' calls (+1) >> throwIO ThreadKilled)) >>= right + result <- try (R.closeResource r 0 d) + case result of + Left (_ :: AsyncException) -> pure () + Right _ -> assertFailure "async close swallowed" + R.closeScope r 0 >>= (@?= []) + readIORef calls >>= (@?= 1) + , testCase "invalid Unicode, components and excess names poison without demanding excess character" $ fixture $ \r _ -> do + forM_ [("\xdcff", "invalid-encoding"),("a/b", "invalid-name"), + (replicate 4096 'a' ++ [error "excess name character forced"], "invalid-name")] $ \(name, expected) -> do + d <- R.acquire r 0 (F.directoryBackend (pure name) (pure ())) >>= right + R.nextDirectory r 0 d >>= category expected + R.nextDirectory r 0 d >>= category "invalid-state" + R.closeResource r 0 d >>= (@?= Right ()) + , testCase "actual non-Unicode directory entry fails rather than emitting replacement text" $ fixture $ \r dir -> do + PB.createSymbolicLink (B.pack "absent") (B.pack (dir++"/") <> B.singleton '\xff') + d <- F.openDirectory r 0 dir >>= right + R.nextDirectory r 0 d >>= category "invalid-encoding" + R.nextDirectory r 0 d >>= category "invalid-state" + , testCase "cancelled cursor holds one lease, poisons, and releases before settlement" $ fixture $ \r _ -> do + entered <- newEmptyMVar; gate <- newEmptyMVar; done <- newEmptyMVar + closed <- newIORef False + d <- R.acquire r 0 (F.directoryBackend (putMVar entered () >> takeMVar gate) + (writeIORef closed True)) >>= right + bracket (forkFinally (R.nextDirectory r 0 d) (putMVar done)) + (\worker -> killThread worker >> void (wait done)) $ \worker -> do + takeMVar entered + R.nextDirectory r 0 d >>= category "busy" + atomically (R.reserveChildSTM r 0 [d]) >>= category "busy" + readIORef closed >>= (@?= False) + killThread worker + wait done >>= \outcome -> case outcome of + Left _ -> pure () + Right _ -> assertFailure "cancelled operation resumed" + R.nextDirectory r 0 d >>= category "invalid-state" + R.closeResource r 0 d >>= (@?= Right ()) + readIORef closed >>= (@?= True) + , testCase "EOF never re-enters host and repeated dot entries have bounded scanning" $ fixture $ \r _ -> do + count <- newIORef (0::Int) + d <- R.acquire r 0 (F.directoryBackend (modifyIORef' count (+1) >> pure "") (pure ())) >>= right + replicateM_ 10 (R.nextDirectory r 0 d >>= (@?= Right Nothing)) + readIORef count >>= (@?= 1) + looping <- R.acquire r 0 (F.directoryBackend (pure ".") (pure ())) >>= right + R.nextDirectory r 0 looping >>= category "invalid-state" + R.nextDirectory r 0 looping >>= category "invalid-state" + ] diff --git a/test/IONetworkTests.hs b/test/IONetworkTests.hs @@ -0,0 +1,220 @@ +{-# LANGUAGE ScopedTypeVariables #-} +module IONetworkTests (ioNetworkTests) where +import qualified IO.Resources as R +import qualified IO.Network as TCP +import qualified IO.Tasks as S +import qualified IO.Driver as V +import Runtime (T(..), ofString, ofNumber, ofList) +import qualified Network.Socket as N +import qualified Network.Socket.ByteString as NB +import qualified Data.ByteString as B +import Control.Concurrent +import Control.Concurrent.STM +import Control.Exception +import Control.Monad +import Data.IORef +import GHC.Conc (threadStatus, ThreadStatus(..)) +import System.Timeout (timeout) +import Test.Tasty +import Test.Tasty.HUnit + +right :: Show e => Either e a -> IO a +right = either (\e -> assertFailure (show e) >> error "assertion") pure +category name result = case result of + Left p -> R.category p @?= name + _ -> assertFailure ("expected "++name) +withRuntime = bracket R.newRuntime (\r -> void (R.closeScope r 0)) +wait done = timeout 3000000 (readMVar done) >>= maybe (assertFailure "timeout" >> error "timeout") pure +fork action = do + done <- newEmptyMVar + worker <- forkFinally action (putMVar done) + pure (worker,done) +stop (worker,done) = killThread worker >> void (wait done) +-- Probing a live socket with an exclusive address query can WIN the lease and +-- make the intended reader/acceptor fail busy. Observe the actual worker instead. +waitBlocked worker = do + ready <- timeout 3000000 loop + case ready of Just () -> pure (); Nothing -> assertFailure "worker never blocked" + where loop = threadStatus worker >>= \status -> case status of + ThreadBlocked _ -> pure () + ThreadFinished -> assertFailure "worker finished instead of blocking" + ThreadDied -> assertFailure "worker died instead of blocking" + _ -> yield >> loop +loop4 = R.IPv4 (127,0,0,1) 0 +loop6 = R.IPv6 (0,0,0,0,0,0,0,1) 0 0 +connected r target = do + listener <- TCP.listenTCP r 0 target 16 >>= right + address <- R.socketAddress r 0 listener False >>= right + client <- TCP.connectTCP r 0 address >>= right + server <- R.acceptConnection r 0 listener 0 >>= right + pure (listener,client,server,address) +write r token bytes = do + n <- R.writeSome r 0 token bytes >>= right + if n == B.length bytes then pure () else write r token (B.drop n bytes) +readAll r scope token = do + bytes <- R.readSome r scope token 4096 >>= right + if B.null bytes then pure B.empty else B.append bytes <$> readAll r scope token + +ioNetworkTests = localOption (mkTimeout 30000000) $ testGroup "IO TCP" + [ testCase "real IPv4/IPv6 byte streams, addresses, EOF and independent write half-close" $ withRuntime $ \r -> + forM_ [loop4,loop6] $ \target -> do + (listener,client,server,address) <- connected r target + R.socketAddress r 0 server False >>= (@?= Right address) + R.socketAddress r 0 client True >>= (@?= Right address) + R.socketAddress r 0 listener True >>= category "wrong-kind" + R.readSome r 0 listener 1 >>= category "wrong-kind" + R.filePosition r 0 client >>= category "wrong-kind" + let bytes = B.pack ([0..255]++[255,0,13,10]) + write r client bytes + R.shutdownWrite r 0 client >>= (@?= Right ()) + R.shutdownWrite r 0 client >>= (@?= Right ()) + R.writeSome r 0 client (B.singleton 7) >>= category "invalid-state" + readAll r 0 server >>= (@?= bytes) + R.readSome r 0 server 1 >>= (@?= Right B.empty) + write r server (B.reverse bytes) + R.shutdownWrite r 0 server >>= (@?= Right ()) + readAll r 0 client >>= (@?= B.reverse bytes) + mapM_ (\token -> R.closeResource r 0 token >>= (@?= Right ())) [listener,client,server] + R.liveCounts r >>= (@?= (1,3)) + , testCase "duplex leases coexist, release only their own bit, and close waits for both" $ withRuntime $ \r -> do + enteredRead <- newEmptyMVar; enteredWrite <- newEmptyMVar + readGate <- newEmptyMVar; writeGate <- newEmptyMVar + released <- newIORef False + let methods = R.DuplexOps + (\_ -> putMVar enteredRead () >> takeMVar readGate >> pure B.empty) + (\bytes -> putMVar enteredWrite () >> takeMVar writeGate >> pure (B.length bytes)) + (pure ()) (pure loop4) (pure loop4) (writeIORef released True) + token <- R.acquire r 0 (pure (R.Duplex methods)) >>= right + bracket (fork (R.readSome r 0 token 1)) stop $ \(_,readDone) -> + bracket (fork (R.writeSome r 0 token (B.singleton 1))) stop $ \(_,writeDone) -> do + wait enteredRead; wait enteredWrite + R.readSome r 0 token 1 >>= category "busy" + R.writeSome r 0 token (B.singleton 2) >>= category "busy" + R.shutdownWrite r 0 token >>= category "busy" + R.socketAddress r 0 token False >>= category "busy" + atomically (R.reserveChildSTM r 0 [token]) >>= category "busy" + putMVar readGate () + wait readDone >>= right >>= (@?= Right B.empty) + -- Finishing a read must not clear the simultaneous write lease. + atomically (R.reserveChildSTM r 0 [token]) >>= category "busy" + R.writeSome r 0 token (B.singleton 3) >>= category "busy" + bracket (fork (R.closeResource r 0 token)) stop $ \(_,closed) -> do + threadDelay 20000 + isEmptyMVar closed >>= (@?= True) + readIORef released >>= (@?= False) + putMVar writeGate () + wait writeDone >>= right >>= (@?= Right 1) + wait closed >>= right >>= (@?= Right ()) + readIORef released >>= (@?= True) + , testCase "real pending receive still permits send and shutdown-write" $ withRuntime $ \r -> do + (_,client,server,_) <- connected r loop4 + bracket (fork (R.readSome r 0 server 1)) stop $ \(worker,done) -> do + waitBlocked worker + write r server (B.singleton 255) + R.shutdownWrite r 0 server >>= (@?= Right ()) + readAll r 0 client >>= (@?= B.singleton 255) + write r client (B.singleton 7) + wait done >>= right >>= (@?= Right (B.singleton 7)) + , testCase "cancelled receive poisons only that direction and drains its lease" $ withRuntime $ \r -> do + (_,client,server,_) <- connected r loop4 + bracket (fork (R.readSome r 0 server 1)) stop $ \worker@(_,done) -> do + waitBlocked (fst worker) + stop worker + wait done >>= \outcome -> case outcome of Left _ -> pure (); _ -> assertFailure "receive resumed" + R.readSome r 0 server 1 >>= category "invalid-state" + write r server (B.singleton 8) + R.shutdownWrite r 0 server >>= (@?= Right ()) + readAll r 0 client >>= (@?= B.singleton 8) + , testCase "accepted connection transfers atomically and child settlement sends EOF" $ withRuntime $ \r -> do + (_,client,server,_) <- connected r loop4 + (scope,tokens) <- atomically (R.reserveChildSTM r 0 [server]) >>= right + R.readSome r 0 server 1 >>= category "stale-handle" + write r client (B.pack [0,255]) + R.shutdownWrite r 0 client >>= (@?= Right ()) + case tokens of + [fresh] -> readAll r scope fresh >>= (@?= B.pack [0,255]) + _ -> assertFailure "missing transferred connection" + R.closeScope r scope >>= (@?= []) + R.readSome r 0 client 1 >>= (@?= Right B.empty) + , testCase "accept cancellation releases admission and keeps listener reusable" $ withRuntime $ \r -> do + listener <- TCP.listenTCP r 0 loop4 1 >>= right + address <- R.socketAddress r 0 listener False >>= right + scope <- R.childScope r 0 >>= right + bracket (fork (R.acceptConnection r scope listener scope)) stop $ \worker -> do + waitBlocked (fst worker) + R.acceptConnection r 0 listener 0 >>= category "busy" + stop worker + R.closeScope r scope >>= (@?= []) + client <- TCP.connectTCP r 0 address >>= right + accepted <- R.acceptConnection r 0 listener 0 >>= right + R.shutdownWrite r 0 client >>= (@?= Right ()) + R.readSome r 0 accepted 1 >>= (@?= Right B.empty) + , testCase "closed owner admission cannot accept or leak a new connection" $ withRuntime $ \r -> do + (listener,_,_,_) <- connected r loop4 + child <- R.childScope r 0 >>= right + atomically (R.beginCloseScopeSTM r 0) + R.acceptConnection r child listener 0 >>= category "closed-scope" + R.liveCounts r >>= (@?= (2,6)) + , testCase "bind/refused errors and bounds remain ordinary with no leaked entries" $ withRuntime $ \r -> do + listener <- TCP.listenTCP r 0 loop4 1 >>= right + address <- R.socketAddress r 0 listener False >>= right + TCP.listenTCP r 0 address 1 >>= category "address-in-use" + TCP.listenTCP r 0 loop4 0 >>= category "invalid-bound" + TCP.listenTCP r 0 loop4 4097 >>= category "invalid-bound" + TCP.connectTCP r 0 loop4 >>= category "invalid-bound" + R.liveCounts r >>= (@?= (1,4)) + R.closeResource r 0 listener >>= (@?= Right ()) + TCP.connectTCP r 0 address >>= category "connection-refused" + R.liveCounts r >>= (@?= (1,3)) + , testCase "structured cancellation settles a real accept before scope release" $ withRuntime $ \r -> do + scheduler <- S.newScheduler r + root <- S.rootGroup scheduler + outcome <- S.runGroup root $ do + listener <- TCP.listenTCP r 0 loop4 1 >>= right + started <- newEmptyMVar + child <- S.spawn root True True [] (\group _ -> do + myThreadId >>= putMVar started + _ <- R.acceptConnection r (S.scopeOf group) listener (S.scopeOf group) >>= right + pure (S.Completed Leaf)) >>= right + takeMVar started >>= waitBlocked + S.cancel root child (ofString "stop") >>= (@?= Right ()) + S.join root child >>= right >>= \result -> case result of + S.Cancelled reason [] -> reason @?= ofString "stop" + _ -> assertFailure "cancellation lost" + R.liveCounts r >>= (@?= (1,4)) + pure (S.Completed Leaf) + case outcome of S.Completed Leaf -> pure (); _ -> assertFailure ("parent failed: " ++ show outcome) + R.liveCounts r >>= (@?= (0,0)) + , testCase "wire bounds reject noncanonical/excess address fields before acquisition" $ do + let v4 bytes port = ofList [ofString "ipv4",bytes,port] + v6 words scope = ofList [ofString "ipv6",words,scope,ofNumber 80] + zero4 = ofList (map ofNumber [127,0,0,1]) + zero6 = ofList (replicate 8 Leaf) + excess = foldr Fork (Fork (error "excess octet forced") (error "excess tail forced")) + (replicate 4 Leaf) + addresses = [v4 (ofList (map ofNumber [127,0,0,256])) (ofNumber 80), + v4 zero4 (ofNumber 65536), v4 excess (ofNumber 80), + v4 (ofList [Fork Leaf Leaf,Leaf,Leaf,Leaf]) (ofNumber 80), + v6 zero6 (ofNumber 4294967296), + v6 (ofList (ofNumber 65536:replicate 7 Leaf)) Leaf] + forM_ addresses $ \address -> withRuntime $ \r -> do + identity <- R.runtimeIdentity r + let owner = ofList [ofString "io.v2.scope",ofNumber identity,Leaf] + action = Fork (ofNumber 10) (Fork (ofString "tcp") (Fork (ofNumber 1) + (Fork (ofString "connect") (Fork owner address)))) + V.runInRuntime r action >>= \outcome -> case outcome of + V.Completed value -> value @?= Fork Leaf (Fork + (ofList (map ofString ["invalid-argument","tcp/connect","","none"])) Leaf) + _ -> assertFailure "bad address executed" + , testCase "blocked real write cancellation poisons writer; read direction survives" $ withRuntime $ \r -> + bracket (N.socketPair N.AF_UNIX N.Stream N.defaultProtocol) + (\(a,b) -> N.close a >> N.close b) $ \(socket,peer) -> do + N.setSocketOption socket N.SendBuffer 4096 + token <- R.acquire r 0 (TCP.duplexBackend socket) >>= right + bracket (fork (forever (write r token (B.replicate 4096 7)))) stop $ \worker -> do + waitBlocked (fst worker) + stop worker + R.writeSome r 0 token (B.singleton 1) >>= category "invalid-state" + NB.sendAll peer (B.singleton 255) + R.readSome r 0 token 1 >>= (@?= Right (B.singleton 255)) + ] diff --git a/test/IOPositionTests.hs b/test/IOPositionTests.hs @@ -0,0 +1,198 @@ +{-# LANGUAGE ScopedTypeVariables #-} +module IOPositionTests (ioPositionTests) where +import qualified IO.Resources as R +import qualified IO.Driver as V +import qualified Data.ByteString as BS +import Control.Concurrent +import Control.Concurrent.STM +import Control.Exception +import Control.Monad +import Data.IORef +import Foreign.Ptr (castPtr) +import qualified System.Posix.IO as P +import System.Directory (renameFile) +import System.IO.Temp (withSystemTempDirectory) +import System.Timeout (timeout) +import Runtime (T(..), ofNumber, ofString, ofList) +import Test.Tasty +import Test.Tasty.HUnit + +right :: Show e => Either e a -> IO a +right = either (\e -> assertFailure (show e) >> error "assertion") pure +category name result = case result of + Left p -> R.category p @?= name + _ -> assertFailure ("expected " ++ name) +fixture use = withSystemTempDirectory "v2-position" $ \dir -> + bracket R.newRuntime (\r -> void (R.closeScope r 0)) (\r -> use r (dir++"/file")) +write r token bytes = R.writeSome r 0 token (BS.pack bytes) >>= (@?= Right (length bytes)) +wait done = timeout 3000000 (readMVar done) >>= maybe (assertFailure "timeout" >> error "timeout") pure +fake = R.FileOps Nothing Nothing Nothing Nothing (pure 0) Nothing Nothing (pure ()) + +ioPositionTests = localOption (mkTimeout 30000000) $ testGroup "IO positioned files" + [ testCase "update preserves bytes; logical cursor switches read/write correctly" $ fixture $ \r path -> do + BS.writeFile path (BS.pack [0..9]) + f <- R.openUpdate r 0 path >>= right + R.fileSize r 0 f >>= (@?= Right 10) + R.readSome r 0 f 2 >>= (@?= Right (BS.pack [0,1])) + R.filePosition r 0 f >>= (@?= Right 2) + write r f [255,254] + R.filePosition r 0 f >>= (@?= Right 4) + R.readSome r 0 f 2 >>= (@?= Right (BS.pack [4,5])) + R.seekFile r 0 f 0 >>= (@?= Right ()) + R.readSome r 0 f 10 >>= (@?= Right (BS.pack [0,1,255,254,4,5,6,7,8,9])) + R.closeResource r 0 f >>= (@?= Right ()) + BS.readFile path >>= (@?= BS.pack [0,1,255,254,4,5,6,7,8,9]) + , testCase "resize discards read ahead, preserves cursor and zero-fills growth" $ fixture $ \r path -> do + BS.writeFile path (BS.pack [0..99]) + f <- R.openUpdate r 0 path >>= right + R.readSome r 0 f 3 >>= (@?= Right (BS.pack [0,1,2])) + R.resizeFile r 0 f 2 >>= (@?= Right ()) + R.filePosition r 0 f >>= (@?= Right 3) + R.readSome r 0 f 1 >>= (@?= Right BS.empty) + R.resizeFile r 0 f 8 >>= (@?= Right ()) + R.filePosition r 0 f >>= (@?= Right 3) + R.readSome r 0 f 5 >>= (@?= Right (BS.replicate 5 0)) + R.seekFile r 0 f 12 >>= (@?= Right ()) + write r f [255] + R.fileSize r 0 f >>= (@?= Right 13) + R.closeResource r 0 f >>= (@?= Right ()) + BS.readFile path >>= (@?= BS.pack ([0,1]++replicate 10 0++[255])) + , testCase "new update/append files and wide sparse offsets" $ fixture $ \r path -> do + f <- R.openUpdate r 0 path >>= right + R.fileSize r 0 f >>= (@?= Right 0) + R.seekFile r 0 f 4294967296 >>= (@?= Right ()) + write r f [255] + R.filePosition r 0 f >>= (@?= Right 4294967297) + R.fileSize r 0 f >>= (@?= Right 4294967297) + R.seekFile r 0 f 4294967294 >>= (@?= Right ()) + R.readSome r 0 f 3 >>= (@?= Right (BS.pack [0,0,255])) + R.resizeFile r 0 f 0 >>= (@?= Right ()) + R.closeResource r 0 f >>= (@?= Right ()) + BS.readFile path >>= (@?= BS.empty) + appended <- R.openAppender r 0 (path++"-append") >>= right + write r appended [0,255] + R.closeResource r 0 appended >>= (@?= Right ()) + BS.readFile (path++"-append") >>= (@?= BS.pack [0,255]) + , testCase "append is at each write's EOF, not an open-time seek" $ fixture $ \r path -> do + BS.writeFile path (BS.pack [1,2]) + f <- R.openAppender r 0 path >>= right + -- Separate POSIX fd models an external writer, bypassing GHC Handle locks. + bracket (P.openFd path P.WriteOnly (P.defaultFileFlags {P.append=True})) P.closeFd $ \fd -> + BS.useAsCStringLen (BS.pack [3,4]) $ \(ptr,n) -> + P.fdWriteBuf fd (castPtr ptr) (fromIntegral n) >>= (@?= 2) + write r f [5,255] + R.fileSize r 0 f >>= (@?= Right 6) + R.seekFile r 0 f 0 >>= category "unsupported" + R.filePosition r 0 f >>= category "unsupported" + R.resizeFile r 0 f 0 >>= category "unsupported" + R.readSome r 0 f 1 >>= category "wrong-kind" + R.closeResource r 0 f >>= (@?= Right ()) + BS.readFile path >>= (@?= BS.pack [1,2,3,4,5,255]) + , testCase "reader mode and unrelated resource kinds cannot mutate files" $ fixture $ \r path -> do + BS.writeFile path (BS.pack [1,2,3]) + f <- R.openReader r 0 path >>= right + R.resizeFile r 0 f 0 >>= category "unsupported" + R.writeSome r 0 f (BS.singleton 4) >>= category "wrong-kind" + R.seekFile r 0 f 2 >>= (@?= Right ()) + R.readSome r 0 f 2 >>= (@?= Right (BS.singleton 3)) + standard <- R.standardStream r 0 R.StandardInput >>= right + R.fileSize r 0 standard >>= category "wrong-kind" + R.seekFile r 0 standard 0 >>= category "wrong-kind" + R.closeResource r 0 f >>= (@?= Right ()) + R.filePosition r 0 f >>= category "stale-handle" + BS.readFile path >>= (@?= BS.pack [1,2,3]) + , testCase "host Handle locking rejects conflicting opens without changing content" $ fixture $ \r path -> do + BS.writeFile path (BS.pack [1,2,3]) + reader <- R.openReader r 0 path >>= right + R.openUpdate r 0 path >>= category "io-error" + R.openAppender r 0 path >>= category "io-error" + R.readSome r 0 reader 3 >>= (@?= Right (BS.pack [1,2,3])) + R.closeResource r 0 reader >>= (@?= Right ()) + BS.readFile path >>= (@?= BS.pack [1,2,3]) + , testCase "size is queried on the opened file, not its replaced pathname" $ fixture $ \r path -> do + BS.writeFile path (BS.pack [1,2,3]) + f <- R.openReader r 0 path >>= right + renameFile path (path++"-old") + BS.writeFile path (BS.replicate 10 7) + R.fileSize r 0 f >>= (@?= Right 3) + R.readSome r 0 f 4 >>= (@?= Right (BS.pack [1,2,3])) + , testCase "position transfers with ownership and old aliases become stale" $ fixture $ \r path -> do + BS.writeFile path (BS.pack [0..9]) + f <- R.openUpdate r 0 path >>= right + R.seekFile r 0 f 7 >>= (@?= Right ()) + (child, moved) <- atomically (R.reserveChildSTM r 0 [f]) >>= right + case moved of + [fresh] -> do + R.filePosition r child fresh >>= (@?= Right 7) + R.seekFile r child fresh 3 >>= (@?= Right ()) + R.readSome r child fresh 1 >>= (@?= Right (BS.singleton 3)) + _ -> assertFailure "bad transfer" + R.fileSize r 0 f >>= category "stale-handle" + R.closeScope r child >>= (@?= []) + R.liveCounts r >>= (@?= (1,3)) + , testCase "bounds rejected before effects; bad backend positions cannot escape" $ fixture $ \r _ -> do + calls <- newIORef (0::Int) + let methods = fake {R.fileSeekAction=Just (\_ -> modifyIORef' calls (+1)), + R.fileResizeAction=Just (\_ -> modifyIORef' calls (+1)), + R.filePositionAction=Just (pure (-1)), R.fileSizeAction=pure (-1)} + f <- R.acquire r 0 (pure (R.File methods)) >>= right + forM_ [-1,9223372036854775808] $ \n -> do + R.seekFile r 0 f n >>= category "invalid-bound" + R.resizeFile r 0 f n >>= category "invalid-bound" + readIORef calls >>= (@?= 0) + R.filePosition r 0 f >>= category "unsupported" + R.fileSize r 0 f >>= category "unsupported" + R.seekFile r 0 f 9223372036854775807 >>= (@?= Right ()) + readIORef calls >>= (@?= 1) + , testCase "position IO shares read/write lease; cancellation drains it before release" $ fixture $ \r _ -> do + entered <- newEmptyMVar; blocked <- newEmptyMVar; finished <- newEmptyMVar + released <- newIORef False + let methods = fake {R.fileSeekAction=Just (\_ -> putMVar entered () >> takeMVar blocked), + R.fileReadAction=Just (const (pure BS.empty)), + R.fileWriteAction=Just (pure . BS.length), + R.fileReleaseAction=writeIORef released True} + f <- R.acquire r 0 (pure (R.File methods)) >>= right + bracket (forkFinally (R.seekFile r 0 f 2) (putMVar finished)) + (\worker -> killThread worker >> void (wait finished)) $ \worker -> do + takeMVar entered + R.fileSize r 0 f >>= category "busy" + R.readSome r 0 f 1 >>= category "busy" + R.writeSome r 0 f (BS.singleton 1) >>= category "busy" + atomically (R.reserveChildSTM r 0 [f]) >>= category "busy" + readIORef released >>= (@?= False) + killThread worker + wait finished >>= \outcome -> case outcome of + Left _ -> pure () + Right _ -> assertFailure "cancelled seek resumed" + R.closeResource r 0 f >>= (@?= Right ()) + readIORef released >>= (@?= True) + , testCase "backend mutation errors report unknown state, not no progress" $ fixture $ \r _ -> do + let methods = fake {R.fileSeekAction=Just (\_ -> ioError (userError "seek fault")), + R.fileResizeAction=Just (\_ -> ioError (userError "resize fault"))} + f <- R.acquire r 0 (pure (R.File methods)) >>= right + forM_ [R.seekFile r 0 f 0,R.resizeFile r 0 f 0] $ \action -> action >>= \reply -> case reply of + Left problem -> R.progress problem @?= "unknown" + Right _ -> assertFailure "fault succeeded" + , testCase "wire preserves the entire signed-64 positive offset range" $ fixture $ \r _ -> do + observed <- newIORef (-1) + f@(R.Token identity key) <- R.acquire r 0 (pure (R.File + (fake {R.fileSeekAction=Just (writeIORef observed)}))) >>= right + let token = ofList [ofString "io.v2.resource",ofNumber identity,ofNumber key] + action = Fork (ofNumber 10) (Fork (ofString "file") (Fork (ofNumber 1) + (Fork (ofString "seek") (Fork token (ofNumber 9223372036854775807))))) + V.runInRuntime r action >>= \outcome -> case outcome of + V.Completed value -> value @?= Fork (Stem Leaf) (Fork Leaf Leaf) + _ -> assertFailure "wide offset failed" + readIORef observed >>= (@?= 9223372036854775807) + R.filePosition r 0 f >>= category "stale-handle" + , testCase "wire rejects overlong offset without demanding excess bits" $ fixture $ \r _ -> do + identity <- R.runtimeIdentity r + let token = ofList [ofString "io.v2.resource",ofNumber identity,ofNumber 1] + offset = foldr Fork (Stem (error "excess bits demanded")) (replicate 63 Leaf) + action = Fork (ofNumber 10) (Fork (ofString "file") (Fork (ofNumber 1) + (Fork (ofString "seek") (Fork token offset)))) + V.runInRuntime r action >>= \outcome -> case outcome of + V.Completed (Fork Leaf (Fork problem Leaf)) -> problem @?= + ofList (map ofString ["invalid-argument","file/seek","","none"]) + _ -> assertFailure "invalid offset executed" + ] diff --git a/test/IOProcessTests.hs b/test/IOProcessTests.hs @@ -0,0 +1,181 @@ +{-# LANGUAGE ScopedTypeVariables #-} +module IOProcessTests (ioProcessTests) where +import qualified IO.Process as P +import qualified IO.Driver as V +import qualified IO.Protocol as Protocol +import qualified IO.Resources as R +import qualified IO.Tasks as S +import Runtime (T(..), ofString, ofList, ofNumber) +import Control.Concurrent +import Control.Exception +import Data.IORef +import Control.Monad +import qualified Data.ByteString as BS +import qualified Data.ByteString.Char8 as B +import System.Directory (findExecutable) +import System.Posix.Signals (signalProcess, nullSignal) +import System.IO.Error (isDoesNotExistError) +import Test.Tasty +import Test.Tasty.HUnit + +right (Right x) = pure x +right (Left e) = assertFailure (show e) >> error "failed" +withGroup action = do + runtime <- R.newRuntime + scheduler <- S.newScheduler runtime + group <- S.rootGroup scheduler + outcome <- S.runGroup group (action runtime group) + R.liveCounts runtime >>= (@?= (0,0)) + S.liveCounts scheduler >>= (@?= (0,0,0)) + pure outcome +success (S.Completed Leaf) = pure () +success other = assertFailure (show other) +program name = findExecutable name >>= maybe (assertFailure ("missing test executable: " ++ name) >> error "missing") pure +readAll runtime owner token = do + chunk <- R.readSome runtime owner token 4096 >>= right + if BS.null chunk then pure BS.empty else BS.append chunk <$> readAll runtime owner token +joined group task wanted = S.join group task >>= \value -> case value of + Right (S.Completed actual) -> actual @?= wanted + other -> assertFailure (show other) +exit n = ofList [ofString "exit",ofNumber n] + +ioProcessTests = localOption (mkTimeout 15000000) $ testGroup "IO direct child processes" + [ testCase "argv is literal; environment and cwd are explicit; nonzero exit is data" $ do + shell <- program "sh" + withGroup (\runtime group -> do + (task,[input,output,errors]) <- P.launch runtime group + (P.Spec shell ["-c","printf '%s|%s|%s' \"$1\" \"$VALUE\" \"$PWD\"; exit 7", "test", "$(not-a-command); *"] (Just "/") (Just [("VALUE","literal")]) 20) >>= right + R.closeResource runtime (S.scopeOf group) input >>= right + readAll runtime (S.scopeOf group) output >>= (@?= B.pack "$(not-a-command); *|literal|/") + readAll runtime (S.scopeOf group) errors >>= (@?= BS.empty) + joined group task (exit 7) + pure (S.Completed Leaf)) >>= success + , testCase "stream a binary pipeline while draining concurrently" $ do + cat <- program "cat" + let bytes = BS.pack (take 262144 (cycle [0..255])) + withGroup (\runtime group -> do + (task,[input,output,errors]) <- P.launch runtime group (P.Spec cat [] Nothing Nothing 20) >>= right + drained <- newEmptyMVar + _ <- forkIO $ (try (readAll runtime (S.scopeOf group) output) :: IO (Either SomeException BS.ByteString)) >>= putMVar drained + let write remaining | BS.null remaining = pure () + | otherwise = do + let (chunk,rest) = BS.splitAt 4096 remaining + R.writeSome runtime (S.scopeOf group) input chunk >>= right >>= (@?= BS.length chunk) + write rest + write bytes + R.closeResource runtime (S.scopeOf group) input >>= right + takeMVar drained >>= either throwIO (@?= bytes) + readAll runtime (S.scopeOf group) errors >>= (@?= BS.empty) + joined group task (exit 0) + pure (S.Completed Leaf)) >>= success + , testCase "missing executable is an ordinary error, settled without leaked tickets" $ do + withGroup (\runtime group -> do + result <- P.launch runtime group (P.Spec "/definitely-absent-tricu-process" [] Nothing Nothing 0) + case result of Left _ -> pure (); _ -> assertFailure "launch succeeded" + pure (S.Completed Leaf)) >>= success + , testCase "normal scope exit joins an unconsumed process result" $ do + shell <- program "sh" + withGroup (\runtime group -> do + -- Output fits the pipe, so implicit joining does not require buffering it. + _ <- P.launch runtime group (P.Spec shell ["-c","printf done; exit 3"] Nothing Nothing 20) >>= right + pure (S.Completed Leaf)) >>= success + , testCase "cancellation while scope joins escalates TERM-ignoring child and reaps" $ do + shell <- program "sh" + withGroup (\runtime root -> do + entered <- newEmptyMVar + parent <- S.spawn root True True [] (\group _ -> do + (_,[_input,output,_errors]) <- P.launch runtime group + (P.Spec shell ["-c","trap '' TERM; printf '%s\\n' \"$$\"; while :; do :; done"] Nothing Nothing 20) >>= right + bytes <- R.readSome runtime (S.scopeOf group) output 4096 >>= right + let pid = read (B.unpack bytes) + putMVar entered (pid,S.scopeOf group) + pure (S.Completed Leaf)) >>= right + (pid,owner) <- takeMVar entered + -- Observe closed admission, rather than guessing the phase from a delay. + -- A still-live process child means this scope cannot yet be Releasing. + let joining = do + admitted <- R.acquire runtime owner (pure (R.Internal (pure ()))) + case admitted of + Left problem -> R.category problem @?= "closed-scope" + Right token -> R.closeInternalResource runtime owner token >>= right >> threadDelay 1000 >> joining + joining + signalProcess nullSignal pid + S.cancel root parent (ofString "stop") >>= right + result <- S.join root parent + case result of Right (S.Cancelled reason []) -> reason @?= ofString "stop"; _ -> assertFailure (show result) + alive <- try (signalProcess nullSignal pid) + case alive of Left (e :: IOException) -> assertBool "reaped PID" (isDoesNotExistError e); Right _ -> assertFailure "child remains live or zombie" + pure (S.Completed Leaf)) >>= success + , testCase "failure cancels a blocked pipe child and preserves the primary" $ do + cat <- program "cat" + outcome <- withGroup (\runtime group -> do + _ <- P.launch runtime group (P.Spec cat [] Nothing Nothing 20) >>= right + pure (S.Failed (ofString "primary") [])) + case outcome of S.Failed problem [] -> problem @?= ofString "primary"; _ -> assertFailure (show outcome) + , testCase "repeated implicit joins release pipes, private handles, scopes and tickets" $ do + shell <- program "sh" + withGroup (\runtime group -> do + baseline <- R.liveCounts runtime + replicateM_ 30 $ do + S.nested group (\child -> do + _ <- P.launch runtime child (P.Spec shell ["-c","exit 0"] Nothing Nothing 20) >>= right + pure (S.Completed Leaf)) >>= success + R.liveCounts runtime >>= (@?= baseline) + pure (S.Completed Leaf)) >>= success + , testCase "failed pipe writes poison subsequent attempts instead of replay" $ do + shell <- program "sh" + withGroup (\runtime group -> do + (task,[input,_,_]) <- P.launch runtime group (P.Spec shell ["-c","exit 0"] Nothing Nothing 0) >>= right + joined group task (exit 0) + first <- R.writeSome runtime (S.scopeOf group) input (B.pack "x") + case first of Left _ -> pure (); _ -> assertFailure "write to exited child succeeded" + second <- R.writeSome runtime (S.scopeOf group) input (B.pack "x") + case second of Left problem -> R.category problem @?= "invalid-state"; _ -> assertFailure "failed pipe was reused" + -- hClose may report the buffered failed write; keep that cleanup failure + -- visible instead of insisting that the parent must complete successfully. + pure (S.Completed Leaf)) >>= \outcome -> case outcome of + S.Completed Leaf -> pure () + S.Failed _ _ -> pure () + _ -> assertFailure (show outcome) + , testCase "termination failures still attempt escalation and reaping exactly once" $ do + forM_ [[],["term"],["kill"],["term","kill","reap"]] $ \failures -> do + calls <- newIORef [] + let action name = modifyIORef' calls (++[name]) >> when (name `elem` failures) (ioError (userError name)) + ops = P.TerminationOps (pure False) (action "term") (action "kill") (action "reap") (pure 0) (action "pause") + outcome <- try (P.terminateWith ops 0) :: IO (Either SomeException ()) + readIORef calls >>= (@?= ["term","kill","reap"]) + case (failures,outcome) of ([],Right ()) -> pure (); (_:_,Left _) -> pure (); _ -> assertFailure "wrong cleanup result" + , testCase "relative grace survives monotonic Word64 wrap" $ do + samples <- newIORef [18446744073709551606,5,1000000] + calls <- newIORef [] + let now = atomicModifyIORef' samples (\(x:xs) -> (xs,x)) + action name = modifyIORef' calls (++[name]) + ops = P.TerminationOps (pure False) (action "term") (action "kill") (action "reap") now (action "pause") + P.terminateWith ops 1 + readIORef calls >>= (@?= ["term","pause","kill","reap"]) + , testCase "raw pipe cleanup failure does not skip process settlement" $ do + settled <- newIORef False + outcome <- try (P.cleanupAll [ioError (userError "pipe-close"), writeIORef settled True]) :: IO (Either SomeException ()) + readIORef settled >>= (@?= True) + case outcome of Left _ -> pure (); _ -> assertFailure "cleanup error lost" + , testCase "wire bounds reject excess arguments/text without demanding the extra element" $ do + let args n tail = foldr Fork tail (replicate n (ofString "a")) + text n tail = foldr Fork tail (replicate n (ofNumber 97)) + excess = Fork (error "excess element demanded") Leaf + inherit = ofList [ofString "inherit"] + payload arguments = ofList [ofString "/not-executed",arguments,inherit,inherit,ofNumber 0] + request arguments = Fork (ofNumber 10) (Fork (ofString "process") (Fork (ofNumber 1) (Fork (ofString "spawn") arguments))) + expected = Fork Leaf (Fork (ofList (map ofString ["invalid-argument","process/spawn","","none"])) Leaf) + forM_ [payload (args 256 excess),payload (ofList [text 4096 excess])] $ \value -> + V.runDetailed (request value) >>= \outcome -> case outcome of + S.Completed actual -> actual @?= expected + _ -> assertFailure (show outcome) + Protocol.inspectAction (request (error "inspection demanded process spec")) @?= + Right (ofList [ofString "facility",ofString "process",ofNumber 1,ofString "spawn",ofString "available"]) + , testCase "invalid launch values are rejected before admission" $ do + forM_ [P.Spec "" [] Nothing Nothing 0, P.Spec "x" [] Nothing Nothing (-1), + P.Spec "x" [] Nothing (Just [("X","1"),("X","2")]) 0, + P.Spec "x" ["a\0b"] Nothing Nothing 0, + P.Spec "x" (replicate 257 "a") Nothing Nothing 0] $ \spec -> + assertBool (show spec) (not (P.validSpec spec)) + ] diff --git a/test/IOProtocolTests.hs b/test/IOProtocolTests.hs @@ -0,0 +1,83 @@ +module IOProtocolTests (ioProtocolTests) where + +import qualified IO.Driver as V +import qualified IO.Protocol as P +import qualified IO.Resources as R +import Runtime (T(..), ofNumber, ofString, ofList) +import Test.Tasty +import Test.Tasty.HUnit + +request :: String -> Integer -> String -> T -> T +request g v op args = Fork (ofNumber 10) (Fork (ofString g) (Fork (ofNumber v) (Fork (ofString op) args))) +ok, err :: T -> T +ok x = Fork (Stem Leaf) (Fork x Leaf) +err x = Fork Leaf (Fork x Leaf) +problem category operation detail = ofList (map ofString [category,operation,detail,"none"]) +completed action expected = V.runDetailed action >>= \outcome -> case outcome of + V.Completed value -> value @?= expected + _ -> assertFailure "expected completion" + +ioProtocolTests :: TestTree +ioProtocolTests = testGroup "IO protocol" + [ testCase "capability schema reports exact implemented groups and limits" $ do + let strings = ofList . map ofString + row key value = ofList [ofString key,value] + groups = [("runtime",["capabilities","inspect"]),("standard",["input","output","error"]),("file",["open-reader","open-writer","open-appender","open-update","seek","position","size","resize","sync"]), + ("path",["stat","lstat","remove-file","rename"]),("directory",["create","remove","open","next","sync"]),("tcp",["listen","connect","accept","local-address","peer-address","shutdown-write"]),("resolver",["resolve"]),("indexed-tree",["read","hash","write","verify"]),("utf8",["encode","decode"]),("stream",["read","write","snapshot","transfer"]),("resource",["close"]),("stage",["open","seal","publish"]), + ("digest",["open","update","finish"]),("clock",["monotonic","sleep"]),("scheduler",["yield"]),("process",["spawn","working-directory","environment-variable"])] + expected = ofList [row "schema" (ofNumber 1), row "io-version" (ofNumber 2), + row "backend" (ofString "haskell-native"), row "authority" (ofString "os-process"), + row "core" (strings ["pure","bind","fail","scope","current-scope","spawn","join","discard","cancel","cancelled"]), + row "groups" (ofList [ofList [ofString g,ofNumber 1,strings ops] | (g,ops) <- groups]), + row "limits" (ofList [row "chunk-bytes" (ofNumber 4096), row "transfer-handles" (ofNumber 256), row "file-offset-max" (ofNumber 9223372036854775807), row "directory-name-scalars" (ofNumber 4096), row "tcp-backlog" (ofNumber 4096), row "resolver-host-characters" (ofNumber 253), row "resolver-addresses" (ofNumber 64), row "indexed-sections" (ofNumber 64), row "indexed-manifest-bytes" (ofNumber 65536), row "utf8-encode-scalars" (ofNumber 1024), row "sleep-milliseconds-max" (ofNumber 9223372036854775807), row "process-arguments" (ofNumber 256), row "process-environment-entries" (ofNumber 256), row "process-text-scalars" (ofNumber 4096), row "process-total-scalars" (ofNumber 65536), row "process-grace-milliseconds-max" (ofNumber 9223372036854775807)]), + row "tcp-address-families" (strings ["ipv4","ipv6"]), + row "digest-algorithms" (strings ["sha256"]), + row "restrictions" (strings ["action-boundary-cancellation","no-hard-deadlines","stage-same-filesystem","stage-no-copy-fallback", + "stage-private-namespace","publish-new-requires-hard-links","no-crash-recovery","borrowed-standard-streams","standard-stream-leases-per-run","file-single-cursor-lease","append-no-position-or-resize","file-host-handle-locking","directory-unsorted-non-snapshot","filesystem-host-encoding","directory-failure-poisons-cursor","tcp-numeric-addresses","tcp-ipv6-only-listeners","tcp-direction-leases","tcp-failure-poisons-direction","resolver-host-order-and-buffering","resolver-may-delay-cancellation","indexed-authenticated-snapshot","indexed-caller-frame-and-disk-limit","indexed-dag-resident","indexed-writer-preflight","indexed-budget-not-heap-quota","sync-host-filesystem-guarantees","utf8-strict-scalars","clock-host-monotonic-word64","sleep-may-run-late","yield-no-ordering-guarantee", "process-direct-child-only", "process-normal-exit-joins", "process-explicit-pipe-draining"])] + completed (request "runtime" 1 "capabilities" Leaf) (ok expected) + R.chunkLimit @?= 4096 + P.transferLimit @?= 256 + , testCase "unsupported requests do not inspect arguments and remain ordinary errors" $ + mapM_ (\(g,v,op) -> completed (request g v op (error "unsupported arguments demanded")) + (err (problem "unsupported" (g++"/"++op) ("group revision "++show v)))) + [("future-tcp",1,"connect"),("digest",2,"open"),("file",1,"absent"),("clock",2,"sleep"),("clock",1,"deadline"),("scheduler",1,"absent"),("process",2,"spawn"),("process",1,"shell")] + , testCase "recognized operation arguments fail ordinarily" $ + mapM_ (\(g,op) -> V.runDetailed (request g 1 op (if g == "utf8" && op == "encode" then Stem Leaf else Leaf)) >>= \outcome -> case outcome of + V.Completed (Fork Leaf (Fork (Fork _ (Fork operation _)) Leaf)) -> operation @?= ofString (g++"/"++op) + _ -> assertFailure (g++"/"++op)) + [("file","open-reader"),("file","open-writer"),("file","open-appender"),("file","open-update"),("file","seek"),("file","position"),("file","size"),("file","resize"),("file","sync"),("stream","read"),("stream","write"),("stream","snapshot"),("stream","transfer"), + ("path","stat"),("path","lstat"),("path","remove-file"),("path","rename"),("directory","create"),("directory","remove"),("directory","open"),("directory","next"),("directory","sync"),("tcp","listen"),("tcp","connect"),("tcp","accept"),("tcp","local-address"),("tcp","peer-address"),("tcp","shutdown-write"),("resolver","resolve"),("indexed-tree","read"),("indexed-tree","hash"),("indexed-tree","write"),("indexed-tree","verify"),("utf8","encode"),("utf8","decode"),("resource","close"),("stage","open"),("stage","seal"),("stage","publish"), + ("digest","open"),("digest","update"),("digest","finish"),("runtime","inspect")] + , testCase "inspection does not demand core payloads or callbacks" $ + mapM_ (\(tag,label) -> completed (request "runtime" 1 "inspect" (Fork (ofNumber tag) (error "payload demanded"))) + (ok (ofList [ofString "core",ofString label]))) + (zip [0..9] ["pure","bind","fail","scope","current-scope","spawn","join","discard","cancel","cancelled"]) + , testCase "inspection classifies headers without executing or validating arguments" $ do + completed (request "runtime" 1 "inspect" (request "file" 1 "open-writer" (error "writer executed"))) + (ok (ofList [ofString "facility",ofString "file",ofNumber 1,ofString "open-writer",ofString "available"])) + completed (request "runtime" 1 "inspect" (request "file" 2 "open-writer" (error "unsupported arguments demanded"))) + (ok (ofList [ofString "facility",ofString "file",ofNumber 2,ofString "open-writer",ofString "unsupported"])) + , testCase "retired numeric facility tags cannot accidentally execute" $ + mapM_ (\tag -> V.runDetailed (Fork (ofNumber tag) (error "retired payload demanded")) >>= \outcome -> case outcome of + V.Failed diagnostic [] -> diagnostic @?= problem "protocol-error" "core/decode" "unknown computation constructor" + _ -> assertFailure "retired tag accepted") [20,21,22,23,24,25,26,27,30,31,32,40,41,42,50,51,52,53,54,55,56,60,61,62,63,64,65,66,67,68,80,81,82,83,84,85,86,87,88,89,95,96,97,100,101,255] + , testCase "malformed headers are protocol failures rather than unsupported" $ do + let header g v op = Fork (ofNumber 10) (Fork g (Fork v (Fork op (error "malformed args demanded")))) + oversized = foldr Fork (Fork (error "excess name head demanded") (error "excess name tail demanded")) + (replicate 64 (ofNumber 97)) + inputs = [header (ofString "") (ofNumber 1) (ofString "read"), + header (ofString "File") (ofNumber 1) (ofString "read"), + header (ofString "file") Leaf (ofString "read"), + header (ofString "file") (Fork Leaf Leaf) (ofString "read"), + header oversized (ofNumber 1) (ofString "read"), + Fork (Fork Leaf Leaf) (error "noncanonical core payload demanded")] + mapM_ (\input -> V.runDetailed input >>= \outcome -> case outcome of + V.Failed _ [] -> pure () + _ -> assertFailure "malformed header accepted") inputs + , testCase "standard stream lookup validates unit without demanding its child" $ + mapM_ (\op -> completed (request "standard" 1 op (Stem (error "payload forced"))) + (err (problem "invalid-argument" ("standard/"++op) ""))) ["input","output","error"] + , testCase "runtime capability request validates unit without traversing payload" $ + completed (request "runtime" 1 "capabilities" (Stem (error "ignored child demanded"))) + (err (problem "invalid-argument" "runtime/capabilities" "")) + ] diff --git a/test/IOResolverTests.hs b/test/IOResolverTests.hs @@ -0,0 +1,71 @@ +{-# LANGUAGE ScopedTypeVariables #-} +module IOResolverTests (ioResolverTests) where +import qualified IO.Resolver as D +import qualified IO.Resources as R +import qualified IO.Driver as V +import Runtime (T(..), ofString, ofNumber, ofList) +import Control.Exception +import Control.Concurrent +import Control.Monad +import Data.IORef +import System.Timeout (timeout) +import Test.Tasty +import Test.Tasty.HUnit + +category name reply = case reply of Left p -> R.category p @?= name; _ -> assertFailure name +address = R.IPv4 (127,0,0,1) 1234 +ioResolverTests = localOption (mkTimeout 30000000) $ testGroup "IO host resolver" + [ testCase "invalid inputs fail before touching resolver" $ do + touched <- newIORef False + let query = writeIORef touched True >> pure [] + forM_ ["","bad name","bad/name","a\0b","λ",replicate 254 'a'] $ \host -> + D.resolveWith host 80 1 query >>= category "invalid-argument" + forM_ [(-1,1),(65536,1),(80,0),(80,65)] $ \(port,limit) -> + D.resolveWith "localhost" port limit query >>= category "invalid-bound" + readIORef touched >>= (@?= False) + , testCase "prefix is bounded without demanding an excess endpoint or tail" $ do + D.resolveWith "localhost" 1234 1 (pure [address]) >>= (@?= Right (D.Resolution [address] False)) + D.resolveWith "localhost" 1234 1 (pure (address:error "excess endpoint forced":error "excess tail forced")) + >>= (@?= Right (D.Resolution [address] True)) + D.resolveWith "localhost" 1234 1 (pure []) >>= (@?= Right (D.Resolution [] False)) + , testCase "host order and duplicates are preserved, not silently reprioritized" $ + D.resolveWith "localhost" 1234 3 (pure [address,address,R.IPv4 (127,0,0,2) 1234,address]) + >>= (@?= Right (D.Resolution [address,address,R.IPv4 (127,0,0,2) 1234] True)) + , testCase "resolver IO errors are ordinary and bounded; unrelated faults are not" $ do + D.resolveWith "localhost" 80 1 (ioError (userError (replicate 1000 'x'))) >>= \reply -> case reply of + Left p -> do + R.category p @?= "resolution-failed" + assertBool "bounded diagnostic" (length (R.detail p) <= 512) + R.progress p @?= "none" + _ -> assertFailure "failure swallowed" + actual <- try (D.resolveWith "localhost" 80 1 (throwIO (ErrorCall "unexpected"))) + case actual of Left (_ :: ErrorCall) -> pure (); Right _ -> assertFailure "fault converted to ordinary result" + , testCase "numeric/localhost host resolution preserves requested port and family" $ do + D.resolveTCP "127.0.0.1" D.IPv4Only 1234 64 >>= \reply -> case reply of + Right (D.Resolution values False) -> do + assertBool "numeric endpoint missing" (address `elem` values) + assertBool "unexpected endpoint" (all (== address) values) + _ -> assertFailure (show reply) + D.resolveTCP "localhost" D.AnyFamily 4321 1 >>= \reply -> case reply of + Right (D.Resolution [value] _) -> case value of + R.IPv4 _ port -> port @?= 4321 + R.IPv6 _ _ port -> port @?= 4321 + _ -> assertFailure (show reply) + , testCase "cancellation is not swallowed as a resolver error" $ do + entered <- newEmptyMVar; gate <- newEmptyMVar; done <- newEmptyMVar + worker <- forkFinally (D.resolveWith "localhost" 80 1 (putMVar entered () >> takeMVar gate)) (putMVar done) + takeMVar entered + killThread worker + timeout 3000000 (takeMVar done) >>= \reply -> case reply of + Just (Left _) -> pure () + _ -> assertFailure "cancellation became a result" + , testCase "wire host bound does not demand excess characters" $ do + let host = foldr Fork (Fork (error "excess character forced") (error "excess tail forced")) + (replicate 253 (ofNumber 97)) + action = Fork (ofNumber 10) (Fork (ofString "resolver") (Fork (ofNumber 1) + (Fork (ofString "resolve") (ofList [host,ofString "any",ofNumber 80,ofNumber 1])))) + V.runDetailed action >>= \outcome -> case outcome of + V.Completed value -> value @?= Fork Leaf (Fork + (ofList (map ofString ["invalid-argument","resolver/resolve","","none"])) Leaf) + _ -> assertFailure "invalid host executed" + ] diff --git a/test/IOStandardTests.hs b/test/IOStandardTests.hs @@ -0,0 +1,133 @@ +{-# LANGUAGE ScopedTypeVariables #-} +module IOStandardTests (ioStandardTests) where + +import qualified IO.Resources as R +import qualified Data.ByteString as BS +import Control.Concurrent +import Control.Concurrent.STM +import GHC.Conc (threadStatus, ThreadStatus(..)) +import Control.Exception +import Control.Monad +import System.IO +import System.IO.Temp +import System.Posix.IO (createPipe, fdToHandle) +import System.Timeout (timeout) +import Test.Tasty +import Test.Tasty.HUnit + +right :: Show e => Either e a -> IO a +right = either (\e -> assertFailure (show e) >> error "assertion") pure +category name result = case result of + Left p -> R.category p @?= name + _ -> assertFailure ("expected " ++ name) +pipe = bracket (createPipe >>= \(r,w) -> (,) <$> fdToHandle r <*> fdToHandle w) + (\(r,w) -> hClose r >> (hClose w `catch` (\(_ :: IOException) -> pure ()))) +withStreams use = withSystemTempDirectory "v2-standard" $ \dir -> + bracket (openBinaryFile (dir++"/in") ReadWriteMode) hClose $ \input -> + bracket (openBinaryFile (dir++"/out") ReadWriteMode) hClose $ \output -> + bracket (openBinaryFile (dir++"/err") ReadWriteMode) hClose $ \diagnostic -> + bracket (R.newRuntimeWithStreams input output diagnostic) (\r -> void (R.closeScope r 0)) + (\r -> use r input output diagnostic) + +ioStandardTests = localOption (mkTimeout 30000000) $ testGroup "IO inherited streams" + [ testCase "fixed aliases, root borrowing, no close or transfer, bounded entries" $ + withStreams $ \r _ output _ -> do + child <- R.childScope r 0 >>= right + token <- R.standardStream r child R.StandardOutput >>= right + replicateM_ 1000 (R.standardStream r 0 R.StandardOutput >>= (@?= Right token)) + R.liveCounts r >>= (@?= (2,3)) + R.closeResource r child token >>= category "borrowed-resource" + atomically (R.reserveChildSTM r 0 [token]) >>= category "borrowed-resource" + R.liveCounts r >>= (@?= (2,3)) + owned <- R.acquire r 0 (pure (R.Writer (pure . BS.length) (pure ()))) >>= right + atomically (R.reserveChildSTM r 0 [owned, token]) >>= category "borrowed-resource" + R.liveCounts r >>= (@?= (2,4)) + R.writeSome r 0 owned (BS.singleton 97) >>= (@?= Right 1) + R.closeResource r 0 owned >>= (@?= Right ()) + R.closeScope r child >>= (@?= []) + R.writeSome r 0 token (BS.pack [0,255,65]) >>= (@?= Right 3) + R.closeScope r 0 >>= (@?= []) + hIsClosed output >>= (@?= False) + hSeek output AbsoluteSeek 0 + BS.hGet output 3 >>= (@?= BS.pack [0,255,65]) + R.writeSome r 0 token (BS.singleton 1) >>= category "stale-handle" + R.liveCounts r >>= (@?= (0,0)) + , testCase "first lookup during root joining remains available to existing children" $ + withStreams $ \r _ _ _ -> do + child <- R.childScope r 0 >>= right + atomically (R.beginCloseScopeSTM r 0) + token <- R.standardStream r child R.StandardError >>= right + R.writeSome r child token (BS.singleton 255) >>= (@?= Right 1) + R.closeScope r child >>= (@?= []) + R.standardStream r child R.StandardError >>= category "out-of-scope" + , testCase "binary reads, explicit EOF, wrong kinds and cross-run references" $ + withStreams $ \r input _ _ -> do + BS.hPut input (BS.pack [0,255,13,10]); hSeek input AbsoluteSeek 0 + reader <- R.standardStream r 0 R.StandardInput >>= right + writer <- R.standardStream r 0 R.StandardOutput >>= right + R.readSome r 0 reader 4 >>= (@?= Right (BS.pack [0,255,13,10])) + R.readSome r 0 reader 1 >>= (@?= Right BS.empty) + R.readSome r 0 writer 1 >>= category "wrong-kind" + R.writeSome r 0 reader (BS.singleton 1) >>= category "wrong-kind" + withStreams $ \other _ _ _ -> R.readSome other 0 reader 1 >>= category "stale-handle" + , testCase "shared pending-read lease and cancellation leave inherited pipe open" $ + pipe $ \(input, producer) -> withStreams $ \_ _ output diagnostic -> + bracket (R.newRuntimeWithStreams input output diagnostic) (\r -> void (R.closeScope r 0)) $ \r -> do + reader <- R.standardStream r 0 R.StandardInput >>= right + started <- newEmptyMVar; finished <- newEmptyMVar + let work = putMVar started () >> void (R.readSome r 0 reader 1) + waitBlocked worker = do + status <- threadStatus worker + case status of ThreadBlocked _ -> pure (); _ -> yield >> waitBlocked worker + bracket (forkFinally work (\_ -> putMVar finished ())) + (\worker -> killThread worker >> void (timeout 3000000 (readMVar finished))) $ \worker -> do + takeMVar started + -- A second read cannot be used as the barrier: it could win the + -- race and block. Observe the actual worker's blocked state. + timeout 3000000 (waitBlocked worker) >>= (@?= Just ()) + alias <- R.standardStream r 0 R.StandardInput >>= right + R.readSome r 0 alias 1 >>= category "busy" + killThread worker + timeout 3000000 (readMVar finished) >>= (@?= Just ()) + BS.hPut producer (BS.singleton 97); hFlush producer + R.readSome r 0 reader 1 >>= (@?= Right (BS.singleton 97)) + R.closeScope r 0 >>= (@?= []) + hIsClosed input >>= (@?= False) + , testCase "cancelled blocked output poisons the borrowed writer without closing it" $ + pipe $ \(consumer, output) -> withStreams $ \_ input _ diagnostic -> + bracket (R.newRuntimeWithStreams input output diagnostic) (\r -> void (R.closeScope r 0)) $ \r -> do + writer <- R.standardStream r 0 R.StandardOutput >>= right + finished <- newEmptyMVar + let work = forever (R.writeSome r 0 writer (BS.replicate 4096 97) >>= right) + waitBlocked worker = do + status <- threadStatus worker + case status of ThreadBlocked _ -> pure (); _ -> yield >> waitBlocked worker + bracket (forkFinally work (\_ -> putMVar finished ())) + (\worker -> killThread worker >> void (timeout 3000000 (readMVar finished)) >> hClose consumer) $ \worker -> do + timeout 3000000 (waitBlocked worker) >>= (@?= Just ()) + alias <- R.standardStream r 0 R.StandardOutput >>= right + R.writeSome r 0 alias (BS.singleton 98) >>= category "busy" + killThread worker + timeout 3000000 (readMVar finished) >>= (@?= Just ()) + R.writeSome r 0 writer (BS.singleton 98) >>= category "invalid-state" + R.closeScope r 0 >>= (@?= []) + hIsClosed output >>= (@?= False) + , testCase "broken pipes report uncertain progress without closing inherited output" $ + pipe $ \(consumer, output) -> withStreams $ \_ input _ diagnostic -> do + hClose consumer + bracket (R.newRuntimeWithStreams input output diagnostic) (\r -> void (R.closeScope r 0)) $ \r -> do + writer <- R.standardStream r 0 R.StandardOutput >>= right + result <- R.writeSome r 0 writer (BS.singleton 97) + case result of + Left p -> do { R.category p @?= "io-error"; R.progress p @?= "unknown" } + Right _ -> assertFailure "broken pipe acknowledged" + R.writeSome r 0 writer (BS.singleton 98) >>= category "invalid-state" + hIsClosed output >>= (@?= False) + , testCase "output acknowledgement flushes without altering host buffering" $ + withStreams $ \r _ output _ -> do + hSetBuffering output (BlockBuffering (Just 8192)) + writer <- R.standardStream r 0 R.StandardOutput >>= right + R.writeSome r 0 writer (BS.pack [0,255]) >>= (@?= Right 2) + hGetBuffering output >>= (@?= BlockBuffering (Just 8192)) + hFileSize output >>= (@?= 2) + ] diff --git a/test/IOStorageTests.hs b/test/IOStorageTests.hs @@ -0,0 +1,455 @@ +{-# LANGUAGE ScopedTypeVariables #-} +module IOStorageTests (ioStorageTests) where + +import qualified IO.Resources as R +import qualified IO.Staging as S +import qualified IO.Digest as D +import Control.Concurrent +import Control.Concurrent.STM +import Control.Exception +import Control.Monad +import Data.IORef +import Foreign.C.Error (eXDEV, errnoToIOError) +import Data.List (sort, isInfixOf) +import qualified Data.ByteString.Char8 as BS +import System.Directory +import System.Posix.IO (closeFd) +import System.Posix.Files (getFdStatus, fileID, deviceID) +import System.IO.Temp (withSystemTempDirectory) +import Test.Tasty +import Test.Tasty.HUnit + +right :: Show e => Either e a -> IO a +right (Right value) = pure value +right (Left e) = assertFailure (show e) >> error "assertion failed" +category :: String -> Either R.Problem a -> Assertion +category name (Left p) = R.category p @?= name +category name _ = assertFailure ("expected " ++ name) +withRuntime :: (R.Runtime -> IO a) -> IO a +withRuntime = bracket R.newRuntime (\r -> void (R.closeScope r 0)) +write :: R.Runtime -> R.Token -> String -> IO () +write r token bytes = R.writeSome r 0 token (BS.pack bytes) >>= (@?= Right (length bytes)) +sealed :: S.StageOps -> R.Runtime -> FilePath -> String -> IO R.Token +sealed ops r dir bytes = do + token <- S.openStageWith ops r 0 dir >>= right + write r token bytes + R.sealStage r 0 token >>= (@?= Right ()) + pure token + +ioStorageTests :: TestTree +ioStorageTests = localOption (mkTimeout 30000000) $ testGroup "IO staging and digests" + [ testCase "independent SHA-256 vectors, bounded updates and consuming finish" $ withRuntime $ \r -> do + forM_ [(0,"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"), + (1000,"cdc76e5c9914fb9281a1c7e284d73e67f1809a48a497200e046d39ccc7112cd0")] $ \(n,expected) -> do + token <- D.openDigest r 0 "sha256" >>= right + replicateM_ n (R.updateDigest r 0 token (BS.replicate 1000 'a') >>= (@?= Right ())) + R.finishDigest r 0 token >>= (@?= Right expected) + R.finishDigest r 0 token >>= category "invalid-state" + R.updateDigest r 0 token (BS.pack "x") >>= category "invalid-state" + R.closeResource r 0 token >>= (@?= Right ()) + D.openDigest r 0 "sha999" >>= category "unsupported" + token <- D.openDigest r 0 "sha256" >>= right + R.updateDigest r 0 token BS.empty >>= category "invalid-bound" + R.updateDigest r 0 token (BS.replicate 4097 'a') >>= category "invalid-bound" + R.writeSome r 0 token (BS.pack "a") >>= category "wrong-kind" + forM_ ["a","b","c"] $ \chunk -> R.updateDigest r 0 token (BS.pack chunk) >>= (@?= Right ()) + R.finishDigest r 0 token >>= (@?= Right "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad") + , testCase "staging is invisible at destination until sealed publication" $ + withSystemTempDirectory "v2-stage" $ \dir -> withRuntime $ \r -> do + S.openStage r 0 "" >>= category "invalid-argument" + let target = dir ++ "/target" + BS.writeFile target (BS.pack "old") + token <- S.openStage r 0 dir >>= right + R.publishStage r 0 token True target >>= category "invalid-state" + write r token "new" + BS.readFile target >>= (@?= BS.pack "old") + R.sealStage r 0 token >>= (@?= Right ()) + BS.readFile target >>= (@?= BS.pack "old") + R.writeSome r 0 token (BS.pack "extra") >>= category "invalid-state" + R.publishStage r 0 token True target >>= (@?= Right "published") + BS.readFile target >>= (@?= BS.pack "new") + R.publishStage r 0 token True target >>= category "invalid-state" + R.closeResource r 0 token >>= (@?= Right ()) + listDirectory dir >>= (@?= ["target"]) + , testCase "no-replace preserves existing winner and abandon removes staging" $ + withSystemTempDirectory "v2-stage" $ \dir -> withRuntime $ \r -> do + BS.writeFile (dir++"/target") (BS.pack "winner") + token <- sealed S.nativeOps r dir "loser" + R.publishStage r 0 token False (dir++"/target") >>= (@?= Right "exists") + BS.readFile (dir++"/target") >>= (@?= BS.pack "winner") + R.closeResource r 0 token >>= (@?= Right ()) + listDirectory dir >>= (@?= ["target"]) + , testCase "concurrent no-replace has exactly one whole-file winner" $ + withSystemTempDirectory "v2-race" $ \dir -> withRuntime $ \r -> do + a <- sealed S.nativeOps r dir "first" + b <- sealed S.nativeOps r dir "second" + gate <- newEmptyMVar + outputs <- newEmptyMVar + forM_ [a,b] $ \token -> void $ forkIO $ readMVar gate >> + R.publishStage r 0 token False (dir++"/target") >>= putMVar outputs + putMVar gate () + x <- takeMVar outputs >>= right + y <- takeMVar outputs >>= right + sort [x,y] @?= ["exists","published"] + bytes <- BS.readFile (dir++"/target") + assertBool "whole winner" (bytes `elem` map BS.pack ["first","second"]) + R.closeScope r 0 >>= (@?= []) + listDirectory dir >>= (@?= ["target"]) + , testCase "file sync failure prevents publication and cleanup preserves old target" $ + withSystemTempDirectory "v2-sync" $ \dir -> withRuntime $ \r -> do + let ops = S.nativeOps { S.syncFile = \_ -> ioError (userError "sync failed") } + BS.writeFile (dir++"/target") (BS.pack "old") + token <- S.openStageWith ops r 0 dir >>= right + write r token "new" + R.sealStage r 0 token >>= category "io-error" + R.publishStage r 0 token True (dir++"/target") >>= category "invalid-state" + R.closeScope r 0 >>= (@?= []) + BS.readFile (dir++"/target") >>= (@?= BS.pack "old") + listDirectory dir >>= (@?= ["target"]) + , testCase "post-publication sync failure reports visible output, never rolls it back" $ + withSystemTempDirectory "v2-sync" $ \dir -> withRuntime $ \r -> do + let ops = S.nativeOps { S.syncDirectory = \_ -> ioError (userError "directory sync failed") } + token <- sealed ops r dir "new" + outcome <- R.publishStage r 0 token True (dir++"/target") + case outcome of Left p -> R.progress p @?= "published"; _ -> assertFailure "failure hidden" + R.publishStage r 0 token True (dir++"/target") >>= category "invalid-state" + R.closeScope r 0 >>= (@?= []) + BS.readFile (dir++"/target") >>= (@?= BS.pack "new") + listDirectory dir >>= (@?= ["target"]) + , testCase "ambiguous namespace failure freezes stage and never deletes destination" $ + withSystemTempDirectory "v2-ambiguous" $ \dir -> withRuntime $ \r -> do + let ops = S.nativeOps { S.replaceName = \from to -> renameFile from to >> ioError (userError "lost reply") } + token <- sealed ops r dir "new" + outcome <- R.publishStage r 0 token True (dir++"/target") + case outcome of Left p -> R.progress p @?= "unknown"; _ -> assertFailure "ambiguity lost" + R.publishStage r 0 token True (dir++"/target") >>= category "invalid-state" + R.closeScope r 0 >>= (@?= []) + BS.readFile (dir++"/target") >>= (@?= BS.pack "new") + , testCase "partial failed staged write cannot be sealed or published" $ + withSystemTempDirectory "v2-write" $ \dir -> withRuntime $ \r -> do + let ops = S.nativeOps { S.putBytes = \h bytes -> BS.hPut h (BS.take 2 bytes) >> ioError (userError "partial write") } + token <- S.openStageWith ops r 0 dir >>= right + result <- R.writeSome r 0 token (BS.pack "abcdef") + case result of Left p -> R.progress p @?= "unknown"; _ -> assertFailure "write failure hidden" + R.sealStage r 0 token >>= category "invalid-state" + R.closeScope r 0 >>= (@?= []) + listDirectory dir >>= (@?= []) + , testCase "cancellation settles a staged write before abandonment" $ + withSystemTempDirectory "v2-cancel" $ \dir -> withRuntime $ \r -> do + entered <- newEmptyMVar + blocked <- newEmptyMVar + finished <- newEmptyMVar + let ops = S.nativeOps { S.putBytes = \_ _ -> putMVar entered () >> takeMVar blocked } + token <- S.openStageWith ops r 0 dir >>= right + worker <- forkIO $ (try (R.writeSome r 0 token (BS.pack "x")) :: IO (Either SomeException (Either R.Problem Int))) >>= putMVar finished + takeMVar entered + R.sealStage r 0 token >>= category "busy" + killThread worker + result <- takeMVar finished + case result of Left _ -> pure (); _ -> assertFailure "cancelled write resumed" + R.sealStage r 0 token >>= category "invalid-state" + R.closeScope r 0 >>= (@?= []) + listDirectory dir >>= (@?= []) + , testCase "stage and digest transfer together, with old aliases invalidated" $ + withSystemTempDirectory "v2-transfer" $ \dir -> withRuntime $ \r -> do + stage <- S.openStage r 0 dir >>= right + digest <- D.openDigest r 0 "sha256" >>= right + (child, moved) <- atomically (R.reserveChildSTM r 0 [stage,digest]) >>= right + R.writeSome r 0 stage (BS.pack "abc") >>= category "stale-handle" + R.updateDigest r 0 digest (BS.pack "abc") >>= category "stale-handle" + case moved of + [freshStage,freshDigest] -> do + R.writeSome r child freshStage (BS.pack "abc") >>= (@?= Right 3) + R.updateDigest r child freshDigest (BS.pack "abc") >>= (@?= Right ()) + R.finishDigest r child freshDigest >>= (@?= Right "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad") + R.sealStage r child freshStage >>= (@?= Right ()) + R.publishStage r child freshStage False (dir++"/target") >>= (@?= Right "published") + _ -> assertFailure "transfer arity" + R.closeScope r child >>= (@?= []) + BS.readFile (dir++"/target") >>= (@?= BS.pack "abc") + listDirectory dir >>= (@?= ["target"]) + , testCase "cancelled seal closes its descriptor and abandons private output" $ + withSystemTempDirectory "v2-seal-cancel" $ \dir -> withRuntime $ \r -> do + entered <- newEmptyMVar + blocked <- newEmptyMVar + done <- newEmptyMVar + let ops = S.nativeOps { S.syncFile = \fd -> putMVar entered fd >> takeMVar blocked } + stage <- S.openStageWith ops r 0 dir >>= right + write r stage "abc" + worker <- forkIO $ (try (R.sealStage r 0 stage) :: IO (Either SomeException (Either R.Problem ()))) >>= putMVar done + fd <- takeMVar entered + original <- getFdStatus fd + killThread worker + takeMVar done >>= \outcome -> case outcome of Left _ -> pure (); _ -> assertFailure "seal resumed" + R.publishStage r 0 stage True (dir++"/target") >>= category "invalid-state" + R.closeScope r 0 >>= (@?= []) + descriptor <- try (getFdStatus fd) + case descriptor of + Left (_ :: IOException) -> pure () + Right current -> assertBool "original descriptor leaked" + ((deviceID current, fileID current) /= (deviceID original, fileID original)) + listDirectory dir >>= (@?= []) + , testCase "cancellation after namespace publication cannot undo visibility" $ + withSystemTempDirectory "v2-publish-cancel" $ \dir -> withRuntime $ \r -> do + entered <- newEmptyMVar + blocked <- newEmptyMVar + done <- newEmptyMVar + let ops = S.nativeOps { S.syncDirectory = \_ -> putMVar entered () >> takeMVar blocked } + stage <- sealed ops r dir "abc" + worker <- forkIO $ (try (R.publishStage r 0 stage True (dir++"/target")) :: IO (Either SomeException (Either R.Problem String))) >>= putMVar done + takeMVar entered + BS.readFile (dir++"/target") >>= (@?= BS.pack "abc") + killThread worker + takeMVar done >>= \outcome -> case outcome of Left _ -> pure (); _ -> assertFailure "publish resumed" + R.publishStage r 0 stage True (dir++"/target") >>= category "invalid-state" + R.closeScope r 0 >>= (@?= []) + BS.readFile (dir++"/target") >>= (@?= BS.pack "abc") + listDirectory dir >>= (@?= ["target"]) + , testCase "no-replace unlink failure reports published and cleanup retries only private name" $ + withSystemTempDirectory "v2-unlink" $ \dir -> withRuntime $ \r -> do + first <- newIORef True + let ops = S.nativeOps { S.removeName = \path -> do + failNow <- atomicModifyIORef' first (\value -> (False,value)) + if failNow then ioError (userError "unlink failed") else removeFile path } + stage <- sealed ops r dir "abc" + result <- R.publishStage r 0 stage False (dir++"/target") + case result of Left p -> R.progress p @?= "published"; _ -> assertFailure "unlink failure hidden" + R.closeScope r 0 >>= (@?= []) + BS.readFile (dir++"/target") >>= (@?= BS.pack "abc") + listDirectory dir >>= (@?= ["target"]) + , testCase "cross-directory publication succeeds but self-publication is rejected" $ + withSystemTempDirectory "v2-destination" $ \dir -> withRuntime $ \r -> do + createDirectory (dir++"/other") + a <- sealed S.nativeOps r dir "abc" + R.publishStage r 0 a True (dir++"/other/target") >>= (@?= Right "published") + BS.readFile (dir++"/other/target") >>= (@?= BS.pack "abc") + R.closeResource r 0 a >>= (@?= Right ()) + b <- sealed S.nativeOps r dir "abc" + names <- listDirectory dir + let temporary = head (filter (/= "other") names) + R.publishStage r 0 b True (dir++"/"++temporary) >>= category "invalid-destination" + R.closeScope r 0 >>= (@?= []) + listDirectory dir >>= (@?= ["other"]) + , testCase "cross-directory link and replace sync destination then source, preserving existing winners" $ + withSystemTempDirectory "v2-cross-directory" $ \dir -> withRuntime $ \r -> do + let source = dir++"/source"; target = dir++"/target" + createDirectory source + createDirectory target + synced <- newIORef [] + let ops = S.nativeOps { S.openDirectory = \path -> do + modifyIORef' synced (++ [path]) + S.openDirectory S.nativeOps path } + forM_ [False, True] $ \replace -> do + stage <- sealed ops r source (if replace then "replacement" else "new") + writeIORef synced [] + R.publishStage r 0 stage replace (target++"/object") >>= (@?= Right "published") + readIORef synced >>= (@?= [target, source]) + R.closeResource r 0 stage >>= (@?= Right ()) + listDirectory source >>= (@?= []) + BS.readFile (target++"/object") >>= (@?= BS.pack "replacement") + winner <- sealed ops r source "must not replace" + writeIORef synced [] + R.publishStage r 0 winner False (target++"/object") >>= (@?= Right "exists") + readIORef synced >>= (@?= []) -- exists is not an identity/durability claim + R.closeScope r 0 >>= (@?= []) + BS.readFile (target++"/object") >>= (@?= BS.pack "replacement") + listDirectory source >>= (@?= []) + , testCase "cross-filesystem syscall failures never fall back to copying or alter destination" $ + withSystemTempDirectory "v2-exdev" $ \dir -> withRuntime $ \r -> do + let target = dir++"/target" + createDirectory target + BS.writeFile (target++"/object") (BS.pack "keep") + calls <- newIORef (0 :: Int) + let unavailable _ destination = do + modifyIORef' calls (+1) + ioError (errnoToIOError "publish" eXDEV Nothing (Just destination)) + ops = S.nativeOps { S.linkName = unavailable, S.replaceName = unavailable } + forM_ [False, True] $ \replace -> do + stage <- sealed ops r dir "new" + outcome <- R.publishStage r 0 stage replace (target++"/object") + case outcome of + Left p -> R.progress p @?= "unknown" -- never turn errno into replay permission + _ -> assertFailure "cross-filesystem failure hidden" + R.publishStage r 0 stage replace (target++"/object") >>= category "invalid-state" + R.closeResource r 0 stage >>= (@?= Right ()) + BS.readFile (target++"/object") >>= (@?= BS.pack "keep") + readIORef calls >>= (@?= 2) + listDirectory dir >>= (@?= ["target"]) + , testCase "failure syncing either publication directory preserves published data" $ + withSystemTempDirectory "v2-cross-sync" $ \dir -> withRuntime $ \r -> do + let source = dir++"/source"; target = dir++"/target" + createDirectory source + createDirectory target + forM_ [1,2 :: Int] $ \failureAt -> do + count <- newIORef (0 :: Int) + let ops = S.nativeOps { S.syncDirectory = \fd -> do + n <- atomicModifyIORef' count (\n -> (n+1,n+1)) + if n == failureAt then ioError (userError "directory sync failed") + else S.syncDirectory S.nativeOps fd } + stage <- sealed ops r source "visible" + outcome <- R.publishStage r 0 stage True (target++"/object") + case outcome of + Left p -> do + R.progress p @?= "published" + assertBool "sync diagnostic" ("directory sync failed" `isInfixOf` R.detail p) + _ -> assertFailure "directory sync failure hidden" + R.publishStage r 0 stage True (target++"/object") >>= category "invalid-state" + R.closeResource r 0 stage >>= (@?= Right ()) + listDirectory source >>= (@?= []) + BS.readFile (target++"/object") >>= (@?= BS.pack "visible") + , testCase "cross-directory ambiguous link error never unlinks the published destination" $ + withSystemTempDirectory "v2-cross-ambiguous" $ \dir -> withRuntime $ \r -> do + let target = dir++"/target" + createDirectory target + let ops = S.nativeOps { S.linkName = \source destination -> do + S.linkName S.nativeOps source destination + ioError (userError "reply lost after link") } + stage <- sealed ops r dir "visible" + outcome <- R.publishStage r 0 stage False (target++"/object") + case outcome of Left p -> R.progress p @?= "unknown"; _ -> assertFailure "ambiguity hidden" + R.closeScope r 0 >>= (@?= []) + listDirectory dir >>= (@?= ["target"]) + BS.readFile (target++"/object") >>= (@?= BS.pack "visible") + , testCase "cancellation during source-directory sync preserves cross-directory publication" $ + withSystemTempDirectory "v2-cross-cancel" $ \dir -> withRuntime $ \r -> do + let target = dir++"/target" + createDirectory target + count <- newIORef (0 :: Int) + entered <- newEmptyMVar + blocked <- newEmptyMVar + done <- newEmptyMVar + let ops = S.nativeOps { S.syncDirectory = \fd -> do + n <- atomicModifyIORef' count (\n -> (n+1,n+1)) + if n == 2 then putMVar entered () >> takeMVar blocked else S.syncDirectory S.nativeOps fd } + stage <- sealed ops r dir "visible" + worker <- forkIO $ (try (R.publishStage r 0 stage False (target++"/object")) :: IO (Either SomeException (Either R.Problem String))) >>= putMVar done + takeMVar entered + killThread worker + takeMVar done >>= \outcome -> case outcome of Left _ -> pure (); _ -> assertFailure "cancellation lost" + R.closeScope r 0 >>= (@?= []) + listDirectory dir >>= (@?= ["target"]) + BS.readFile (target++"/object") >>= (@?= BS.pack "visible") + , testCase "source-directory close failure is published and never retries the descriptor" $ + withSystemTempDirectory "v2-cross-close" $ \dir -> withRuntime $ \r -> do + let target = dir++"/target" + createDirectory target + calls <- newIORef (0 :: Int) + let ops = S.nativeOps { S.closeDescriptor = \fd -> do + n <- atomicModifyIORef' calls (\n -> (n+1,n+1)) + closeFd fd + when (n == 3) (ioError (userError "source directory close")) } + stage <- sealed ops r dir "visible" + outcome <- R.publishStage r 0 stage True (target++"/object") + case outcome of Left p -> R.progress p @?= "published"; _ -> assertFailure "close failure hidden" + R.closeScope r 0 >>= (@?= []) + readIORef calls >>= (@?= 3) + BS.readFile (target++"/object") >>= (@?= BS.pack "visible") + , testCase "cross-directory publication cannot target another stage's private namespace" $ + withSystemTempDirectory "v2-cross-private" $ \dir -> withRuntime $ \r -> do + let target = dir++"/target" + createDirectory target + stage <- sealed S.nativeOps r dir "source" + other <- sealed S.nativeOps r target "private" + names <- listDirectory target + R.publishStage r 0 stage True (target++"/"++head names) >>= category "invalid-destination" + R.publishStage r 0 other False (target++"/safe") >>= (@?= Right "published") + R.closeScope r 0 >>= (@?= []) + BS.readFile (target++"/safe") >>= (@?= BS.pack "private") + listDirectory target >>= (@?= ["safe"]) + , testCase "repeated digest/stage scopes leave no private files or registry history" $ + withSystemTempDirectory "v2-repeat" $ \dir -> withRuntime $ \r -> do + replicateM_ 100 $ do + child <- R.childScope r 0 >>= right + _ <- S.openStage r child dir >>= right + digest <- D.openDigest r child "sha256" >>= right + R.updateDigest r child digest (BS.pack "abc") >>= (@?= Right ()) + R.closeScope r child >>= (@?= []) + R.liveCounts r >>= (@?= (1,3)) + listDirectory dir >>= (@?= []) + , testCase "failed sync keeps its primary error and reports descriptor release separately" $ + withSystemTempDirectory "v2-sync-close" $ \dir -> withRuntime $ \r -> do + let ops = S.nativeOps + { S.syncFile = \_ -> ioError (userError "primary sync") + , S.closeDescriptor = \fd -> closeFd fd >> ioError (userError "secondary close") } + stage <- S.openStageWith ops r 0 dir >>= right + write r stage "abc" + outcome <- R.sealStage r 0 stage + case outcome of + Left p -> assertBool "sync error masked" ("primary sync" `isInfixOf` R.detail p) + _ -> assertFailure "sync error hidden" + errors <- R.closeScope r 0 + case errors of + [p] -> assertBool "release error hidden" ("secondary close" `isInfixOf` R.detail p) + _ -> assertFailure (show errors) + listDirectory dir >>= (@?= []) + , testCase "failed descriptor close is never retried after possible descriptor reuse" $ + withSystemTempDirectory "io-close-once" $ \dir -> withRuntime $ \r -> do + attempts <- newIORef (0 :: Int) + let ops = S.nativeOps { S.closeDescriptor = \fd -> do + modifyIORef' attempts (+1) + closeFd fd + ioError (userError "close reported failure") } + stage <- S.openStageWith ops r 0 dir >>= right + write r stage "abc" + R.sealStage r 0 stage >>= category "io-error" + R.closeScope r 0 >>= (@?= []) + readIORef attempts >>= (@?= 1) + listDirectory dir >>= (@?= []) + , testCase "unexpected descriptor release fault still attempts private-name cleanup" $ + withSystemTempDirectory "v2-release-fault" $ \dir -> withRuntime $ \r -> do + let ops = S.nativeOps + { S.syncFile = \_ -> ioError (userError "sync failed") + , S.closeDescriptor = \fd -> closeFd fd >> throwIO (ErrorCall "unexpected close") } + stage <- S.openStageWith ops r 0 dir >>= right + R.sealStage r 0 stage >>= category "io-error" + errors <- R.closeScope r 0 + case errors of [p] -> R.category p @?= "backend-fault"; _ -> assertFailure (show errors) + listDirectory dir >>= (@?= []) + , testCase "directory sync failure preserves its error and owns directory release until settlement" $ + withSystemTempDirectory "v2-directory-close" $ \dir -> withRuntime $ \r -> do + calls <- newIORef (0 :: Int) + let ops = S.nativeOps + { S.syncDirectory = \_ -> ioError (userError "primary directory sync") + , S.closeDescriptor = \fd -> do + count <- atomicModifyIORef' calls (\n -> (n+1,n)) + closeFd fd + when (count == 1) (ioError (userError "secondary directory close")) } + stage <- sealed ops r dir "abc" + result <- R.publishStage r 0 stage False (dir++"/target") + case result of + Left p -> do + R.progress p @?= "published" + assertBool "primary hidden" ("primary directory sync" `isInfixOf` R.detail p) + _ -> assertFailure "directory sync failure hidden" + errors <- R.closeScope r 0 + case errors of + [p] -> assertBool "directory release hidden" ("secondary directory close" `isInfixOf` R.detail p) + _ -> assertFailure (show errors) + readIORef calls >>= (@?= 2) + BS.readFile (dir++"/target") >>= (@?= BS.pack "abc") + listDirectory dir >>= (@?= ["target"]) + , testCase "publication cannot overwrite another stage's private name" $ + withSystemTempDirectory "v2-private-namespace" $ \dir -> withRuntime $ \r -> do + first <- sealed S.nativeOps r dir "first" + original <- listDirectory dir + second <- S.openStage r 0 dir >>= right + write r second "second" + names <- listDirectory dir + assertBool "temporary naming contract" (all (".tricu-stage-" `isInfixOf`) names) + let other = head (filter (`notElem` original) names) + R.publishStage r 0 first True (dir++"/"++other) >>= category "invalid-destination" + write r second "tail" + R.sealStage r 0 second >>= (@?= Right ()) + R.publishStage r 0 second False (dir++"/safe") >>= (@?= Right "published") + R.closeScope r 0 >>= (@?= []) + BS.readFile (dir++"/safe") >>= (@?= BS.pack "secondtail") + listDirectory dir >>= (@?= ["safe"]) + , testCase "explicit abandonment reports removal failure" $ + withSystemTempDirectory "v2-cleanup" $ \dir -> withRuntime $ \r -> do + attempts <- newIORef (0 :: Int) + let ops = S.nativeOps { S.removeName = \_ -> modifyIORef' attempts (+1) >> ioError (userError "remove failed") } + token <- S.openStageWith ops r 0 dir >>= right + R.closeResource r 0 token >>= category "io-error" + readIORef attempts >>= (@?= 1) + R.liveCounts r >>= (@?= (1,3)) + ] diff --git a/test/IOSyncTests.hs b/test/IOSyncTests.hs @@ -0,0 +1,113 @@ +{-# LANGUAGE ScopedTypeVariables #-} +module IOSyncTests (ioSyncTests) where + +import qualified IO.Resources as R +import qualified IO.Filesystem as F +import qualified IO.Descriptor as D +import Control.Concurrent +import Control.Exception +import Control.Monad (forM_) +import Data.IORef +import qualified Data.ByteString as BS +import Data.List (isInfixOf) +import System.Directory (renameFile) +import System.Posix.Files (createNamedPipe) +import System.FilePath ((</>)) +import System.IO.Temp (withSystemTempDirectory) +import System.Timeout (timeout) +import Test.Tasty +import Test.Tasty.HUnit + +fake = R.FileOps Nothing Nothing Nothing Nothing (pure 0) Nothing Nothing (pure ()) +category wanted value = case value of Left p -> R.category p @?= wanted; Right _ -> assertFailure wanted + +ioSyncTests = localOption (mkTimeout 30000000) $ testGroup "IO synchronization" + [ testCase "reader/update/append sync preserves bytes and logical cursor" $ withSystemTempDirectory "tricu-sync" $ \directory -> do + let path = directory </> "file" + BS.writeFile path "abcdef" + runtime <- R.newRuntime + Right reader <- R.openReader runtime R.rootScope path + R.readSome runtime R.rootScope reader 2 >>= (@?= Right "ab") + forM_ [1..10::Int] $ \_ -> R.syncFile runtime R.rootScope reader >>= (@?= Right ()) + R.filePosition runtime R.rootScope reader >>= (@?= Right 2) + R.readSome runtime R.rootScope reader 2 >>= (@?= Right "cd") + R.closeResource runtime R.rootScope reader >>= (@?= Right ()) + Right update <- R.openUpdate runtime R.rootScope path + R.seekFile runtime R.rootScope update 3 >>= (@?= Right ()) + R.writeSome runtime R.rootScope update "X" >>= (@?= Right 1) + R.syncFile runtime R.rootScope update >>= (@?= Right ()) + R.filePosition runtime R.rootScope update >>= (@?= Right 4) + R.closeResource runtime R.rootScope update >>= (@?= Right ()) + Right append <- R.openAppender runtime R.rootScope path + R.writeSome runtime R.rootScope append "Z" >>= (@?= Right 1) + R.syncFile runtime R.rootScope append >>= (@?= Right ()) + R.closeScope runtime R.rootScope >>= (@?= []) + BS.readFile path >>= (@?= "abcXefZ") + , testCase "sync uses the opened inode, not a fresh lookup of its old path" $ withSystemTempDirectory "tricu-sync" $ \directory -> do + let path = directory </> "old" + BS.writeFile path "bytes" + runtime <- R.newRuntime + Right reader <- R.openReader runtime R.rootScope path + renameFile path (directory </> "moved") + R.syncFile runtime R.rootScope reader >>= (@?= Right ()) + F.syncDirectory runtime R.rootScope directory >>= (@?= Right ()) + R.closeScope runtime R.rootScope >>= (@?= []) + , testCase "directory sync rejects non-directories and missing paths ordinarily" $ withSystemTempDirectory "tricu-sync" $ \directory -> do + let path = directory </> "file" + BS.writeFile path "bytes" + runtime <- R.newRuntime + counts <- R.liveCounts runtime + F.syncDirectory runtime R.rootScope path >>= category "not-directory" + createNamedPipe (directory </> "fifo") 0o600 + F.syncDirectory runtime R.rootScope (directory </> "fifo") >>= category "not-directory" + F.syncDirectory runtime R.rootScope (directory </> "missing") >>= category "not-found" + F.syncDirectory runtime R.rootScope "" >>= category "invalid-argument" + R.liveCounts runtime >>= (@?= counts) + R.closeScope runtime R.rootScope >>= (@?= []) + , testCase "sync and release failures preserve primary diagnostics without history" $ do + runtime <- R.newRuntime + calls <- newIORef (0::Int) + let actions = pure (ioError (userError "primary sync failure"), + modifyIORef' calls (+1) >> ioError (userError "secondary release failure")) + Right file <- R.acquire runtime R.rootScope (pure (R.File fake {R.fileSyncActions=Just actions})) + R.syncFile runtime R.rootScope file >>= \reply -> case reply of + Left p -> do + R.progress p @?= "unknown" + assertBool "lost primary" ("primary sync failure" `isInfixOf` R.detail p) + assertBool "lost cleanup" ("secondary release failure" `isInfixOf` R.detail p) + _ -> assertFailure "sync failure swallowed" + R.liveCounts runtime >>= (@?= (1,4)) + readIORef calls >>= (@?= 1) + R.closeScope runtime R.rootScope >>= (@?= []) + , testCase "cancelled sync releases both leases and settles cleanup lexically" $ do + runtime <- R.newRuntime + entered <- newEmptyMVar; gate <- newEmptyMVar; done <- newEmptyMVar + released <- newIORef (0::Int) + let actions = pure (putMVar entered () >> takeMVar gate,modifyIORef' released (+1)) + Right file <- R.acquire runtime R.rootScope (pure (R.File fake {R.fileSyncActions=Just actions})) + worker <- forkFinally (R.syncFile runtime R.rootScope file) (putMVar done) + takeMVar entered + R.syncFile runtime R.rootScope file >>= category "busy" + killThread worker + timeout 3000000 (takeMVar done) >>= \reply -> case reply of + Just (Left err) -> case fromException err of Just ThreadKilled -> pure (); _ -> assertFailure (show err) + _ -> assertFailure "cancellation lost" + readIORef released >>= (@?= 0) + R.closeScope runtime R.rootScope >>= (@?= []) + readIORef released >>= (@?= 1) + , testCase "once-only raw release is not retried after close error or interruption" $ do + forM_ [toException (userError "uncertain close"),toException ThreadKilled] $ \exception -> do + calls <- newIORef (0::Int) + release <- D.releaseOnce (modifyIORef' calls (+1) >> throwIO exception) + first <- try release + case first of Left (_ :: SomeException) -> pure (); Right _ -> assertFailure "injection missing" + release + readIORef calls >>= (@?= 1) + , testCase "unsupported methods and borrowed streams cannot become file sync" $ do + runtime <- R.newRuntime + Right file <- R.acquire runtime R.rootScope (pure (R.File fake)) + R.syncFile runtime R.rootScope file >>= category "unsupported" + Right standard <- R.standardStream runtime R.rootScope R.StandardOutput + R.syncFile runtime R.rootScope standard >>= category "wrong-kind" + R.closeScope runtime R.rootScope >>= (@?= []) + ] diff --git a/test/IOTaskTests.hs b/test/IOTaskTests.hs @@ -0,0 +1,328 @@ +{-# LANGUAGE ScopedTypeVariables #-} +module IOTaskTests (ioTaskTests) where + +import qualified IO.Resources as R +import qualified IO.Tasks as S +import Runtime +import Control.Concurrent hiding (yield) +import Control.Exception +import Control.Monad hiding (join) +import Data.IORef +import qualified Data.ByteString as BS +import qualified System.Timeout as Timeout +import Test.Tasty +import Test.Tasty.HUnit + +right :: Show e => Either e a -> IO a +right (Right x) = pure x +right (Left e) = assertFailure (show e) >> error "assertion failed" +wait :: MVar a -> IO a +wait var = Timeout.timeout 3000000 (takeMVar var) >>= maybe (assertFailure "timeout" >> error "timeout") pure +completed :: S.Outcome -> Assertion +completed (S.Completed value) = value @?= Leaf +completed value = assertFailure (show value) +rejected :: String -> Either R.Problem a -> Assertion +rejected name (Left e) = R.category e @?= name +rejected name _ = assertFailure ("expected " ++ name) +withGroup :: (R.Runtime -> S.Scheduler -> S.Group -> IO S.Outcome) -> IO S.Outcome +withGroup action = do + runtime <- R.newRuntime + scheduler <- S.newScheduler runtime + group <- S.rootGroup scheduler + result <- S.runGroup group (action runtime scheduler group) + R.liveCounts runtime >>= (@?= (0,0)) + S.liveCounts scheduler >>= (@?= (0,0,0)) + pure result + +ioTaskTests :: TestTree +ioTaskTests = localOption (mkTimeout 30000000) $ testGroup "IO structured tasks" + [ testCase "normal exit joins borrowing child before ancestor release" $ do + released <- newIORef False + entered <- newEmptyMVar + finish <- newEmptyMVar + result <- newEmptyMVar + _ <- forkIO $ withGroup (\runtime _ group -> do + token <- R.acquire runtime 0 (pure (R.Reader (const (pure BS.empty)) (writeIORef released True))) >>= right + _ <- S.spawn group False False [] $ \child _ -> do + putMVar entered () + wait finish + readIORef released >>= (@?= False) + R.readSome runtime (S.scopeOf child) token 1 >>= (@?= Right BS.empty) + pure (S.Completed Leaf) + pure (S.Completed Leaf)) >>= putMVar result + wait entered + tryTakeMVar result >>= \value -> case value of Nothing -> pure (); _ -> assertFailure "scope returned before child" + readIORef released >>= (@?= False) + putMVar finish () + wait result >>= completed + readIORef released >>= (@?= True) + , testCase "joined outcome is consumed once, ordinary errors remain values" $ + withGroup (\_ _ group -> do + ref <- S.spawn group False True [] (\_ _ -> pure (S.Completed (Fork Leaf Leaf))) >>= right + S.join group ref >>= right >>= \outcome -> case outcome of + S.Completed value -> value @?= Fork Leaf Leaf + _ -> assertFailure "domain value became terminal failure" + S.join group ref >>= rejected "stale-task" + S.discard group ref >>= rejected "stale-task" + pure (S.Completed Leaf)) >>= completed + , testCase "supervision is installed before execution and preserves lazy diagnostic" $ + withGroup (\_ _ group -> do + ref <- S.spawn group True True [] (\_ _ -> pure (S.Failed (error "diagnostic forced") [])) >>= right + S.join group ref >>= right >>= \outcome -> case outcome of + S.Failed _ [] -> pure () + _ -> assertFailure "supervised failure lost" + pure (S.Completed Leaf)) >>= completed + , testCase "uncontained fault interrupts body, cancels sibling and joins its lease" $ do + siblingReady <- newEmptyMVar + parentReady <- newEmptyMVar + blocked <- newEmptyMVar + resumed <- newIORef False + released <- newIORef False + outcome <- withGroup $ \runtime _ group -> do + _ <- S.spawn group False False [] $ \child _ -> do + token <- R.acquire runtime (S.scopeOf child) (pure (R.Writer + (\_ -> putMVar siblingReady () >> takeMVar blocked) + (writeIORef released True))) >>= right + _ <- R.writeSome runtime (S.scopeOf child) token (BS.singleton 1) + writeIORef resumed True + pure (S.Completed Leaf) + _ <- S.spawn group False False [] $ \_ _ -> do + wait siblingReady + wait parentReady + pure (S.Failed (ofString "child fault") []) + putMVar parentReady () + takeMVar blocked + writeIORef resumed True + pure (S.Completed Leaf) + case outcome of S.Failed diagnostic [] -> diagnostic @?= ofString "child fault"; _ -> assertFailure "fault lost" + readIORef resumed >>= (@?= False) + readIORef released >>= (@?= True) + , testCase "explicit cancellation settles blocked acquisition and reports reason" $ + withGroup (\runtime _ group -> do + entered <- newEmptyMVar + block <- newEmptyMVar + resumed <- newIORef False + ref <- S.spawn group True True [] (\child _ -> do + _ <- R.acquire runtime (S.scopeOf child) (putMVar entered () >> takeMVar block) + writeIORef resumed True + pure (S.Completed Leaf)) >>= right + wait entered + S.cancel group ref (ofString "shutdown") >>= (@?= Right ()) + S.join group ref >>= right >>= \outcome -> case outcome of + S.Cancelled reason [] -> reason @?= ofString "shutdown" + _ -> assertFailure "cancellation not observed" + readIORef resumed >>= (@?= False) + pure (S.Completed Leaf)) >>= completed + , testCase "transfer publication gives child fresh tokens and rejects old aliases" $ + withGroup (\runtime _ group -> do + released <- newIORef False + ready <- newEmptyMVar + finish <- newEmptyMVar + token <- R.acquire runtime 0 (pure (R.Reader (const (pure BS.empty)) (writeIORef released True))) >>= right + ref <- S.spawn group False True [token] (\child tokens -> do + case tokens of + [fresh] -> R.readSome runtime (S.scopeOf child) fresh 1 >>= (@?= Right BS.empty) + _ -> assertFailure "bad fresh tokens" + putMVar ready () + wait finish + pure (S.Completed Leaf)) >>= right + wait ready + R.readSome runtime 0 token 1 >>= rejected "stale-handle" + readIORef released >>= (@?= False) + putMVar finish () + S.join group ref >>= right >>= completed + readIORef released >>= (@?= True) + pure (S.Completed Leaf)) >>= completed + , testCase "failed transfer does not start callback or allocate task history" $ + withGroup (\runtime scheduler group -> do + started <- newIORef False + token <- R.acquire runtime 0 (pure (R.Reader (const (pure BS.empty)) (pure ()))) >>= right + S.spawn group False True [token,token] (\_ _ -> writeIORef started True >> pure (S.Completed Leaf)) >>= rejected "duplicate-handle" + readIORef started >>= (@?= False) + S.liveCounts scheduler >>= (@?= (0,0,0)) + R.readSome runtime 0 token 1 >>= (@?= Right BS.empty) + pure (S.Completed Leaf)) >>= completed + , testCase "repeated joins and discards leave no result archive" $ + withGroup (\_ scheduler group -> do + replicateM_ 500 $ do + ref <- S.spawn group False True [] (\_ _ -> pure (S.Completed (error "value forced"))) >>= right + S.join group ref >>= right >>= \outcome -> case outcome of S.Completed _ -> pure (); _ -> assertFailure "failed" + S.liveCounts scheduler >>= (@?= (0,0,0)) + replicateM_ 500 $ do + ref <- S.spawn group False True [] (\_ _ -> pure (S.Completed (error "discard forced"))) >>= right + S.discard group ref >>= (@?= Right ()) + pure (S.Completed Leaf)) >>= completed + , testCase "cancellation preserves primary and attaches all release errors" $ + withGroup (\runtime _ group -> do + ready <- newEmptyMVar + blocked <- newEmptyMVar + ref <- S.spawn group True True [] (\child _ -> do + replicateM_ 24 $ void $ R.acquire runtime (S.scopeOf child) + (pure (R.Reader (const (pure BS.empty)) (ioError (userError "release")))) >>= right + putMVar ready () + takeMVar blocked + pure (S.Completed Leaf)) >>= right + wait ready + S.cancel group ref (error "reason forced") >>= (@?= Right ()) + S.join group ref >>= right >>= \outcome -> case outcome of + S.Cancelled _ errors -> do + length errors @?= 17 + R.detail (last errors) @?= "8" + _ -> assertFailure "primary cancellation lost" + pure (S.Completed Leaf)) >>= completed + , testCase "explicit cancellation of leased read/write remains sticky after backend catches wakeup" $ + withGroup (\runtime _ group -> do + forM_ [True, False] $ \reading -> do + entered <- newEmptyMVar + blocked <- newEmptyMVar + resumed <- newIORef False + released <- newIORef False + let operation = (putMVar entered () >> takeMVar blocked) + `catch` (\(_ :: AsyncException) -> pure ()) + backend = if reading + then R.Reader (\_ -> operation >> pure BS.empty) (writeIORef released True) + else R.Writer (\_ -> operation >> pure 1) (writeIORef released True) + ref <- S.spawn group True True [] (\child _ -> do + token <- R.acquire runtime (S.scopeOf child) (pure backend) >>= right + if reading then void (R.readSome runtime (S.scopeOf child) token 1) + else void (R.writeSome runtime (S.scopeOf child) token (BS.singleton 1)) + S.checkpoint child + writeIORef resumed True + pure (S.Completed Leaf)) >>= right + wait entered + S.cancel group ref Leaf >>= (@?= Right ()) + S.join group ref >>= right >>= \outcome -> case outcome of + S.Cancelled Leaf [] -> pure () + _ -> assertFailure "backend neutralized cancellation" + readIORef resumed >>= (@?= False) + readIORef released >>= (@?= True) + pure (S.Completed Leaf)) >>= completed + , testCase "cancellation versus completion linearizes to one terminal outcome" $ + withGroup (\_ _ group -> do + replicateM_ 200 $ do + ready <- newEmptyMVar + finish <- newEmptyMVar + cancelled <- newEmptyMVar + ref <- S.spawn group True True [] (\_ _ -> putMVar ready () >> wait finish >> pure (S.Completed Leaf)) >>= right + wait ready + _ <- forkIO (S.cancel group ref Leaf >>= putMVar cancelled) + putMVar finish () + request <- wait cancelled + outcome <- S.join group ref >>= right + case (request, outcome) of + (Right (), S.Cancelled Leaf []) -> pure () + (Left e, S.Completed Leaf) -> assertBool "completion won" (R.category e `elem` ["stale-task", "completed-task"]) + _ -> assertFailure "inconsistent completion/cancellation race" + pure (S.Completed Leaf)) >>= completed + , testCase "self/ancestor joins reject without consuming the ticket" $ + withGroup (\_ _ group -> do + reference <- newEmptyMVar + checked <- newEmptyMVar + ref <- S.spawn group True True [] (\child _ -> do + self <- wait reference + S.join child self >>= rejected "join-cycle" + result <- S.nested child (\inner -> S.join inner self >>= rejected "join-cycle" >> pure (S.Completed Leaf)) + putMVar checked () + pure result) >>= right + putMVar reference ref + wait checked + S.join group ref >>= right >>= completed + pure (S.Completed Leaf)) >>= completed + , testCase "implicit scope joins participate in wait-cycle detection" $ do + outcome <- withGroup $ \_ _ group -> do + aRef <- newEmptyMVar + bRef <- newEmptyMVar + aReady <- newEmptyMVar + b <- S.spawn group False True [] (\child _ -> do + a <- wait aRef + wait aReady + -- A's descendant already waits on B, or will do so after this claim. + result <- S.join child a + case result of Left e -> R.category e @?= "join-cycle"; Right _ -> pure () + pure (S.Completed Leaf)) >>= right + putMVar bRef b + a <- S.spawn group False True [] (\child _ -> do + _ <- S.spawn child False False [] (\grandchild _ -> do + target <- wait bRef + putMVar aReady () + result <- S.join grandchild target + case result of Left e -> R.category e @?= "join-cycle"; Right _ -> pure () + pure (S.Completed Leaf)) >>= right + pure (S.Completed Leaf)) >>= right + putMVar aRef a + pure (S.Completed Leaf) + case outcome of + S.Completed Leaf -> pure () -- Explicit join rejected the last edge. + S.Failed message [] -> message @?= ofString "task join cycle" + _ -> assertFailure "cycle was not settled" + , testCase "cancellation waits for lexical release and preserves its error" $ + withGroup (\runtime _ group -> do + entered <- newEmptyMVar + proceed <- newEmptyMVar + interrupted <- newIORef False + ref <- S.spawn group True True [] (\child _ -> S.nested child (\inner -> do + _ <- R.acquire runtime (S.scopeOf inner) (pure (R.Reader (const (pure BS.empty)) + ((putMVar entered () >> takeMVar proceed >> ioError (userError "release")) + `catch` (\(e :: AsyncException) -> writeIORef interrupted True >> throwIO e)))) >>= right + pure (S.Completed Leaf))) >>= right + wait entered + S.cancel group ref (ofString "shutdown") >>= (@?= Right ()) + putMVar proceed () + S.join group ref >>= right >>= \outcome -> case outcome of + S.Cancelled reason errors -> do + reason @?= ofString "shutdown" + length errors @?= 1 + _ -> assertFailure "lexical cleanup/cancellation lost" + readIORef interrupted >>= (@?= False) + pure (S.Completed Leaf)) >>= completed + , testCase "concurrent ticket claims have exactly one consumer" $ + withGroup (\_ _ group -> do + ready <- newEmptyMVar + finish <- newEmptyMVar + loser <- newEmptyMVar + target <- S.spawn group True True [] (\_ _ -> putMVar ready () >> wait finish >> pure (S.Completed Leaf)) >>= right + wait ready + let consumer child _ = do + result <- S.join child target + case result of + Right (S.Completed Leaf) -> pure (S.Completed (Stem Leaf)) + Left e -> do + R.category e @?= "stale-task" + putMVar loser () + pure (S.Completed Leaf) + _ -> assertFailure "unexpected join outcome" >> pure (S.Failed Leaf []) + a <- S.spawn group True True [] consumer >>= right + b <- S.spawn group True True [] consumer >>= right + wait loser + putMVar finish () + x <- S.join group a >>= right + y <- S.join group b >>= right + case (x,y) of + (S.Completed Leaf, S.Completed (Stem Leaf)) -> pure () + (S.Completed (Stem Leaf), S.Completed Leaf) -> pure () + _ -> assertFailure "ticket consumed more or less than once" + pure (S.Completed Leaf)) >>= completed + , testCase "lexical fault preserves every release diagnostic without duplicate attachment" $ do + outcome <- withGroup $ \runtime _ group -> S.nested group $ \inner -> do + replicateM_ 3 $ void $ R.acquire runtime (S.scopeOf inner) + (pure (R.Reader (const (pure BS.empty)) (ioError (userError "lexical release")))) >>= right + pure (S.Failed (ofString "lexical fault") []) + case outcome of + S.Failed message errors -> do + message @?= ofString "lexical fault" + length errors @?= 3 + _ -> assertFailure "lexical failure lost" + , testCase "nested lexical scope cancellation settles inner resources" $ do + ready <- newEmptyMVar + blocked <- newEmptyMVar + released <- newIORef False + outcome <- withGroup $ \runtime _ group -> do + _ <- S.spawn group False False [] (\_ _ -> wait ready >> pure (S.Failed Leaf [])) + S.nested group $ \inner -> do + _ <- R.acquire runtime (S.scopeOf inner) (pure (R.Reader (const (pure BS.empty)) (writeIORef released True))) >>= right + putMVar ready () + takeMVar blocked + pure (S.Completed Leaf) + case outcome of S.Failed Leaf [] -> pure (); _ -> assertFailure "nested interruption changed primary" + readIORef released >>= (@?= True) + ] diff --git a/test/IOTests.hs b/test/IOTests.hs @@ -0,0 +1,356 @@ +{-# LANGUAGE ScopedTypeVariables #-} +module IOTests (ioTests) where + +import qualified IO.Resources as R +import qualified IO.Driver as V +import IODriver (runIO) +import Runtime +import Control.Concurrent +import Control.Concurrent.STM +import Control.Exception +import Control.Monad +import qualified Data.ByteString as BS +import Data.IORef +import System.Directory (listDirectory) +import System.IO.Temp (withSystemTempDirectory) +import qualified System.Timeout as Timeout +import Test.Tasty +import Test.Tasty.HUnit + +-- Independent wire construction: no production encoder/catalogue expectations. +request :: String -> String -> T -> T +request group operation arguments = Fork (ofNumber 10) + (Fork (ofString group) (Fork (ofNumber 1) (Fork (ofString operation) arguments))) + +right :: Show e => Either e a -> IO a +right (Right value) = pure value +right (Left e) = assertFailure (show e) >> error "assertion failed" +categoryIs :: String -> Either R.Problem a -> Assertion +categoryIs expected result = case result of + Left p -> R.category p @?= expected + Right _ -> assertFailure ("expected " ++ expected) + +withRuntime :: (R.Runtime -> IO a) -> IO a +withRuntime = bracket R.newRuntime (\r -> void (R.closeScope r R.rootScope)) +spawn :: IO a -> IO (ThreadId, MVar (Either SomeException a)) +spawn action = do + result <- newEmptyMVar + tid <- forkIO (try action >>= putMVar result) + pure (tid, result) +wait :: MVar a -> IO a +wait result = Timeout.timeout 3000000 (takeMVar result) >>= maybe (assertFailure "thread timeout" >> error "timeout") pure + +ioTests :: TestTree +ioTests = localOption (mkTimeout 30000000) $ testGroup "IO resource/file foundation" + [ testCase "bounded binary copy, EOF, no grants" $ + withSystemTempDirectory "v2-copy" $ \dir -> withRuntime $ \runtime -> do + let bytes = BS.pack (take 20003 (cycle [0..255])) + BS.writeFile (dir++"/in") bytes + child <- R.childScope runtime 0 >>= right + reader <- R.openReader runtime child (dir++"/in") >>= right + writer <- R.openWriter runtime child (dir++"/out") >>= right + let copy = do + part <- R.readSome runtime child reader 997 >>= right + unless (BS.null part) $ do + n <- R.writeSome runtime child writer part >>= right + n @?= BS.length part + copy + copy + R.readSome runtime child reader 5 >>= (@?= Right BS.empty) + R.closeScope runtime child >>= (@?= []) + BS.readFile (dir++"/out") >>= (@?= bytes) + R.liveCounts runtime >>= (@?= (1,3)) + R.readSome runtime 0 reader 1 >>= categoryIs "stale-handle" + , testCase "scope ancestry, wrong kind, bounds and cross-run handles" $ + withRuntime $ \runtime -> withRuntime $ \other -> do + a <- R.childScope runtime 0 >>= right + b <- R.childScope runtime 0 >>= right + child <- R.childScope runtime a >>= right + reader <- R.acquire runtime a (pure (R.Reader (const (pure BS.empty)) (pure ()))) >>= right + R.readSome runtime child reader 1 >>= (@?= Right BS.empty) + R.readSome runtime b reader 1 >>= categoryIs "out-of-scope" + R.readSome runtime 0 reader 1 >>= categoryIs "out-of-scope" + R.readSome other 0 reader 1 >>= categoryIs "stale-handle" + R.writeSome runtime a reader (BS.singleton 1) >>= categoryIs "wrong-kind" + forM_ [0,4097] $ \n -> R.readSome runtime a reader n >>= categoryIs "invalid-bound" + R.closeResource runtime a reader >>= (@?= Right ()) + R.closeResource runtime a reader >>= categoryIs "stale-handle" + , testCase "second acquisition failure releases first at scope exit" $ + withSystemTempDirectory "io-open" $ \dir -> withRuntime $ \runtime -> do + released <- newIORef False + scope <- R.childScope runtime 0 >>= right + _ <- R.acquire runtime scope (pure (R.Reader (const (pure BS.empty)) (writeIORef released True))) >>= right + R.openReader runtime scope (dir++"/missing") >>= categoryIs "not-found" + R.closeScope runtime scope >>= (@?= []) + readIORef released >>= (@?= True) + , testCase "release order and cleanup errors do not skip remaining resources" $ + withRuntime $ \runtime -> do + order <- newIORef ([] :: [Int]) + scope <- R.childScope runtime 0 >>= right + forM_ [1,2,3] $ \i -> do + _ <- R.acquire runtime scope (pure (R.Reader (const (pure BS.empty)) + (modifyIORef' order (++[i]) >> when (i == 2) (ioError (userError "close failure"))))) >>= right + pure () + errors <- R.closeScope runtime scope + map R.operation errors @?= ["close"] + readIORef order >>= (@?= [3,2,1]) + R.liveCounts runtime >>= (@?= (1,3)) + , testCase "pending lease is busy; close waits; cancellation releases lease" $ + withRuntime $ \runtime -> do + entered <- newEmptyMVar + blocked <- newEmptyMVar + released <- newEmptyMVar + token <- R.acquire runtime 0 (pure (R.Reader (\_ -> putMVar entered () >> takeMVar blocked) + (putMVar released ()))) >>= right + (reader, readDone) <- spawn (R.readSome runtime 0 token 1) + wait entered + R.readSome runtime 0 token 1 >>= categoryIs "busy" + (_, closeDone) <- spawn (R.closeResource runtime 0 token) + -- Wait for closing admission, not a timing guess about the close worker. + let closing = do + result <- R.readSome runtime 0 token 1 + case result of + Left p | R.category p == "closed-handle" -> pure () + Left p | R.category p == "busy" -> yield >> closing + _ -> assertFailure "unexpected lease state" + (Timeout.timeout 3000000 closing >>= (@?= Just ())) `onException` killThread reader + tryTakeMVar released >>= (@?= Nothing) + killThread reader + readResult <- wait readDone + case readResult of Left e -> assertBool "async cancellation" (case fromException e :: Maybe AsyncException of Just ThreadKilled -> True; _ -> False) + Right _ -> assertFailure "cancelled read resumed" + wait closeDone >>= right >>= (@?= Right ()) + wait released + R.liveCounts runtime >>= (@?= (1,3)) + , testCase "scope closure drains an acquisition already admitted" $ + withRuntime $ \runtime -> do + entered <- newEmptyMVar + proceed <- newEmptyMVar + released <- newEmptyMVar + scope <- R.childScope runtime 0 >>= right + (_, acquired) <- spawn (R.acquire runtime scope (putMVar entered () >> takeMVar proceed >> + pure (R.Reader (const (pure BS.empty)) (putMVar released ())))) + wait entered + (_, closed) <- spawn (R.closeScope runtime scope) + putMVar proceed () + wait acquired >>= right >>= right >>= \_ -> pure () + wait closed >>= right >>= (@?= []) + wait released + R.liveCounts runtime >>= (@?= (1,3)) + , testCase "cancelled acquisition leaves no admission or resource behind" $ + withRuntime $ \runtime -> do + entered <- newEmptyMVar + gate <- newEmptyMVar + scope <- R.childScope runtime 0 >>= right + (worker, finished) <- spawn (R.acquire runtime scope (putMVar entered () >> takeMVar gate)) + wait entered + killThread worker + outcome <- wait finished + case outcome of Left _ -> pure (); Right _ -> assertFailure "acquisition resumed" + R.closeScope runtime scope >>= (@?= []) + R.liveCounts runtime >>= (@?= (1,3)) + , testCase "partial write progress and invalid backend progress" $ + withRuntime $ \runtime -> do + writer <- R.acquire runtime 0 (pure (R.Writer (\_ -> pure 2) (pure ()))) >>= right + R.writeSome runtime 0 writer (BS.pack [1,2,3]) >>= (@?= Right 2) + badWriter <- R.acquire runtime 0 (pure (R.Writer (\_ -> pure 0) (pure ()))) >>= right + result <- R.writeSome runtime 0 badWriter (BS.singleton 1) + categoryIs "io-error" result + case result of Left p -> R.progress p @?= "unknown"; _ -> assertFailure "bad progress accepted" + , testCase "driver host cancellation closes resources and never resumes continuation" $ + withSystemTempDirectory "v2-cancel" $ \dir -> do + runtime <- R.newRuntime + entered <- newEmptyMVar + gate <- newEmptyMVar + released <- newIORef False + token@(R.Token identity key) <- R.acquire runtime 0 + (pure (R.Writer (\_ -> putMVar entered () >> takeMVar gate) (writeIORef released True))) >>= right + let encoded = ofList [ofString "io.v2.resource", ofNumber identity, ofNumber key] + scope = ofList [ofString "io.v2.scope", ofNumber identity, Leaf] + write = request "stream" "write" (Fork encoded (ofBytes (BS.singleton 1))) + -- If resumed, this would create an observable file. + next = Fork Leaf (request "file" "open-writer" (Fork scope (ofString (dir++"/SHOULD-NOT-EXIST")))) + program = Fork (ofNumber 1) (Fork write next) + (worker, finished) <- spawn (V.runInRuntime runtime program) + wait entered + killThread worker + outcome <- wait finished + case outcome of Left e -> assertBool "async outcome not converted to Result" (case fromException e :: Maybe AsyncException of Just ThreadKilled -> True; _ -> False) + Right _ -> assertFailure "cancelled driver returned normally" + readIORef released >>= (@?= True) + R.liveCounts runtime >>= (@?= (0,0)) + R.writeSome runtime 0 token (BS.singleton 1) >>= categoryIs "stale-handle" + files <- listDirectory dir + files @?= [] + , testCase "driver preserves primary failure, aggregates bounded cleanup errors" $ do + runtime <- R.newRuntime + closed <- newIORef (0 :: Int) + replicateM_ 40 $ do + _ <- R.acquire runtime 0 (pure (R.Reader (const (pure BS.empty)) + (modifyIORef' closed (+1) >> throwIO (ErrorCall "backend close fault")))) >>= right + pure () + outcome <- V.runInRuntime runtime (Fork (ofNumber 2) (error "primary diagnostic forced")) + case outcome of + V.Failed _ errors -> do + length errors @?= 17 + R.category (last errors) @?= "cleanup-omitted" + R.detail (last errors) @?= "24" + _ -> assertFailure "primary failure lost" + readIORef closed >>= (@?= 40) + R.liveCounts runtime >>= (@?= (0,0)) + , testCase "successful body becomes failed when cleanup fails" $ do + runtime <- R.newRuntime + _ <- R.acquire runtime 0 (pure (R.Reader (const (pure BS.empty)) (ioError (userError "close")))) >>= right + outcome <- V.runInRuntime runtime (Fork (ofNumber 0) Leaf) + case outcome of V.Failed _ [] -> pure (); _ -> assertFailure "cleanup failure silently discarded" + , testCase "repeated lexical scopes do not retain closed resources or scope history" $ + withRuntime $ \runtime -> replicateM_ 1000 $ do + scope <- R.childScope runtime 0 >>= right + _ <- R.acquire runtime scope (pure (R.Reader (const (pure BS.empty)) (pure ()))) >>= right + R.closeScope runtime scope >>= (@?= []) + R.liveCounts runtime >>= (@?= (1,3)) + , testCase "closing admission preserves child borrowing and child-local acquisition" $ + withRuntime $ \runtime -> do + released <- newIORef (0 :: Int) + let backend = pure (R.Reader (const (pure BS.empty)) (modifyIORef' released (+1))) + token <- R.acquire runtime 0 backend >>= right + child <- R.childScope runtime 0 >>= right + atomically (R.beginCloseScopeSTM runtime 0) + R.acquire runtime 0 backend >>= categoryIs "closed-scope" + R.childScope runtime 0 >>= categoryIs "closed-scope" + R.readSome runtime child token 1 >>= (@?= Right BS.empty) + local <- R.acquire runtime child backend >>= right + grandchild <- R.childScope runtime child >>= right + R.readSome runtime grandchild local 1 >>= (@?= Right BS.empty) + readIORef released >>= (@?= 0) + R.closeScope runtime child >>= (@?= []) + readIORef released >>= (@?= 1) + R.readSome runtime 0 token 1 >>= (@?= Right BS.empty) + R.closeScope runtime 0 >>= (@?= []) + readIORef released >>= (@?= 2) + R.liveCounts runtime >>= (@?= (0,0)) + , testCase "handoff rekeys aliases and preserves acquisition order, not argument order" $ + withRuntime $ \runtime -> do + order <- newIORef ([] :: [Int]) + let backend n = pure (R.Reader (const (pure (BS.singleton (fromIntegral n)))) + (modifyIORef' order (++[n]))) + first <- R.acquire runtime 0 (backend 1) >>= right + second <- R.acquire runtime 0 (backend 2) >>= right + (child, fresh) <- atomically (R.reserveChildSTM runtime 0 [second, first]) >>= right + case fresh of + [newSecond, newFirst] -> do + forM_ [first, second] $ \old -> do + R.readSome runtime child old 1 >>= categoryIs "stale-handle" + R.closeResource runtime 0 old >>= categoryIs "stale-handle" + R.readSome runtime 0 newFirst 1 >>= categoryIs "out-of-scope" + R.readSome runtime child newFirst 1 >>= (@?= Right (BS.singleton 1)) + R.readSome runtime child newSecond 1 >>= (@?= Right (BS.singleton 2)) + _ -> assertFailure "wrong transfer arity" + _ <- R.acquire runtime child (backend 3) >>= right + R.closeScope runtime child >>= (@?= []) + readIORef order >>= (@?= [3,2,1]) + R.liveCounts runtime >>= (@?= (1,3)) + , testCase "failed transfer validates all handles without partial movement" $ + withRuntime $ \runtime -> withRuntime $ \other -> do + let backend = pure (R.Reader (const (pure BS.empty)) (pure ())) + token <- R.acquire runtime 0 backend >>= right + foreignToken <- R.acquire other 0 backend >>= right + child <- R.childScope runtime 0 >>= right + local <- R.acquire runtime child backend >>= right + let rejected owner tokens expected = do + before <- R.liveCounts runtime + atomically (R.reserveChildSTM runtime owner tokens) >>= categoryIs expected + R.liveCounts runtime >>= (@?= before) + R.readSome runtime 0 token 1 >>= (@?= Right BS.empty) + R.readSome runtime child local 1 >>= (@?= Right BS.empty) + rejected 0 [token, token] "duplicate-handle" + rejected 0 [token, R.Token (-1) 999] "stale-handle" + rejected 0 [token, foreignToken] "stale-handle" + rejected 0 [token, local] "out-of-scope" + rejected child [local, token] "not-owner" + atomically (R.beginCloseScopeSTM runtime child) + rejected child [local] "closed-scope" + , testCase "handoff transaction rolls back with task-publication failure" $ + withRuntime $ \runtime -> do + token <- R.acquire runtime 0 (pure (R.Reader (const (pure BS.empty)) (pure ()))) >>= right + publication <- newTVarIO False + result <- try $ atomically $ do + reserved <- R.reserveChildSTM runtime 0 [token] + case reserved of + Left _ -> error "reservation should succeed" + Right _ -> writeTVar publication True >> throwSTM (ErrorCall "publication failed") + case result of Left (_ :: ErrorCall) -> pure (); Right () -> assertFailure "expected rollback" + readTVarIO publication >>= (@?= False) + R.liveCounts runtime >>= (@?= (1,4)) + R.readSome runtime 0 token 1 >>= (@?= Right BS.empty) + R.closeResource runtime 0 token >>= (@?= Right ()) + , testCase "handoff rejects a leased or closing resource, with no partial movement" $ + withRuntime $ \runtime -> do + entered <- newEmptyMVar + proceed <- newEmptyMVar + released <- newIORef False + first <- R.acquire runtime 0 (pure (R.Reader (const (pure BS.empty)) (pure ()))) >>= right + busy <- R.acquire runtime 0 (pure (R.Reader (\_ -> putMVar entered () >> takeMVar proceed) + (writeIORef released True))) >>= right + (_, reading) <- spawn (R.readSome runtime 0 busy 1) + wait entered + atomically (R.reserveChildSTM runtime 0 [first, busy]) >>= categoryIs "busy" + R.liveCounts runtime >>= (@?= (1,5)) + (_, closing) <- spawn (R.closeResource runtime 0 busy) + let marked = do + result <- atomically (R.reserveChildSTM runtime 0 [first, busy]) + case result of + Left e | R.category e == "closed-handle" -> pure () + Left e | R.category e == "busy" -> yield >> marked + _ -> assertFailure "unexpected transfer outcome" + Timeout.timeout 3000000 marked >>= (@?= Just ()) + R.readSome runtime 0 first 1 >>= (@?= Right BS.empty) + readIORef released >>= (@?= False) + putMVar proceed BS.empty + wait reading >>= right >>= (@?= Right BS.empty) + wait closing >>= right >>= (@?= Right ()) + R.liveCounts runtime >>= (@?= (1,4)) + , testCase "close versus handoff has one winner and one backend release" $ + replicateM_ 200 $ withRuntime $ \runtime -> do + released <- newIORef (0 :: Int) + token <- R.acquire runtime 0 (pure (R.Reader (const (pure BS.empty)) + (atomicModifyIORef' released (\n -> (n+1, ()))))) >>= right + gate <- newTVarIO False + let start = atomically (readTVar gate >>= check) + (_, moved) <- spawn (start >> atomically (R.reserveChildSTM runtime 0 [token])) + (_, closed) <- spawn (start >> R.closeResource runtime 0 token) + atomically (writeTVar gate True) + handoff <- wait moved >>= right + close <- wait closed >>= right + case handoff of + Right (child, [fresh]) -> do + categoryIs "stale-handle" close + readIORef released >>= (@?= 0) + R.readSome runtime child fresh 1 >>= (@?= Right BS.empty) + R.closeScope runtime child >>= (@?= []) + Left e -> do + assertBool "close won" (R.category e `elem` ["stale-handle", "closed-handle"]) + close @?= Right () + _ -> assertFailure "wrong handoff arity" + readIORef released >>= (@?= 1) + R.liveCounts runtime >>= (@?= (1,3)) + , testCase "strict octet decode stops without forcing excess element" $ do + V.decodeChunk (ofBytes (BS.pack [0..255])) @?= Right (BS.pack [0..255]) + assertBool "noncanonical zero rejected" (case V.decodeChunk (ofList [Fork Leaf Leaf]) of Left _ -> True; _ -> False) + let tooLong = foldr Fork (Fork (error "excess head forced") (error "excess suffix forced")) (replicate 4096 Leaf) + V.decodeChunk tooLong @?= Left "chunk exceeds bound" + , testCase "v2 dispatch, ordinary errors, lazy failure and version rejection" $ do + let action code x = Fork (ofNumber code) x + envelope version x = Fork (ofString "tricuIO") (Fork (ofNumber version) x) + runIO (envelope 2 (action 0 (ofNumber 7))) >>= (@?= Right (ofNumber 7)) + failed <- V.runDetailed (action 2 (error "diagnostic was forced")) + case failed of V.Failed _ [] -> pure (); _ -> assertFailure "terminal failure lost" + runIO (envelope 99 (action 0 Leaf)) >>= (@?= Left "Unsupported tricuIO version") + let badVersion = Fork Leaf (Fork (Stem Leaf) (Fork Leaf Leaf)) + runIO (Fork (ofString "tricuIO") (Fork badVersion (error "invalid version body forced"))) + >>= (@?= Left "invalid IO envelope version") + runIO (Fork (ofString "tricuIO") (Fork (ofNumber 65536) (error "excess version body forced"))) + >>= (@?= Left "invalid IO envelope version") + ordinary <- V.runDetailed (request "stream" "read" Leaf) + case ordinary of V.Completed (Fork Leaf _) -> pure (); _ -> assertFailure "argument failure not ordinary Result" + ] diff --git a/test/IOUTF8Tests.hs b/test/IOUTF8Tests.hs @@ -0,0 +1,77 @@ +module IOUTF8Tests (ioUTF8Tests) where + +import qualified IO.UTF8 as U +import qualified IO.Resources as R +import qualified IO.Driver as V +import Runtime (T(..), ofList, ofNumber, ofString, ofBytes) +import qualified Data.ByteString as BS +import Data.Char (chr) +import qualified Data.Text as Text +import qualified Data.Text.Encoding as E +import Control.Monad (forM_) +import Test.Tasty +import Test.Tasty.HUnit + +category wanted value = case value of Left p -> R.category p @?= wanted; Right _ -> assertFailure wanted +request op payload = Fork (ofNumber 10) (Fork (ofString "utf8") (Fork (ofNumber 1) (Fork (ofString op) payload))) +bad op action = V.runDetailed action >>= \result -> case result of + V.Completed (Fork Leaf (Fork problem Leaf)) -> problem @?= ofList (map ofString ["invalid-argument","utf8/"++op,"","none"]) + _ -> assertFailure "expected ordinary argument failure" + +ioUTF8Tests = localOption (mkTimeout 30000000) $ testGroup "Strict bounded UTF-8" + [ testCase "independent boundary vectors, NUL, BOM and noncharacters" $ do + forM_ [(0,[0]),(0x7f,[0x7f]),(0x80,[0xc2,0x80]),(0x7ff,[0xdf,0xbf]), + (0x800,[0xe0,0xa0,0x80]),(0xd7ff,[0xed,0x9f,0xbf]),(0xe000,[0xee,0x80,0x80]), + (0xfeff,[0xef,0xbb,0xbf]),(0xffff,[0xef,0xbf,0xbf]), + (0x10000,[0xf0,0x90,0x80,0x80]),(0x10ffff,[0xf4,0x8f,0xbf,0xbf])] $ \(scalar,octets) -> do + let bytes = BS.pack octets + U.encode [chr scalar] @?= Right bytes + U.decode True bytes @?= Right ([chr scalar],BS.empty) + forM_ [1..BS.length bytes-1] $ \split -> do + U.decode False (BS.take split bytes) @?= Right ([],BS.take split bytes) + U.decode True (BS.take split bytes) `seq` category "invalid-data" (U.decode True (BS.take split bytes)) + , testCase "every Unicode scalar agrees with the standard UTF-8 encoder" $ do + forM_ ([0..0xd7ff] ++ [0xe000..0x10ffff]) $ \scalar -> do + let text = [chr scalar] + bytes = E.encodeUtf8 (Text.pack text) + U.decode True bytes @?= Right (text,BS.empty) + , testCase "incomplete valid suffix is explicit and composes at every split" $ do + let text = "A\0λ😀\r\n\xfeff" + bytes = E.encodeUtf8 (Text.pack text) + forM_ [0..BS.length bytes] $ \split -> do + case U.decode False (BS.take split bytes) of + Left p -> assertFailure (show p) + Right (first,carry) -> do + assertBool "oversized carry" (BS.length carry <= 3) + U.decode True (carry <> BS.drop split bytes) @?= Right (drop (length first) text,BS.empty) + case U.decode True (carry <> BS.drop split bytes) of + Right (last,_) -> first ++ last @?= text + _ -> assertFailure "split decode failed" + , testCase "malformed and noncanonical prefixes fail even before final input" $ do + forM_ [[0x80],[0xc0],[0xc1],[0xf5],[0xff],[0xe0,0x80],[0xed,0xa0], + [0xf0,0x80],[0xf4,0x90],[0xe2,0x28],[0xf0,0x90,0x7f], + [0xc2,0x7f],[0xe0,0xa0,0x7f],[0xf0,0x90,0x80,0xc0]] $ \bytes -> + forM_ [False,True] $ \final -> category "invalid-data" (U.decode final (BS.pack bytes)) + case U.decode False ("good" <> BS.singleton 0xff) of + Left p -> R.detail p @?= "invalid UTF-8 leading byte at byte 4" + _ -> assertFailure "invalid suffix accepted" + , testCase "bounds and surrogate controls do not demand excess characters" $ do + U.encode (replicate 1024 '\x10ffff') @?= Right (BS.concat (replicate 1024 (BS.pack [0xf4,0x8f,0xbf,0xbf]))) + category "invalid-bound" (U.encode (replicate 1024 'a' ++ (error "excess scalar":error "excess tail"))) + forM_ [0xd800,0xdfff] $ \n -> category "invalid-data" (U.encode [chr n]) + category "invalid-bound" (U.decode False (BS.replicate 4097 0)) + U.decode True BS.empty @?= Right ([],BS.empty) + U.encode [] @?= Right BS.empty + , testCase "wire scalar controls reject aliases, surrogates, overflow and excess" $ do + forM_ [Fork Leaf Leaf,ofNumber 0xd800,ofNumber 0x110000] $ \scalar -> + bad "encode" (request "encode" (ofList [scalar])) + let excess = foldr Fork (Fork (error "excess wire scalar") (error "excess wire tail")) (replicate 1024 (ofNumber 97)) + bad "encode" (request "encode" excess) + bad "decode" (request "decode" (ofList [ofNumber 2,error "invalid final forced bytes"])) + bad "decode" (request "decode" (ofList [ofNumber 1,ofList [ofNumber 256]])) + , testCase "wire empty text and NUL are valid, distinct values" $ do + forM_ [(Leaf,BS.empty),(ofList [Leaf],BS.singleton 0)] $ \(text,bytes) -> + V.runDetailed (request "encode" text) >>= \result -> case result of + V.Completed value -> value @?= Fork (Stem Leaf) (Fork (ofBytes bytes) Leaf) + _ -> assertFailure "valid text rejected" + ] diff --git a/test/IOV2ByteStreamTests.hs b/test/IOV2ByteStreamTests.hs @@ -1,150 +0,0 @@ -{-# LANGUAGE ScopedTypeVariables #-} -module IOV2ByteStreamTests (ioV2ByteStreamTests) where - -import qualified IOV2.ByteStream as B -import qualified IOV2.Resources as R -import Control.Concurrent -import Control.Exception -import Control.Monad (forM_) -import Crypto.Hash (hash, Digest, SHA256) -import qualified Data.ByteString as BS -import Data.IORef -import System.Directory (listDirectory) -import System.FilePath ((</>)) -import System.IO (hClose) -import System.IO.Temp (withSystemTempDirectory) -import Test.Tasty -import Test.Tasty.HUnit - -sha :: BS.ByteString -> String -sha bytes = show (hash bytes :: Digest SHA256) -category wanted value = case value of - Left p -> R.category p @?= wanted - Right _ -> assertFailure ("expected " ++ wanted) -reader runtime bytes = do - remaining <- newIORef bytes - Right token <- R.acquire runtime R.rootScope (pure (R.Reader (\n -> - atomicModifyIORef' remaining (\rest -> (BS.drop n rest, BS.take n rest))) (pure ()))) - pure token - -ioV2ByteStreamTests = localOption (mkTimeout 30000000) $ testGroup "IO v2 native byte streams" - [ testCase "authenticated binary snapshots and partial writes across chunk boundaries" $ - withSystemTempDirectory "tricu-bytes" $ \directory -> forM_ [0,1,4095,4096,4097,917828,1048576] $ \size -> do - let bytes = BS.pack (take size (cycle [0..255])) - runtime <- R.newRuntime - input <- reader runtime bytes - Right (snapshot,count) <- B.snapshot runtime R.rootScope input directory (toInteger size) (sha bytes) - count @?= toInteger size - listDirectory directory >>= (@?= []) - chunks <- newIORef [] - Right output <- R.acquire runtime R.rootScope (pure (R.Writer (\chunk -> do - let part = BS.take 997 chunk - modifyIORef' chunks (part:) - pure (BS.length part)) (pure ()))) - B.transfer runtime R.rootScope snapshot output count >>= (@?= Right ()) - BS.concat . reverse <$> readIORef chunks >>= (@?= bytes) - R.readSome runtime R.rootScope snapshot 1 >>= (@?= Right BS.empty) - R.seekFile runtime R.rootScope snapshot 0 >>= (@?= Right ()) - R.fileSize runtime R.rootScope snapshot >>= (@?= Right count) - R.writeSome runtime R.rootScope snapshot "x" >>= category "wrong-kind" - R.resizeFile runtime R.rootScope snapshot 0 >>= category "unsupported" - R.closeScope runtime R.rootScope >>= (@?= []) - , testCase "digest and limit failures remove the spool; EOF at the limit is required" $ - withSystemTempDirectory "tricu-bytes" $ \directory -> do - runtime <- R.newRuntime - forM_ [("abc",3,sha "xyz","integrity-failed"),("abcd",3,sha "abc","invalid-bound"),("",0,sha "x","integrity-failed")] $ \(bytes,limit,digest,wanted) -> do - input <- reader runtime bytes - counts <- R.liveCounts runtime - B.snapshot runtime R.rootScope input directory limit digest >>= category wanted - R.liveCounts runtime >>= (@?= counts) - listDirectory directory >>= (@?= []) - input <- reader runtime "abc" - B.snapshot runtime R.rootScope input directory (-1) (sha "abc") >>= category "invalid-bound" - B.snapshot runtime R.rootScope input directory 3 (replicate 64 'Z') >>= category "invalid-argument" - R.readSome runtime R.rootScope input 3 >>= (@?= Right "abc") - R.closeScope runtime R.rootScope >>= (@?= []) - , testCase "source mutation after snapshot cannot change transferred bytes" $ - withSystemTempDirectory "tricu-bytes" $ \directory -> do - runtime <- R.newRuntime - -- Injectable source bytes model in-place changes, independent of host - -- Handle locking of a simultaneously opened file. - source <- newIORef "original" - cursor <- newIORef 0 - Right input <- R.acquire runtime R.rootScope (pure (R.Reader (\n -> do - pos <- readIORef cursor - bytes <- BS.take n . BS.drop pos <$> readIORef source - modifyIORef' cursor (+ BS.length bytes) - pure bytes) (pure ()))) - Right (snapshot,count) <- B.snapshot runtime R.rootScope input directory 100 (sha "original") - writeIORef source "modified" - Right output <- R.openWriter runtime R.rootScope (directory </> "output") - B.transfer runtime R.rootScope snapshot output count >>= (@?= Right ()) - R.closeScope runtime R.rootScope >>= (@?= []) - BS.readFile (directory </> "output") >>= (@?= "original") - , testCase "mutation during copying fails authentication" $ - withSystemTempDirectory "tricu-bytes" $ \directory -> do - runtime <- R.newRuntime - let original = BS.replicate 8192 65 - calls <- newIORef (0 :: Int) - Right input <- R.acquire runtime R.rootScope (pure (R.Reader (\n -> do - call <- atomicModifyIORef' calls (\x -> (x+1,x)) - pure (if call < 2 then BS.replicate n (if call == 0 then 65 else 66) else BS.empty)) (pure ()))) - B.snapshot runtime R.rootScope input directory 8192 (sha original) >>= category "integrity-failed" - listDirectory directory >>= (@?= []) - R.closeScope runtime R.rootScope >>= (@?= []) - , testCase "snapshot cancellation keeps scoped cleanup and releases source lease" $ - withSystemTempDirectory "tricu-bytes" $ \directory -> do - runtime <- R.newRuntime - entered <- newEmptyMVar; block <- newEmptyMVar; done <- newEmptyMVar - Right input <- R.acquire runtime R.rootScope (pure (R.Reader (\_ -> putMVar entered () >> takeMVar block) (pure ()))) - released <- newIORef (0 :: Int) - worker <- forkIO $ (B.snapshotWith (\h -> modifyIORef' released (+1) >> hClose h) - runtime R.rootScope input directory 4096 (sha "") >> pure ()) - `catch` (\(_ :: AsyncException) -> pure ()) `finally` putMVar done () - takeMVar entered - listDirectory directory >>= (@?= []) - killThread worker; takeMVar done - R.closeScope runtime R.rootScope >>= (@?= []) - readIORef released >>= (@?= 1) - , testCase "cleanup failure preserves primary integrity diagnostic" $ - withSystemTempDirectory "tricu-bytes" $ \directory -> do - runtime <- R.newRuntime - input <- reader runtime "bad" - result <- B.snapshotWith (\h -> hClose h >> ioError (userError "injected close")) - runtime R.rootScope input directory 3 (sha "yes") - category "integrity-failed" result - case result of Left p -> assertBool "cleanup recorded" (length (R.detail p) > length ("SHA-256 mismatch" :: String)); _ -> pure () - R.closeScope runtime R.rootScope >>= (@?= []) - , testCase "transfer failure and cancellation release leases without retrying" $ - withSystemTempDirectory "tricu-bytes" $ \directory -> do - runtime <- R.newRuntime - input <- reader runtime "abcdef" - writes <- newIORef (0 :: Int) - Right broken <- R.acquire runtime R.rootScope (pure (R.Writer (\_ -> do - modifyIORef' writes (+1) - ioError (userError "peer disconnected")) (pure ()))) - B.transfer runtime R.rootScope input broken 6 >>= category "io-error" - readIORef writes >>= (@?= 1) - R.closeResource runtime R.rootScope broken >>= (@?= Right ()) - second <- reader runtime "abcdef" - entered <- newEmptyMVar; block <- newEmptyMVar; done <- newEmptyMVar - Right blocked <- R.acquire runtime R.rootScope (pure (R.Writer (\_ -> putMVar entered () >> takeMVar block) (pure ()))) - worker <- forkIO $ (B.transfer runtime R.rootScope second blocked 6 >> pure ()) - `catch` (\(_ :: AsyncException) -> pure ()) `finally` putMVar done () - takeMVar entered - R.readSome runtime R.rootScope second 1 >>= category "busy" - R.writeSome runtime R.rootScope blocked "x" >>= category "busy" - killThread worker; takeMVar done - R.closeResource runtime R.rootScope second >>= (@?= Right ()) - R.closeResource runtime R.rootScope blocked >>= (@?= Right ()) - R.closeScope runtime R.rootScope >>= (@?= []) - , testCase "transfer rejects short sources and wrong-kind targets" $ do - runtime <- R.newRuntime - input <- reader runtime "x" - B.transfer runtime R.rootScope input input 1 >>= category "wrong-kind" - chunks <- newIORef [] - Right output <- R.acquire runtime R.rootScope (pure (R.Writer (\bytes -> modifyIORef' chunks (bytes:) >> pure (BS.length bytes)) (pure ()))) - B.transfer runtime R.rootScope input output 2 >>= category "invalid-data" - readIORef chunks >>= (@?= ["x"]) - R.closeScope runtime R.rootScope >>= (@?= []) - ] diff --git a/test/IOV2ClockTests.hs b/test/IOV2ClockTests.hs @@ -1,68 +0,0 @@ -{-# LANGUAGE ScopedTypeVariables #-} -module IOV2ClockTests (ioV2ClockTests) where - -import qualified IOV2.Clock as C -import qualified IOV2.Driver as V -import qualified IOV2.Protocol as P -import Runtime (T(..), ofNumber, ofString, ofList) -import Control.Concurrent -import Control.Exception -import Control.Monad -import Data.IORef -import System.Timeout (timeout) -import Test.Tasty -import Test.Tasty.HUnit - -request g op payload = Fork (ofNumber 10) (Fork (ofString g) (Fork (ofNumber 1) (Fork (ofString op) payload))) -ok value = Fork (Stem Leaf) (Fork value Leaf) -completed action value = V.runDetailed action >>= \result -> case result of - V.Completed actual -> actual @?= value - _ -> assertFailure "expected completion" - -ioV2ClockTests = localOption (mkTimeout 10000000) $ testGroup "IO v2 clocks/scheduler" - [ testCase "monotonic result is canonical unsigned Word64, not a wall epoch" $ do - before <- C.monotonicNanoseconds - V.runDetailed (request "clock" "monotonic" Leaf) >>= \outcome -> case outcome of - V.Completed (Fork (Stem Leaf) (Fork value Leaf)) -> case V.canonicalNat 64 value of - Just value -> do - after <- C.monotonicNanoseconds - assertBool "sample order" (before <= value && value <= after) - _ -> assertFailure "noncanonical clock value" - _ -> assertFailure "clock failed" - , testCase "zero sleep and yield return ordinary unit Results" $ do - completed (request "clock" "sleep" Leaf) (ok Leaf) - completed (request "scheduler" "yield" Leaf) (ok Leaf) - , testCase "relative sleep does not acknowledge before the requested duration" $ do - before <- C.monotonicNanoseconds - completed (request "clock" "sleep" (ofNumber 20)) (ok Leaf) - after <- C.monotonicNanoseconds - assertBool "early sleep completion" (after-before >= 20000000) - , testCase "wait chunks preserve milliseconds and never overflow host microseconds" $ do - calls <- newIORef [] - C.sleepWith (\n -> modifyIORef' calls (++[n])) 2501 - readIORef calls >>= (@?= [1000000,1000000,501000]) - writeIORef calls [] - C.sleepWith (\n -> modifyIORef' calls (++[n])) 0 - readIORef calls >>= (@?= []) - result <- try (C.sleepWith (\n -> writeIORef calls [n] >> ioError (userError "stop")) 9223372036854775807) :: IO (Either IOException ()) - case result of Left _ -> pure (); _ -> assertFailure "waiter failure ignored" - readIORef calls >>= (@?= [1000000]) - , testCase "maximum relative wait is interruptible rather than an overflowing short wait" $ do - entered <- newEmptyMVar - done <- newEmptyMVar - tid <- forkIO $ (try (putMVar entered () >> C.sleepMilliseconds 9223372036854775807) :: IO (Either SomeException ())) >>= putMVar done - takeMVar entered - threadDelay 20000 - tryReadMVar done >>= \result -> case result of Nothing -> pure (); _ -> assertFailure "wait completed early" - killThread tid - timeout 2000000 (takeMVar done) >>= \result -> case result of - Just (Left _) -> pure () - _ -> assertFailure "wait ignored interruption" - , testCase "invalid clock/scheduler arguments fail ordinarily without truncation" $ - forM_ [("clock","sleep",ofNumber 9223372036854775808), ("clock","sleep",Fork Leaf Leaf), - ("clock","sleep",Stem Leaf), ("clock","monotonic",ofNumber 1), ("scheduler","yield",ofNumber 1)] $ \(g,op,args) -> - completed (request g op args) (Fork Leaf (Fork (ofList (map ofString ["invalid-argument",g++"/"++op,"","none"])) Leaf)) - , testCase "clock inspection is inert even with an undefined duration" $ - P.inspectAction (request "clock" "sleep" (error "duration demanded")) @?= - Right (ofList [ofString "facility",ofString "clock",ofNumber 1,ofString "sleep",ofString "available"]) - ] diff --git a/test/IOV2ContextTests.hs b/test/IOV2ContextTests.hs @@ -1,39 +0,0 @@ -module IOV2ContextTests (ioV2ContextTests) where -import qualified IOV2.Context as C -import qualified IOV2.Driver as V -import qualified IOV2.Resources as R -import qualified IOV2.Protocol as P -import Runtime (T(..),ofString,ofList,ofNumber) -import System.Directory (getCurrentDirectory) -import System.Environment (lookupEnv) -import Test.Tasty -import Test.Tasty.HUnit - -request op payload = Fork (ofNumber 10) (Fork (ofString "process") (Fork (ofNumber 1) (Fork (ofString op) payload))) -ok value = Fork (Stem Leaf) (Fork value Leaf) -check action expected = V.runDetailed action >>= \outcome -> case outcome of - V.Completed value -> value @?= expected - _ -> assertFailure (show outcome) -ioV2ContextTests = testGroup "IO v2 read-only process context" - [ testCase "cwd is the actual host directory" $ do - expected <- getCurrentDirectory - check (request "working-directory" Leaf) (ok (ofString expected)) - , testCase "environment query is distinct from lexical application configuration" $ do - expected <- lookupEnv "PATH" - actual <- C.environmentVariable "PATH" - actual @?= traverse (C.boundedText "environment-variable") expected - , testCase "invalid names and payloads reject ordinarily" $ do - mapM_ (\name -> C.environmentVariable name >>= \result -> case result of - Left _ -> pure (); _ -> assertFailure "invalid name accepted") ["","A=B","A\0B",replicate 4097 'x'] - let bad op = Fork Leaf (Fork (ofList (map ofString ["invalid-argument","process/"++op,"","none"])) Leaf) - check (request "working-directory" (Stem Leaf)) (bad "working-directory") - check (request "environment-variable" (Stem Leaf)) (bad "environment-variable") - , testCase "result bounds and strict scalars do not truncate or normalize" $ do - C.boundedText "test" "λ\n\xFEFF" @?= Right "λ\n\xFEFF" - C.boundedText "test" (replicate 4096 'x') @?= Right (replicate 4096 'x') - mapM_ (\value -> case C.boundedText "test" value of - Left _ -> pure (); _ -> assertFailure "invalid context text accepted") [replicate 4097 'x',"\xD800","\0"] - , testCase "context inspection does not execute or demand payloads" $ do - P.inspectAction (request "working-directory" (error "demanded")) @?= - Right (ofList [ofString "facility",ofString "process",ofNumber 1,ofString "working-directory",ofString "available"]) - ] diff --git a/test/IOV2FilesystemTests.hs b/test/IOV2FilesystemTests.hs @@ -1,158 +0,0 @@ -{-# LANGUAGE ScopedTypeVariables #-} -module IOV2FilesystemTests (ioV2FilesystemTests) where -import qualified IOV2.Filesystem as F -import qualified IOV2.Resources as R -import qualified System.Posix.Files as P -import qualified System.Posix.Files.ByteString as PB -import qualified Data.ByteString.Char8 as B -import System.IO.Temp (withSystemTempDirectory) -import Control.Concurrent -import Control.Concurrent.STM -import Control.Exception -import Control.Monad -import Data.IORef -import Data.List (sort) -import System.Timeout (timeout) -import Test.Tasty -import Test.Tasty.HUnit - -right :: Show e => Either e a -> IO a -right = either (\e -> assertFailure (show e) >> error "assertion") pure -category name result = case result of - Left p -> R.category p @?= name - _ -> assertFailure ("expected "++name) -fixture body = withSystemTempDirectory "v2-fs" $ \dir -> - bracket R.newRuntime (\r -> void (R.closeScope r 0)) (\r -> body r dir) -allNames r scope token = do - name <- R.nextDirectory r scope token >>= right - case name of Nothing -> pure []; Just value -> (value:) <$> allNames r scope token -wait done = timeout 3000000 (readMVar done) >>= maybe (assertFailure "timeout" >> error "timeout") pure - -ioV2FilesystemTests = localOption (mkTimeout 30000000) $ testGroup "IO v2 filesystem" - [ testCase "stat/lstat distinguish symlinks, dangling links, directory and file" $ fixture $ \_ dir -> do - B.writeFile (dir++"/file") (B.pack "abc") - P.createSymbolicLink "file" (dir++"/link") - P.createSymbolicLink "absent" (dir++"/dangling") - F.statPath True (dir++"/file") >>= (@?= Right (F.Regular 3)) - F.statPath True dir >>= (@?= Right F.Directory) - F.statPath True (dir++"/link") >>= (@?= Right (F.Regular 3)) - F.statPath False (dir++"/link") >>= (@?= Right F.Symlink) - F.statPath False (dir++"/dangling") >>= (@?= Right F.Symlink) - F.statPath True (dir++"/dangling") >>= category "not-found" - F.statPath True "" >>= category "invalid-argument" - , testCase "strict directory creation and nonrecursive removal retain contents" $ fixture $ \_ dir -> do - F.createDirectory (dir++"/new") >>= (@?= Right ()) - F.createDirectory (dir++"/new") >>= category "already-exists" - B.writeFile (dir++"/new/item") (B.pack "keep") - F.removeDirectory (dir++"/new") >>= \reply -> case reply of - Left p -> R.progress p @?= "unknown" - _ -> assertFailure "removed nonempty directory" - B.readFile (dir++"/new/item") >>= (@?= B.pack "keep") - F.removeFile (dir++"/new/item") >>= (@?= Right ()) - F.removeDirectory (dir++"/new") >>= (@?= Right ()) - F.statPath False (dir++"/new") >>= category "not-found" - , testCase "rename replaces names; unlink removes link rather than its target" $ fixture $ \_ dir -> do - B.writeFile (dir++"/old") (B.pack "new") - B.writeFile (dir++"/target") (B.pack "prior") - F.renamePath (dir++"/old") (dir++"/target") >>= (@?= Right ()) - F.statPath True (dir++"/old") >>= category "not-found" - B.readFile (dir++"/target") >>= (@?= B.pack "new") - P.createSymbolicLink "target" (dir++"/link") - F.removeFile (dir++"/link") >>= (@?= Right ()) - B.readFile (dir++"/target") >>= (@?= B.pack "new") - F.removeFile dir >>= category "io-error" - , testCase "directory is bounded per entry, excludes dots, has sticky EOF and normal lifetime" $ fixture $ \r dir -> do - let names = ["a", "λ", "space name", "line\nname"] - forM_ names $ \name -> B.writeFile (dir++"/"++name) B.empty - d <- F.openDirectory r 0 dir >>= right - allNames r 0 d >>= (\actual -> sort actual @?= sort names) - R.nextDirectory r 0 d >>= (@?= Right Nothing) - R.nextDirectory r 0 d >>= (@?= Right Nothing) - R.readSome r 0 d 1 >>= category "wrong-kind" - R.fileSize r 0 d >>= category "wrong-kind" - R.closeResource r 0 d >>= (@?= Right ()) - R.nextDirectory r 0 d >>= category "stale-handle" - R.liveCounts r >>= (@?= (1,3)) - , testCase "directory cursor follows opened inode across rename and transfer" $ fixture $ \r dir -> do - F.createDirectory (dir++"/old") >>= right - B.writeFile (dir++"/old/item") B.empty - d <- F.openDirectory r 0 (dir++"/old") >>= right - F.renamePath (dir++"/old") (dir++"/new") >>= right - F.createDirectory (dir++"/old") >>= right - (scope, tokens) <- atomically (R.reserveChildSTM r 0 [d]) >>= right - case tokens of - [fresh] -> allNames r scope fresh >>= (@?= ["item"]) - _ -> assertFailure "transfer failed" - R.nextDirectory r 0 d >>= category "stale-handle" - R.closeScope r scope >>= (@?= []) - R.liveCounts r >>= (@?= (1,3)) - , testCase "IO failure poisons cursor without replay and close runs once" $ fixture $ \r _ -> do - calls <- newIORef (0::Int); closed <- newIORef (0::Int) - d <- R.acquire r 0 (F.directoryBackend - (modifyIORef' calls (+1) >> ioError (userError "read fault")) - (modifyIORef' closed (+1))) >>= right - R.nextDirectory r 0 d >>= category "io-error" - R.nextDirectory r 0 d >>= category "invalid-state" - readIORef calls >>= (@?= 1) - R.closeResource r 0 d >>= (@?= Right ()) - R.closeScope r 0 >>= (@?= []) - readIORef closed >>= (@?= 1) - , testCase "failed raw close reports uncertainty and is never retried" $ fixture $ \r _ -> do - calls <- newIORef (0::Int) - d <- R.acquire r 0 (F.directoryBackend (pure "") - (modifyIORef' calls (+1) >> ioError (userError "close fault"))) >>= right - R.closeResource r 0 d >>= \reply -> case reply of - Left p -> R.progress p @?= "unknown" - _ -> assertFailure "close fault disappeared" - R.closeResource r 0 d >>= category "stale-handle" - R.closeScope r 0 >>= (@?= []) - readIORef calls >>= (@?= 1) - , testCase "async close settlement cannot reuse a possibly freed directory pointer" $ fixture $ \r _ -> do - calls <- newIORef (0::Int) - d <- R.acquire r 0 (F.directoryBackend (pure "") - (modifyIORef' calls (+1) >> throwIO ThreadKilled)) >>= right - result <- try (R.closeResource r 0 d) - case result of - Left (_ :: AsyncException) -> pure () - Right _ -> assertFailure "async close swallowed" - R.closeScope r 0 >>= (@?= []) - readIORef calls >>= (@?= 1) - , testCase "invalid Unicode, components and excess names poison without demanding excess character" $ fixture $ \r _ -> do - forM_ [("\xdcff", "invalid-encoding"),("a/b", "invalid-name"), - (replicate 4096 'a' ++ [error "excess name character forced"], "invalid-name")] $ \(name, expected) -> do - d <- R.acquire r 0 (F.directoryBackend (pure name) (pure ())) >>= right - R.nextDirectory r 0 d >>= category expected - R.nextDirectory r 0 d >>= category "invalid-state" - R.closeResource r 0 d >>= (@?= Right ()) - , testCase "actual non-Unicode directory entry fails rather than emitting replacement text" $ fixture $ \r dir -> do - PB.createSymbolicLink (B.pack "absent") (B.pack (dir++"/") <> B.singleton '\xff') - d <- F.openDirectory r 0 dir >>= right - R.nextDirectory r 0 d >>= category "invalid-encoding" - R.nextDirectory r 0 d >>= category "invalid-state" - , testCase "cancelled cursor holds one lease, poisons, and releases before settlement" $ fixture $ \r _ -> do - entered <- newEmptyMVar; gate <- newEmptyMVar; done <- newEmptyMVar - closed <- newIORef False - d <- R.acquire r 0 (F.directoryBackend (putMVar entered () >> takeMVar gate) - (writeIORef closed True)) >>= right - bracket (forkFinally (R.nextDirectory r 0 d) (putMVar done)) - (\worker -> killThread worker >> void (wait done)) $ \worker -> do - takeMVar entered - R.nextDirectory r 0 d >>= category "busy" - atomically (R.reserveChildSTM r 0 [d]) >>= category "busy" - readIORef closed >>= (@?= False) - killThread worker - wait done >>= \outcome -> case outcome of - Left _ -> pure () - Right _ -> assertFailure "cancelled operation resumed" - R.nextDirectory r 0 d >>= category "invalid-state" - R.closeResource r 0 d >>= (@?= Right ()) - readIORef closed >>= (@?= True) - , testCase "EOF never re-enters host and repeated dot entries have bounded scanning" $ fixture $ \r _ -> do - count <- newIORef (0::Int) - d <- R.acquire r 0 (F.directoryBackend (modifyIORef' count (+1) >> pure "") (pure ())) >>= right - replicateM_ 10 (R.nextDirectory r 0 d >>= (@?= Right Nothing)) - readIORef count >>= (@?= 1) - looping <- R.acquire r 0 (F.directoryBackend (pure ".") (pure ())) >>= right - R.nextDirectory r 0 looping >>= category "invalid-state" - R.nextDirectory r 0 looping >>= category "invalid-state" - ] diff --git a/test/IOV2NetworkTests.hs b/test/IOV2NetworkTests.hs @@ -1,220 +0,0 @@ -{-# LANGUAGE ScopedTypeVariables #-} -module IOV2NetworkTests (ioV2NetworkTests) where -import qualified IOV2.Resources as R -import qualified IOV2.Network as TCP -import qualified IOV2.Tasks as S -import qualified IOV2.Driver as V -import Runtime (T(..), ofString, ofNumber, ofList) -import qualified Network.Socket as N -import qualified Network.Socket.ByteString as NB -import qualified Data.ByteString as B -import Control.Concurrent -import Control.Concurrent.STM -import Control.Exception -import Control.Monad -import Data.IORef -import GHC.Conc (threadStatus, ThreadStatus(..)) -import System.Timeout (timeout) -import Test.Tasty -import Test.Tasty.HUnit - -right :: Show e => Either e a -> IO a -right = either (\e -> assertFailure (show e) >> error "assertion") pure -category name result = case result of - Left p -> R.category p @?= name - _ -> assertFailure ("expected "++name) -withRuntime = bracket R.newRuntime (\r -> void (R.closeScope r 0)) -wait done = timeout 3000000 (readMVar done) >>= maybe (assertFailure "timeout" >> error "timeout") pure -fork action = do - done <- newEmptyMVar - worker <- forkFinally action (putMVar done) - pure (worker,done) -stop (worker,done) = killThread worker >> void (wait done) --- Probing a live socket with an exclusive address query can WIN the lease and --- make the intended reader/acceptor fail busy. Observe the actual worker instead. -waitBlocked worker = do - ready <- timeout 3000000 loop - case ready of Just () -> pure (); Nothing -> assertFailure "worker never blocked" - where loop = threadStatus worker >>= \status -> case status of - ThreadBlocked _ -> pure () - ThreadFinished -> assertFailure "worker finished instead of blocking" - ThreadDied -> assertFailure "worker died instead of blocking" - _ -> yield >> loop -loop4 = R.IPv4 (127,0,0,1) 0 -loop6 = R.IPv6 (0,0,0,0,0,0,0,1) 0 0 -connected r target = do - listener <- TCP.listenTCP r 0 target 16 >>= right - address <- R.socketAddress r 0 listener False >>= right - client <- TCP.connectTCP r 0 address >>= right - server <- R.acceptConnection r 0 listener 0 >>= right - pure (listener,client,server,address) -write r token bytes = do - n <- R.writeSome r 0 token bytes >>= right - if n == B.length bytes then pure () else write r token (B.drop n bytes) -readAll r scope token = do - bytes <- R.readSome r scope token 4096 >>= right - if B.null bytes then pure B.empty else B.append bytes <$> readAll r scope token - -ioV2NetworkTests = localOption (mkTimeout 30000000) $ testGroup "IO v2 TCP" - [ testCase "real IPv4/IPv6 byte streams, addresses, EOF and independent write half-close" $ withRuntime $ \r -> - forM_ [loop4,loop6] $ \target -> do - (listener,client,server,address) <- connected r target - R.socketAddress r 0 server False >>= (@?= Right address) - R.socketAddress r 0 client True >>= (@?= Right address) - R.socketAddress r 0 listener True >>= category "wrong-kind" - R.readSome r 0 listener 1 >>= category "wrong-kind" - R.filePosition r 0 client >>= category "wrong-kind" - let bytes = B.pack ([0..255]++[255,0,13,10]) - write r client bytes - R.shutdownWrite r 0 client >>= (@?= Right ()) - R.shutdownWrite r 0 client >>= (@?= Right ()) - R.writeSome r 0 client (B.singleton 7) >>= category "invalid-state" - readAll r 0 server >>= (@?= bytes) - R.readSome r 0 server 1 >>= (@?= Right B.empty) - write r server (B.reverse bytes) - R.shutdownWrite r 0 server >>= (@?= Right ()) - readAll r 0 client >>= (@?= B.reverse bytes) - mapM_ (\token -> R.closeResource r 0 token >>= (@?= Right ())) [listener,client,server] - R.liveCounts r >>= (@?= (1,3)) - , testCase "duplex leases coexist, release only their own bit, and close waits for both" $ withRuntime $ \r -> do - enteredRead <- newEmptyMVar; enteredWrite <- newEmptyMVar - readGate <- newEmptyMVar; writeGate <- newEmptyMVar - released <- newIORef False - let methods = R.DuplexOps - (\_ -> putMVar enteredRead () >> takeMVar readGate >> pure B.empty) - (\bytes -> putMVar enteredWrite () >> takeMVar writeGate >> pure (B.length bytes)) - (pure ()) (pure loop4) (pure loop4) (writeIORef released True) - token <- R.acquire r 0 (pure (R.Duplex methods)) >>= right - bracket (fork (R.readSome r 0 token 1)) stop $ \(_,readDone) -> - bracket (fork (R.writeSome r 0 token (B.singleton 1))) stop $ \(_,writeDone) -> do - wait enteredRead; wait enteredWrite - R.readSome r 0 token 1 >>= category "busy" - R.writeSome r 0 token (B.singleton 2) >>= category "busy" - R.shutdownWrite r 0 token >>= category "busy" - R.socketAddress r 0 token False >>= category "busy" - atomically (R.reserveChildSTM r 0 [token]) >>= category "busy" - putMVar readGate () - wait readDone >>= right >>= (@?= Right B.empty) - -- Finishing a read must not clear the simultaneous write lease. - atomically (R.reserveChildSTM r 0 [token]) >>= category "busy" - R.writeSome r 0 token (B.singleton 3) >>= category "busy" - bracket (fork (R.closeResource r 0 token)) stop $ \(_,closed) -> do - threadDelay 20000 - isEmptyMVar closed >>= (@?= True) - readIORef released >>= (@?= False) - putMVar writeGate () - wait writeDone >>= right >>= (@?= Right 1) - wait closed >>= right >>= (@?= Right ()) - readIORef released >>= (@?= True) - , testCase "real pending receive still permits send and shutdown-write" $ withRuntime $ \r -> do - (_,client,server,_) <- connected r loop4 - bracket (fork (R.readSome r 0 server 1)) stop $ \(worker,done) -> do - waitBlocked worker - write r server (B.singleton 255) - R.shutdownWrite r 0 server >>= (@?= Right ()) - readAll r 0 client >>= (@?= B.singleton 255) - write r client (B.singleton 7) - wait done >>= right >>= (@?= Right (B.singleton 7)) - , testCase "cancelled receive poisons only that direction and drains its lease" $ withRuntime $ \r -> do - (_,client,server,_) <- connected r loop4 - bracket (fork (R.readSome r 0 server 1)) stop $ \worker@(_,done) -> do - waitBlocked (fst worker) - stop worker - wait done >>= \outcome -> case outcome of Left _ -> pure (); _ -> assertFailure "receive resumed" - R.readSome r 0 server 1 >>= category "invalid-state" - write r server (B.singleton 8) - R.shutdownWrite r 0 server >>= (@?= Right ()) - readAll r 0 client >>= (@?= B.singleton 8) - , testCase "accepted connection transfers atomically and child settlement sends EOF" $ withRuntime $ \r -> do - (_,client,server,_) <- connected r loop4 - (scope,tokens) <- atomically (R.reserveChildSTM r 0 [server]) >>= right - R.readSome r 0 server 1 >>= category "stale-handle" - write r client (B.pack [0,255]) - R.shutdownWrite r 0 client >>= (@?= Right ()) - case tokens of - [fresh] -> readAll r scope fresh >>= (@?= B.pack [0,255]) - _ -> assertFailure "missing transferred connection" - R.closeScope r scope >>= (@?= []) - R.readSome r 0 client 1 >>= (@?= Right B.empty) - , testCase "accept cancellation releases admission and keeps listener reusable" $ withRuntime $ \r -> do - listener <- TCP.listenTCP r 0 loop4 1 >>= right - address <- R.socketAddress r 0 listener False >>= right - scope <- R.childScope r 0 >>= right - bracket (fork (R.acceptConnection r scope listener scope)) stop $ \worker -> do - waitBlocked (fst worker) - R.acceptConnection r 0 listener 0 >>= category "busy" - stop worker - R.closeScope r scope >>= (@?= []) - client <- TCP.connectTCP r 0 address >>= right - accepted <- R.acceptConnection r 0 listener 0 >>= right - R.shutdownWrite r 0 client >>= (@?= Right ()) - R.readSome r 0 accepted 1 >>= (@?= Right B.empty) - , testCase "closed owner admission cannot accept or leak a new connection" $ withRuntime $ \r -> do - (listener,_,_,_) <- connected r loop4 - child <- R.childScope r 0 >>= right - atomically (R.beginCloseScopeSTM r 0) - R.acceptConnection r child listener 0 >>= category "closed-scope" - R.liveCounts r >>= (@?= (2,6)) - , testCase "bind/refused errors and bounds remain ordinary with no leaked entries" $ withRuntime $ \r -> do - listener <- TCP.listenTCP r 0 loop4 1 >>= right - address <- R.socketAddress r 0 listener False >>= right - TCP.listenTCP r 0 address 1 >>= category "address-in-use" - TCP.listenTCP r 0 loop4 0 >>= category "invalid-bound" - TCP.listenTCP r 0 loop4 4097 >>= category "invalid-bound" - TCP.connectTCP r 0 loop4 >>= category "invalid-bound" - R.liveCounts r >>= (@?= (1,4)) - R.closeResource r 0 listener >>= (@?= Right ()) - TCP.connectTCP r 0 address >>= category "connection-refused" - R.liveCounts r >>= (@?= (1,3)) - , testCase "structured cancellation settles a real accept before scope release" $ withRuntime $ \r -> do - scheduler <- S.newScheduler r - root <- S.rootGroup scheduler - outcome <- S.runGroup root $ do - listener <- TCP.listenTCP r 0 loop4 1 >>= right - started <- newEmptyMVar - child <- S.spawn root True True [] (\group _ -> do - myThreadId >>= putMVar started - _ <- R.acceptConnection r (S.scopeOf group) listener (S.scopeOf group) >>= right - pure (S.Completed Leaf)) >>= right - takeMVar started >>= waitBlocked - S.cancel root child (ofString "stop") >>= (@?= Right ()) - S.join root child >>= right >>= \result -> case result of - S.Cancelled reason [] -> reason @?= ofString "stop" - _ -> assertFailure "cancellation lost" - R.liveCounts r >>= (@?= (1,4)) - pure (S.Completed Leaf) - case outcome of S.Completed Leaf -> pure (); _ -> assertFailure ("parent failed: " ++ show outcome) - R.liveCounts r >>= (@?= (0,0)) - , testCase "wire bounds reject noncanonical/excess address fields before acquisition" $ do - let v4 bytes port = ofList [ofString "ipv4",bytes,port] - v6 words scope = ofList [ofString "ipv6",words,scope,ofNumber 80] - zero4 = ofList (map ofNumber [127,0,0,1]) - zero6 = ofList (replicate 8 Leaf) - excess = foldr Fork (Fork (error "excess octet forced") (error "excess tail forced")) - (replicate 4 Leaf) - addresses = [v4 (ofList (map ofNumber [127,0,0,256])) (ofNumber 80), - v4 zero4 (ofNumber 65536), v4 excess (ofNumber 80), - v4 (ofList [Fork Leaf Leaf,Leaf,Leaf,Leaf]) (ofNumber 80), - v6 zero6 (ofNumber 4294967296), - v6 (ofList (ofNumber 65536:replicate 7 Leaf)) Leaf] - forM_ addresses $ \address -> withRuntime $ \r -> do - identity <- R.runtimeIdentity r - let owner = ofList [ofString "io.v2.scope",ofNumber identity,Leaf] - action = Fork (ofNumber 10) (Fork (ofString "tcp") (Fork (ofNumber 1) - (Fork (ofString "connect") (Fork owner address)))) - V.runInRuntime r action >>= \outcome -> case outcome of - V.Completed value -> value @?= Fork Leaf (Fork - (ofList (map ofString ["invalid-argument","tcp/connect","","none"])) Leaf) - _ -> assertFailure "bad address executed" - , testCase "blocked real write cancellation poisons writer; read direction survives" $ withRuntime $ \r -> - bracket (N.socketPair N.AF_UNIX N.Stream N.defaultProtocol) - (\(a,b) -> N.close a >> N.close b) $ \(socket,peer) -> do - N.setSocketOption socket N.SendBuffer 4096 - token <- R.acquire r 0 (TCP.duplexBackend socket) >>= right - bracket (fork (forever (write r token (B.replicate 4096 7)))) stop $ \worker -> do - waitBlocked (fst worker) - stop worker - R.writeSome r 0 token (B.singleton 1) >>= category "invalid-state" - NB.sendAll peer (B.singleton 255) - R.readSome r 0 token 1 >>= (@?= Right (B.singleton 255)) - ] diff --git a/test/IOV2PositionTests.hs b/test/IOV2PositionTests.hs @@ -1,198 +0,0 @@ -{-# LANGUAGE ScopedTypeVariables #-} -module IOV2PositionTests (ioV2PositionTests) where -import qualified IOV2.Resources as R -import qualified IOV2.Driver as V -import qualified Data.ByteString as BS -import Control.Concurrent -import Control.Concurrent.STM -import Control.Exception -import Control.Monad -import Data.IORef -import Foreign.Ptr (castPtr) -import qualified System.Posix.IO as P -import System.Directory (renameFile) -import System.IO.Temp (withSystemTempDirectory) -import System.Timeout (timeout) -import Runtime (T(..), ofNumber, ofString, ofList) -import Test.Tasty -import Test.Tasty.HUnit - -right :: Show e => Either e a -> IO a -right = either (\e -> assertFailure (show e) >> error "assertion") pure -category name result = case result of - Left p -> R.category p @?= name - _ -> assertFailure ("expected " ++ name) -fixture use = withSystemTempDirectory "v2-position" $ \dir -> - bracket R.newRuntime (\r -> void (R.closeScope r 0)) (\r -> use r (dir++"/file")) -write r token bytes = R.writeSome r 0 token (BS.pack bytes) >>= (@?= Right (length bytes)) -wait done = timeout 3000000 (readMVar done) >>= maybe (assertFailure "timeout" >> error "timeout") pure -fake = R.FileOps Nothing Nothing Nothing Nothing (pure 0) Nothing Nothing (pure ()) - -ioV2PositionTests = localOption (mkTimeout 30000000) $ testGroup "IO v2 positioned files" - [ testCase "update preserves bytes; logical cursor switches read/write correctly" $ fixture $ \r path -> do - BS.writeFile path (BS.pack [0..9]) - f <- R.openUpdate r 0 path >>= right - R.fileSize r 0 f >>= (@?= Right 10) - R.readSome r 0 f 2 >>= (@?= Right (BS.pack [0,1])) - R.filePosition r 0 f >>= (@?= Right 2) - write r f [255,254] - R.filePosition r 0 f >>= (@?= Right 4) - R.readSome r 0 f 2 >>= (@?= Right (BS.pack [4,5])) - R.seekFile r 0 f 0 >>= (@?= Right ()) - R.readSome r 0 f 10 >>= (@?= Right (BS.pack [0,1,255,254,4,5,6,7,8,9])) - R.closeResource r 0 f >>= (@?= Right ()) - BS.readFile path >>= (@?= BS.pack [0,1,255,254,4,5,6,7,8,9]) - , testCase "resize discards read ahead, preserves cursor and zero-fills growth" $ fixture $ \r path -> do - BS.writeFile path (BS.pack [0..99]) - f <- R.openUpdate r 0 path >>= right - R.readSome r 0 f 3 >>= (@?= Right (BS.pack [0,1,2])) - R.resizeFile r 0 f 2 >>= (@?= Right ()) - R.filePosition r 0 f >>= (@?= Right 3) - R.readSome r 0 f 1 >>= (@?= Right BS.empty) - R.resizeFile r 0 f 8 >>= (@?= Right ()) - R.filePosition r 0 f >>= (@?= Right 3) - R.readSome r 0 f 5 >>= (@?= Right (BS.replicate 5 0)) - R.seekFile r 0 f 12 >>= (@?= Right ()) - write r f [255] - R.fileSize r 0 f >>= (@?= Right 13) - R.closeResource r 0 f >>= (@?= Right ()) - BS.readFile path >>= (@?= BS.pack ([0,1]++replicate 10 0++[255])) - , testCase "new update/append files and wide sparse offsets" $ fixture $ \r path -> do - f <- R.openUpdate r 0 path >>= right - R.fileSize r 0 f >>= (@?= Right 0) - R.seekFile r 0 f 4294967296 >>= (@?= Right ()) - write r f [255] - R.filePosition r 0 f >>= (@?= Right 4294967297) - R.fileSize r 0 f >>= (@?= Right 4294967297) - R.seekFile r 0 f 4294967294 >>= (@?= Right ()) - R.readSome r 0 f 3 >>= (@?= Right (BS.pack [0,0,255])) - R.resizeFile r 0 f 0 >>= (@?= Right ()) - R.closeResource r 0 f >>= (@?= Right ()) - BS.readFile path >>= (@?= BS.empty) - appended <- R.openAppender r 0 (path++"-append") >>= right - write r appended [0,255] - R.closeResource r 0 appended >>= (@?= Right ()) - BS.readFile (path++"-append") >>= (@?= BS.pack [0,255]) - , testCase "append is at each write's EOF, not an open-time seek" $ fixture $ \r path -> do - BS.writeFile path (BS.pack [1,2]) - f <- R.openAppender r 0 path >>= right - -- Separate POSIX fd models an external writer, bypassing GHC Handle locks. - bracket (P.openFd path P.WriteOnly (P.defaultFileFlags {P.append=True})) P.closeFd $ \fd -> - BS.useAsCStringLen (BS.pack [3,4]) $ \(ptr,n) -> - P.fdWriteBuf fd (castPtr ptr) (fromIntegral n) >>= (@?= 2) - write r f [5,255] - R.fileSize r 0 f >>= (@?= Right 6) - R.seekFile r 0 f 0 >>= category "unsupported" - R.filePosition r 0 f >>= category "unsupported" - R.resizeFile r 0 f 0 >>= category "unsupported" - R.readSome r 0 f 1 >>= category "wrong-kind" - R.closeResource r 0 f >>= (@?= Right ()) - BS.readFile path >>= (@?= BS.pack [1,2,3,4,5,255]) - , testCase "reader mode and unrelated resource kinds cannot mutate files" $ fixture $ \r path -> do - BS.writeFile path (BS.pack [1,2,3]) - f <- R.openReader r 0 path >>= right - R.resizeFile r 0 f 0 >>= category "unsupported" - R.writeSome r 0 f (BS.singleton 4) >>= category "wrong-kind" - R.seekFile r 0 f 2 >>= (@?= Right ()) - R.readSome r 0 f 2 >>= (@?= Right (BS.singleton 3)) - standard <- R.standardStream r 0 R.StandardInput >>= right - R.fileSize r 0 standard >>= category "wrong-kind" - R.seekFile r 0 standard 0 >>= category "wrong-kind" - R.closeResource r 0 f >>= (@?= Right ()) - R.filePosition r 0 f >>= category "stale-handle" - BS.readFile path >>= (@?= BS.pack [1,2,3]) - , testCase "host Handle locking rejects conflicting opens without changing content" $ fixture $ \r path -> do - BS.writeFile path (BS.pack [1,2,3]) - reader <- R.openReader r 0 path >>= right - R.openUpdate r 0 path >>= category "io-error" - R.openAppender r 0 path >>= category "io-error" - R.readSome r 0 reader 3 >>= (@?= Right (BS.pack [1,2,3])) - R.closeResource r 0 reader >>= (@?= Right ()) - BS.readFile path >>= (@?= BS.pack [1,2,3]) - , testCase "size is queried on the opened file, not its replaced pathname" $ fixture $ \r path -> do - BS.writeFile path (BS.pack [1,2,3]) - f <- R.openReader r 0 path >>= right - renameFile path (path++"-old") - BS.writeFile path (BS.replicate 10 7) - R.fileSize r 0 f >>= (@?= Right 3) - R.readSome r 0 f 4 >>= (@?= Right (BS.pack [1,2,3])) - , testCase "position transfers with ownership and old aliases become stale" $ fixture $ \r path -> do - BS.writeFile path (BS.pack [0..9]) - f <- R.openUpdate r 0 path >>= right - R.seekFile r 0 f 7 >>= (@?= Right ()) - (child, moved) <- atomically (R.reserveChildSTM r 0 [f]) >>= right - case moved of - [fresh] -> do - R.filePosition r child fresh >>= (@?= Right 7) - R.seekFile r child fresh 3 >>= (@?= Right ()) - R.readSome r child fresh 1 >>= (@?= Right (BS.singleton 3)) - _ -> assertFailure "bad transfer" - R.fileSize r 0 f >>= category "stale-handle" - R.closeScope r child >>= (@?= []) - R.liveCounts r >>= (@?= (1,3)) - , testCase "bounds rejected before effects; bad backend positions cannot escape" $ fixture $ \r _ -> do - calls <- newIORef (0::Int) - let methods = fake {R.fileSeekAction=Just (\_ -> modifyIORef' calls (+1)), - R.fileResizeAction=Just (\_ -> modifyIORef' calls (+1)), - R.filePositionAction=Just (pure (-1)), R.fileSizeAction=pure (-1)} - f <- R.acquire r 0 (pure (R.File methods)) >>= right - forM_ [-1,9223372036854775808] $ \n -> do - R.seekFile r 0 f n >>= category "invalid-bound" - R.resizeFile r 0 f n >>= category "invalid-bound" - readIORef calls >>= (@?= 0) - R.filePosition r 0 f >>= category "unsupported" - R.fileSize r 0 f >>= category "unsupported" - R.seekFile r 0 f 9223372036854775807 >>= (@?= Right ()) - readIORef calls >>= (@?= 1) - , testCase "position IO shares read/write lease; cancellation drains it before release" $ fixture $ \r _ -> do - entered <- newEmptyMVar; blocked <- newEmptyMVar; finished <- newEmptyMVar - released <- newIORef False - let methods = fake {R.fileSeekAction=Just (\_ -> putMVar entered () >> takeMVar blocked), - R.fileReadAction=Just (const (pure BS.empty)), - R.fileWriteAction=Just (pure . BS.length), - R.fileReleaseAction=writeIORef released True} - f <- R.acquire r 0 (pure (R.File methods)) >>= right - bracket (forkFinally (R.seekFile r 0 f 2) (putMVar finished)) - (\worker -> killThread worker >> void (wait finished)) $ \worker -> do - takeMVar entered - R.fileSize r 0 f >>= category "busy" - R.readSome r 0 f 1 >>= category "busy" - R.writeSome r 0 f (BS.singleton 1) >>= category "busy" - atomically (R.reserveChildSTM r 0 [f]) >>= category "busy" - readIORef released >>= (@?= False) - killThread worker - wait finished >>= \outcome -> case outcome of - Left _ -> pure () - Right _ -> assertFailure "cancelled seek resumed" - R.closeResource r 0 f >>= (@?= Right ()) - readIORef released >>= (@?= True) - , testCase "backend mutation errors report unknown state, not no progress" $ fixture $ \r _ -> do - let methods = fake {R.fileSeekAction=Just (\_ -> ioError (userError "seek fault")), - R.fileResizeAction=Just (\_ -> ioError (userError "resize fault"))} - f <- R.acquire r 0 (pure (R.File methods)) >>= right - forM_ [R.seekFile r 0 f 0,R.resizeFile r 0 f 0] $ \action -> action >>= \reply -> case reply of - Left problem -> R.progress problem @?= "unknown" - Right _ -> assertFailure "fault succeeded" - , testCase "wire preserves the entire signed-64 positive offset range" $ fixture $ \r _ -> do - observed <- newIORef (-1) - f@(R.Token identity key) <- R.acquire r 0 (pure (R.File - (fake {R.fileSeekAction=Just (writeIORef observed)}))) >>= right - let token = ofList [ofString "io.v2.resource",ofNumber identity,ofNumber key] - action = Fork (ofNumber 10) (Fork (ofString "file") (Fork (ofNumber 1) - (Fork (ofString "seek") (Fork token (ofNumber 9223372036854775807))))) - V.runInRuntime r action >>= \outcome -> case outcome of - V.Completed value -> value @?= Fork (Stem Leaf) (Fork Leaf Leaf) - _ -> assertFailure "wide offset failed" - readIORef observed >>= (@?= 9223372036854775807) - R.filePosition r 0 f >>= category "stale-handle" - , testCase "wire rejects overlong offset without demanding excess bits" $ fixture $ \r _ -> do - identity <- R.runtimeIdentity r - let token = ofList [ofString "io.v2.resource",ofNumber identity,ofNumber 1] - offset = foldr Fork (Stem (error "excess bits demanded")) (replicate 63 Leaf) - action = Fork (ofNumber 10) (Fork (ofString "file") (Fork (ofNumber 1) - (Fork (ofString "seek") (Fork token offset)))) - V.runInRuntime r action >>= \outcome -> case outcome of - V.Completed (Fork Leaf (Fork problem Leaf)) -> problem @?= - ofList (map ofString ["invalid-argument","file/seek","","none"]) - _ -> assertFailure "invalid offset executed" - ] diff --git a/test/IOV2ProcessTests.hs b/test/IOV2ProcessTests.hs @@ -1,181 +0,0 @@ -{-# LANGUAGE ScopedTypeVariables #-} -module IOV2ProcessTests (ioV2ProcessTests) where -import qualified IOV2.Process as P -import qualified IOV2.Driver as V -import qualified IOV2.Protocol as Protocol -import qualified IOV2.Resources as R -import qualified IOV2.Tasks as S -import Runtime (T(..), ofString, ofList, ofNumber) -import Control.Concurrent -import Control.Exception -import Data.IORef -import Control.Monad -import qualified Data.ByteString as BS -import qualified Data.ByteString.Char8 as B -import System.Directory (findExecutable) -import System.Posix.Signals (signalProcess, nullSignal) -import System.IO.Error (isDoesNotExistError) -import Test.Tasty -import Test.Tasty.HUnit - -right (Right x) = pure x -right (Left e) = assertFailure (show e) >> error "failed" -withGroup action = do - runtime <- R.newRuntime - scheduler <- S.newScheduler runtime - group <- S.rootGroup scheduler - outcome <- S.runGroup group (action runtime group) - R.liveCounts runtime >>= (@?= (0,0)) - S.liveCounts scheduler >>= (@?= (0,0,0)) - pure outcome -success (S.Completed Leaf) = pure () -success other = assertFailure (show other) -program name = findExecutable name >>= maybe (assertFailure ("missing test executable: " ++ name) >> error "missing") pure -readAll runtime owner token = do - chunk <- R.readSome runtime owner token 4096 >>= right - if BS.null chunk then pure BS.empty else BS.append chunk <$> readAll runtime owner token -joined group task wanted = S.join group task >>= \value -> case value of - Right (S.Completed actual) -> actual @?= wanted - other -> assertFailure (show other) -exit n = ofList [ofString "exit",ofNumber n] - -ioV2ProcessTests = localOption (mkTimeout 15000000) $ testGroup "IO v2 direct child processes" - [ testCase "argv is literal; environment and cwd are explicit; nonzero exit is data" $ do - shell <- program "sh" - withGroup (\runtime group -> do - (task,[input,output,errors]) <- P.launch runtime group - (P.Spec shell ["-c","printf '%s|%s|%s' \"$1\" \"$VALUE\" \"$PWD\"; exit 7", "test", "$(not-a-command); *"] (Just "/") (Just [("VALUE","literal")]) 20) >>= right - R.closeResource runtime (S.scopeOf group) input >>= right - readAll runtime (S.scopeOf group) output >>= (@?= B.pack "$(not-a-command); *|literal|/") - readAll runtime (S.scopeOf group) errors >>= (@?= BS.empty) - joined group task (exit 7) - pure (S.Completed Leaf)) >>= success - , testCase "stream a binary pipeline while draining concurrently" $ do - cat <- program "cat" - let bytes = BS.pack (take 262144 (cycle [0..255])) - withGroup (\runtime group -> do - (task,[input,output,errors]) <- P.launch runtime group (P.Spec cat [] Nothing Nothing 20) >>= right - drained <- newEmptyMVar - _ <- forkIO $ (try (readAll runtime (S.scopeOf group) output) :: IO (Either SomeException BS.ByteString)) >>= putMVar drained - let write remaining | BS.null remaining = pure () - | otherwise = do - let (chunk,rest) = BS.splitAt 4096 remaining - R.writeSome runtime (S.scopeOf group) input chunk >>= right >>= (@?= BS.length chunk) - write rest - write bytes - R.closeResource runtime (S.scopeOf group) input >>= right - takeMVar drained >>= either throwIO (@?= bytes) - readAll runtime (S.scopeOf group) errors >>= (@?= BS.empty) - joined group task (exit 0) - pure (S.Completed Leaf)) >>= success - , testCase "missing executable is an ordinary error, settled without leaked tickets" $ do - withGroup (\runtime group -> do - result <- P.launch runtime group (P.Spec "/definitely-absent-tricu-process" [] Nothing Nothing 0) - case result of Left _ -> pure (); _ -> assertFailure "launch succeeded" - pure (S.Completed Leaf)) >>= success - , testCase "normal scope exit joins an unconsumed process result" $ do - shell <- program "sh" - withGroup (\runtime group -> do - -- Output fits the pipe, so implicit joining does not require buffering it. - _ <- P.launch runtime group (P.Spec shell ["-c","printf done; exit 3"] Nothing Nothing 20) >>= right - pure (S.Completed Leaf)) >>= success - , testCase "cancellation while scope joins escalates TERM-ignoring child and reaps" $ do - shell <- program "sh" - withGroup (\runtime root -> do - entered <- newEmptyMVar - parent <- S.spawn root True True [] (\group _ -> do - (_,[_input,output,_errors]) <- P.launch runtime group - (P.Spec shell ["-c","trap '' TERM; printf '%s\\n' \"$$\"; while :; do :; done"] Nothing Nothing 20) >>= right - bytes <- R.readSome runtime (S.scopeOf group) output 4096 >>= right - let pid = read (B.unpack bytes) - putMVar entered (pid,S.scopeOf group) - pure (S.Completed Leaf)) >>= right - (pid,owner) <- takeMVar entered - -- Observe closed admission, rather than guessing the phase from a delay. - -- A still-live process child means this scope cannot yet be Releasing. - let joining = do - admitted <- R.acquire runtime owner (pure (R.Internal (pure ()))) - case admitted of - Left problem -> R.category problem @?= "closed-scope" - Right token -> R.closeInternalResource runtime owner token >>= right >> threadDelay 1000 >> joining - joining - signalProcess nullSignal pid - S.cancel root parent (ofString "stop") >>= right - result <- S.join root parent - case result of Right (S.Cancelled reason []) -> reason @?= ofString "stop"; _ -> assertFailure (show result) - alive <- try (signalProcess nullSignal pid) - case alive of Left (e :: IOException) -> assertBool "reaped PID" (isDoesNotExistError e); Right _ -> assertFailure "child remains live or zombie" - pure (S.Completed Leaf)) >>= success - , testCase "failure cancels a blocked pipe child and preserves the primary" $ do - cat <- program "cat" - outcome <- withGroup (\runtime group -> do - _ <- P.launch runtime group (P.Spec cat [] Nothing Nothing 20) >>= right - pure (S.Failed (ofString "primary") [])) - case outcome of S.Failed problem [] -> problem @?= ofString "primary"; _ -> assertFailure (show outcome) - , testCase "repeated implicit joins release pipes, private handles, scopes and tickets" $ do - shell <- program "sh" - withGroup (\runtime group -> do - baseline <- R.liveCounts runtime - replicateM_ 30 $ do - S.nested group (\child -> do - _ <- P.launch runtime child (P.Spec shell ["-c","exit 0"] Nothing Nothing 20) >>= right - pure (S.Completed Leaf)) >>= success - R.liveCounts runtime >>= (@?= baseline) - pure (S.Completed Leaf)) >>= success - , testCase "failed pipe writes poison subsequent attempts instead of replay" $ do - shell <- program "sh" - withGroup (\runtime group -> do - (task,[input,_,_]) <- P.launch runtime group (P.Spec shell ["-c","exit 0"] Nothing Nothing 0) >>= right - joined group task (exit 0) - first <- R.writeSome runtime (S.scopeOf group) input (B.pack "x") - case first of Left _ -> pure (); _ -> assertFailure "write to exited child succeeded" - second <- R.writeSome runtime (S.scopeOf group) input (B.pack "x") - case second of Left problem -> R.category problem @?= "invalid-state"; _ -> assertFailure "failed pipe was reused" - -- hClose may report the buffered failed write; keep that cleanup failure - -- visible instead of insisting that the parent must complete successfully. - pure (S.Completed Leaf)) >>= \outcome -> case outcome of - S.Completed Leaf -> pure () - S.Failed _ _ -> pure () - _ -> assertFailure (show outcome) - , testCase "termination failures still attempt escalation and reaping exactly once" $ do - forM_ [[],["term"],["kill"],["term","kill","reap"]] $ \failures -> do - calls <- newIORef [] - let action name = modifyIORef' calls (++[name]) >> when (name `elem` failures) (ioError (userError name)) - ops = P.TerminationOps (pure False) (action "term") (action "kill") (action "reap") (pure 0) (action "pause") - outcome <- try (P.terminateWith ops 0) :: IO (Either SomeException ()) - readIORef calls >>= (@?= ["term","kill","reap"]) - case (failures,outcome) of ([],Right ()) -> pure (); (_:_,Left _) -> pure (); _ -> assertFailure "wrong cleanup result" - , testCase "relative grace survives monotonic Word64 wrap" $ do - samples <- newIORef [18446744073709551606,5,1000000] - calls <- newIORef [] - let now = atomicModifyIORef' samples (\(x:xs) -> (xs,x)) - action name = modifyIORef' calls (++[name]) - ops = P.TerminationOps (pure False) (action "term") (action "kill") (action "reap") now (action "pause") - P.terminateWith ops 1 - readIORef calls >>= (@?= ["term","pause","kill","reap"]) - , testCase "raw pipe cleanup failure does not skip process settlement" $ do - settled <- newIORef False - outcome <- try (P.cleanupAll [ioError (userError "pipe-close"), writeIORef settled True]) :: IO (Either SomeException ()) - readIORef settled >>= (@?= True) - case outcome of Left _ -> pure (); _ -> assertFailure "cleanup error lost" - , testCase "wire bounds reject excess arguments/text without demanding the extra element" $ do - let args n tail = foldr Fork tail (replicate n (ofString "a")) - text n tail = foldr Fork tail (replicate n (ofNumber 97)) - excess = Fork (error "excess element demanded") Leaf - inherit = ofList [ofString "inherit"] - payload arguments = ofList [ofString "/not-executed",arguments,inherit,inherit,ofNumber 0] - request arguments = Fork (ofNumber 10) (Fork (ofString "process") (Fork (ofNumber 1) (Fork (ofString "spawn") arguments))) - expected = Fork Leaf (Fork (ofList (map ofString ["invalid-argument","process/spawn","","none"])) Leaf) - forM_ [payload (args 256 excess),payload (ofList [text 4096 excess])] $ \value -> - V.runDetailed (request value) >>= \outcome -> case outcome of - S.Completed actual -> actual @?= expected - _ -> assertFailure (show outcome) - Protocol.inspectAction (request (error "inspection demanded process spec")) @?= - Right (ofList [ofString "facility",ofString "process",ofNumber 1,ofString "spawn",ofString "available"]) - , testCase "invalid launch values are rejected before admission" $ do - forM_ [P.Spec "" [] Nothing Nothing 0, P.Spec "x" [] Nothing Nothing (-1), - P.Spec "x" [] Nothing (Just [("X","1"),("X","2")]) 0, - P.Spec "x" ["a\0b"] Nothing Nothing 0, - P.Spec "x" (replicate 257 "a") Nothing Nothing 0] $ \spec -> - assertBool (show spec) (not (P.validSpec spec)) - ] diff --git a/test/IOV2ProtocolTests.hs b/test/IOV2ProtocolTests.hs @@ -1,83 +0,0 @@ -module IOV2ProtocolTests (ioV2ProtocolTests) where - -import qualified IOV2.Driver as V -import qualified IOV2.Protocol as P -import qualified IOV2.Resources as R -import Runtime (T(..), ofNumber, ofString, ofList) -import Test.Tasty -import Test.Tasty.HUnit - -request :: String -> Integer -> String -> T -> T -request g v op args = Fork (ofNumber 10) (Fork (ofString g) (Fork (ofNumber v) (Fork (ofString op) args))) -ok, err :: T -> T -ok x = Fork (Stem Leaf) (Fork x Leaf) -err x = Fork Leaf (Fork x Leaf) -problem category operation detail = ofList (map ofString [category,operation,detail,"none"]) -completed action expected = V.runDetailed action >>= \outcome -> case outcome of - V.Completed value -> value @?= expected - _ -> assertFailure "expected completion" - -ioV2ProtocolTests :: TestTree -ioV2ProtocolTests = testGroup "IO v2 protocol" - [ testCase "capability schema reports exact implemented groups and limits" $ do - let strings = ofList . map ofString - row key value = ofList [ofString key,value] - groups = [("runtime",["capabilities","inspect"]),("standard",["input","output","error"]),("file",["open-reader","open-writer","open-appender","open-update","seek","position","size","resize","sync"]), - ("path",["stat","lstat","remove-file","rename"]),("directory",["create","remove","open","next","sync"]),("tcp",["listen","connect","accept","local-address","peer-address","shutdown-write"]),("resolver",["resolve"]),("indexed-tree",["read","hash","write","verify"]),("utf8",["encode","decode"]),("stream",["read","write","snapshot","transfer"]),("resource",["close"]),("stage",["open","seal","publish"]), - ("digest",["open","update","finish"]),("clock",["monotonic","sleep"]),("scheduler",["yield"]),("process",["spawn","working-directory","environment-variable"])] - expected = ofList [row "schema" (ofNumber 1), row "io-version" (ofNumber 2), - row "backend" (ofString "haskell-native"), row "authority" (ofString "os-process"), - row "core" (strings ["pure","bind","fail","scope","current-scope","spawn","join","discard","cancel","cancelled"]), - row "groups" (ofList [ofList [ofString g,ofNumber 1,strings ops] | (g,ops) <- groups]), - row "limits" (ofList [row "chunk-bytes" (ofNumber 4096), row "transfer-handles" (ofNumber 256), row "file-offset-max" (ofNumber 9223372036854775807), row "directory-name-scalars" (ofNumber 4096), row "tcp-backlog" (ofNumber 4096), row "resolver-host-characters" (ofNumber 253), row "resolver-addresses" (ofNumber 64), row "indexed-sections" (ofNumber 64), row "indexed-manifest-bytes" (ofNumber 65536), row "utf8-encode-scalars" (ofNumber 1024), row "sleep-milliseconds-max" (ofNumber 9223372036854775807), row "process-arguments" (ofNumber 256), row "process-environment-entries" (ofNumber 256), row "process-text-scalars" (ofNumber 4096), row "process-total-scalars" (ofNumber 65536), row "process-grace-milliseconds-max" (ofNumber 9223372036854775807)]), - row "tcp-address-families" (strings ["ipv4","ipv6"]), - row "digest-algorithms" (strings ["sha256"]), - row "restrictions" (strings ["action-boundary-cancellation","no-hard-deadlines","stage-same-filesystem","stage-no-copy-fallback", - "stage-private-namespace","publish-new-requires-hard-links","no-crash-recovery","borrowed-standard-streams","standard-stream-leases-per-run","file-single-cursor-lease","append-no-position-or-resize","file-host-handle-locking","directory-unsorted-non-snapshot","filesystem-host-encoding","directory-failure-poisons-cursor","tcp-numeric-addresses","tcp-ipv6-only-listeners","tcp-direction-leases","tcp-failure-poisons-direction","resolver-host-order-and-buffering","resolver-may-delay-cancellation","indexed-authenticated-snapshot","indexed-caller-frame-and-disk-limit","indexed-dag-resident","indexed-writer-preflight","indexed-budget-not-heap-quota","sync-host-filesystem-guarantees","utf8-strict-scalars","clock-host-monotonic-word64","sleep-may-run-late","yield-no-ordering-guarantee", "process-direct-child-only", "process-normal-exit-joins", "process-explicit-pipe-draining"])] - completed (request "runtime" 1 "capabilities" Leaf) (ok expected) - R.chunkLimit @?= 4096 - P.transferLimit @?= 256 - , testCase "unsupported requests do not inspect arguments and remain ordinary errors" $ - mapM_ (\(g,v,op) -> completed (request g v op (error "unsupported arguments demanded")) - (err (problem "unsupported" (g++"/"++op) ("group revision "++show v)))) - [("future-tcp",1,"connect"),("digest",2,"open"),("file",1,"absent"),("clock",2,"sleep"),("clock",1,"deadline"),("scheduler",1,"absent"),("process",2,"spawn"),("process",1,"shell")] - , testCase "recognized operation arguments fail ordinarily" $ - mapM_ (\(g,op) -> V.runDetailed (request g 1 op (if g == "utf8" && op == "encode" then Stem Leaf else Leaf)) >>= \outcome -> case outcome of - V.Completed (Fork Leaf (Fork (Fork _ (Fork operation _)) Leaf)) -> operation @?= ofString (g++"/"++op) - _ -> assertFailure (g++"/"++op)) - [("file","open-reader"),("file","open-writer"),("file","open-appender"),("file","open-update"),("file","seek"),("file","position"),("file","size"),("file","resize"),("file","sync"),("stream","read"),("stream","write"),("stream","snapshot"),("stream","transfer"), - ("path","stat"),("path","lstat"),("path","remove-file"),("path","rename"),("directory","create"),("directory","remove"),("directory","open"),("directory","next"),("directory","sync"),("tcp","listen"),("tcp","connect"),("tcp","accept"),("tcp","local-address"),("tcp","peer-address"),("tcp","shutdown-write"),("resolver","resolve"),("indexed-tree","read"),("indexed-tree","hash"),("indexed-tree","write"),("indexed-tree","verify"),("utf8","encode"),("utf8","decode"),("resource","close"),("stage","open"),("stage","seal"),("stage","publish"), - ("digest","open"),("digest","update"),("digest","finish"),("runtime","inspect")] - , testCase "inspection does not demand core payloads or callbacks" $ - mapM_ (\(tag,label) -> completed (request "runtime" 1 "inspect" (Fork (ofNumber tag) (error "payload demanded"))) - (ok (ofList [ofString "core",ofString label]))) - (zip [0..9] ["pure","bind","fail","scope","current-scope","spawn","join","discard","cancel","cancelled"]) - , testCase "inspection classifies headers without executing or validating arguments" $ do - completed (request "runtime" 1 "inspect" (request "file" 1 "open-writer" (error "writer executed"))) - (ok (ofList [ofString "facility",ofString "file",ofNumber 1,ofString "open-writer",ofString "available"])) - completed (request "runtime" 1 "inspect" (request "file" 2 "open-writer" (error "unsupported arguments demanded"))) - (ok (ofList [ofString "facility",ofString "file",ofNumber 2,ofString "open-writer",ofString "unsupported"])) - , testCase "retired numeric facility tags cannot accidentally execute" $ - mapM_ (\tag -> V.runDetailed (Fork (ofNumber tag) (error "retired payload demanded")) >>= \outcome -> case outcome of - V.Failed diagnostic [] -> diagnostic @?= problem "protocol-error" "core/decode" "unknown computation constructor" - _ -> assertFailure "retired tag accepted") [20,21,22,23,24,25,26,27,30,31,32,40,41,42,50,51,52,53,54,55,56,60,61,62,63,64,65,66,67,68,80,81,82,83,84,85,86,87,88,89,95,96,97,100,101,255] - , testCase "malformed headers are protocol failures rather than unsupported" $ do - let header g v op = Fork (ofNumber 10) (Fork g (Fork v (Fork op (error "malformed args demanded")))) - oversized = foldr Fork (Fork (error "excess name head demanded") (error "excess name tail demanded")) - (replicate 64 (ofNumber 97)) - inputs = [header (ofString "") (ofNumber 1) (ofString "read"), - header (ofString "File") (ofNumber 1) (ofString "read"), - header (ofString "file") Leaf (ofString "read"), - header (ofString "file") (Fork Leaf Leaf) (ofString "read"), - header oversized (ofNumber 1) (ofString "read"), - Fork (Fork Leaf Leaf) (error "noncanonical core payload demanded")] - mapM_ (\input -> V.runDetailed input >>= \outcome -> case outcome of - V.Failed _ [] -> pure () - _ -> assertFailure "malformed header accepted") inputs - , testCase "standard stream lookup validates unit without demanding its child" $ - mapM_ (\op -> completed (request "standard" 1 op (Stem (error "payload forced"))) - (err (problem "invalid-argument" ("standard/"++op) ""))) ["input","output","error"] - , testCase "runtime capability request validates unit without traversing payload" $ - completed (request "runtime" 1 "capabilities" (Stem (error "ignored child demanded"))) - (err (problem "invalid-argument" "runtime/capabilities" "")) - ] diff --git a/test/IOV2ResolverTests.hs b/test/IOV2ResolverTests.hs @@ -1,71 +0,0 @@ -{-# LANGUAGE ScopedTypeVariables #-} -module IOV2ResolverTests (ioV2ResolverTests) where -import qualified IOV2.Resolver as D -import qualified IOV2.Resources as R -import qualified IOV2.Driver as V -import Runtime (T(..), ofString, ofNumber, ofList) -import Control.Exception -import Control.Concurrent -import Control.Monad -import Data.IORef -import System.Timeout (timeout) -import Test.Tasty -import Test.Tasty.HUnit - -category name reply = case reply of Left p -> R.category p @?= name; _ -> assertFailure name -address = R.IPv4 (127,0,0,1) 1234 -ioV2ResolverTests = localOption (mkTimeout 30000000) $ testGroup "IO v2 host resolver" - [ testCase "invalid inputs fail before touching resolver" $ do - touched <- newIORef False - let query = writeIORef touched True >> pure [] - forM_ ["","bad name","bad/name","a\0b","λ",replicate 254 'a'] $ \host -> - D.resolveWith host 80 1 query >>= category "invalid-argument" - forM_ [(-1,1),(65536,1),(80,0),(80,65)] $ \(port,limit) -> - D.resolveWith "localhost" port limit query >>= category "invalid-bound" - readIORef touched >>= (@?= False) - , testCase "prefix is bounded without demanding an excess endpoint or tail" $ do - D.resolveWith "localhost" 1234 1 (pure [address]) >>= (@?= Right (D.Resolution [address] False)) - D.resolveWith "localhost" 1234 1 (pure (address:error "excess endpoint forced":error "excess tail forced")) - >>= (@?= Right (D.Resolution [address] True)) - D.resolveWith "localhost" 1234 1 (pure []) >>= (@?= Right (D.Resolution [] False)) - , testCase "host order and duplicates are preserved, not silently reprioritized" $ - D.resolveWith "localhost" 1234 3 (pure [address,address,R.IPv4 (127,0,0,2) 1234,address]) - >>= (@?= Right (D.Resolution [address,address,R.IPv4 (127,0,0,2) 1234] True)) - , testCase "resolver IO errors are ordinary and bounded; unrelated faults are not" $ do - D.resolveWith "localhost" 80 1 (ioError (userError (replicate 1000 'x'))) >>= \reply -> case reply of - Left p -> do - R.category p @?= "resolution-failed" - assertBool "bounded diagnostic" (length (R.detail p) <= 512) - R.progress p @?= "none" - _ -> assertFailure "failure swallowed" - actual <- try (D.resolveWith "localhost" 80 1 (throwIO (ErrorCall "unexpected"))) - case actual of Left (_ :: ErrorCall) -> pure (); Right _ -> assertFailure "fault converted to ordinary result" - , testCase "numeric/localhost host resolution preserves requested port and family" $ do - D.resolveTCP "127.0.0.1" D.IPv4Only 1234 64 >>= \reply -> case reply of - Right (D.Resolution values False) -> do - assertBool "numeric endpoint missing" (address `elem` values) - assertBool "unexpected endpoint" (all (== address) values) - _ -> assertFailure (show reply) - D.resolveTCP "localhost" D.AnyFamily 4321 1 >>= \reply -> case reply of - Right (D.Resolution [value] _) -> case value of - R.IPv4 _ port -> port @?= 4321 - R.IPv6 _ _ port -> port @?= 4321 - _ -> assertFailure (show reply) - , testCase "cancellation is not swallowed as a resolver error" $ do - entered <- newEmptyMVar; gate <- newEmptyMVar; done <- newEmptyMVar - worker <- forkFinally (D.resolveWith "localhost" 80 1 (putMVar entered () >> takeMVar gate)) (putMVar done) - takeMVar entered - killThread worker - timeout 3000000 (takeMVar done) >>= \reply -> case reply of - Just (Left _) -> pure () - _ -> assertFailure "cancellation became a result" - , testCase "wire host bound does not demand excess characters" $ do - let host = foldr Fork (Fork (error "excess character forced") (error "excess tail forced")) - (replicate 253 (ofNumber 97)) - action = Fork (ofNumber 10) (Fork (ofString "resolver") (Fork (ofNumber 1) - (Fork (ofString "resolve") (ofList [host,ofString "any",ofNumber 80,ofNumber 1])))) - V.runDetailed action >>= \outcome -> case outcome of - V.Completed value -> value @?= Fork Leaf (Fork - (ofList (map ofString ["invalid-argument","resolver/resolve","","none"])) Leaf) - _ -> assertFailure "invalid host executed" - ] diff --git a/test/IOV2StandardTests.hs b/test/IOV2StandardTests.hs @@ -1,133 +0,0 @@ -{-# LANGUAGE ScopedTypeVariables #-} -module IOV2StandardTests (ioV2StandardTests) where - -import qualified IOV2.Resources as R -import qualified Data.ByteString as BS -import Control.Concurrent -import Control.Concurrent.STM -import GHC.Conc (threadStatus, ThreadStatus(..)) -import Control.Exception -import Control.Monad -import System.IO -import System.IO.Temp -import System.Posix.IO (createPipe, fdToHandle) -import System.Timeout (timeout) -import Test.Tasty -import Test.Tasty.HUnit - -right :: Show e => Either e a -> IO a -right = either (\e -> assertFailure (show e) >> error "assertion") pure -category name result = case result of - Left p -> R.category p @?= name - _ -> assertFailure ("expected " ++ name) -pipe = bracket (createPipe >>= \(r,w) -> (,) <$> fdToHandle r <*> fdToHandle w) - (\(r,w) -> hClose r >> (hClose w `catch` (\(_ :: IOException) -> pure ()))) -withStreams use = withSystemTempDirectory "v2-standard" $ \dir -> - bracket (openBinaryFile (dir++"/in") ReadWriteMode) hClose $ \input -> - bracket (openBinaryFile (dir++"/out") ReadWriteMode) hClose $ \output -> - bracket (openBinaryFile (dir++"/err") ReadWriteMode) hClose $ \diagnostic -> - bracket (R.newRuntimeWithStreams input output diagnostic) (\r -> void (R.closeScope r 0)) - (\r -> use r input output diagnostic) - -ioV2StandardTests = localOption (mkTimeout 30000000) $ testGroup "IO v2 inherited streams" - [ testCase "fixed aliases, root borrowing, no close or transfer, bounded entries" $ - withStreams $ \r _ output _ -> do - child <- R.childScope r 0 >>= right - token <- R.standardStream r child R.StandardOutput >>= right - replicateM_ 1000 (R.standardStream r 0 R.StandardOutput >>= (@?= Right token)) - R.liveCounts r >>= (@?= (2,3)) - R.closeResource r child token >>= category "borrowed-resource" - atomically (R.reserveChildSTM r 0 [token]) >>= category "borrowed-resource" - R.liveCounts r >>= (@?= (2,3)) - owned <- R.acquire r 0 (pure (R.Writer (pure . BS.length) (pure ()))) >>= right - atomically (R.reserveChildSTM r 0 [owned, token]) >>= category "borrowed-resource" - R.liveCounts r >>= (@?= (2,4)) - R.writeSome r 0 owned (BS.singleton 97) >>= (@?= Right 1) - R.closeResource r 0 owned >>= (@?= Right ()) - R.closeScope r child >>= (@?= []) - R.writeSome r 0 token (BS.pack [0,255,65]) >>= (@?= Right 3) - R.closeScope r 0 >>= (@?= []) - hIsClosed output >>= (@?= False) - hSeek output AbsoluteSeek 0 - BS.hGet output 3 >>= (@?= BS.pack [0,255,65]) - R.writeSome r 0 token (BS.singleton 1) >>= category "stale-handle" - R.liveCounts r >>= (@?= (0,0)) - , testCase "first lookup during root joining remains available to existing children" $ - withStreams $ \r _ _ _ -> do - child <- R.childScope r 0 >>= right - atomically (R.beginCloseScopeSTM r 0) - token <- R.standardStream r child R.StandardError >>= right - R.writeSome r child token (BS.singleton 255) >>= (@?= Right 1) - R.closeScope r child >>= (@?= []) - R.standardStream r child R.StandardError >>= category "out-of-scope" - , testCase "binary reads, explicit EOF, wrong kinds and cross-run references" $ - withStreams $ \r input _ _ -> do - BS.hPut input (BS.pack [0,255,13,10]); hSeek input AbsoluteSeek 0 - reader <- R.standardStream r 0 R.StandardInput >>= right - writer <- R.standardStream r 0 R.StandardOutput >>= right - R.readSome r 0 reader 4 >>= (@?= Right (BS.pack [0,255,13,10])) - R.readSome r 0 reader 1 >>= (@?= Right BS.empty) - R.readSome r 0 writer 1 >>= category "wrong-kind" - R.writeSome r 0 reader (BS.singleton 1) >>= category "wrong-kind" - withStreams $ \other _ _ _ -> R.readSome other 0 reader 1 >>= category "stale-handle" - , testCase "shared pending-read lease and cancellation leave inherited pipe open" $ - pipe $ \(input, producer) -> withStreams $ \_ _ output diagnostic -> - bracket (R.newRuntimeWithStreams input output diagnostic) (\r -> void (R.closeScope r 0)) $ \r -> do - reader <- R.standardStream r 0 R.StandardInput >>= right - started <- newEmptyMVar; finished <- newEmptyMVar - let work = putMVar started () >> void (R.readSome r 0 reader 1) - waitBlocked worker = do - status <- threadStatus worker - case status of ThreadBlocked _ -> pure (); _ -> yield >> waitBlocked worker - bracket (forkFinally work (\_ -> putMVar finished ())) - (\worker -> killThread worker >> void (timeout 3000000 (readMVar finished))) $ \worker -> do - takeMVar started - -- A second read cannot be used as the barrier: it could win the - -- race and block. Observe the actual worker's blocked state. - timeout 3000000 (waitBlocked worker) >>= (@?= Just ()) - alias <- R.standardStream r 0 R.StandardInput >>= right - R.readSome r 0 alias 1 >>= category "busy" - killThread worker - timeout 3000000 (readMVar finished) >>= (@?= Just ()) - BS.hPut producer (BS.singleton 97); hFlush producer - R.readSome r 0 reader 1 >>= (@?= Right (BS.singleton 97)) - R.closeScope r 0 >>= (@?= []) - hIsClosed input >>= (@?= False) - , testCase "cancelled blocked output poisons the borrowed writer without closing it" $ - pipe $ \(consumer, output) -> withStreams $ \_ input _ diagnostic -> - bracket (R.newRuntimeWithStreams input output diagnostic) (\r -> void (R.closeScope r 0)) $ \r -> do - writer <- R.standardStream r 0 R.StandardOutput >>= right - finished <- newEmptyMVar - let work = forever (R.writeSome r 0 writer (BS.replicate 4096 97) >>= right) - waitBlocked worker = do - status <- threadStatus worker - case status of ThreadBlocked _ -> pure (); _ -> yield >> waitBlocked worker - bracket (forkFinally work (\_ -> putMVar finished ())) - (\worker -> killThread worker >> void (timeout 3000000 (readMVar finished)) >> hClose consumer) $ \worker -> do - timeout 3000000 (waitBlocked worker) >>= (@?= Just ()) - alias <- R.standardStream r 0 R.StandardOutput >>= right - R.writeSome r 0 alias (BS.singleton 98) >>= category "busy" - killThread worker - timeout 3000000 (readMVar finished) >>= (@?= Just ()) - R.writeSome r 0 writer (BS.singleton 98) >>= category "invalid-state" - R.closeScope r 0 >>= (@?= []) - hIsClosed output >>= (@?= False) - , testCase "broken pipes report uncertain progress without closing inherited output" $ - pipe $ \(consumer, output) -> withStreams $ \_ input _ diagnostic -> do - hClose consumer - bracket (R.newRuntimeWithStreams input output diagnostic) (\r -> void (R.closeScope r 0)) $ \r -> do - writer <- R.standardStream r 0 R.StandardOutput >>= right - result <- R.writeSome r 0 writer (BS.singleton 97) - case result of - Left p -> do { R.category p @?= "io-error"; R.progress p @?= "unknown" } - Right _ -> assertFailure "broken pipe acknowledged" - R.writeSome r 0 writer (BS.singleton 98) >>= category "invalid-state" - hIsClosed output >>= (@?= False) - , testCase "output acknowledgement flushes without altering host buffering" $ - withStreams $ \r _ output _ -> do - hSetBuffering output (BlockBuffering (Just 8192)) - writer <- R.standardStream r 0 R.StandardOutput >>= right - R.writeSome r 0 writer (BS.pack [0,255]) >>= (@?= Right 2) - hGetBuffering output >>= (@?= BlockBuffering (Just 8192)) - hFileSize output >>= (@?= 2) - ] diff --git a/test/IOV2StorageTests.hs b/test/IOV2StorageTests.hs @@ -1,455 +0,0 @@ -{-# LANGUAGE ScopedTypeVariables #-} -module IOV2StorageTests (ioV2StorageTests) where - -import qualified IOV2.Resources as R -import qualified IOV2.Staging as S -import qualified IOV2.Digest as D -import Control.Concurrent -import Control.Concurrent.STM -import Control.Exception -import Control.Monad -import Data.IORef -import Foreign.C.Error (eXDEV, errnoToIOError) -import Data.List (sort, isInfixOf) -import qualified Data.ByteString.Char8 as BS -import System.Directory -import System.Posix.IO (closeFd) -import System.Posix.Files (getFdStatus, fileID, deviceID) -import System.IO.Temp (withSystemTempDirectory) -import Test.Tasty -import Test.Tasty.HUnit - -right :: Show e => Either e a -> IO a -right (Right value) = pure value -right (Left e) = assertFailure (show e) >> error "assertion failed" -category :: String -> Either R.Problem a -> Assertion -category name (Left p) = R.category p @?= name -category name _ = assertFailure ("expected " ++ name) -withRuntime :: (R.Runtime -> IO a) -> IO a -withRuntime = bracket R.newRuntime (\r -> void (R.closeScope r 0)) -write :: R.Runtime -> R.Token -> String -> IO () -write r token bytes = R.writeSome r 0 token (BS.pack bytes) >>= (@?= Right (length bytes)) -sealed :: S.StageOps -> R.Runtime -> FilePath -> String -> IO R.Token -sealed ops r dir bytes = do - token <- S.openStageWith ops r 0 dir >>= right - write r token bytes - R.sealStage r 0 token >>= (@?= Right ()) - pure token - -ioV2StorageTests :: TestTree -ioV2StorageTests = localOption (mkTimeout 30000000) $ testGroup "IO v2 staging and digests" - [ testCase "independent SHA-256 vectors, bounded updates and consuming finish" $ withRuntime $ \r -> do - forM_ [(0,"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"), - (1000,"cdc76e5c9914fb9281a1c7e284d73e67f1809a48a497200e046d39ccc7112cd0")] $ \(n,expected) -> do - token <- D.openDigest r 0 "sha256" >>= right - replicateM_ n (R.updateDigest r 0 token (BS.replicate 1000 'a') >>= (@?= Right ())) - R.finishDigest r 0 token >>= (@?= Right expected) - R.finishDigest r 0 token >>= category "invalid-state" - R.updateDigest r 0 token (BS.pack "x") >>= category "invalid-state" - R.closeResource r 0 token >>= (@?= Right ()) - D.openDigest r 0 "sha999" >>= category "unsupported" - token <- D.openDigest r 0 "sha256" >>= right - R.updateDigest r 0 token BS.empty >>= category "invalid-bound" - R.updateDigest r 0 token (BS.replicate 4097 'a') >>= category "invalid-bound" - R.writeSome r 0 token (BS.pack "a") >>= category "wrong-kind" - forM_ ["a","b","c"] $ \chunk -> R.updateDigest r 0 token (BS.pack chunk) >>= (@?= Right ()) - R.finishDigest r 0 token >>= (@?= Right "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad") - , testCase "staging is invisible at destination until sealed publication" $ - withSystemTempDirectory "v2-stage" $ \dir -> withRuntime $ \r -> do - S.openStage r 0 "" >>= category "invalid-argument" - let target = dir ++ "/target" - BS.writeFile target (BS.pack "old") - token <- S.openStage r 0 dir >>= right - R.publishStage r 0 token True target >>= category "invalid-state" - write r token "new" - BS.readFile target >>= (@?= BS.pack "old") - R.sealStage r 0 token >>= (@?= Right ()) - BS.readFile target >>= (@?= BS.pack "old") - R.writeSome r 0 token (BS.pack "extra") >>= category "invalid-state" - R.publishStage r 0 token True target >>= (@?= Right "published") - BS.readFile target >>= (@?= BS.pack "new") - R.publishStage r 0 token True target >>= category "invalid-state" - R.closeResource r 0 token >>= (@?= Right ()) - listDirectory dir >>= (@?= ["target"]) - , testCase "no-replace preserves existing winner and abandon removes staging" $ - withSystemTempDirectory "v2-stage" $ \dir -> withRuntime $ \r -> do - BS.writeFile (dir++"/target") (BS.pack "winner") - token <- sealed S.nativeOps r dir "loser" - R.publishStage r 0 token False (dir++"/target") >>= (@?= Right "exists") - BS.readFile (dir++"/target") >>= (@?= BS.pack "winner") - R.closeResource r 0 token >>= (@?= Right ()) - listDirectory dir >>= (@?= ["target"]) - , testCase "concurrent no-replace has exactly one whole-file winner" $ - withSystemTempDirectory "v2-race" $ \dir -> withRuntime $ \r -> do - a <- sealed S.nativeOps r dir "first" - b <- sealed S.nativeOps r dir "second" - gate <- newEmptyMVar - outputs <- newEmptyMVar - forM_ [a,b] $ \token -> void $ forkIO $ readMVar gate >> - R.publishStage r 0 token False (dir++"/target") >>= putMVar outputs - putMVar gate () - x <- takeMVar outputs >>= right - y <- takeMVar outputs >>= right - sort [x,y] @?= ["exists","published"] - bytes <- BS.readFile (dir++"/target") - assertBool "whole winner" (bytes `elem` map BS.pack ["first","second"]) - R.closeScope r 0 >>= (@?= []) - listDirectory dir >>= (@?= ["target"]) - , testCase "file sync failure prevents publication and cleanup preserves old target" $ - withSystemTempDirectory "v2-sync" $ \dir -> withRuntime $ \r -> do - let ops = S.nativeOps { S.syncFile = \_ -> ioError (userError "sync failed") } - BS.writeFile (dir++"/target") (BS.pack "old") - token <- S.openStageWith ops r 0 dir >>= right - write r token "new" - R.sealStage r 0 token >>= category "io-error" - R.publishStage r 0 token True (dir++"/target") >>= category "invalid-state" - R.closeScope r 0 >>= (@?= []) - BS.readFile (dir++"/target") >>= (@?= BS.pack "old") - listDirectory dir >>= (@?= ["target"]) - , testCase "post-publication sync failure reports visible output, never rolls it back" $ - withSystemTempDirectory "v2-sync" $ \dir -> withRuntime $ \r -> do - let ops = S.nativeOps { S.syncDirectory = \_ -> ioError (userError "directory sync failed") } - token <- sealed ops r dir "new" - outcome <- R.publishStage r 0 token True (dir++"/target") - case outcome of Left p -> R.progress p @?= "published"; _ -> assertFailure "failure hidden" - R.publishStage r 0 token True (dir++"/target") >>= category "invalid-state" - R.closeScope r 0 >>= (@?= []) - BS.readFile (dir++"/target") >>= (@?= BS.pack "new") - listDirectory dir >>= (@?= ["target"]) - , testCase "ambiguous namespace failure freezes stage and never deletes destination" $ - withSystemTempDirectory "v2-ambiguous" $ \dir -> withRuntime $ \r -> do - let ops = S.nativeOps { S.replaceName = \from to -> renameFile from to >> ioError (userError "lost reply") } - token <- sealed ops r dir "new" - outcome <- R.publishStage r 0 token True (dir++"/target") - case outcome of Left p -> R.progress p @?= "unknown"; _ -> assertFailure "ambiguity lost" - R.publishStage r 0 token True (dir++"/target") >>= category "invalid-state" - R.closeScope r 0 >>= (@?= []) - BS.readFile (dir++"/target") >>= (@?= BS.pack "new") - , testCase "partial failed staged write cannot be sealed or published" $ - withSystemTempDirectory "v2-write" $ \dir -> withRuntime $ \r -> do - let ops = S.nativeOps { S.putBytes = \h bytes -> BS.hPut h (BS.take 2 bytes) >> ioError (userError "partial write") } - token <- S.openStageWith ops r 0 dir >>= right - result <- R.writeSome r 0 token (BS.pack "abcdef") - case result of Left p -> R.progress p @?= "unknown"; _ -> assertFailure "write failure hidden" - R.sealStage r 0 token >>= category "invalid-state" - R.closeScope r 0 >>= (@?= []) - listDirectory dir >>= (@?= []) - , testCase "cancellation settles a staged write before abandonment" $ - withSystemTempDirectory "v2-cancel" $ \dir -> withRuntime $ \r -> do - entered <- newEmptyMVar - blocked <- newEmptyMVar - finished <- newEmptyMVar - let ops = S.nativeOps { S.putBytes = \_ _ -> putMVar entered () >> takeMVar blocked } - token <- S.openStageWith ops r 0 dir >>= right - worker <- forkIO $ (try (R.writeSome r 0 token (BS.pack "x")) :: IO (Either SomeException (Either R.Problem Int))) >>= putMVar finished - takeMVar entered - R.sealStage r 0 token >>= category "busy" - killThread worker - result <- takeMVar finished - case result of Left _ -> pure (); _ -> assertFailure "cancelled write resumed" - R.sealStage r 0 token >>= category "invalid-state" - R.closeScope r 0 >>= (@?= []) - listDirectory dir >>= (@?= []) - , testCase "stage and digest transfer together, with old aliases invalidated" $ - withSystemTempDirectory "v2-transfer" $ \dir -> withRuntime $ \r -> do - stage <- S.openStage r 0 dir >>= right - digest <- D.openDigest r 0 "sha256" >>= right - (child, moved) <- atomically (R.reserveChildSTM r 0 [stage,digest]) >>= right - R.writeSome r 0 stage (BS.pack "abc") >>= category "stale-handle" - R.updateDigest r 0 digest (BS.pack "abc") >>= category "stale-handle" - case moved of - [freshStage,freshDigest] -> do - R.writeSome r child freshStage (BS.pack "abc") >>= (@?= Right 3) - R.updateDigest r child freshDigest (BS.pack "abc") >>= (@?= Right ()) - R.finishDigest r child freshDigest >>= (@?= Right "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad") - R.sealStage r child freshStage >>= (@?= Right ()) - R.publishStage r child freshStage False (dir++"/target") >>= (@?= Right "published") - _ -> assertFailure "transfer arity" - R.closeScope r child >>= (@?= []) - BS.readFile (dir++"/target") >>= (@?= BS.pack "abc") - listDirectory dir >>= (@?= ["target"]) - , testCase "cancelled seal closes its descriptor and abandons private output" $ - withSystemTempDirectory "v2-seal-cancel" $ \dir -> withRuntime $ \r -> do - entered <- newEmptyMVar - blocked <- newEmptyMVar - done <- newEmptyMVar - let ops = S.nativeOps { S.syncFile = \fd -> putMVar entered fd >> takeMVar blocked } - stage <- S.openStageWith ops r 0 dir >>= right - write r stage "abc" - worker <- forkIO $ (try (R.sealStage r 0 stage) :: IO (Either SomeException (Either R.Problem ()))) >>= putMVar done - fd <- takeMVar entered - original <- getFdStatus fd - killThread worker - takeMVar done >>= \outcome -> case outcome of Left _ -> pure (); _ -> assertFailure "seal resumed" - R.publishStage r 0 stage True (dir++"/target") >>= category "invalid-state" - R.closeScope r 0 >>= (@?= []) - descriptor <- try (getFdStatus fd) - case descriptor of - Left (_ :: IOException) -> pure () - Right current -> assertBool "original descriptor leaked" - ((deviceID current, fileID current) /= (deviceID original, fileID original)) - listDirectory dir >>= (@?= []) - , testCase "cancellation after namespace publication cannot undo visibility" $ - withSystemTempDirectory "v2-publish-cancel" $ \dir -> withRuntime $ \r -> do - entered <- newEmptyMVar - blocked <- newEmptyMVar - done <- newEmptyMVar - let ops = S.nativeOps { S.syncDirectory = \_ -> putMVar entered () >> takeMVar blocked } - stage <- sealed ops r dir "abc" - worker <- forkIO $ (try (R.publishStage r 0 stage True (dir++"/target")) :: IO (Either SomeException (Either R.Problem String))) >>= putMVar done - takeMVar entered - BS.readFile (dir++"/target") >>= (@?= BS.pack "abc") - killThread worker - takeMVar done >>= \outcome -> case outcome of Left _ -> pure (); _ -> assertFailure "publish resumed" - R.publishStage r 0 stage True (dir++"/target") >>= category "invalid-state" - R.closeScope r 0 >>= (@?= []) - BS.readFile (dir++"/target") >>= (@?= BS.pack "abc") - listDirectory dir >>= (@?= ["target"]) - , testCase "no-replace unlink failure reports published and cleanup retries only private name" $ - withSystemTempDirectory "v2-unlink" $ \dir -> withRuntime $ \r -> do - first <- newIORef True - let ops = S.nativeOps { S.removeName = \path -> do - failNow <- atomicModifyIORef' first (\value -> (False,value)) - if failNow then ioError (userError "unlink failed") else removeFile path } - stage <- sealed ops r dir "abc" - result <- R.publishStage r 0 stage False (dir++"/target") - case result of Left p -> R.progress p @?= "published"; _ -> assertFailure "unlink failure hidden" - R.closeScope r 0 >>= (@?= []) - BS.readFile (dir++"/target") >>= (@?= BS.pack "abc") - listDirectory dir >>= (@?= ["target"]) - , testCase "cross-directory publication succeeds but self-publication is rejected" $ - withSystemTempDirectory "v2-destination" $ \dir -> withRuntime $ \r -> do - createDirectory (dir++"/other") - a <- sealed S.nativeOps r dir "abc" - R.publishStage r 0 a True (dir++"/other/target") >>= (@?= Right "published") - BS.readFile (dir++"/other/target") >>= (@?= BS.pack "abc") - R.closeResource r 0 a >>= (@?= Right ()) - b <- sealed S.nativeOps r dir "abc" - names <- listDirectory dir - let temporary = head (filter (/= "other") names) - R.publishStage r 0 b True (dir++"/"++temporary) >>= category "invalid-destination" - R.closeScope r 0 >>= (@?= []) - listDirectory dir >>= (@?= ["other"]) - , testCase "cross-directory link and replace sync destination then source, preserving existing winners" $ - withSystemTempDirectory "v2-cross-directory" $ \dir -> withRuntime $ \r -> do - let source = dir++"/source"; target = dir++"/target" - createDirectory source - createDirectory target - synced <- newIORef [] - let ops = S.nativeOps { S.openDirectory = \path -> do - modifyIORef' synced (++ [path]) - S.openDirectory S.nativeOps path } - forM_ [False, True] $ \replace -> do - stage <- sealed ops r source (if replace then "replacement" else "new") - writeIORef synced [] - R.publishStage r 0 stage replace (target++"/object") >>= (@?= Right "published") - readIORef synced >>= (@?= [target, source]) - R.closeResource r 0 stage >>= (@?= Right ()) - listDirectory source >>= (@?= []) - BS.readFile (target++"/object") >>= (@?= BS.pack "replacement") - winner <- sealed ops r source "must not replace" - writeIORef synced [] - R.publishStage r 0 winner False (target++"/object") >>= (@?= Right "exists") - readIORef synced >>= (@?= []) -- exists is not an identity/durability claim - R.closeScope r 0 >>= (@?= []) - BS.readFile (target++"/object") >>= (@?= BS.pack "replacement") - listDirectory source >>= (@?= []) - , testCase "cross-filesystem syscall failures never fall back to copying or alter destination" $ - withSystemTempDirectory "v2-exdev" $ \dir -> withRuntime $ \r -> do - let target = dir++"/target" - createDirectory target - BS.writeFile (target++"/object") (BS.pack "keep") - calls <- newIORef (0 :: Int) - let unavailable _ destination = do - modifyIORef' calls (+1) - ioError (errnoToIOError "publish" eXDEV Nothing (Just destination)) - ops = S.nativeOps { S.linkName = unavailable, S.replaceName = unavailable } - forM_ [False, True] $ \replace -> do - stage <- sealed ops r dir "new" - outcome <- R.publishStage r 0 stage replace (target++"/object") - case outcome of - Left p -> R.progress p @?= "unknown" -- never turn errno into replay permission - _ -> assertFailure "cross-filesystem failure hidden" - R.publishStage r 0 stage replace (target++"/object") >>= category "invalid-state" - R.closeResource r 0 stage >>= (@?= Right ()) - BS.readFile (target++"/object") >>= (@?= BS.pack "keep") - readIORef calls >>= (@?= 2) - listDirectory dir >>= (@?= ["target"]) - , testCase "failure syncing either publication directory preserves published data" $ - withSystemTempDirectory "v2-cross-sync" $ \dir -> withRuntime $ \r -> do - let source = dir++"/source"; target = dir++"/target" - createDirectory source - createDirectory target - forM_ [1,2 :: Int] $ \failureAt -> do - count <- newIORef (0 :: Int) - let ops = S.nativeOps { S.syncDirectory = \fd -> do - n <- atomicModifyIORef' count (\n -> (n+1,n+1)) - if n == failureAt then ioError (userError "directory sync failed") - else S.syncDirectory S.nativeOps fd } - stage <- sealed ops r source "visible" - outcome <- R.publishStage r 0 stage True (target++"/object") - case outcome of - Left p -> do - R.progress p @?= "published" - assertBool "sync diagnostic" ("directory sync failed" `isInfixOf` R.detail p) - _ -> assertFailure "directory sync failure hidden" - R.publishStage r 0 stage True (target++"/object") >>= category "invalid-state" - R.closeResource r 0 stage >>= (@?= Right ()) - listDirectory source >>= (@?= []) - BS.readFile (target++"/object") >>= (@?= BS.pack "visible") - , testCase "cross-directory ambiguous link error never unlinks the published destination" $ - withSystemTempDirectory "v2-cross-ambiguous" $ \dir -> withRuntime $ \r -> do - let target = dir++"/target" - createDirectory target - let ops = S.nativeOps { S.linkName = \source destination -> do - S.linkName S.nativeOps source destination - ioError (userError "reply lost after link") } - stage <- sealed ops r dir "visible" - outcome <- R.publishStage r 0 stage False (target++"/object") - case outcome of Left p -> R.progress p @?= "unknown"; _ -> assertFailure "ambiguity hidden" - R.closeScope r 0 >>= (@?= []) - listDirectory dir >>= (@?= ["target"]) - BS.readFile (target++"/object") >>= (@?= BS.pack "visible") - , testCase "cancellation during source-directory sync preserves cross-directory publication" $ - withSystemTempDirectory "v2-cross-cancel" $ \dir -> withRuntime $ \r -> do - let target = dir++"/target" - createDirectory target - count <- newIORef (0 :: Int) - entered <- newEmptyMVar - blocked <- newEmptyMVar - done <- newEmptyMVar - let ops = S.nativeOps { S.syncDirectory = \fd -> do - n <- atomicModifyIORef' count (\n -> (n+1,n+1)) - if n == 2 then putMVar entered () >> takeMVar blocked else S.syncDirectory S.nativeOps fd } - stage <- sealed ops r dir "visible" - worker <- forkIO $ (try (R.publishStage r 0 stage False (target++"/object")) :: IO (Either SomeException (Either R.Problem String))) >>= putMVar done - takeMVar entered - killThread worker - takeMVar done >>= \outcome -> case outcome of Left _ -> pure (); _ -> assertFailure "cancellation lost" - R.closeScope r 0 >>= (@?= []) - listDirectory dir >>= (@?= ["target"]) - BS.readFile (target++"/object") >>= (@?= BS.pack "visible") - , testCase "source-directory close failure is published and never retries the descriptor" $ - withSystemTempDirectory "v2-cross-close" $ \dir -> withRuntime $ \r -> do - let target = dir++"/target" - createDirectory target - calls <- newIORef (0 :: Int) - let ops = S.nativeOps { S.closeDescriptor = \fd -> do - n <- atomicModifyIORef' calls (\n -> (n+1,n+1)) - closeFd fd - when (n == 3) (ioError (userError "source directory close")) } - stage <- sealed ops r dir "visible" - outcome <- R.publishStage r 0 stage True (target++"/object") - case outcome of Left p -> R.progress p @?= "published"; _ -> assertFailure "close failure hidden" - R.closeScope r 0 >>= (@?= []) - readIORef calls >>= (@?= 3) - BS.readFile (target++"/object") >>= (@?= BS.pack "visible") - , testCase "cross-directory publication cannot target another stage's private namespace" $ - withSystemTempDirectory "v2-cross-private" $ \dir -> withRuntime $ \r -> do - let target = dir++"/target" - createDirectory target - stage <- sealed S.nativeOps r dir "source" - other <- sealed S.nativeOps r target "private" - names <- listDirectory target - R.publishStage r 0 stage True (target++"/"++head names) >>= category "invalid-destination" - R.publishStage r 0 other False (target++"/safe") >>= (@?= Right "published") - R.closeScope r 0 >>= (@?= []) - BS.readFile (target++"/safe") >>= (@?= BS.pack "private") - listDirectory target >>= (@?= ["safe"]) - , testCase "repeated digest/stage scopes leave no private files or registry history" $ - withSystemTempDirectory "v2-repeat" $ \dir -> withRuntime $ \r -> do - replicateM_ 100 $ do - child <- R.childScope r 0 >>= right - _ <- S.openStage r child dir >>= right - digest <- D.openDigest r child "sha256" >>= right - R.updateDigest r child digest (BS.pack "abc") >>= (@?= Right ()) - R.closeScope r child >>= (@?= []) - R.liveCounts r >>= (@?= (1,3)) - listDirectory dir >>= (@?= []) - , testCase "failed sync keeps its primary error and reports descriptor release separately" $ - withSystemTempDirectory "v2-sync-close" $ \dir -> withRuntime $ \r -> do - let ops = S.nativeOps - { S.syncFile = \_ -> ioError (userError "primary sync") - , S.closeDescriptor = \fd -> closeFd fd >> ioError (userError "secondary close") } - stage <- S.openStageWith ops r 0 dir >>= right - write r stage "abc" - outcome <- R.sealStage r 0 stage - case outcome of - Left p -> assertBool "sync error masked" ("primary sync" `isInfixOf` R.detail p) - _ -> assertFailure "sync error hidden" - errors <- R.closeScope r 0 - case errors of - [p] -> assertBool "release error hidden" ("secondary close" `isInfixOf` R.detail p) - _ -> assertFailure (show errors) - listDirectory dir >>= (@?= []) - , testCase "failed descriptor close is never retried after possible descriptor reuse" $ - withSystemTempDirectory "v2-close-once" $ \dir -> withRuntime $ \r -> do - attempts <- newIORef (0 :: Int) - let ops = S.nativeOps { S.closeDescriptor = \fd -> do - modifyIORef' attempts (+1) - closeFd fd - ioError (userError "close reported failure") } - stage <- S.openStageWith ops r 0 dir >>= right - write r stage "abc" - R.sealStage r 0 stage >>= category "io-error" - R.closeScope r 0 >>= (@?= []) - readIORef attempts >>= (@?= 1) - listDirectory dir >>= (@?= []) - , testCase "unexpected descriptor release fault still attempts private-name cleanup" $ - withSystemTempDirectory "v2-release-fault" $ \dir -> withRuntime $ \r -> do - let ops = S.nativeOps - { S.syncFile = \_ -> ioError (userError "sync failed") - , S.closeDescriptor = \fd -> closeFd fd >> throwIO (ErrorCall "unexpected close") } - stage <- S.openStageWith ops r 0 dir >>= right - R.sealStage r 0 stage >>= category "io-error" - errors <- R.closeScope r 0 - case errors of [p] -> R.category p @?= "backend-fault"; _ -> assertFailure (show errors) - listDirectory dir >>= (@?= []) - , testCase "directory sync failure preserves its error and owns directory release until settlement" $ - withSystemTempDirectory "v2-directory-close" $ \dir -> withRuntime $ \r -> do - calls <- newIORef (0 :: Int) - let ops = S.nativeOps - { S.syncDirectory = \_ -> ioError (userError "primary directory sync") - , S.closeDescriptor = \fd -> do - count <- atomicModifyIORef' calls (\n -> (n+1,n)) - closeFd fd - when (count == 1) (ioError (userError "secondary directory close")) } - stage <- sealed ops r dir "abc" - result <- R.publishStage r 0 stage False (dir++"/target") - case result of - Left p -> do - R.progress p @?= "published" - assertBool "primary hidden" ("primary directory sync" `isInfixOf` R.detail p) - _ -> assertFailure "directory sync failure hidden" - errors <- R.closeScope r 0 - case errors of - [p] -> assertBool "directory release hidden" ("secondary directory close" `isInfixOf` R.detail p) - _ -> assertFailure (show errors) - readIORef calls >>= (@?= 2) - BS.readFile (dir++"/target") >>= (@?= BS.pack "abc") - listDirectory dir >>= (@?= ["target"]) - , testCase "publication cannot overwrite another stage's private name" $ - withSystemTempDirectory "v2-private-namespace" $ \dir -> withRuntime $ \r -> do - first <- sealed S.nativeOps r dir "first" - original <- listDirectory dir - second <- S.openStage r 0 dir >>= right - write r second "second" - names <- listDirectory dir - assertBool "temporary naming contract" (all (".tricu-stage-" `isInfixOf`) names) - let other = head (filter (`notElem` original) names) - R.publishStage r 0 first True (dir++"/"++other) >>= category "invalid-destination" - write r second "tail" - R.sealStage r 0 second >>= (@?= Right ()) - R.publishStage r 0 second False (dir++"/safe") >>= (@?= Right "published") - R.closeScope r 0 >>= (@?= []) - BS.readFile (dir++"/safe") >>= (@?= BS.pack "secondtail") - listDirectory dir >>= (@?= ["safe"]) - , testCase "explicit abandonment reports removal failure" $ - withSystemTempDirectory "v2-cleanup" $ \dir -> withRuntime $ \r -> do - attempts <- newIORef (0 :: Int) - let ops = S.nativeOps { S.removeName = \_ -> modifyIORef' attempts (+1) >> ioError (userError "remove failed") } - token <- S.openStageWith ops r 0 dir >>= right - R.closeResource r 0 token >>= category "io-error" - readIORef attempts >>= (@?= 1) - R.liveCounts r >>= (@?= (1,3)) - ] diff --git a/test/IOV2SyncTests.hs b/test/IOV2SyncTests.hs @@ -1,113 +0,0 @@ -{-# LANGUAGE ScopedTypeVariables #-} -module IOV2SyncTests (ioV2SyncTests) where - -import qualified IOV2.Resources as R -import qualified IOV2.Filesystem as F -import qualified IOV2.Descriptor as D -import Control.Concurrent -import Control.Exception -import Control.Monad (forM_) -import Data.IORef -import qualified Data.ByteString as BS -import Data.List (isInfixOf) -import System.Directory (renameFile) -import System.Posix.Files (createNamedPipe) -import System.FilePath ((</>)) -import System.IO.Temp (withSystemTempDirectory) -import System.Timeout (timeout) -import Test.Tasty -import Test.Tasty.HUnit - -fake = R.FileOps Nothing Nothing Nothing Nothing (pure 0) Nothing Nothing (pure ()) -category wanted value = case value of Left p -> R.category p @?= wanted; Right _ -> assertFailure wanted - -ioV2SyncTests = localOption (mkTimeout 30000000) $ testGroup "IO v2 synchronization" - [ testCase "reader/update/append sync preserves bytes and logical cursor" $ withSystemTempDirectory "tricu-sync" $ \directory -> do - let path = directory </> "file" - BS.writeFile path "abcdef" - runtime <- R.newRuntime - Right reader <- R.openReader runtime R.rootScope path - R.readSome runtime R.rootScope reader 2 >>= (@?= Right "ab") - forM_ [1..10::Int] $ \_ -> R.syncFile runtime R.rootScope reader >>= (@?= Right ()) - R.filePosition runtime R.rootScope reader >>= (@?= Right 2) - R.readSome runtime R.rootScope reader 2 >>= (@?= Right "cd") - R.closeResource runtime R.rootScope reader >>= (@?= Right ()) - Right update <- R.openUpdate runtime R.rootScope path - R.seekFile runtime R.rootScope update 3 >>= (@?= Right ()) - R.writeSome runtime R.rootScope update "X" >>= (@?= Right 1) - R.syncFile runtime R.rootScope update >>= (@?= Right ()) - R.filePosition runtime R.rootScope update >>= (@?= Right 4) - R.closeResource runtime R.rootScope update >>= (@?= Right ()) - Right append <- R.openAppender runtime R.rootScope path - R.writeSome runtime R.rootScope append "Z" >>= (@?= Right 1) - R.syncFile runtime R.rootScope append >>= (@?= Right ()) - R.closeScope runtime R.rootScope >>= (@?= []) - BS.readFile path >>= (@?= "abcXefZ") - , testCase "sync uses the opened inode, not a fresh lookup of its old path" $ withSystemTempDirectory "tricu-sync" $ \directory -> do - let path = directory </> "old" - BS.writeFile path "bytes" - runtime <- R.newRuntime - Right reader <- R.openReader runtime R.rootScope path - renameFile path (directory </> "moved") - R.syncFile runtime R.rootScope reader >>= (@?= Right ()) - F.syncDirectory runtime R.rootScope directory >>= (@?= Right ()) - R.closeScope runtime R.rootScope >>= (@?= []) - , testCase "directory sync rejects non-directories and missing paths ordinarily" $ withSystemTempDirectory "tricu-sync" $ \directory -> do - let path = directory </> "file" - BS.writeFile path "bytes" - runtime <- R.newRuntime - counts <- R.liveCounts runtime - F.syncDirectory runtime R.rootScope path >>= category "not-directory" - createNamedPipe (directory </> "fifo") 0o600 - F.syncDirectory runtime R.rootScope (directory </> "fifo") >>= category "not-directory" - F.syncDirectory runtime R.rootScope (directory </> "missing") >>= category "not-found" - F.syncDirectory runtime R.rootScope "" >>= category "invalid-argument" - R.liveCounts runtime >>= (@?= counts) - R.closeScope runtime R.rootScope >>= (@?= []) - , testCase "sync and release failures preserve primary diagnostics without history" $ do - runtime <- R.newRuntime - calls <- newIORef (0::Int) - let actions = pure (ioError (userError "primary sync failure"), - modifyIORef' calls (+1) >> ioError (userError "secondary release failure")) - Right file <- R.acquire runtime R.rootScope (pure (R.File fake {R.fileSyncActions=Just actions})) - R.syncFile runtime R.rootScope file >>= \reply -> case reply of - Left p -> do - R.progress p @?= "unknown" - assertBool "lost primary" ("primary sync failure" `isInfixOf` R.detail p) - assertBool "lost cleanup" ("secondary release failure" `isInfixOf` R.detail p) - _ -> assertFailure "sync failure swallowed" - R.liveCounts runtime >>= (@?= (1,4)) - readIORef calls >>= (@?= 1) - R.closeScope runtime R.rootScope >>= (@?= []) - , testCase "cancelled sync releases both leases and settles cleanup lexically" $ do - runtime <- R.newRuntime - entered <- newEmptyMVar; gate <- newEmptyMVar; done <- newEmptyMVar - released <- newIORef (0::Int) - let actions = pure (putMVar entered () >> takeMVar gate,modifyIORef' released (+1)) - Right file <- R.acquire runtime R.rootScope (pure (R.File fake {R.fileSyncActions=Just actions})) - worker <- forkFinally (R.syncFile runtime R.rootScope file) (putMVar done) - takeMVar entered - R.syncFile runtime R.rootScope file >>= category "busy" - killThread worker - timeout 3000000 (takeMVar done) >>= \reply -> case reply of - Just (Left err) -> case fromException err of Just ThreadKilled -> pure (); _ -> assertFailure (show err) - _ -> assertFailure "cancellation lost" - readIORef released >>= (@?= 0) - R.closeScope runtime R.rootScope >>= (@?= []) - readIORef released >>= (@?= 1) - , testCase "once-only raw release is not retried after close error or interruption" $ do - forM_ [toException (userError "uncertain close"),toException ThreadKilled] $ \exception -> do - calls <- newIORef (0::Int) - release <- D.releaseOnce (modifyIORef' calls (+1) >> throwIO exception) - first <- try release - case first of Left (_ :: SomeException) -> pure (); Right _ -> assertFailure "injection missing" - release - readIORef calls >>= (@?= 1) - , testCase "unsupported methods and borrowed streams cannot become file sync" $ do - runtime <- R.newRuntime - Right file <- R.acquire runtime R.rootScope (pure (R.File fake)) - R.syncFile runtime R.rootScope file >>= category "unsupported" - Right standard <- R.standardStream runtime R.rootScope R.StandardOutput - R.syncFile runtime R.rootScope standard >>= category "wrong-kind" - R.closeScope runtime R.rootScope >>= (@?= []) - ] diff --git a/test/IOV2TaskTests.hs b/test/IOV2TaskTests.hs @@ -1,328 +0,0 @@ -{-# LANGUAGE ScopedTypeVariables #-} -module IOV2TaskTests (ioV2TaskTests) where - -import qualified IOV2.Resources as R -import qualified IOV2.Tasks as S -import Runtime -import Control.Concurrent hiding (yield) -import Control.Exception -import Control.Monad hiding (join) -import Data.IORef -import qualified Data.ByteString as BS -import qualified System.Timeout as Timeout -import Test.Tasty -import Test.Tasty.HUnit - -right :: Show e => Either e a -> IO a -right (Right x) = pure x -right (Left e) = assertFailure (show e) >> error "assertion failed" -wait :: MVar a -> IO a -wait var = Timeout.timeout 3000000 (takeMVar var) >>= maybe (assertFailure "timeout" >> error "timeout") pure -completed :: S.Outcome -> Assertion -completed (S.Completed value) = value @?= Leaf -completed value = assertFailure (show value) -rejected :: String -> Either R.Problem a -> Assertion -rejected name (Left e) = R.category e @?= name -rejected name _ = assertFailure ("expected " ++ name) -withGroup :: (R.Runtime -> S.Scheduler -> S.Group -> IO S.Outcome) -> IO S.Outcome -withGroup action = do - runtime <- R.newRuntime - scheduler <- S.newScheduler runtime - group <- S.rootGroup scheduler - result <- S.runGroup group (action runtime scheduler group) - R.liveCounts runtime >>= (@?= (0,0)) - S.liveCounts scheduler >>= (@?= (0,0,0)) - pure result - -ioV2TaskTests :: TestTree -ioV2TaskTests = localOption (mkTimeout 30000000) $ testGroup "IO v2 structured tasks" - [ testCase "normal exit joins borrowing child before ancestor release" $ do - released <- newIORef False - entered <- newEmptyMVar - finish <- newEmptyMVar - result <- newEmptyMVar - _ <- forkIO $ withGroup (\runtime _ group -> do - token <- R.acquire runtime 0 (pure (R.Reader (const (pure BS.empty)) (writeIORef released True))) >>= right - _ <- S.spawn group False False [] $ \child _ -> do - putMVar entered () - wait finish - readIORef released >>= (@?= False) - R.readSome runtime (S.scopeOf child) token 1 >>= (@?= Right BS.empty) - pure (S.Completed Leaf) - pure (S.Completed Leaf)) >>= putMVar result - wait entered - tryTakeMVar result >>= \value -> case value of Nothing -> pure (); _ -> assertFailure "scope returned before child" - readIORef released >>= (@?= False) - putMVar finish () - wait result >>= completed - readIORef released >>= (@?= True) - , testCase "joined outcome is consumed once, ordinary errors remain values" $ - withGroup (\_ _ group -> do - ref <- S.spawn group False True [] (\_ _ -> pure (S.Completed (Fork Leaf Leaf))) >>= right - S.join group ref >>= right >>= \outcome -> case outcome of - S.Completed value -> value @?= Fork Leaf Leaf - _ -> assertFailure "domain value became terminal failure" - S.join group ref >>= rejected "stale-task" - S.discard group ref >>= rejected "stale-task" - pure (S.Completed Leaf)) >>= completed - , testCase "supervision is installed before execution and preserves lazy diagnostic" $ - withGroup (\_ _ group -> do - ref <- S.spawn group True True [] (\_ _ -> pure (S.Failed (error "diagnostic forced") [])) >>= right - S.join group ref >>= right >>= \outcome -> case outcome of - S.Failed _ [] -> pure () - _ -> assertFailure "supervised failure lost" - pure (S.Completed Leaf)) >>= completed - , testCase "uncontained fault interrupts body, cancels sibling and joins its lease" $ do - siblingReady <- newEmptyMVar - parentReady <- newEmptyMVar - blocked <- newEmptyMVar - resumed <- newIORef False - released <- newIORef False - outcome <- withGroup $ \runtime _ group -> do - _ <- S.spawn group False False [] $ \child _ -> do - token <- R.acquire runtime (S.scopeOf child) (pure (R.Writer - (\_ -> putMVar siblingReady () >> takeMVar blocked) - (writeIORef released True))) >>= right - _ <- R.writeSome runtime (S.scopeOf child) token (BS.singleton 1) - writeIORef resumed True - pure (S.Completed Leaf) - _ <- S.spawn group False False [] $ \_ _ -> do - wait siblingReady - wait parentReady - pure (S.Failed (ofString "child fault") []) - putMVar parentReady () - takeMVar blocked - writeIORef resumed True - pure (S.Completed Leaf) - case outcome of S.Failed diagnostic [] -> diagnostic @?= ofString "child fault"; _ -> assertFailure "fault lost" - readIORef resumed >>= (@?= False) - readIORef released >>= (@?= True) - , testCase "explicit cancellation settles blocked acquisition and reports reason" $ - withGroup (\runtime _ group -> do - entered <- newEmptyMVar - block <- newEmptyMVar - resumed <- newIORef False - ref <- S.spawn group True True [] (\child _ -> do - _ <- R.acquire runtime (S.scopeOf child) (putMVar entered () >> takeMVar block) - writeIORef resumed True - pure (S.Completed Leaf)) >>= right - wait entered - S.cancel group ref (ofString "shutdown") >>= (@?= Right ()) - S.join group ref >>= right >>= \outcome -> case outcome of - S.Cancelled reason [] -> reason @?= ofString "shutdown" - _ -> assertFailure "cancellation not observed" - readIORef resumed >>= (@?= False) - pure (S.Completed Leaf)) >>= completed - , testCase "transfer publication gives child fresh tokens and rejects old aliases" $ - withGroup (\runtime _ group -> do - released <- newIORef False - ready <- newEmptyMVar - finish <- newEmptyMVar - token <- R.acquire runtime 0 (pure (R.Reader (const (pure BS.empty)) (writeIORef released True))) >>= right - ref <- S.spawn group False True [token] (\child tokens -> do - case tokens of - [fresh] -> R.readSome runtime (S.scopeOf child) fresh 1 >>= (@?= Right BS.empty) - _ -> assertFailure "bad fresh tokens" - putMVar ready () - wait finish - pure (S.Completed Leaf)) >>= right - wait ready - R.readSome runtime 0 token 1 >>= rejected "stale-handle" - readIORef released >>= (@?= False) - putMVar finish () - S.join group ref >>= right >>= completed - readIORef released >>= (@?= True) - pure (S.Completed Leaf)) >>= completed - , testCase "failed transfer does not start callback or allocate task history" $ - withGroup (\runtime scheduler group -> do - started <- newIORef False - token <- R.acquire runtime 0 (pure (R.Reader (const (pure BS.empty)) (pure ()))) >>= right - S.spawn group False True [token,token] (\_ _ -> writeIORef started True >> pure (S.Completed Leaf)) >>= rejected "duplicate-handle" - readIORef started >>= (@?= False) - S.liveCounts scheduler >>= (@?= (0,0,0)) - R.readSome runtime 0 token 1 >>= (@?= Right BS.empty) - pure (S.Completed Leaf)) >>= completed - , testCase "repeated joins and discards leave no result archive" $ - withGroup (\_ scheduler group -> do - replicateM_ 500 $ do - ref <- S.spawn group False True [] (\_ _ -> pure (S.Completed (error "value forced"))) >>= right - S.join group ref >>= right >>= \outcome -> case outcome of S.Completed _ -> pure (); _ -> assertFailure "failed" - S.liveCounts scheduler >>= (@?= (0,0,0)) - replicateM_ 500 $ do - ref <- S.spawn group False True [] (\_ _ -> pure (S.Completed (error "discard forced"))) >>= right - S.discard group ref >>= (@?= Right ()) - pure (S.Completed Leaf)) >>= completed - , testCase "cancellation preserves primary and attaches all release errors" $ - withGroup (\runtime _ group -> do - ready <- newEmptyMVar - blocked <- newEmptyMVar - ref <- S.spawn group True True [] (\child _ -> do - replicateM_ 24 $ void $ R.acquire runtime (S.scopeOf child) - (pure (R.Reader (const (pure BS.empty)) (ioError (userError "release")))) >>= right - putMVar ready () - takeMVar blocked - pure (S.Completed Leaf)) >>= right - wait ready - S.cancel group ref (error "reason forced") >>= (@?= Right ()) - S.join group ref >>= right >>= \outcome -> case outcome of - S.Cancelled _ errors -> do - length errors @?= 17 - R.detail (last errors) @?= "8" - _ -> assertFailure "primary cancellation lost" - pure (S.Completed Leaf)) >>= completed - , testCase "explicit cancellation of leased read/write remains sticky after backend catches wakeup" $ - withGroup (\runtime _ group -> do - forM_ [True, False] $ \reading -> do - entered <- newEmptyMVar - blocked <- newEmptyMVar - resumed <- newIORef False - released <- newIORef False - let operation = (putMVar entered () >> takeMVar blocked) - `catch` (\(_ :: AsyncException) -> pure ()) - backend = if reading - then R.Reader (\_ -> operation >> pure BS.empty) (writeIORef released True) - else R.Writer (\_ -> operation >> pure 1) (writeIORef released True) - ref <- S.spawn group True True [] (\child _ -> do - token <- R.acquire runtime (S.scopeOf child) (pure backend) >>= right - if reading then void (R.readSome runtime (S.scopeOf child) token 1) - else void (R.writeSome runtime (S.scopeOf child) token (BS.singleton 1)) - S.checkpoint child - writeIORef resumed True - pure (S.Completed Leaf)) >>= right - wait entered - S.cancel group ref Leaf >>= (@?= Right ()) - S.join group ref >>= right >>= \outcome -> case outcome of - S.Cancelled Leaf [] -> pure () - _ -> assertFailure "backend neutralized cancellation" - readIORef resumed >>= (@?= False) - readIORef released >>= (@?= True) - pure (S.Completed Leaf)) >>= completed - , testCase "cancellation versus completion linearizes to one terminal outcome" $ - withGroup (\_ _ group -> do - replicateM_ 200 $ do - ready <- newEmptyMVar - finish <- newEmptyMVar - cancelled <- newEmptyMVar - ref <- S.spawn group True True [] (\_ _ -> putMVar ready () >> wait finish >> pure (S.Completed Leaf)) >>= right - wait ready - _ <- forkIO (S.cancel group ref Leaf >>= putMVar cancelled) - putMVar finish () - request <- wait cancelled - outcome <- S.join group ref >>= right - case (request, outcome) of - (Right (), S.Cancelled Leaf []) -> pure () - (Left e, S.Completed Leaf) -> assertBool "completion won" (R.category e `elem` ["stale-task", "completed-task"]) - _ -> assertFailure "inconsistent completion/cancellation race" - pure (S.Completed Leaf)) >>= completed - , testCase "self/ancestor joins reject without consuming the ticket" $ - withGroup (\_ _ group -> do - reference <- newEmptyMVar - checked <- newEmptyMVar - ref <- S.spawn group True True [] (\child _ -> do - self <- wait reference - S.join child self >>= rejected "join-cycle" - result <- S.nested child (\inner -> S.join inner self >>= rejected "join-cycle" >> pure (S.Completed Leaf)) - putMVar checked () - pure result) >>= right - putMVar reference ref - wait checked - S.join group ref >>= right >>= completed - pure (S.Completed Leaf)) >>= completed - , testCase "implicit scope joins participate in wait-cycle detection" $ do - outcome <- withGroup $ \_ _ group -> do - aRef <- newEmptyMVar - bRef <- newEmptyMVar - aReady <- newEmptyMVar - b <- S.spawn group False True [] (\child _ -> do - a <- wait aRef - wait aReady - -- A's descendant already waits on B, or will do so after this claim. - result <- S.join child a - case result of Left e -> R.category e @?= "join-cycle"; Right _ -> pure () - pure (S.Completed Leaf)) >>= right - putMVar bRef b - a <- S.spawn group False True [] (\child _ -> do - _ <- S.spawn child False False [] (\grandchild _ -> do - target <- wait bRef - putMVar aReady () - result <- S.join grandchild target - case result of Left e -> R.category e @?= "join-cycle"; Right _ -> pure () - pure (S.Completed Leaf)) >>= right - pure (S.Completed Leaf)) >>= right - putMVar aRef a - pure (S.Completed Leaf) - case outcome of - S.Completed Leaf -> pure () -- Explicit join rejected the last edge. - S.Failed message [] -> message @?= ofString "task join cycle" - _ -> assertFailure "cycle was not settled" - , testCase "cancellation waits for lexical release and preserves its error" $ - withGroup (\runtime _ group -> do - entered <- newEmptyMVar - proceed <- newEmptyMVar - interrupted <- newIORef False - ref <- S.spawn group True True [] (\child _ -> S.nested child (\inner -> do - _ <- R.acquire runtime (S.scopeOf inner) (pure (R.Reader (const (pure BS.empty)) - ((putMVar entered () >> takeMVar proceed >> ioError (userError "release")) - `catch` (\(e :: AsyncException) -> writeIORef interrupted True >> throwIO e)))) >>= right - pure (S.Completed Leaf))) >>= right - wait entered - S.cancel group ref (ofString "shutdown") >>= (@?= Right ()) - putMVar proceed () - S.join group ref >>= right >>= \outcome -> case outcome of - S.Cancelled reason errors -> do - reason @?= ofString "shutdown" - length errors @?= 1 - _ -> assertFailure "lexical cleanup/cancellation lost" - readIORef interrupted >>= (@?= False) - pure (S.Completed Leaf)) >>= completed - , testCase "concurrent ticket claims have exactly one consumer" $ - withGroup (\_ _ group -> do - ready <- newEmptyMVar - finish <- newEmptyMVar - loser <- newEmptyMVar - target <- S.spawn group True True [] (\_ _ -> putMVar ready () >> wait finish >> pure (S.Completed Leaf)) >>= right - wait ready - let consumer child _ = do - result <- S.join child target - case result of - Right (S.Completed Leaf) -> pure (S.Completed (Stem Leaf)) - Left e -> do - R.category e @?= "stale-task" - putMVar loser () - pure (S.Completed Leaf) - _ -> assertFailure "unexpected join outcome" >> pure (S.Failed Leaf []) - a <- S.spawn group True True [] consumer >>= right - b <- S.spawn group True True [] consumer >>= right - wait loser - putMVar finish () - x <- S.join group a >>= right - y <- S.join group b >>= right - case (x,y) of - (S.Completed Leaf, S.Completed (Stem Leaf)) -> pure () - (S.Completed (Stem Leaf), S.Completed Leaf) -> pure () - _ -> assertFailure "ticket consumed more or less than once" - pure (S.Completed Leaf)) >>= completed - , testCase "lexical fault preserves every release diagnostic without duplicate attachment" $ do - outcome <- withGroup $ \runtime _ group -> S.nested group $ \inner -> do - replicateM_ 3 $ void $ R.acquire runtime (S.scopeOf inner) - (pure (R.Reader (const (pure BS.empty)) (ioError (userError "lexical release")))) >>= right - pure (S.Failed (ofString "lexical fault") []) - case outcome of - S.Failed message errors -> do - message @?= ofString "lexical fault" - length errors @?= 3 - _ -> assertFailure "lexical failure lost" - , testCase "nested lexical scope cancellation settles inner resources" $ do - ready <- newEmptyMVar - blocked <- newEmptyMVar - released <- newIORef False - outcome <- withGroup $ \runtime _ group -> do - _ <- S.spawn group False False [] (\_ _ -> wait ready >> pure (S.Failed Leaf [])) - S.nested group $ \inner -> do - _ <- R.acquire runtime (S.scopeOf inner) (pure (R.Reader (const (pure BS.empty)) (writeIORef released True))) >>= right - putMVar ready () - takeMVar blocked - pure (S.Completed Leaf) - case outcome of S.Failed Leaf [] -> pure (); _ -> assertFailure "nested interruption changed primary" - readIORef released >>= (@?= True) - ] diff --git a/test/IOV2Tests.hs b/test/IOV2Tests.hs @@ -1,356 +0,0 @@ -{-# LANGUAGE ScopedTypeVariables #-} -module IOV2Tests (ioV2Tests) where - -import qualified IOV2.Resources as R -import qualified IOV2.Driver as V -import IODriver (runIO) -import Runtime -import Control.Concurrent -import Control.Concurrent.STM -import Control.Exception -import Control.Monad -import qualified Data.ByteString as BS -import Data.IORef -import System.Directory (listDirectory) -import System.IO.Temp (withSystemTempDirectory) -import qualified System.Timeout as Timeout -import Test.Tasty -import Test.Tasty.HUnit - --- Independent wire construction: no production encoder/catalogue expectations. -request :: String -> String -> T -> T -request group operation arguments = Fork (ofNumber 10) - (Fork (ofString group) (Fork (ofNumber 1) (Fork (ofString operation) arguments))) - -right :: Show e => Either e a -> IO a -right (Right value) = pure value -right (Left e) = assertFailure (show e) >> error "assertion failed" -categoryIs :: String -> Either R.Problem a -> Assertion -categoryIs expected result = case result of - Left p -> R.category p @?= expected - Right _ -> assertFailure ("expected " ++ expected) - -withRuntime :: (R.Runtime -> IO a) -> IO a -withRuntime = bracket R.newRuntime (\r -> void (R.closeScope r R.rootScope)) -spawn :: IO a -> IO (ThreadId, MVar (Either SomeException a)) -spawn action = do - result <- newEmptyMVar - tid <- forkIO (try action >>= putMVar result) - pure (tid, result) -wait :: MVar a -> IO a -wait result = Timeout.timeout 3000000 (takeMVar result) >>= maybe (assertFailure "thread timeout" >> error "timeout") pure - -ioV2Tests :: TestTree -ioV2Tests = localOption (mkTimeout 30000000) $ testGroup "IO v2 resource/file foundation" - [ testCase "bounded binary copy, EOF, no grants" $ - withSystemTempDirectory "v2-copy" $ \dir -> withRuntime $ \runtime -> do - let bytes = BS.pack (take 20003 (cycle [0..255])) - BS.writeFile (dir++"/in") bytes - child <- R.childScope runtime 0 >>= right - reader <- R.openReader runtime child (dir++"/in") >>= right - writer <- R.openWriter runtime child (dir++"/out") >>= right - let copy = do - part <- R.readSome runtime child reader 997 >>= right - unless (BS.null part) $ do - n <- R.writeSome runtime child writer part >>= right - n @?= BS.length part - copy - copy - R.readSome runtime child reader 5 >>= (@?= Right BS.empty) - R.closeScope runtime child >>= (@?= []) - BS.readFile (dir++"/out") >>= (@?= bytes) - R.liveCounts runtime >>= (@?= (1,3)) - R.readSome runtime 0 reader 1 >>= categoryIs "stale-handle" - , testCase "scope ancestry, wrong kind, bounds and cross-run handles" $ - withRuntime $ \runtime -> withRuntime $ \other -> do - a <- R.childScope runtime 0 >>= right - b <- R.childScope runtime 0 >>= right - child <- R.childScope runtime a >>= right - reader <- R.acquire runtime a (pure (R.Reader (const (pure BS.empty)) (pure ()))) >>= right - R.readSome runtime child reader 1 >>= (@?= Right BS.empty) - R.readSome runtime b reader 1 >>= categoryIs "out-of-scope" - R.readSome runtime 0 reader 1 >>= categoryIs "out-of-scope" - R.readSome other 0 reader 1 >>= categoryIs "stale-handle" - R.writeSome runtime a reader (BS.singleton 1) >>= categoryIs "wrong-kind" - forM_ [0,4097] $ \n -> R.readSome runtime a reader n >>= categoryIs "invalid-bound" - R.closeResource runtime a reader >>= (@?= Right ()) - R.closeResource runtime a reader >>= categoryIs "stale-handle" - , testCase "second acquisition failure releases first at scope exit" $ - withSystemTempDirectory "v2-open" $ \dir -> withRuntime $ \runtime -> do - released <- newIORef False - scope <- R.childScope runtime 0 >>= right - _ <- R.acquire runtime scope (pure (R.Reader (const (pure BS.empty)) (writeIORef released True))) >>= right - R.openReader runtime scope (dir++"/missing") >>= categoryIs "not-found" - R.closeScope runtime scope >>= (@?= []) - readIORef released >>= (@?= True) - , testCase "release order and cleanup errors do not skip remaining resources" $ - withRuntime $ \runtime -> do - order <- newIORef ([] :: [Int]) - scope <- R.childScope runtime 0 >>= right - forM_ [1,2,3] $ \i -> do - _ <- R.acquire runtime scope (pure (R.Reader (const (pure BS.empty)) - (modifyIORef' order (++[i]) >> when (i == 2) (ioError (userError "close failure"))))) >>= right - pure () - errors <- R.closeScope runtime scope - map R.operation errors @?= ["close"] - readIORef order >>= (@?= [3,2,1]) - R.liveCounts runtime >>= (@?= (1,3)) - , testCase "pending lease is busy; close waits; cancellation releases lease" $ - withRuntime $ \runtime -> do - entered <- newEmptyMVar - blocked <- newEmptyMVar - released <- newEmptyMVar - token <- R.acquire runtime 0 (pure (R.Reader (\_ -> putMVar entered () >> takeMVar blocked) - (putMVar released ()))) >>= right - (reader, readDone) <- spawn (R.readSome runtime 0 token 1) - wait entered - R.readSome runtime 0 token 1 >>= categoryIs "busy" - (_, closeDone) <- spawn (R.closeResource runtime 0 token) - -- Wait for closing admission, not a timing guess about the close worker. - let closing = do - result <- R.readSome runtime 0 token 1 - case result of - Left p | R.category p == "closed-handle" -> pure () - Left p | R.category p == "busy" -> yield >> closing - _ -> assertFailure "unexpected lease state" - (Timeout.timeout 3000000 closing >>= (@?= Just ())) `onException` killThread reader - tryTakeMVar released >>= (@?= Nothing) - killThread reader - readResult <- wait readDone - case readResult of Left e -> assertBool "async cancellation" (case fromException e :: Maybe AsyncException of Just ThreadKilled -> True; _ -> False) - Right _ -> assertFailure "cancelled read resumed" - wait closeDone >>= right >>= (@?= Right ()) - wait released - R.liveCounts runtime >>= (@?= (1,3)) - , testCase "scope closure drains an acquisition already admitted" $ - withRuntime $ \runtime -> do - entered <- newEmptyMVar - proceed <- newEmptyMVar - released <- newEmptyMVar - scope <- R.childScope runtime 0 >>= right - (_, acquired) <- spawn (R.acquire runtime scope (putMVar entered () >> takeMVar proceed >> - pure (R.Reader (const (pure BS.empty)) (putMVar released ())))) - wait entered - (_, closed) <- spawn (R.closeScope runtime scope) - putMVar proceed () - wait acquired >>= right >>= right >>= \_ -> pure () - wait closed >>= right >>= (@?= []) - wait released - R.liveCounts runtime >>= (@?= (1,3)) - , testCase "cancelled acquisition leaves no admission or resource behind" $ - withRuntime $ \runtime -> do - entered <- newEmptyMVar - gate <- newEmptyMVar - scope <- R.childScope runtime 0 >>= right - (worker, finished) <- spawn (R.acquire runtime scope (putMVar entered () >> takeMVar gate)) - wait entered - killThread worker - outcome <- wait finished - case outcome of Left _ -> pure (); Right _ -> assertFailure "acquisition resumed" - R.closeScope runtime scope >>= (@?= []) - R.liveCounts runtime >>= (@?= (1,3)) - , testCase "partial write progress and invalid backend progress" $ - withRuntime $ \runtime -> do - writer <- R.acquire runtime 0 (pure (R.Writer (\_ -> pure 2) (pure ()))) >>= right - R.writeSome runtime 0 writer (BS.pack [1,2,3]) >>= (@?= Right 2) - badWriter <- R.acquire runtime 0 (pure (R.Writer (\_ -> pure 0) (pure ()))) >>= right - result <- R.writeSome runtime 0 badWriter (BS.singleton 1) - categoryIs "io-error" result - case result of Left p -> R.progress p @?= "unknown"; _ -> assertFailure "bad progress accepted" - , testCase "driver host cancellation closes resources and never resumes continuation" $ - withSystemTempDirectory "v2-cancel" $ \dir -> do - runtime <- R.newRuntime - entered <- newEmptyMVar - gate <- newEmptyMVar - released <- newIORef False - token@(R.Token identity key) <- R.acquire runtime 0 - (pure (R.Writer (\_ -> putMVar entered () >> takeMVar gate) (writeIORef released True))) >>= right - let encoded = ofList [ofString "io.v2.resource", ofNumber identity, ofNumber key] - scope = ofList [ofString "io.v2.scope", ofNumber identity, Leaf] - write = request "stream" "write" (Fork encoded (ofBytes (BS.singleton 1))) - -- If resumed, this would create an observable file. - next = Fork Leaf (request "file" "open-writer" (Fork scope (ofString (dir++"/SHOULD-NOT-EXIST")))) - program = Fork (ofNumber 1) (Fork write next) - (worker, finished) <- spawn (V.runInRuntime runtime program) - wait entered - killThread worker - outcome <- wait finished - case outcome of Left e -> assertBool "async outcome not converted to Result" (case fromException e :: Maybe AsyncException of Just ThreadKilled -> True; _ -> False) - Right _ -> assertFailure "cancelled driver returned normally" - readIORef released >>= (@?= True) - R.liveCounts runtime >>= (@?= (0,0)) - R.writeSome runtime 0 token (BS.singleton 1) >>= categoryIs "stale-handle" - files <- listDirectory dir - files @?= [] - , testCase "driver preserves primary failure, aggregates bounded cleanup errors" $ do - runtime <- R.newRuntime - closed <- newIORef (0 :: Int) - replicateM_ 40 $ do - _ <- R.acquire runtime 0 (pure (R.Reader (const (pure BS.empty)) - (modifyIORef' closed (+1) >> throwIO (ErrorCall "backend close fault")))) >>= right - pure () - outcome <- V.runInRuntime runtime (Fork (ofNumber 2) (error "primary diagnostic forced")) - case outcome of - V.Failed _ errors -> do - length errors @?= 17 - R.category (last errors) @?= "cleanup-omitted" - R.detail (last errors) @?= "24" - _ -> assertFailure "primary failure lost" - readIORef closed >>= (@?= 40) - R.liveCounts runtime >>= (@?= (0,0)) - , testCase "successful body becomes failed when cleanup fails" $ do - runtime <- R.newRuntime - _ <- R.acquire runtime 0 (pure (R.Reader (const (pure BS.empty)) (ioError (userError "close")))) >>= right - outcome <- V.runInRuntime runtime (Fork (ofNumber 0) Leaf) - case outcome of V.Failed _ [] -> pure (); _ -> assertFailure "cleanup failure silently discarded" - , testCase "repeated lexical scopes do not retain closed resources or scope history" $ - withRuntime $ \runtime -> replicateM_ 1000 $ do - scope <- R.childScope runtime 0 >>= right - _ <- R.acquire runtime scope (pure (R.Reader (const (pure BS.empty)) (pure ()))) >>= right - R.closeScope runtime scope >>= (@?= []) - R.liveCounts runtime >>= (@?= (1,3)) - , testCase "closing admission preserves child borrowing and child-local acquisition" $ - withRuntime $ \runtime -> do - released <- newIORef (0 :: Int) - let backend = pure (R.Reader (const (pure BS.empty)) (modifyIORef' released (+1))) - token <- R.acquire runtime 0 backend >>= right - child <- R.childScope runtime 0 >>= right - atomically (R.beginCloseScopeSTM runtime 0) - R.acquire runtime 0 backend >>= categoryIs "closed-scope" - R.childScope runtime 0 >>= categoryIs "closed-scope" - R.readSome runtime child token 1 >>= (@?= Right BS.empty) - local <- R.acquire runtime child backend >>= right - grandchild <- R.childScope runtime child >>= right - R.readSome runtime grandchild local 1 >>= (@?= Right BS.empty) - readIORef released >>= (@?= 0) - R.closeScope runtime child >>= (@?= []) - readIORef released >>= (@?= 1) - R.readSome runtime 0 token 1 >>= (@?= Right BS.empty) - R.closeScope runtime 0 >>= (@?= []) - readIORef released >>= (@?= 2) - R.liveCounts runtime >>= (@?= (0,0)) - , testCase "handoff rekeys aliases and preserves acquisition order, not argument order" $ - withRuntime $ \runtime -> do - order <- newIORef ([] :: [Int]) - let backend n = pure (R.Reader (const (pure (BS.singleton (fromIntegral n)))) - (modifyIORef' order (++[n]))) - first <- R.acquire runtime 0 (backend 1) >>= right - second <- R.acquire runtime 0 (backend 2) >>= right - (child, fresh) <- atomically (R.reserveChildSTM runtime 0 [second, first]) >>= right - case fresh of - [newSecond, newFirst] -> do - forM_ [first, second] $ \old -> do - R.readSome runtime child old 1 >>= categoryIs "stale-handle" - R.closeResource runtime 0 old >>= categoryIs "stale-handle" - R.readSome runtime 0 newFirst 1 >>= categoryIs "out-of-scope" - R.readSome runtime child newFirst 1 >>= (@?= Right (BS.singleton 1)) - R.readSome runtime child newSecond 1 >>= (@?= Right (BS.singleton 2)) - _ -> assertFailure "wrong transfer arity" - _ <- R.acquire runtime child (backend 3) >>= right - R.closeScope runtime child >>= (@?= []) - readIORef order >>= (@?= [3,2,1]) - R.liveCounts runtime >>= (@?= (1,3)) - , testCase "failed transfer validates all handles without partial movement" $ - withRuntime $ \runtime -> withRuntime $ \other -> do - let backend = pure (R.Reader (const (pure BS.empty)) (pure ())) - token <- R.acquire runtime 0 backend >>= right - foreignToken <- R.acquire other 0 backend >>= right - child <- R.childScope runtime 0 >>= right - local <- R.acquire runtime child backend >>= right - let rejected owner tokens expected = do - before <- R.liveCounts runtime - atomically (R.reserveChildSTM runtime owner tokens) >>= categoryIs expected - R.liveCounts runtime >>= (@?= before) - R.readSome runtime 0 token 1 >>= (@?= Right BS.empty) - R.readSome runtime child local 1 >>= (@?= Right BS.empty) - rejected 0 [token, token] "duplicate-handle" - rejected 0 [token, R.Token (-1) 999] "stale-handle" - rejected 0 [token, foreignToken] "stale-handle" - rejected 0 [token, local] "out-of-scope" - rejected child [local, token] "not-owner" - atomically (R.beginCloseScopeSTM runtime child) - rejected child [local] "closed-scope" - , testCase "handoff transaction rolls back with task-publication failure" $ - withRuntime $ \runtime -> do - token <- R.acquire runtime 0 (pure (R.Reader (const (pure BS.empty)) (pure ()))) >>= right - publication <- newTVarIO False - result <- try $ atomically $ do - reserved <- R.reserveChildSTM runtime 0 [token] - case reserved of - Left _ -> error "reservation should succeed" - Right _ -> writeTVar publication True >> throwSTM (ErrorCall "publication failed") - case result of Left (_ :: ErrorCall) -> pure (); Right () -> assertFailure "expected rollback" - readTVarIO publication >>= (@?= False) - R.liveCounts runtime >>= (@?= (1,4)) - R.readSome runtime 0 token 1 >>= (@?= Right BS.empty) - R.closeResource runtime 0 token >>= (@?= Right ()) - , testCase "handoff rejects a leased or closing resource, with no partial movement" $ - withRuntime $ \runtime -> do - entered <- newEmptyMVar - proceed <- newEmptyMVar - released <- newIORef False - first <- R.acquire runtime 0 (pure (R.Reader (const (pure BS.empty)) (pure ()))) >>= right - busy <- R.acquire runtime 0 (pure (R.Reader (\_ -> putMVar entered () >> takeMVar proceed) - (writeIORef released True))) >>= right - (_, reading) <- spawn (R.readSome runtime 0 busy 1) - wait entered - atomically (R.reserveChildSTM runtime 0 [first, busy]) >>= categoryIs "busy" - R.liveCounts runtime >>= (@?= (1,5)) - (_, closing) <- spawn (R.closeResource runtime 0 busy) - let marked = do - result <- atomically (R.reserveChildSTM runtime 0 [first, busy]) - case result of - Left e | R.category e == "closed-handle" -> pure () - Left e | R.category e == "busy" -> yield >> marked - _ -> assertFailure "unexpected transfer outcome" - Timeout.timeout 3000000 marked >>= (@?= Just ()) - R.readSome runtime 0 first 1 >>= (@?= Right BS.empty) - readIORef released >>= (@?= False) - putMVar proceed BS.empty - wait reading >>= right >>= (@?= Right BS.empty) - wait closing >>= right >>= (@?= Right ()) - R.liveCounts runtime >>= (@?= (1,4)) - , testCase "close versus handoff has one winner and one backend release" $ - replicateM_ 200 $ withRuntime $ \runtime -> do - released <- newIORef (0 :: Int) - token <- R.acquire runtime 0 (pure (R.Reader (const (pure BS.empty)) - (atomicModifyIORef' released (\n -> (n+1, ()))))) >>= right - gate <- newTVarIO False - let start = atomically (readTVar gate >>= check) - (_, moved) <- spawn (start >> atomically (R.reserveChildSTM runtime 0 [token])) - (_, closed) <- spawn (start >> R.closeResource runtime 0 token) - atomically (writeTVar gate True) - handoff <- wait moved >>= right - close <- wait closed >>= right - case handoff of - Right (child, [fresh]) -> do - categoryIs "stale-handle" close - readIORef released >>= (@?= 0) - R.readSome runtime child fresh 1 >>= (@?= Right BS.empty) - R.closeScope runtime child >>= (@?= []) - Left e -> do - assertBool "close won" (R.category e `elem` ["stale-handle", "closed-handle"]) - close @?= Right () - _ -> assertFailure "wrong handoff arity" - readIORef released >>= (@?= 1) - R.liveCounts runtime >>= (@?= (1,3)) - , testCase "strict octet decode stops without forcing excess element" $ do - V.decodeChunk (ofBytes (BS.pack [0..255])) @?= Right (BS.pack [0..255]) - assertBool "noncanonical zero rejected" (case V.decodeChunk (ofList [Fork Leaf Leaf]) of Left _ -> True; _ -> False) - let tooLong = foldr Fork (Fork (error "excess head forced") (error "excess suffix forced")) (replicate 4096 Leaf) - V.decodeChunk tooLong @?= Left "chunk exceeds bound" - , testCase "v2 dispatch, ordinary errors, lazy failure and version rejection" $ do - let action code x = Fork (ofNumber code) x - envelope version x = Fork (ofString "tricuIO") (Fork (ofNumber version) x) - runIO (envelope 2 (action 0 (ofNumber 7))) >>= (@?= Right (ofNumber 7)) - failed <- V.runDetailed (action 2 (error "diagnostic was forced")) - case failed of V.Failed _ [] -> pure (); _ -> assertFailure "terminal failure lost" - runIO (envelope 99 (action 0 Leaf)) >>= (@?= Left "Unsupported tricuIO version") - let badVersion = Fork Leaf (Fork (Stem Leaf) (Fork Leaf Leaf)) - runIO (Fork (ofString "tricuIO") (Fork badVersion (error "invalid version body forced"))) - >>= (@?= Left "invalid IO envelope version") - runIO (Fork (ofString "tricuIO") (Fork (ofNumber 65536) (error "excess version body forced"))) - >>= (@?= Left "invalid IO envelope version") - ordinary <- V.runDetailed (request "stream" "read" Leaf) - case ordinary of V.Completed (Fork Leaf _) -> pure (); _ -> assertFailure "argument failure not ordinary Result" - ] diff --git a/test/IOV2UTF8Tests.hs b/test/IOV2UTF8Tests.hs @@ -1,77 +0,0 @@ -module IOV2UTF8Tests (ioV2UTF8Tests) where - -import qualified IOV2.UTF8 as U -import qualified IOV2.Resources as R -import qualified IOV2.Driver as V -import Runtime (T(..), ofList, ofNumber, ofString, ofBytes) -import qualified Data.ByteString as BS -import Data.Char (chr) -import qualified Data.Text as Text -import qualified Data.Text.Encoding as E -import Control.Monad (forM_) -import Test.Tasty -import Test.Tasty.HUnit - -category wanted value = case value of Left p -> R.category p @?= wanted; Right _ -> assertFailure wanted -request op payload = Fork (ofNumber 10) (Fork (ofString "utf8") (Fork (ofNumber 1) (Fork (ofString op) payload))) -bad op action = V.runDetailed action >>= \result -> case result of - V.Completed (Fork Leaf (Fork problem Leaf)) -> problem @?= ofList (map ofString ["invalid-argument","utf8/"++op,"","none"]) - _ -> assertFailure "expected ordinary argument failure" - -ioV2UTF8Tests = localOption (mkTimeout 30000000) $ testGroup "Strict bounded UTF-8" - [ testCase "independent boundary vectors, NUL, BOM and noncharacters" $ do - forM_ [(0,[0]),(0x7f,[0x7f]),(0x80,[0xc2,0x80]),(0x7ff,[0xdf,0xbf]), - (0x800,[0xe0,0xa0,0x80]),(0xd7ff,[0xed,0x9f,0xbf]),(0xe000,[0xee,0x80,0x80]), - (0xfeff,[0xef,0xbb,0xbf]),(0xffff,[0xef,0xbf,0xbf]), - (0x10000,[0xf0,0x90,0x80,0x80]),(0x10ffff,[0xf4,0x8f,0xbf,0xbf])] $ \(scalar,octets) -> do - let bytes = BS.pack octets - U.encode [chr scalar] @?= Right bytes - U.decode True bytes @?= Right ([chr scalar],BS.empty) - forM_ [1..BS.length bytes-1] $ \split -> do - U.decode False (BS.take split bytes) @?= Right ([],BS.take split bytes) - U.decode True (BS.take split bytes) `seq` category "invalid-data" (U.decode True (BS.take split bytes)) - , testCase "every Unicode scalar agrees with the standard UTF-8 encoder" $ do - forM_ ([0..0xd7ff] ++ [0xe000..0x10ffff]) $ \scalar -> do - let text = [chr scalar] - bytes = E.encodeUtf8 (Text.pack text) - U.decode True bytes @?= Right (text,BS.empty) - , testCase "incomplete valid suffix is explicit and composes at every split" $ do - let text = "A\0λ😀\r\n\xfeff" - bytes = E.encodeUtf8 (Text.pack text) - forM_ [0..BS.length bytes] $ \split -> do - case U.decode False (BS.take split bytes) of - Left p -> assertFailure (show p) - Right (first,carry) -> do - assertBool "oversized carry" (BS.length carry <= 3) - U.decode True (carry <> BS.drop split bytes) @?= Right (drop (length first) text,BS.empty) - case U.decode True (carry <> BS.drop split bytes) of - Right (last,_) -> first ++ last @?= text - _ -> assertFailure "split decode failed" - , testCase "malformed and noncanonical prefixes fail even before final input" $ do - forM_ [[0x80],[0xc0],[0xc1],[0xf5],[0xff],[0xe0,0x80],[0xed,0xa0], - [0xf0,0x80],[0xf4,0x90],[0xe2,0x28],[0xf0,0x90,0x7f], - [0xc2,0x7f],[0xe0,0xa0,0x7f],[0xf0,0x90,0x80,0xc0]] $ \bytes -> - forM_ [False,True] $ \final -> category "invalid-data" (U.decode final (BS.pack bytes)) - case U.decode False ("good" <> BS.singleton 0xff) of - Left p -> R.detail p @?= "invalid UTF-8 leading byte at byte 4" - _ -> assertFailure "invalid suffix accepted" - , testCase "bounds and surrogate controls do not demand excess characters" $ do - U.encode (replicate 1024 '\x10ffff') @?= Right (BS.concat (replicate 1024 (BS.pack [0xf4,0x8f,0xbf,0xbf]))) - category "invalid-bound" (U.encode (replicate 1024 'a' ++ (error "excess scalar":error "excess tail"))) - forM_ [0xd800,0xdfff] $ \n -> category "invalid-data" (U.encode [chr n]) - category "invalid-bound" (U.decode False (BS.replicate 4097 0)) - U.decode True BS.empty @?= Right ([],BS.empty) - U.encode [] @?= Right BS.empty - , testCase "wire scalar controls reject aliases, surrogates, overflow and excess" $ do - forM_ [Fork Leaf Leaf,ofNumber 0xd800,ofNumber 0x110000] $ \scalar -> - bad "encode" (request "encode" (ofList [scalar])) - let excess = foldr Fork (Fork (error "excess wire scalar") (error "excess wire tail")) (replicate 1024 (ofNumber 97)) - bad "encode" (request "encode" excess) - bad "decode" (request "decode" (ofList [ofNumber 2,error "invalid final forced bytes"])) - bad "decode" (request "decode" (ofList [ofNumber 1,ofList [ofNumber 256]])) - , testCase "wire empty text and NUL are valid, distinct values" $ do - forM_ [(Leaf,BS.empty),(ofList [Leaf],BS.singleton 0)] $ \(text,bytes) -> - V.runDetailed (request "encode" text) >>= \result -> case result of - V.Completed value -> value @?= Fork (Stem Leaf) (Fork (ofBytes bytes) Leaf) - _ -> assertFailure "valid text rejected" - ] diff --git a/test/IndexedWriterTests.hs b/test/IndexedWriterTests.hs @@ -1,8 +1,8 @@ {-# LANGUAGE ScopedTypeVariables #-} module IndexedWriterTests (indexedWriterTests) where -import qualified IOV2.Codec as C -import qualified IOV2.Resources as R +import qualified IO.Codec as C +import qualified IO.Resources as R import ContentStore.Indexed import ContentStore.Object import Runtime (T(..)) diff --git a/test/InteractionInspectionTests.hs b/test/InteractionInspectionTests.hs @@ -111,7 +111,7 @@ interactionInspectionTests = testGroup "Interaction inspection" let req = action 10 (Fork (ofString "file") (Fork (ofNumber 1) (Fork (ofString "open-writer") (action 26 (ofString "DO-NOT-DELETE"))))) envelope = Fork (ofString "tricuIO") . Fork (ofNumber 2) os = occurrences (inspect defaultLimits [("main",envelope (action 1 (Fork req req)))]) - map occurrenceSummary (take 3 os) @?= ["tricuIO v2","Bind","Facility file@1/open-writer"] + map occurrenceSummary (take 3 os) @?= ["tricuIO","Bind","Facility file@1/open-writer"] map occurrenceStatus (drop 3 os) @?= ["opaque","opaque"] assertBool "no payload preview" (not (any (Text.isInfixOf "DO-NOT-DELETE" . occurrenceSummary) os)) , testCase "v2 does not inherit permissive v1 tag decoding or numeric facilities" $ do diff --git a/test/PortableCompilerTests.hs b/test/PortableCompilerTests.hs @@ -74,7 +74,7 @@ portableCompilerTests = testGroup "portable compiler supervision" , testCase "compiler Results and malformed actions are checked" $ fresh $ \store source -> do forM_ [(pureAction Leaf, "Invalid portable compiler Result"), (pureAction (Fork Leaf (Fork (ofString "test failure") Leaf)), "Portable compilation failed"), - (Leaf, "IO v2 task failed")] $ \(action, diagnostic) -> do + (Leaf, "IO task failed")] $ \(action, diagnostic) -> do options <- worker store action rejected diagnostic (buildPortableModule options (Just (unStorePath store)) source) , testCase "returned manifest and receipt are authenticated" $ fresh $ \store source -> do diff --git a/test/ReferenceFixtures.hs b/test/ReferenceFixtures.hs @@ -54,7 +54,7 @@ supportFixture = unsafePerformIO $ do metadata <- lines <$> readFile "./test/fixtures/support-library.v1" bytes <- BS.readFile "./test/fixtures/support-library.arboricx" compilerBytes <- BS.readFile "./test/fixtures/compiler-library.arboricx" - unless (take 1 metadata `elem` [["tricu.support-library-fixture.v1"], ["tricu.support-library-fixture.v2"], ["tricu.support-library-fixture.v3"], ["tricu.support-library-fixture.v4"], ["tricu.support-library-fixture.v5"]]) $ + unless (take 1 metadata `elem` [["tricu.support-library-fixture.v1"], ["tricu.support-library-fixture.v2"], ["tricu.support-library-fixture.v3"], ["tricu.support-library-fixture.v4"], ["tricu.support-library-fixture.v5"], ["tricu.support-library-fixture.v6"]]) $ error "invalid support fixture metadata" let rows = [(legacy, alias, path, digest) | row <- tail metadata, ["module", legacy, alias, path, digest] <- [words row]] @@ -63,7 +63,7 @@ supportFixture = unsafePerformIO $ do error "support fixture bundle digest mismatch" unless (["compiler-fixture", objectHash "tricu.compiler-library-fixture.v1" compilerBytes] `elem` map words metadata) $ error "support fixture dependency mismatch" - if take 1 metadata `elem` [["tricu.support-library-fixture.v2"], ["tricu.support-library-fixture.v3"], ["tricu.support-library-fixture.v4"], ["tricu.support-library-fixture.v5"]] + if take 1 metadata `elem` [["tricu.support-library-fixture.v2"], ["tricu.support-library-fixture.v3"], ["tricu.support-library-fixture.v4"], ["tricu.support-library-fixture.v5"], ["tricu.support-library-fixture.v6"]] then verifyMixedSupport metadata bytes compilerBytes else pure () _ <- evaluate (length (fst compilerFixture)) diff --git a/test/SnapshotTests.hs b/test/SnapshotTests.hs @@ -1,9 +1,9 @@ {-# LANGUAGE ScopedTypeVariables #-} module SnapshotTests (snapshotTests) where -import qualified IOV2.Driver as Driver -import qualified IOV2.Codec as Codec -import qualified IOV2.Resources as R +import qualified IO.Driver as Driver +import qualified IO.Codec as Codec +import qualified IO.Resources as R import ContentStore.Snapshot import ContentStore.Indexed import Wire diff --git a/test/Spec.hs b/test/Spec.hs @@ -5,23 +5,23 @@ import IOBoundaryTests (ioBoundaryTests) import PortableCompilerTests (portableCompilerTests) import ExecutableLoadingTests (executableLoadingTests) import IODriverTests (ioDriverTests) -import IOV2UTF8Tests (ioV2UTF8Tests) -import IOV2ByteStreamTests (ioV2ByteStreamTests) -import IOV2SyncTests (ioV2SyncTests) +import IOUTF8Tests (ioUTF8Tests) +import IOByteStreamTests (ioByteStreamTests) +import IOSyncTests (ioSyncTests) import IndexedWriterTests (indexedWriterTests) import SnapshotTests (snapshotTests) -import IOV2ResolverTests (ioV2ResolverTests) -import IOV2NetworkTests (ioV2NetworkTests) -import IOV2FilesystemTests (ioV2FilesystemTests) -import IOV2PositionTests (ioV2PositionTests) -import IOV2StandardTests (ioV2StandardTests) -import IOV2StorageTests (ioV2StorageTests) -import IOV2TaskTests (ioV2TaskTests) -import IOV2ClockTests (ioV2ClockTests) -import IOV2ProcessTests (ioV2ProcessTests) -import IOV2ContextTests (ioV2ContextTests) -import IOV2ProtocolTests (ioV2ProtocolTests) -import IOV2Tests (ioV2Tests) +import IOResolverTests (ioResolverTests) +import IONetworkTests (ioNetworkTests) +import IOFilesystemTests (ioFilesystemTests) +import IOPositionTests (ioPositionTests) +import IOStandardTests (ioStandardTests) +import IOStorageTests (ioStorageTests) +import IOTaskTests (ioTaskTests) +import IOClockTests (ioClockTests) +import IOProcessTests (ioProcessTests) +import IOContextTests (ioContextTests) +import IOProtocolTests (ioProtocolTests) +import IOTests (ioTests) import LibraryProgramTests (maybeTests, providedLibraries, contractsTests, arithmetic) import qualified ParserValueTests import qualified LibraryValueTests @@ -34,7 +34,7 @@ import Runtime import TreeObservation (forceTreeShared) import qualified NodeTable as Nodes import Wire -import SupportFixture (canonicalEnvs, verifyReuseProviders, verifySupportSources, verifyRelocatedSupportSources, verifyIOV2SupportSources, verifyCompilerIOSupportSources, +import SupportFixture (canonicalEnvs, verifyReuseProviders, verifySupportSources, verifyRelocatedSupportSources, verifyIOSupportSources, verifyCompilerIOSupportSources, verifyCanonicalIOSupportSources, verifySupportExports, verifySupportDependencies, readFixtureSourceBytes, readTestSource, readPinnedSource) import ContentStore import ContentStore.Bundle @@ -112,29 +112,29 @@ tests = testGroup "Tricu Tests" , gateUnitTests , gateResolutionTests , gateCompilationTests - , LibraryValueTests.httpParsingTests (fixtureExports "http") (fixtureExports "io_v2_http") + , LibraryValueTests.httpParsingTests (fixtureExports "http") (fixtureExports "io_http") , registryContractTests , localControlTests , contentStoreTests , artifactSafetyTests , ioDriverTests - , ioV2Tests - , ioV2ClockTests - , ioV2ProcessTests - , ioV2ContextTests - , ioV2ProtocolTests - , ioV2TaskTests - , ioV2UTF8Tests - , ioV2ByteStreamTests - , ioV2SyncTests + , ioTests + , ioClockTests + , ioProcessTests + , ioContextTests + , ioProtocolTests + , ioTaskTests + , ioUTF8Tests + , ioByteStreamTests + , ioSyncTests , indexedWriterTests , snapshotTests - , ioV2ResolverTests - , ioV2NetworkTests - , ioV2FilesystemTests - , ioV2PositionTests - , ioV2StandardTests - , ioV2StorageTests + , ioResolverTests + , ioNetworkTests + , ioFilesystemTests + , ioPositionTests + , ioStandardTests + , ioStorageTests ] @@ -1608,22 +1608,22 @@ compilerFixtureTests = testGroup "self-built reference compiler fixture" (case verifyRelocatedSupportSources old bad (Set.singleton "patterns") of Left _ -> True; _ -> False) verifyRelocatedSupportSources old current Set.empty @?= Left "invalid patterns cohort relocation" verifySupportSources old current (Set.singleton "patterns") @?= Left "support cohort identity changed" - , testCase "IO-v2 support additions require declarations, exact membership and rebuilds" $ do + , testCase "IO support additions require declarations, exact membership and rebuilds" $ do original <- map words . lines <$> readFile "test/fixtures/support-origin.v1" current <- map words . lines <$> readFile "test/fixtures/support-cohort-v4.v1" let built = Set.fromList [name | ["rebuilt",name,_,_,_] <- current] declarations = [row | row <- current, take 1 row == ["added"]] length declarations @?= 10 - verifyIOV2SupportSources original current built @?= Right () + verifyIOSupportSources original current built @?= Right () forM_ [filter ((/= ["added"]) . take 1) current, current ++ [head declarations], current ++ [["module","unexpected","unexpected","lib/unexpected.tri","pin"]], map (\row -> if take 3 row == ["module","io_v2","io_v2"] then ["module","io_v2","io_v2","lib/wrong.tri","pin"] else row) current] $ \bad -> assertBool "unauthorized support growth accepted" - (case verifyIOV2SupportSources original bad built of Left _ -> True; _ -> False) - assertBool "unrebuilt IO-v2 addition accepted" - (case verifyIOV2SupportSources original current (Set.delete "io_v2" built) of Left _ -> True; _ -> False) + (case verifyIOSupportSources original bad built of Left _ -> True; _ -> False) + assertBool "unrebuilt IO addition accepted" + (case verifyIOSupportSources original current (Set.delete "io_v2" built) of Left _ -> True; _ -> False) , testCase "compiler IO cohort has exact disjoint ownership and rebuilt support" $ do original <- map words . lines <$> readFile "test/fixtures/support-origin.v1" old <- map words . lines <$> readFile "test/fixtures/support-cohort-v4.v1" @@ -1653,6 +1653,33 @@ compilerFixtureTests = testGroup "self-built reference compiler fixture" assertBool "incorrect support ownership accepted" (case check compiler bad built of Left _ -> True; _ -> False) assertBool "unrebuilt general IO accepted" (case check compiler current (Set.delete "io" built) of Left _ -> True; _ -> False) + , testCase "canonical IO ownership rejects extra providers and incomplete rebuilds" $ do + original <- map words . lines <$> readFile "test/fixtures/support-origin.v1" + let moved = [("io","lib/io.tri"), ("io_storage","lib/io/storage.tri"), ("io_text","lib/io/text.tri")] + additions = [("arboricx_handlers","lib/arboricx/handlers.tri"), + ("arboricx_streaming","lib/arboricx/streaming.tri"), + ("handler_artifacts","lib/handlers/artifacts.tri"), ("handler_log","lib/handlers/log.tri"), + ("handlers","lib/handlers.tri"), ("io_artifacts","lib/io/artifacts.tri"), + ("io_http","lib/io/http-transport.tri")] + modules = [["module",name,name,path,"source"] | (name,path) <- additions] + patternRow = ["module","patterns","patterns","lib/patterns.tri","source"] + current = original ++ [patternRow, ["relocated","compiler-library","patterns","patterns","lib/patterns.tri"]] ++ + modules ++ [["added",name,name,path] | (name,path) <- additions] ++ + [["compiler-provider",name,path] | (name,path) <- moved] + compiler = [["module",name,name,path,"source"] | (name,path) <- moved ++ [("compiler_io","lib/source/io.tri")]] + built = Set.fromList ("patterns":map fst additions) + check = verifyCanonicalIOSupportSources + check compiler original current built @?= Right () + forM_ [tail compiler, compiler ++ [head compiler]] $ \bad -> + assertBool "incorrect canonical compiler ownership accepted" + (case check bad original current built of Left _ -> True; _ -> False) + forM_ [current ++ [["module","io","io","lib/io.tri","source"]], + current ++ [["compiler-provider","extra","lib/extra.tri"]], + filter ((/= ["added"]) . take 1) current] $ \bad -> + assertBool "incorrect canonical support ownership accepted" + (case check compiler original bad built of Left _ -> True; _ -> False) + assertBool "unrebuilt canonical addition accepted" + (case check compiler original current (Set.delete "io_artifacts" built) of Left _ -> True; _ -> False) , testCase "rebuilt export sets follow the new manifest, not the old interface" $ do let manifest kind names = unlines ("arboricx.module-manifest.v1" : ["export " ++ name ++ " " ++ kind ++ " hash arboricx.abi.tree.v1" | name <- names]) diff --git a/test/SupportFixture.hs b/test/SupportFixture.hs @@ -1,7 +1,7 @@ -- Test-only mixed-origin fixture verification. No source compilation/evaluation: -- complete provider identity, exact DAG interning and receipt authentication. module SupportFixture (verifyMixedSupport, verifyReuseProviders, verifySupportSources, - verifySupportExports, verifySupportDependencies, verifyRelocatedSupportSources, verifyIOV2SupportSources, verifyCompilerIOSupportSources, canonicalEnvs, + verifySupportExports, verifySupportDependencies, verifyRelocatedSupportSources, verifyIOSupportSources, verifyCompilerIOSupportSources, verifyCanonicalIOSupportSources, canonicalEnvs, readFixtureSourceBytes, readTestSource, readPinnedSource) where import Wire @@ -146,14 +146,14 @@ verifyRelocatedSupportSources original current rebuilt = do verifySupportSources (original ++ [row]) current rebuilt _ -> Left "invalid patterns cohort relocation" --- Explicit closed IO-v2 cohort growth, not an ordinary membership rewrite. +-- Explicit closed IO cohort growth, not an ordinary membership rewrite. -- All additions must be rebuilt; the immutable origin and patterns move retain -- their original checks. No arbitrary added/reused alias is admitted. -verifyIOV2SupportSources :: [[String]] -> [[String]] -> S.Set String -> Either String () -verifyIOV2SupportSources = verifyExpandedSupportSources ioV2Additions +verifyIOSupportSources :: [[String]] -> [[String]] -> S.Set String -> Either String () +verifyIOSupportSources = verifyExpandedSupportSources historicalIOAdditions -ioV2Additions :: [(String, String)] -ioV2Additions = +historicalIOAdditions :: [(String, String)] +historicalIOAdditions = [("arboricx_handlers", "lib/arboricx/handlers.tri"), ("arboricx_streaming_v2", "lib/arboricx/streaming-v2.tri"), ("handler_artifacts", "lib/handlers/artifacts.tri"), @@ -175,13 +175,34 @@ verifyCompilerIOSupportSources compiler original current rebuilt = do owns (name,path) = case [p | ["module",_,alias,p,_] <- compiler, alias == name] of [p] -> p == path _ -> False - additions = filter ((`notElem` map fst moved) . fst) ioV2Additions ++ [("io", "lib/io.tri")] + additions = filter ((`notElem` map fst moved) . fst) historicalIOAdditions ++ [("io", "lib/io.tri")] unless (all owns required && null [() | ["module",_,"io",_,_] <- compiler] && filter ((== ["compiler-provider"]) . take 1) current == [["compiler-provider",name,path] | (name,path) <- moved]) $ Left "invalid compiler IO cohort ownership" verifyExpandedSupportSources additions original current rebuilt +-- Current names have one canonical IO owner; historical cohort schemas above +-- retain their exact authenticated membership contracts. +verifyCanonicalIOSupportSources :: [[String]] -> [[String]] -> [[String]] -> S.Set String -> Either String () +verifyCanonicalIOSupportSources compiler original current rebuilt = do + let moved = [("io", "lib/io.tri"), ("io_storage", "lib/io/storage.tri"), + ("io_text", "lib/io/text.tri")] + required = moved ++ [("compiler_io", "lib/source/io.tri")] + additions = [("arboricx_handlers", "lib/arboricx/handlers.tri"), + ("arboricx_streaming", "lib/arboricx/streaming.tri"), + ("handler_artifacts", "lib/handlers/artifacts.tri"), + ("handler_log", "lib/handlers/log.tri"), + ("handlers", "lib/handlers.tri"), + ("io_artifacts", "lib/io/artifacts.tri"), + ("io_http", "lib/io/http-transport.tri")] + owns (name,path) = [p | ["module",_,alias,p,_] <- compiler, alias == name] == [path] + unless (all owns required && + filter ((== ["compiler-provider"]) . take 1) current == + [["compiler-provider",name,path] | (name,path) <- moved]) $ + Left "invalid canonical IO cohort ownership" + verifyExpandedSupportSources additions original current rebuilt + verifyExpandedSupportSources :: [(String, String)] -> [[String]] -> [[String]] -> S.Set String -> Either String () verifyExpandedSupportSources additions original current rebuilt = do let names = S.fromList (map fst additions) @@ -193,7 +214,7 @@ verifyExpandedSupportSources additions original current rebuilt = do unless (filter ((== ["added"]) . take 1) current == declarations && map (take 4) added == [["module",name,name,path] | (name,path) <- additions] && names `S.isSubsetOf` rebuilt && not (any isNew original)) $ - Left "invalid IO-v2 support expansion" + Left "invalid IO support expansion" verifyRelocatedSupportSources original (filter (not . isNew) current) rebuilt verifySupportSources (original ++ patternsRow ++ added) current rebuilt @@ -266,10 +287,12 @@ verifyMixedSupport metadata currentBytes compilerBytes = do error "invalid mixed support cohort partition/export set" compilerRows <- map words . lines <$> readFile "test/fixtures/compiler-library.v1" either error pure $ - if take 1 metadata == ["tricu.support-library-fixture.v5"] + if take 1 metadata == ["tricu.support-library-fixture.v6"] + then verifyCanonicalIOSupportSources compilerRows oldRows (map words metadata) built + else if take 1 metadata == ["tricu.support-library-fixture.v5"] then verifyCompilerIOSupportSources compilerRows oldRows (map words metadata) built else if take 1 metadata == ["tricu.support-library-fixture.v4"] - then verifyIOV2SupportSources oldRows (map words metadata) built + then verifyIOSupportSources oldRows (map words metadata) built else if take 1 metadata == ["tricu.support-library-fixture.v3"] then verifyRelocatedSupportSources oldRows (map words metadata) built else verifySupportSources oldRows (map words metadata) built diff --git a/test/arboricx_deployment.py b/test/arboricx_deployment.py @@ -111,7 +111,7 @@ def main(): if args.handler_policy: assert denied_reply == b'Artifact unavailable\n' # A v1 grant rejects publication through an escaping shard. - # V2 deliberately permits it under OS process authority. + # HostIO deliberately permits it under OS process authority. outside_dir = root / 'outside-dir'; outside_dir.mkdir(exist_ok=True) rejected_body = b'reject publication outside grant' rejected_hash = hashlib.sha256(rejected_body).hexdigest() diff --git a/test/arboricx_server_inspection.py b/test/arboricx_server_inspection.py @@ -74,11 +74,11 @@ def main(): for key in ('path', 'role', 'summary', 'status'): assert before[key] == after[key], (key, before, after) assert [o['summary'] for o in report['occurrences']] == [ - 'tricuIO v2', 'Scope', 'scope callback (not applied)'] + 'tricuIO', 'Scope', 'scope callback (not applied)'] assert report['occurrences'][-1]['status'] == 'opaque' create = inspect('create-view', named, 'createView') assert [o['summary'] for o in create['occurrences']] == [ - 'tricuIO v2', 'Facility directory@1/create', + 'tricuIO', 'Facility directory@1/create', 'arguments (not validated; backend availability not inferred)'] assert 'createRootDirectory' in create['occurrences'][1]['aliases'] assert 'storeRoot' in create['occurrences'][2]['aliases'] @@ -97,15 +97,15 @@ def main(): candidates = { 'storeRoot': '"./store"', 'createRootDirectory': 'IO.createDirectory storeRoot', - 'initializeStore': 'H.runIOV2 (Artifacts.prepareFilesystemV2 storeRoot)', - 'rawPrepare': 'Artifacts.prepareFilesystemV2', + 'initializeStore': 'H.runIO (Artifacts.prepareFilesystem storeRoot)', + 'rawPrepare': 'Artifacts.prepareFilesystem', 'rawListen': 'IO.listenTCP', 'rawScope': 'IO.scope', 'rawPure': 'IO.pure', } source = output / 'ordinary.tri' source.write_text('!import arboricx_app as App\n!import handler_artifacts as Artifacts\n' - '!import handlers as H\n!import io_v2 as IO\n!export main\n' + '!import handlers as H\n!import io as IO\n!export main\n' + ''.join('!export ' + name + '\n' for name in candidates) + 'main = App.main\n' + ''.join(name + ' = ' + value + '\n' for name, value in candidates.items())) diff --git a/test/body_retention.py b/test/body_retention.py @@ -41,9 +41,9 @@ def main(): root = Path(__file__).resolve().parent.parent sources = [Path(__file__).resolve(), root/'lib/http.tri', root/'src/Runtime/Value.hs', root/'lib/handlers.tri'] - sources += sorted((root/'src/IOV2').glob('*.hs')) - sources += sorted((root/'lib/io-v2').glob('*.tri')) - sources += [root/'lib/io-v2.tri', root/'lib/arboricx/streaming-v2.tri', + sources += sorted((root/'src/IO').glob('*.hs')) + sources += sorted((root/'lib/io').glob('*.tri')) + sources += [root/'lib/io.tri', root/'lib/arboricx/streaming.tri', root/'lib/arboricx/handlers.tri', root/'lib/handlers/artifacts.tri', root/'lib/handlers/log.tri'] report = dict(schema='tricu.body-retention.v1', io_version=2, rows=[], diff --git a/test/bootstrap_archive.py b/test/bootstrap_archive.py @@ -80,7 +80,7 @@ def recovery_profile(seed, inputs, adapter, accepted): from generation_io import versions, produced_adapter execution, output = versions(inputs) if (execution, output) != (1, 1) and not accepted: - raise ValueError('IO v2 recovery requires an independently accepted pair') + raise ValueError('IO recovery requires an independently accepted pair') if output == 2 and adapter.read_bytes() != produced_adapter(seed, inputs): raise ValueError('recovery adapter differs from produced adapter') if accepted and sha(adapter) != inputs.get('produced_adapter_sha256', inputs['adapter_sha256']): diff --git a/test/bootstrap_retention_probe.py b/test/bootstrap_retention_probe.py @@ -90,8 +90,8 @@ def main(): command += ['--relocate-patterns'] if support.get('compiler_io_cohort'): command += ['--compiler-io-cohort'] - if support.get('add_io_v2_support'): - command += ['--add-io-v2-support'] + if support.get('add_io_support'): + command += ['--add-io-support'] run('support', command, seconds=1500) compared = {} for name in ['support-library.arboricx', 'support-rebuilt.arboricx', 'support-library.v1']: diff --git a/test/current_io_names.py b/test/current_io_names.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +"""Check current implementation names, excluding authenticated historical data. + +Wire tags, schemas and version dispatch have separate versioned contracts. +""" +from pathlib import Path +import re + +ROOT = Path(__file__).resolve().parent.parent +OBSOLETE = re.compile(r'IOV2|ioV2|\bio_v2\b|io-v2|StreamingV2|streaming-v2|runIOV2|scopeV2|spawnV2|V2Host') + + +def main(): + failures = [] + for directory in ['src', 'lib', 'test', 'demos', 'tricu-apps', 'ext']: + for path in (ROOT / directory).rglob('*'): + relative = path.relative_to(ROOT) + if not path.is_file() or 'fixtures' in relative.parts or '__pycache__' in relative.parts: + continue + if OBSOLETE.search(str(relative)): + failures.append(str(relative)) + production = [*(ROOT/'src').rglob('*.hs'), *(ROOT/'lib').rglob('*.tri'), + *(ROOT/'ext/demo-site/src').rglob('*.ts'), + *(ROOT/'ext/haskell-wasm').glob('*.hs'), + *(ROOT/'ext/haskell-wasm').glob('*.mjs'), + ROOT/'tricu.workspace', ROOT/'bootstrap/compiler-source-map.v1'] + for path in production: + for number, line in enumerate(path.read_text().splitlines(), 1): + if OBSOLETE.search(line): + failures.append(f'{path.relative_to(ROOT)}:{number}') + if failures: + raise SystemExit('Obsolete current IO names: ' + ', '.join(failures)) + print('Current IO implementation names are canonical; wire/provenance versions retained') + + +if __name__ == '__main__': + main() diff --git a/test/effect_interfaces.py b/test/effect_interfaces.py @@ -189,9 +189,9 @@ def main(): inputs={str(path.relative_to(ROOT)): hashlib.sha256(path.read_bytes()).hexdigest() for path in [Path(__file__).resolve(), ROOT / 'test/effect_interfaces/tricu.workspace', ROOT / 'lib/base.tri', - ROOT / 'lib/io-v2.tri', ROOT / 'bootstrap/build-stable.arboricx']}, + ROOT / 'lib/io.tri', ROOT / 'bootstrap/build-stable.arboricx']}, limits=['finite scripted clock/read codecs; filesystem smoke uses fixed clock', - 'no concurrency or resource scopes in the experimental effect protocol; physical files use IO v2 scopes', + 'no concurrency or resource scopes in the experimental effect protocol; physical files use IO scopes', 'benchmark includes trace construction and rendering', 'A depth is a control: captured services bypass unrelated scopes', 'C/D use the same linear environment lookup; B retained only for semantic checks']) diff --git a/test/effect_interfaces/README.md b/test/effect_interfaces/README.md @@ -4,7 +4,7 @@ This is an opt-in, source-level experiment, not a supported library API. It compares closure-backed service bundles (`a*` exports), tree-rewriting handlers (`b*` exports), environment-interpreted handler scopes (`c*` exports), and fused routing/execution (`d*` exports). -All use the same physical request protocol and physical IO v2 filesystem adapter. +All use the same physical request protocol and physical IO filesystem adapter. A/B/C share a scripted evaluator; D incorporates that evaluator's rules into its routing machine. B/C/D reuse identical application functions. B is retained only for semantic regression checks, not as a performance candidate. No compiler or diff --git a/test/effect_interfaces/server.tri b/test/effect_interfaces/server.tri @@ -1,6 +1,6 @@ !import base * -!import io_v2 as IO -!import io_v2_storage as Storage +!import io as IO +!import io_storage as Storage !import effects as E !import arboricx_server as Server !import http as HTTP @@ -102,7 +102,7 @@ cDual context target script = E.runC (requestScope 12 context (dualH target)) sc dDual context target script = E.runD (requestScope 12 context (dualH target)) script -- Explicit sequential log state is threaded through the test interpreter. --- Reads use scoped IO v2 and a 64-KiB budget; no native state or grant flags. +-- Reads use scoped IO and a 64-KiB budget; no native state or grant flags. -- These raw reads test dependency selection, not content-address authentication. backend root alternate events [11 6 hash] = IO.bind (Storage.readBytes (Server.bundleObjectPath root hash) 65536) diff --git a/test/effect_interfaces/workflow.tri b/test/effect_interfaces/workflow.tri @@ -1,5 +1,5 @@ !import base * -!import io_v2 as IO +!import io as IO !export * -- Experimental protocol, deliberately NOT host IO or base.exceptE. diff --git a/test/effect_server.py b/test/effect_server.py @@ -6,7 +6,7 @@ python3 test/effect_server.py result/bin/tricu --directory /tmp/effect-server -- No listening socket or production source changes. Checks complete HTTP bytes, logs, Result envelopes and effect traces against independent Python expectations. -Real filesystem runs use IO v2 and independent byte/error expectations. +Real filesystem runs use IO and independent byte/error expectations. """ import argparse import hashlib @@ -207,7 +207,7 @@ def main(): allocated_bytes=statistics.median(r['allocated_bytes'] for r in records))) sources = [source, ROOT / 'test/effect_interfaces/workflow.tri', Path(__file__).resolve(), ROOT / 'test/effect_interfaces/tricu.workspace', ROOT / 'lib/arboricx/server.tri', - ROOT / 'lib/http.tri', ROOT / 'lib/base.tri', ROOT / 'lib/io-v2.tri', ROOT / 'lib/io-v2/storage.tri', + ROOT / 'lib/http.tri', ROOT / 'lib/base.tri', ROOT / 'lib/io.tri', ROOT / 'lib/io/storage.tri', ROOT / 'bootstrap/build-stable.arboricx'] report = dict(passed=passed, benchmarks=benchmarks, host=str(args.tricu.resolve()), bundle_sha256=hashlib.sha256(bundle.read_bytes()).hexdigest(), diff --git a/test/executable_arguments.py b/test/executable_arguments.py @@ -181,7 +181,7 @@ class ExecutableArguments(unittest.TestCase): action(2, byte_tree(b'explicit failure')) failed = self.call('run', *options, '--io', '--no-result', success=False) self.assertEqual(failed.stdout, '') - self.assertIn('IO v2 task failed', failed.stderr) + self.assertIn('IO task failed', failed.stderr) class ArtifactSafety(unittest.TestCase): """Independent wire fixtures and real CLI paths, all inside temporary stores.""" diff --git a/test/fixture_sources.py b/test/fixture_sources.py @@ -25,7 +25,7 @@ def source_rows(fixtures): if cohort == 'support-library': versions.update(('tricu.support-library-fixture.v2', 'tricu.support-library-fixture.v3', 'tricu.support-library-fixture.v4', - 'tricu.support-library-fixture.v5')) + 'tricu.support-library-fixture.v5', 'tricu.support-library-fixture.v6')) if not lines or lines[0] not in versions: raise ValueError('unsupported fixture metadata: ' + cohort) bundle = (fixtures / (cohort + '.arboricx')).read_bytes() diff --git a/test/generation_io.py b/test/generation_io.py @@ -50,7 +50,7 @@ def produced_adapter(directory, record): def execution_flags(io_version, host='haskell'): value = version(io_version) if host not in ('haskell', 'haskell-ffi'): - raise ValueError('selected execution host does not support IO v2') + raise ValueError('selected execution host does not support IO') if host == 'haskell-ffi' and value != 2: raise ValueError('Haskell embedding requires IO version 2') return ['--unsafe-io'] if value == 1 else [] @@ -83,5 +83,5 @@ def check_invocation(record, command, host='haskell'): def compiler_flags(io_version): # Retained v1 runtimes predate the selector. Their documented default is v1; - # do not probe failure and retry. V2 always requires the explicit new flag. + # do not probe failure and retry. HostIO always requires the explicit new flag. return [] if version(io_version) == 1 else ['--toolchain-io-version', '2'] diff --git a/test/handler_library.py b/test/handler_library.py @@ -289,7 +289,7 @@ def main(): sources = [ROOT / 'lib/handlers.tri', *sorted((ROOT / 'lib/handlers').glob('*.tri')), source, Path(__file__).resolve(), ROOT / 'test/handlers/tricu.workspace', ROOT / 'demos/handlers/artifact-get.tri', ROOT / 'demos/handlers/main.tri', - ROOT / 'lib/base.tri', ROOT / 'lib/io.tri', ROOT / 'lib/io-v2.tri', ROOT / 'lib/http.tri', + ROOT / 'lib/base.tri', ROOT / 'lib/io.tri', ROOT / 'lib/io.tri', ROOT / 'lib/http.tri', ROOT / 'lib/arboricx/server.tri', ROOT / 'lib/arboricx/handlers.tri', ROOT / 'test/handlers/observe.tri', ROOT / 'test/handlers/state.tri', ROOT / 'bootstrap/build-stable.arboricx'] report = dict(passed=passed, benchmarks=benchmarks, host=str(args.tricu.resolve()), inputs={str(path.relative_to(ROOT)): hashlib.sha256(path.read_bytes()).hexdigest() for path in sources}) diff --git a/test/handlers/README.md b/test/handlers/README.md @@ -23,7 +23,7 @@ The full direct comparison has these boundaries: | Per-request context | Proper-list check before entering the diagnostic label | `Log.withContext` | | API digest | `Server.bundleHashC`; invalid input is an ordinary Result | `Artifacts.loadFrom` | | Request callback protocol | `checkedInvoke`, with `B.anyC` input | `H.checkedRequest` | -| Adapter digest/path | Shared authenticated filesystem adapter | `Artifacts.withFilesystemV2` | +| Adapter digest/path | Shared authenticated filesystem adapter | `Artifacts.withFilesystem` | | Load reply | Exact shared `Artifacts.loadReplyC` | Same contract | | Initial logging event | Exact shared `Log.eventC` | `Log.emit` | | Decorated event | Same contract after context concatenation | `Log.withSink` | diff --git a/test/handlers/library.tri b/test/handlers/library.tri @@ -1,9 +1,9 @@ !import arboricx_handlers as Registry -!import io_v2_artifacts as Upload +!import io_artifacts as Upload !import test_observe as Observe -!import io_v2_storage as Storage +!import io_storage as Storage !import base as B -!import io_v2 as IO +!import io as IO !import handlers as H !import handler_log as Log !import handler_artifacts as Artifacts @@ -77,20 +77,20 @@ artifactPublish bytes script = Test.run script (Artifacts.publish bytes) -- Test observer wraps source diagnostics as Results; production uses terminal -- v2 outcomes. See observe.tri, not the retired v1 H.runIO contract. -ioLoad root digest = IO.io (Observe.result (Artifacts.withFilesystemV2 root 1048576 (Artifacts.load digest))) -ioRawLoad root digest = IO.io (Observe.result (Artifacts.withFilesystemV2 root 1048576 (H.request "artifacts" "load" digest))) -ioPublish root bytes = IO.io (Observe.result (Artifacts.withFilesystemV2 root 1048576 (Artifacts.publish bytes))) -ioNamed root digest = IO.io (Observe.result (Artifacts.withFilesystemForV2 "source" root 1048576 (Artifacts.loadFrom "source" digest))) -ioCapture root digest = IO.io (Observe.result (Artifacts.withFilesystemV2 root 1048576 (Log.capture (do H.bind +ioLoad root digest = IO.io (Observe.result (Artifacts.withFilesystem root 1048576 (Artifacts.load digest))) +ioRawLoad root digest = IO.io (Observe.result (Artifacts.withFilesystem root 1048576 (H.request "artifacts" "load" digest))) +ioPublish root bytes = IO.io (Observe.result (Artifacts.withFilesystem root 1048576 (Artifacts.publish bytes))) +ioNamed root digest = IO.io (Observe.result (Artifacts.withFilesystemFor "source" root 1048576 (Artifacts.loadFrom "source" digest))) +ioCapture root digest = IO.io (Observe.result (Artifacts.withFilesystem root 1048576 (Log.capture (do H.bind reply <- Artifacts.load digest Log.info "loaded" H.pure reply)))) -ioFailure root digest = IO.io (Observe.result (H.label "request" (Artifacts.withFilesystemV2 root 1048576 (H.fail "example" "artifacts" "load" digest)))) +ioFailure root digest = IO.io (Observe.result (H.label "request" (Artifacts.withFilesystem root 1048576 (H.fail "example" "artifacts" "load" digest)))) getRequest digest script = Test.run script (Log.capture (Log.withContext [["request" 7]] (Get.getBundle digest))) -ioGet root digest = IO.io (Observe.result (Log.capture (Log.withContext [["request" 7]] (Artifacts.withFilesystemV2 root 1048576 (Get.getBundle digest))))) +ioGet root digest = IO.io (Observe.result (Log.capture (Log.withContext [["request" 7]] (Artifacts.withFilesystem root 1048576 (Get.getBundle digest))))) getDemo = Get.main -ioPrepare root = IO.io (Observe.result (Artifacts.prepareFilesystemV2 root)) +ioPrepare root = IO.io (Observe.result (Artifacts.prepareFilesystem root)) rawBadLog = H.runPure (Log.capture (H.label "raw" (H.request "logging" "emit" 0))) transformedScript script = Test.run script (H.checkedRequest "math" "id" (replaceC 10) (replaceC 20) 1) badRemainderC value _ = B.contractOk value 7 @@ -106,7 +106,7 @@ manyGet digest [context | rest] = do H.bind H.pure (B.pair response responses) manyGet _ _ = H.fail "invalid-request" "benchmark" "contexts" t ioMany root digest contexts = IO.io <| Observe.result - (Log.capture (Artifacts.withFilesystemV2 root 1048576 (manyGet digest contexts))) + (Log.capture (Artifacts.withFilesystem root 1048576 (manyGet digest contexts))) readStatus ((t t), _) = 200 readStatus _ = 404 directMany root digest [] = IO.pure [[] []] @@ -126,7 +126,7 @@ ioDirectMany root digest contexts = IO.io (IO.bind (directMany root digest conte recordLog event = H.bind (H.request "test.trace" "record" event) (_ : H.pure (B.ok t t)) ioSinkMany root digest contexts = IO.io (IO.bind (Observe.run (Log.withSink recordLog - (Artifacts.withFilesystemV2 root 1048576 (manyGet digest contexts))) []) + (Artifacts.withFilesystem root 1048576 (manyGet digest contexts))) []) (observed : case observed of [((t t), (responses, t)) events] = IO.pure (B.ok [responses (B.reverse events)] t) [failure _] = IO.pure failure @@ -134,7 +134,7 @@ ioSinkMany root digest contexts = IO.io (IO.bind -- Direct comparison shares the real authenticated filesystem adapter, not just -- raw reads with fewer obligations. The observer adds an execution Result only. readArtifact root digest = IO.bind - (Observe.result (Artifacts.withFilesystemV2 root 1048576 (H.request "artifacts" "load" digest))) + (Observe.result (Artifacts.withFilesystem root 1048576 (H.request "artifacts" "load" digest))) (reply : case reply of ((t t), (domain, t)) = IO.pure domain _ = IO.fail "Invalid artifact adapter observation") @@ -145,7 +145,7 @@ registryRequest method target body packageReply script = Test.run script (Log.capture (Log.withContext [["method" method] ["target" target]] (Registry.request (_ : H.pure packageReply) method target [] body))) -registryStdout = IO.io (Observe.result (Registry.stdoutLogV2 ["info" [] 200])) +registryStdout = IO.io (Observe.result (Registry.stdoutLog ["info" [] 200])) writeChunks [] = H.pure (B.ok t t) writeChunks [chunk | rest] = do H.bind @@ -154,14 +154,14 @@ writeChunks [chunk | rest] = do H.bind ((t t), (t, t)) = writeChunks rest _ = H.pure written writeChunks _ = H.fail "invalid-request" "test" "chunks" t -streamChunks root expected chunks = IO.io (Observe.result (Artifacts.publishStreamV2 root expected (writeChunks chunks))) -streamAbandon root = IO.io (Observe.result (Artifacts.publishStreamV2 root 1 (do H.bind +streamChunks root expected chunks = IO.io (Observe.result (Artifacts.publishStream root expected (writeChunks chunks))) +streamAbandon root = IO.io (Observe.result (Artifacts.publishStream root 1 (do H.bind Upload.write [65] H.pure (B.err "stopped" t)))) -streamProtocolFailure root = IO.io (Observe.result (Artifacts.publishStreamV2 root 1 (H.fail "test-failure" "test" "producer" t))) -streamFalseAck root = IO.io (Observe.result (Artifacts.publishStreamV2 root 1 +streamProtocolFailure root = IO.io (Observe.result (Artifacts.publishStream root 1 (H.fail "test-failure" "test" "producer" t))) +streamFalseAck root = IO.io (Observe.result (Artifacts.publishStream root 1 (H.handle "artifact-stream" (_ _ : H.pure (B.ok t t)) (Upload.write [65])))) -streamIgnoreFailure root = IO.io (Observe.result (Artifacts.publishStreamV2 root 1 (do H.bind +streamIgnoreFailure root = IO.io (Observe.result (Artifacts.publishStream root 1 (do H.bind Upload.write [256] H.pure (B.ok t t)))) unforcedForever value = unforcedForever value diff --git a/test/handlers/observe.tri b/test/handlers/observe.tri @@ -1,5 +1,5 @@ !import base as B -!import io_v2 as IO +!import io as IO !import handlers as H !export run !export result diff --git a/test/handlers/state.tri b/test/handlers/state.tri @@ -1,4 +1,4 @@ -!import io_v2 as IO +!import io as IO !export pure !export bind !export lift @@ -6,7 +6,7 @@ !export replace !export io --- Sequential test-only state transformer over IO v2. Each action is a function +-- Sequential test-only state transformer over IO. Each action is a function -- from explicit state to IO [value nextState]; no host state or concurrency. pure value state = IO.pure [value state] bind action next state = IO.bind (action state) (reply : case reply of diff --git a/test/haskell_generation_executor.py b/test/haskell_generation_executor.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Fresh-process Haskell C-ABI executor for captured IO-v2 generation requests. +"""Fresh-process Haskell C-ABI executor for captured IO generation requests. The supervisor owns transport, process guards and independent store audits. This child authenticates the complete captured request before executing effects. diff --git a/test/interaction_inspection.py b/test/interaction_inspection.py @@ -125,7 +125,7 @@ def main(): failure = json.loads(run('--as', 'io', '--json', ok=False).stdout) assert expected in [o['status'] for o in failure['occurrences']] - # V2 uses named facilities; arguments and callbacks are not evaluated, + # HostIO uses named facilities; arguments and callbacks are not evaluated, # previewed as v1 actions, or treated as evidence of backend support. req = action(10, intern(string('file'), intern(number(1), intern(string('open-writer'), intern(leaf, string(str(victim))))))) @@ -133,7 +133,7 @@ def main(): v2 = json.loads(run('--as', 'io', '--json').stdout) assert v2['supportedVersions'] == [1, 2] assert [o['summary'] for o in v2['occurrences'][:3]] == [ - 'tricuIO v2', 'Bind', 'Facility file@1/open-writer'] + 'tricuIO', 'Bind', 'Facility file@1/open-writer'] assert [o['status'] for o in v2['occurrences'][3:]] == ['opaque', 'opaque'] assert victim.read_text() == 'untouched' diff --git a/test/io-v2/README.md b/test/io-v2/README.md @@ -1,1475 +0,0 @@ -# IO v2: scoped tasks, streams, files and publication - -Current hosts execute canonical IO v2 envelopes. Native execution uses OS process -authority; retired grant arguments and unsupported envelope versions fail before -effects. Haskell provides filesystem, TCP/resolver, process/context and clock -facilities. The Haskell browser host provides -structured core/task execution and terminal streams. Capability queries report -the selected backend's facilities. TLS, UDP and hard deadlines require additional -facilities. Historical v1 execution requires a retained runtime. - -The operation layout is a research protocol subject to revision. - -## Reproduce - -```sh -nix flake check -nix build .# -PYTHONDONTWRITEBYTECODE=1 python3 test/io_v2_protocol.py result/bin/tricu \ - --directory /tmp/new-v2-protocol-check -PYTHONDONTWRITEBYTECODE=1 python3 test/interaction_inspection.py result/bin/tricu -PYTHONDONTWRITEBYTECODE=1 python3 test/io_v2_streams.py result/bin/tricu \ - --directory /tmp/new-v2-stream-check -PYTHONDONTWRITEBYTECODE=1 python3 test/io_v2_tcp.py result/bin/tricu \ - --directory /tmp/new-v2-tcp-check -PYTHONDONTWRITEBYTECODE=1 python3 test/io_v2_directory.py result/bin/tricu \ - --directory /tmp/new-v2-directory-check -PYTHONDONTWRITEBYTECODE=1 python3 test/io_v2_positioned.py result/bin/tricu \ - --directory /tmp/new-v2-positioned-check -PYTHONDONTWRITEBYTECODE=1 python3 test/io_v2_files.py result/bin/tricu \ - --directory /tmp/new-v2-file-check -PYTHONDONTWRITEBYTECODE=1 python3 test/io_v2_tasks.py result/bin/tricu \ - --directory /tmp/new-v2-task-check -PYTHONDONTWRITEBYTECODE=1 python3 test/io_v2_publication.py result/bin/tricu \ - --directory /tmp/new-v2-publication-check -``` - -Use new output directories. The scripts compile current source through the -packaged v2 compiler, execute under OS process authority, and record source hashes, -commands, full results and measurements. File checks cover complete binary output -through 1 MiB, direct and handled borrowing, ownership transfer, implicit joining, -acquisition failures, and cancellation after an acknowledged chunk. Copying uses -**direct truncating output**. Inputs and outputs must be -distinct even through hard links/symlinks; interruption can leave partial output. - -Task source oracles independently check the three outcomes, consumed tickets, -ordinary domain errors, partial-write progress/failure traces for the actual -`IO.writeAll` helper (including full 4096-byte acknowledgements, partial suffixes, -and failures after confirmed progress), child handler inheritance, contextual overrides, callback -routing, lazy label payloads, and explicit state-boundary rejection. Retention -workloads run 16/256/1024 children over fresh 4096-byte chunks in one process. They -force each chunk's list spine before returning it, then join or discard the result; -using only constant or unforced payloads would conceal important retention bugs. - -The file/task/publication scripts enforce workload-specific 128-MiB sampled RSS and 16-MiB GC maximum -live-heap guards. Allocation volume is recorded separately. Shared runner reports now also include -`rts_process`: whole-process allocated/copied bytes, MUT and GC CPU/elapsed time, -and CPU/elapsed productivity from GHC's `-s` output. Missing measurements are -absent, not invented zeros. These include loading, rendering and profiling's -forced collections; they are **not** the separately measured invocation deltas. -MUT includes allocation, interpreter and native work, not just useful reductions. These are sequential -fresh-process observations, not concurrent capacity guarantees or hard runtime -resource bounds. RSS polling can miss short peaks; RTS residency is GC-sampled. - -`IOV2Tests.hs` exercises the resource registry and driver independently of the -compiler. `IOV2TaskTests.hs` adds deterministic blocking/failure controls, lease -cancellation, fail-fast supervision, lexical cleanup, completion races, join -consumption/cycles, transfer publication, and registry reclamation. Fake backends -supply controls without production fault-injection flags. - -## Binary standard streams and reusable copying - -`IO.stdin`, `IO.stdout` and `IO.stderr` return ordinary Results containing borrowed -stream references. `IO.copyStream reader writer` composes bounded `readSome` and -`writeAll`, stopping at explicit EOF or the first error. It acquires/closes neither -endpoint and adds no host copy primitive. It expects the native read/progress -protocol; it does not validate arbitrary replacement interpreters. Error values -preserve `writeAll`'s confirmed prefix and original uncertain progress; copying is -not a transaction, a whole-message atomic write, or a whole-file retry operation. -These APIs use octet lists, **not arbitrary Unicode strings**. - -Each native run starts with exactly three inherited root entries. Repeated lookup -returns the same reference/lease for that slot, without adding registry history. -Descendants borrow them, including a first lookup while their parent is joining; -child scope exit does not revoke them. Explicit close returns `borrowed-resource`. -Root-owned handoff also returns `borrowed-resource`; descendant attempts can fail -`not-owner` first. No transfer occurs on rejection. Root settlement drains leases -and removes the references but **never closes, flushes or changes the mode of the -process handles**. Run-local references become stale afterwards. - -Each successful output operation flushes the host handle before acknowledging its -byte count. That is not durability, peer consumption or transactionality. Broken -pipes are ordinary `io-error` operation failures with `unknown` progress. A failed -or cancelled inherited write poisons that slot for the rest of the run: subsequent -writes return `invalid-state` with unknown progress rather than implicitly retrying -bytes left in a failed host buffer. The host still owns that buffer and may flush -it independently, including during process shutdown. Embedders must perform their -own recovery before reuse in a new run; teardown does not discard/rollback bytes. - -Lease exclusion is **per standard slot per run**. It does not coordinate separate -runs, arbitrary host IO, or stdout/stderr redirected to aliases of the same OS -stream. Avoid unsynchronized mixing. Terminal modes/line discipline are unchanged; -this is not raw terminal IO or a text/line codec. Existing cancellation and foreign -call limits still apply. No deadlines or platform-wide descriptor isolation exist. - -`demos/io-v2/streams.tri` provides direct, handled and attached-child pipelines. -Compile/import it as usual, then run its stored export with **`--io --no-result`** -to reserve stdout for program bytes (no `--repeat` framing): - -```sh -printf 'hello\n' | result/bin/tricu arboricx run --module "$APP_MANIFEST" \ - --store "$WORK/runtime" --name main --io --no-result > "$WORK/output" -``` - -The stream harness compares entire binary outputs and stderr, exercises actual -pipes plus file redirects, empty/boundary inputs through 1 MiB, broken pipes, -close/handoff rejection, handlers and attached children. It enforces 128-MiB RSS -and 16-MiB GC residency guards. These runs have **no final rendering or forced -profile collections**: their RTS totals are whole-process observations, not -matched comparisons with the publication/profile workloads. Native tests also -exercise shared blocked-read leases, read/write cancellation, poisoned writers, -host buffering preservation and non-closing settlement. All seven inherited-stream -native cases are included in the Nix suite. - -## Positioned files, updates and append - -`IO.openReader` opens a readable file; `IO.openWriter` creates/truncates a writable -file. `IO.openUpdate` opens a readable/writable file at position zero without -truncating an existing file, creating it if absent. `IO.openAppender` creates if -absent and writes using the host's append mode. None provides exclusive creation, -atomic replacement, snapshot isolation or publication durability. - -`IO.seekFile file offset` uses an **absolute byte offset**; `IO.filePosition file` -queries the logical cursor, including read-ahead adjustment. `IO.fileSize file` -queries the opened file, not its potentially replaced pathname. `IO.resizeFile -file size` may shrink or grow a writable non-append file, preserving the logical -cursor even beyond the new EOF. Regular-file growth reads as zero-filled space; -seeking beyond EOF alone does not extend the file. These operations use canonical -naturals through **9223372036854775807** (reported as `file-offset-max`). Smaller -filesystem/backend constraints can still fail. Malformed/out-of-range wire -arguments are rejected before that operation's backend IO; negative or excessive -host query results cannot escape as invalid naturals. - -All byte and position/size operations on a file share **one lease**. They reject -concurrent use of the same registry entry with `busy`; handoff preserves the cursor -and invalidates old aliases. A `seek` followed by a `read` is **not an atomic -compound operation**. Coordinate borrowers, transfer ownership, or use independent -read handles; no positioned `read-at`/`write-at` operation is promised here. -Separate OS handles/processes can change file contents or size independently. -Queries are point-in-time observations, not a race-free verification policy. - -Append handles have no seek, position or resize method: those calls return -`unsupported`. Reads on writers/appenders return `wrong-kind`; resize on a reader -returns `unsupported`. Standard streams and stages are not positioned-file handles, -even if their underlying storage happens to be a file. Non-seekable/special files -can report `unsupported` or backend IO errors. Haskell's process-local Handle -locking may reject conflicting file opens that POSIX alone would permit; this -restriction is advertised. Do not infer filesystem confinement or external-writer -serialization from that host behavior. - -Append placement is at the backend's current EOF on writes, not an open-time seek. -This is not a whole-record atomicity guarantee: partial writes, interleaving with -other writers and failure remain possible. IO exceptions during seek/resize report -`unknown` progress/state, not a claim that nothing changed. There is no automatic -retry, rollback, restoration after an operation failure, or new durability promise. -Ordinary Results and scope cleanup retain the existing rules. - -`demos/io-v2/positioned.tri` composes bounded patching, resizing, prefix inspection -and append. It is deliberately **in-place editing**, not a safe publication -replacement. The focused harness compares complete outputs/results, including -Unicode paths, empty files, shrinking/growth, updates/appends against 1-MiB files, -mode errors and malformed offsets. A wide-offset control writes a sparse byte -above 4 GiB and shrinks before comparing its entire final output; it does not -materialize a multi-GiB source value or assert multi-GiB streaming performance. -Native tests cover cursor mode switches, read-ahead invalidation, current-inode -size, external-fd growth before append, bounds, cancellation/lease exclusion, -transfer, host locking and uncertain mutation errors. - -## Paths and directory cursors - -`IO.statPath` follows symlinks; `IO.lstatPath` observes a final link itself. Both -use normal host resolution of intermediate components and trailing slashes. They -return a regular-file size only for a regular file; other tags deliberately do -not invent a zero byte length. Missing paths and permission errors remain ordinary -errors, not a false existence Boolean. These observations do not pin later opens -or constitute race-free authorization, immutable verification or confinement. - -`IO.createDirectory` creates one directory with host mode 0777 subject to umask; -it fails with `already-exists` rather than silently accepting another object. -`IO.ensureDirectory` is a **source** convenience: attempt creation, and on -`already-exists` use stat to check for a directory. Existing symlinks to directories -are accepted. It is nonrecursive; caller-ordered initialization fits store/cache -layouts. The check is only a point-in-time observation. Direct files retain their -host creation modes/umask, not the private-stage permission contract. - -`IO.removeFile` unlinks a file/link, `IO.removeDirectory` removes an empty directory, -and `IO.renamePath` performs the host rename/replacement operation. No recursive -removal, cross-filesystem copy fallback, no-replace rename, sync, rollback, or -transaction is supplied. Path mutations persist after scope exit: scopes own -handles, not arbitrary pathname changes. Mutation IO errors conservatively report -`unknown` progress/state. Failed close IO now also reports `unknown` (correcting -its former `none` report); this does not authorize retrying raw release operations. - -`IO.openDirectory owner path` creates a scoped cursor over the opened directory. -`IO.nextDirectory` yields **one** name at a time, skipping `.` and `..`, with an -explicit, sticky EOF. It never sorts/materializes a complete listing. Host internal -buffering is not specified. Enumeration order and concurrent directory mutation -are not snapshot guarantees. Renaming the directory does not retarget the cursor, -but later path operations built from its original pathname can target another -object; no dirfd-relative child operations are provided here. - -The cursor uses one exclusive lease and the ordinary lifetime/transfer/close -rules. Failed or cancelled advancement poisons it: subsequent next calls return -`invalid-state`; close or explicitly reopen instead of implicitly replaying an -uncertain advance. EOF does not implicitly close the cursor. Closing attempts -`closedir` once, never retrying a possibly freed native directory pointer. - -Paths use canonical Unicode-scalar text with no NUL, mapped through the host's -filesystem encoding. Directory names must be Unicode-scalar single components, -up to **4096 scalars** (advertised as `directory-name-scalars`). Undecodable names -are not silently replaced or skipped: they fail and poison enumeration. This is -not an arbitrary raw-byte POSIX pathname interface. Empty path arguments are -rejected by these path/directory operations; use `.` for the current directory. -Native capability restrictions expose host encoding, unsorted/non-snapshot -iteration and cursor poisoning. No Unicode normalization is performed. - -`demos/io-v2/directory.tri` provides caller-ordered initialization and an inventory -that intentionally accumulates entries, alongside a count that discards names and -forces its small numeric accumulator before each request. The latter avoids -retaining a lazy arithmetic chain; bounded native enumeration alone would not -make arbitrary source accumulation bounded. The focused test compares complete -inventories without assuming order, checks symlink/dangling-link and mutation -behavior, rejects a real invalid-encoding name, and consumes up to 16,384 entries -under RSS/live-heap guards. Native controls exercise ownership transfer, renamed -open directories, cancellation/busy exclusion, EOF, invalid names, poisoning and -once-only close, including uncertain/async release failures. - -## TCP listeners and duplex streams - -Endpoints are canonical tagged lists: - -- `["ipv4" [a b c d] port]`: four octets and a 16-bit port. -- `["ipv6" [a b c d e f g h] scope port]`: eight 16-bit words, a 32-bit - interface scope ID and a 16-bit port. Flow information is zero. - -`IO.ipv4` / `IO.ipv6` construct these values; they do not resolve names. -`IO.listenTCP owner endpoint backlog` acquires an already bound/listening socket. -Backlog must be 1–4096; OS clamping/queue policy still applies. Port zero requests -an ephemeral listening port; query `IO.localAddress` to learn it. IPv6 listeners -are explicitly IPv6-only, not silently dual-stack. Reuse-address is enabled; -there is no reuse-port, ownership or exclusive-bind security guarantee. -`IO.connectTCP owner endpoint` connects exactly that numeric address; zero peer -port is rejected. There is no hidden DNS, address fallback or reconnect policy. -`tcp-address-families` reports implemented encodings, not OS/network availability. - -`IO.acceptTCP listener owner` leases the listener and reserves acquisition in the -selected live ancestor/current scope before accepting. It publishes the connection -before acknowledging success; cancellation cannot leave an unregistered accepted -socket. The caller can use the existing atomic spawn-with-transfer operation to -move it to a worker, with fresh handles and stale old aliases. Listener acceptance -is exclusive; multiple pending accepts return `busy`. Acquiring into an explicitly -selected ancestor retains that ancestor's lifetime, just as file factories do. - -A connected stream permits **one read and one write concurrently**. Another read -or another write returns `busy`. Read EOF is sticky and does not close the write -direction. Write acknowledgements are positive accepted prefixes, not complete -messages, delivery, peer consumption or transaction boundaries. `IO.writeAll` -retains its ordinary partial/uncertain-progress rules. Source multi-call writes -are not whole-message atomic. TCP has no datagram/message framing here. - -`IO.shutdownWrite` leases the write direction, sends the local write half-close -and preserves reading; successful repeats are harmless. Writes afterwards fail -with `invalid-state` and no progress. Failed/cancelled reads or writes poison only -the affected direction; later use reports `invalid-state`/unknown progress rather -than replaying ambiguous work. Failed shutdown poisons writing too. Socket IO -errors include stable categories such as `connection-refused`, `connection-reset`, -`address-in-use`, `broken-pipe` and `timed-out` when the host supplies those errno -values; otherwise the ordinary generic categories remain. An OS timeout error is -not a new language deadline facility. - -Address queries currently lease the whole resource. Transfer and close require -**both** directions idle. Close prevents new leases and waits for pending ones; -it does not close/reuse the descriptor beneath a blocked read/accept to wake it. -Cancel the owning IO task to interrupt supported pending work, then settle/close. -Scope failure/cancellation does that through the structured-task machinery. -There are no hard deadlines; OS/kernel queued output can still reach a peer after -local acknowledgement/close. Cancellation does not undo network effects. -Files, standard streams and directories retain their existing single-lease rules. - -`demos/io-v2/tcp.tri` composes finite concurrent echo servers, lexical child handler -inheritance, and a file/socket client with simultaneous sending and receiving to -avoid bidirectional backpressure deadlock. The handled server's binary banner is -source policy, not a native operation. Default workers are fail-fast; an explicitly -supervised variant tests containment and discards outcomes intentionally, not as -a production logging recommendation. These are raw TCP demonstrations, not HTTP, -TLS or hardened public services. Registry deployments remain unchanged. - -The focused real-socket suite compares every echoed/output byte through 1 MiB, -fragmented sends, IPv4/IPv6, concurrent clients, repeated handoff, EOF/half-close, -refusal/malformed-address errors, and accepted-peer resets with both fail-fast and -contained workers. Native controls additionally cover directional lease exclusion, -close/transfer while only one direction remains busy, blocked receive/write -cancellation, listener reuse after cancelled accept, scope admission, structured -settlement, endpoint bounds and non-demand of excess components. RSS/live-heap -guards and ordinary RTS observations are regression controls, not throughput or -maximum-concurrency guarantees. - -## Explicit host name resolution - -`IO.resolveTCP host family port limit` uses the host TCP name resolver. Family is -`"ipv4"`, `"ipv6"` or `"any"`; port is 0–65535 and result limit is 1–64. Host input -is 1–253 ASCII letters/digits/dots/hyphens. This admits ordinary host names and -IPv4 literals accepted by the host; it is not strict DNS-label validation, IDNA -conversion, hostname authorization or a numeric-address parser. Use the numeric -endpoint constructors for IPv6 literals. Host search rules, hosts/NSS configuration, -ordering, duplicates and address availability remain host-defined. - -The result is `[endpoints truncated]`, with a canonical natural 0/1 truncation flag. -Only the bounded prefix is published; excess endpoint values are not demanded, -and the returned prefix does not retain the discarded host list. **The host -resolver may internally buffer more results and delay cancellation**. There is -no detached resolver worker, guaranteed prompt interruption, public DNS deadline, -exhaustive-answer claim, implicit connection, deduplication or address preference. -OS resolution failures return ordinary `resolution-failed` with bounded diagnostic -text; cancellation/unexpected faults are not converted to that Result. - -`IO.connectFirstTCP owner endpoints` is an explicit **source** policy that tries -candidates sequentially, stops at the first success and returns the last ordinary -failure unchanged if none succeeds. An empty list returns `not-found`. It does not -race candidates, add deadlines or elevate the selected endpoint into authorization. -The named client demo chooses the resolver's bounded prefix, then uses that policy. -Applications needing other retry, selection or authentication policies should -compose their own. TLS peer identity is not supplied by successful resolution. - -Native controls cover local/numeric host lookup, bounds/non-demand, host ordering, -truncation, ordinary errors and cancellation propagation without requiring public -DNS. The real socket source suite also resolves numeric/localhost names, checks -complete output, and exercises failed-first/successful-second source fallback, -including non-execution of a malformed later candidate and scope-bound lifetime. - -## Authenticated native byte snapshots and transfer - -The Haskell native host implements two revision-1 `stream` operations: - -- `IO.snapshotBytes reader directory limit digest` consumes the reader through - EOF under one read lease and returns `[snapshot size]` inside a Result. `digest` - is exactly 64 lowercase hexadecimal SHA-256 digits over the raw bytes. `limit` - is a canonical natural in `[0,2^63-1]`. The EOF probe can consume one excess byte - on a limit failure. A digest mismatch returns `integrity-failed`. -- `IO.transferBytes reader writer count` transfers exactly `count` bytes from the - current cursor with both directional leases held. Positive short writes are - completed without replaying accepted prefixes. Early EOF is `invalid-data`; - an error can follow partial output, so callers close rather than retrying an - uncertain transfer. A zero count still validates and leases both handles. - -Snapshot preparation registers an anonymous temporary file before consuming -input. Only authenticated snapshots are returned; they are read-only file -resources owned by the current scope, with read/seek/position/size operations. -They may be closed or handed off under the ordinary ownership rules. Their -private writable handles remain native. Transfer reads the verified snapshot, -so changes to the original file after authentication cannot change its output. -Concurrent source mutation is accepted only if the captured bytes match the -expected digest. This is integrity checking, not OS-process confinement. - -Both operations use native chunks of at most 4096 bytes and constant-size hash -state, avoiding tree-encoded bulk byte lists. Temporary payload disk is at most -`limit` per snapshot, plus filesystem overhead. Source readers can still block; -operation cancellation is interruptible, with no hard deadline guarantee. Errors -and cancellation retain scoped cleanup and diagnostic precedence. These APIs -are native-host facilities; other backends report their own capabilities. - -The registry's bundle GET/HEAD path verifies before headers, logs the selected -status, then sends the known length and verified body (HEAD sends headers only). -Missing/corrupt/oversized objects return 404; malformed digests return 400. Logger -failure before headers returns 500. Transfer failure after headers closes the -connection. Whole-value artifact APIs and package metadata keep their existing -resident-value contracts. - -`IOV2ByteStreamTests` covers boundaries through 1 MiB, binary/partial transfers, -read-only snapshots, corruption, size limits, source mutation, cancellation, -cleanup faults, early EOF and disconnected writers. `test/io_v2_http.py` exercises -framing, configured limits and failed logging. The production-runner acceptance -gate additionally runs `test/registry_download.py` on the actual compiler bundle -and bounds both upload/download retention in `test/body_retention.py`. - -## Authenticated indexed-tree reads - -`IO.readIndexedTree reader directory limit size digest` consumes exactly `size` -bytes from the current reader position, under one read lease. `limit` is the -caller's maximum encoded-frame size; both are canonical integers in `[0,2^63-1]` -and size must not exceed limit. Digest is exactly 64 lowercase hexadecimal digits, -using the existing `arboricx.indexed-tree.v1` SHA-256 domain (UTF-8 domain, NUL, -encoded bytes). The reply is an ordinary Result containing the decoded tree. -No tree is evaluated, exposed, or decoded before authentication succeeds. -The caller supplies the expected digest: this checks content integrity, not -publisher identity or source provenance. - -The native transport creates a temporary file in the explicitly supplied directory -and unlinks its name before receiving input. It copies/hashes bounded chunks and -then decodes **that same private snapshot**, not a second pass over the public -source. Changing the original inode cannot change the snapshot. This is not a -point-in-time filesystem snapshot: authentication checks the bytes actually -received. A mutation during copying ordinarily makes the expected digest fail. -The directory must exist; empty directory strings are rejected (use `"."`). - -Temporary payload storage is at most `size` bytes, plus filesystem bookkeeping: -no expanded-tree serialization. Storage is reclaimed before ordinary completion; -on terminal fault/cancellation, its registered resource is released during lexical -settlement, with the existing primary-error/cleanup-diagnostic precedence. The -snapshot is private transport, not a source-visible handle, durable publication, -or recovery archive. Guessed registry keys cannot read, explicitly close, or -transfer private spool entries; active private leases still delay scope release. -This is lifecycle protection, not confinement against OS process authority. No fsync or publication is performed. A tmpfs directory uses -RAM-backed storage; ordinary filesystems may cache writes too. Choose the directory -and frame budget accordingly. There is **no aggregate disk quota across tasks**. - -Framing is explicit: no EOF probe consumes bytes of the following frame. The source -reader is not closed or rewound. Digest mismatch, truncation, or invalid format -may have consumed input; their `unknown` progress is not authorization to retry. -Validation of bounds and digest spelling precedes operation IO. An unauthenticated -malformed frame reports digest mismatch, never a parser error. Snapshot acquisition -errors and resource errors remain ordinary Results; unexpected faults/cancellation -remain terminal. Ordinary primary failures retain bounded cleanup detail if release -also fails. - -Decoded nodes preserve sharing and the existing reader's structural/manifest checks, -including backward references and duplicate-node rejection. Additional metadata -limits are 64 section entries and 65,536 manifest bytes. Noncritical opaque sections -remain supported and are authenticated even though not decoded. These are reader -semantics, not a claim that every accepted encoding equals the canonical writer's -bytes. Offset arithmetic is checked without integer wraparound. Transfer buffers -are bounded, but **the indexed DAG and verification tables are resident and grow -with node count**; the caller's frame budget must allow for that amplification. -There is no hard parser CPU deadline or universal safe maximum DAG size. - -The facility works on readable files, borrowed input and duplex streams. File and -borrowed-input leases stay exclusive; duplex connections permit concurrent writing -but not another reader. Other operations cannot interleave reads between chunks. -The reader alone implies no compiler caller migration or deployment/backend switch; -canonical writing and comparison are described below. - -```sh -python3 test/io_v2_indexed.py result/bin/tricu --directory /tmp/new-indexed-check -``` - -`demos/io-v2/indexed-read.tri` demonstrates source-selected framing and spool policy. -Native `SnapshotTests` cover authentication ordering, immutable-source isolation, -shared DAGs, malformed offsets/nodes, metadata bounds, cleanup faults, cancellation, -lease exclusion and wire non-demand. The independent source suite compares complete -results and framing through 64-MiB encoded inputs. Its large inputs grow an opaque -section while keeping the decoded DAG tiny: they test bounded transport, **not** -large-DAG capacity. RTS reports and sampled RSS are included; very short runs may -exit before a representative RSS sample. The 64-MiB transfer has 48-MiB sampled-RSS -and 16-MiB maximum-live regression guards, not service guarantees. - -## Canonical indexed writing and exact comparison - -`IO.hashIndexedTree limit tree` returns Result `[size digest]` for the canonical, -name-neutral indexed encoding. `IO.writeIndexedTree writer limit digest tree` -prepares the same encoding and checks its size and expected digest **before any -writer call**; success returns `[size digest]`. It holds one write lease for the -whole operation and handles positive partial acknowledgements without replaying -acknowledged bytes. Reads may proceed concurrently on duplex connections; files, -stages and borrowed outputs remain exclusive. Cancellation and host faults retain -the existing structured-settlement rules. - -Transport buffers are at most 4096 bytes. The native encoder retains the DAG and -exact interning tables, not an expanded tree or whole encoded ByteString. Hash and -write passes regenerate chunks rather than retaining a shared lazy chunk history. -Bytes and digest domains match the existing canonical indexed writer. The size -budget bounds **encoded output**, not tree evaluation, graph construction memory, -CPU time or an aggregate quota. Preparation can allocate before discovering that -output exceeds the limit; divergent source trees have no hard deadline. - -Preflight failures have `none` writer progress. Once sending starts, IO failures -have `unknown` aggregate progress: no atomicity, rollback, automatic retry, or -resumption promise. A caller opening a truncating writer has already truncated -its file before invoking this operation. Use a stage for publication safety. - -`IO.verifyIndexedTree reader limit tree` compares one canonical frame byte-for-byte -against a known tree and returns `[size digest]` on success. It does not merely -compare hashes, decode untrusted bytes, or probe EOF. Whole-file winner verification -must additionally check EOF; `demos/io-v2/indexed-write.tri` does so. Its publication -composition leaves a corrupt/excess existing winner untouched. An `exists` result -is exact verification at observation time, **not** existing-winner durability or -protection from subsequent external mutation. Compiler publication requires additional synchronization and ordering; the -source-owned object library below supplies that policy without changing this demo -into a durability claim. - -```sh -python3 test/io_v2_indexed_write.py result/bin/tricu --directory /tmp/new-indexed-write -``` - -Native tests cover canonical byte equivalence at varying chunk boundaries, physical -sharing, partial progress, preflight non-effects/non-demand, interrupted leases, -uncertain failures and exact comparison. Independent source checks cover hashes, -complete output bytes, staged publication and corrupt/excess winners, plus a DAG -with 200 shared fork levels and chains through 100,000 nodes. Those graphs remain -indexed during round-trip; their node-table memory is expected to grow. Reported -RSS is sampled and tiny runs may finish before representative samples. - -## Synchronization and source-owned indexed objects - -`IO.syncFile handle` flushes writable file buffering and requests filesystem -synchronization of the **opened inode**. It shares the file's exclusive cursor -lease, preserves its logical position, and supports read/update/write/append file -modes. It is not a standard-stream, stage, socket or directory-cursor operation. -`IO.syncDirectory path` opens and validates a directory, then requests synchronization -of that directory itself (not recursively). Paths follow the existing host/symlink -rules. Nonblocking acquisition avoids hanging on a FIFO substituted for a directory. - -Both operations return ordinary Results. Success means the host synchronization -request succeeded, not a universal crash/power-loss guarantee, peer consumption, -atomicity with another operation, or protection against subsequent mutation. -Filesystem/device behavior can be weaker; some filesystems reject synchronization. -Failures during sync have unknown durability/progress and do not imply rollback. -Transient descriptor resources are registered before use and have exclusive private -leases. Fault/cancellation cleanup follows lexical settlement; failed raw closes are -not retried against potentially reused descriptor numbers. Ordinary sync failures -retain their primary detail and bounded release diagnostics if both fail. - -`io_v2_objects` supplies source-owned `putTree root limit tree` and -`getTree root limit digest` computations. These are library policy, not native -object-store services. The store root must already exist; its creation and parent -namespace durability belong to the caller. Put creates/synchronizes `objects` and -three-character shard directories, uses a scoped stage and no-replace publication, -then verifies **every** winner byte-for-byte through EOF. It synchronizes the -verified open inode and shard directory before returning the digest. Callers must -publish manifests/locks/aliases only after that successful return. - -A mismatch or synchronization failure never deletes/overwrites a winning name and -can occur after a new object became visible. There is no crash pruning or rollback. -Root paths are trusted: this is not symlink confinement or a transaction against -external mutable writers. Get validates lowercase digest spelling before path IO, -gets the opened-file size, and uses the authenticated snapshot reader with the -caller's encoded-size budget. Object domains, paths, canonical bytes and sharding -match the existing compiler store. Returning an authenticated tree does not execute -it or establish publisher identity. - -```sh -python3 test/io_v2_objects.py result/bin/tricu --directory /tmp/new-object-check -``` - -`demos/io-v2/objects.tri` exercises these policies. Independent controls cover -complete object bytes/results, fresh/existing/corrupt/excess winners, malformed -digests, absent/blocked roots, limits, exponentially shared cross-store copying, -and actual concurrent publishers. Native synchronization tests cover opened-inode -identity/cursor behavior, modes, non-directories/FIFOs, leases, cancellation, -primary/cleanup faults and once-only descriptor release. Power-loss durability is -outside these tests. The packaged compiler uses this source-owned v2 storage -policy; full reproduction is covered by the compiler rebuilding guide. - -## Strict UTF-8 and source-text conveniences - -`IO.encodeUTF8 text` accepts at most 1024 canonical Unicode scalars and returns at -most 4096 octets. `IO.decodeUTF8 final bytes` accepts at most 4096 canonical octets -and returns `[text carry]`. Final is canonical 0/1. A nonfinal valid incomplete -suffix is returned as at most three octets; supply it before the next input chunk. -Final input rejects incomplete suffixes. Invalid prefixes fail immediately, even -when incomplete. Overlong encodings, surrogate code points, stray continuation -bytes and values above U+10FFFF are rejected; no replacement decoding occurs. -Empty chunks, NUL, BOM and Unicode noncharacters are valid codec data. No BOM -stripping, newline folding or Unicode normalization occurs. Errors report offsets -within this supplied chunk and return no partial decoded output. - -These bounded codecs are stateless. Carry is ordinary source data, not hidden -per-stream state; callers own sequencing and can use any byte transport. At most -4093 fresh bytes plus a three-byte carry fit a decode request. Bounds and canonical -scalar/octet validation precede conversion; excess elements are not demanded. - -`io_v2_text` composes them into: - -- `readSource reader limit`: whole-source Result `(rawOctets, scalars)`, preserving - exactly the bytes used for decoding and rejecting an initial BOM. The reader is - borrowed, not closed. The byte limit is canonical `[0,2^63-1]`; a one-byte EOF - probe at the boundary may consume one excess byte before rejecting oversize input. - Failure is `["read-source" confirmedInput cause]`; a failing read's own uncertain - progress remains in the unchanged cause. No automatic retry or cursor rewind. -- `encodeText limit scalars`: whole-value encoded octets under a byte budget, without - source-specific BOM rejection. It does not write a resource. -- `writeText writer scalars`: bounded sequential encoding/writing with no aggregate - output quota or ownership transfer. Failure is `["write-text" confirmedBefore - cause]`; writeAll's partial-progress diagnostic is preserved when applicable. - -Read/encode conveniences retain whole values and are **not** constant-memory source -compilation. Budgets bound bytes, not all graph/interpreter memory or CPU. Individual -read/write operations are leased; these source conveniences do not acquire one -whole-text lease, guarantee whole-record atomicity, or make concurrent cursor use -safe. The low-level codecs remain usable in bounded streaming applications instead. - -```sh -python3 test/io_v2_text.py result/bin/tricu --directory /tmp/new-text-check -``` - -Native tests independently exercise all 1,112,064 Unicode scalars against standard -UTF-8 encoding, boundary/invalid vectors, every split of mixed text, canonical -wire bounds and non-demand. Source checks compare complete encoded bytes, raw source -provenance and scalar values across chunk boundaries; retain CR/LF, NUL, combining -characters and interior BOM; reject initial BOM, malformed/incomplete input, excess -budgets and surrogates; preserve an acknowledged prefix on later encoding failure. -Whole-source pipelines through 64 KiB record RTS and sampled RSS observations -for their retained source/decoded values. - -## Internal protocol - -Envelope: `("tricuIO", (2, action))`. Action is a pair of canonical numeric tag and -payload. Comma-separated payload fields below are right-nested pairs, except -where a list is explicitly shown. Pure/bind do not add Result envelopes. - -| Tag | Payload | Reply | -| --- | --- | --- | -| 0 | value | raw value | -| 1 | action, continuation | sequencing | -| 2 | diagnostic | terminal failure | -| 3 | callback | new scope supplied to callback; returns body value after children and release | -| 4 | ignored | current scope reference | -| 5 | supervised, keepResult, handles, callback | Result `[task joinTicket]`; callback receives fresh handle list | -| 6 | join ticket | Result terminal outcome; consumes ticket at claim | -| 7 | join ticket | Result unit; discards the result, not the child | -| 8 | task, reason | Result unit acknowledging cancellation request | -| 9 | reason | cancel this computation, without resuming its continuation | -| 10 | facility name, group revision, operation name, arguments | Result (named external operation) | - -Current core tags are listed above. Facility calls use named requests. Earlier -v2 tags 20–42 reject; historical v1 actions require a retained runtime: - -``` -IO.request group revision operation arguments --- (10, (group, (revision, (operation, arguments)))) -``` - -Group/operation names are nonempty ASCII identifiers of at most 64 characters: -an initial lowercase letter followed by lowercase letters, digits or hyphens. -Revisions are positive canonical 16-bit naturals. The outer envelope version is -also a canonical 16-bit natural, and its sentinel is exactly canonical `tricuIO`. -Native execution and indexed inspection enforce these envelope checks for both -versions; noncanonical envelope aliases are deliberately rejected. - -Implemented group revisions are all **1**: - -| Group | Operation | Arguments | Reply inside Result | -| --- | --- | --- | --- | -| runtime | capabilities | unit `t` | capability association rows | -| runtime | inspect | action | shallow header description | -| standard | input / output / error | unit `t` | borrowed stdin reader / stdout writer / stderr writer | -| file | open-reader | scope, path | reader | -| file | open-writer | scope, path | writer (create/truncate) | -| file | open-appender | scope, path | append-only writer (create if absent) | -| file | open-update | scope, path | readable/writable file (create if absent, no truncation) | -| file | seek | file, absolute byte offset | unit | -| file | position | file | current logical byte offset | -| file | size | file | observed byte length of the opened file | -| file | resize | file, byte length | unit | -| path | stat | path | `["file" bytes]`, `["directory"]`, `["symlink"]`, or `["other"]` | -| path | lstat | path | same tags, without following final symlink (host path resolution rules apply) | -| path | remove-file | path | unit; unlink, not recursive deletion | -| path | rename | source, destination | unit; host replacement semantics | -| directory | create | path | unit; strict nonrecursive creation | -| directory | remove | path | unit; empty-directory removal | -| directory | open | scope, path | directory cursor | -| directory | next | cursor | pair `("entry", name)` or `("eof", t)` | -| tcp | listen | scope, (endpoint, backlog) | listener | -| tcp | connect | scope, endpoint | duplex stream | -| tcp | accept | listener, scope | duplex stream owned by the selected scope | -| tcp | local-address | listener or duplex stream | endpoint | -| tcp | peer-address | duplex stream | endpoint | -| tcp | shutdown-write | duplex stream | unit | -| resolver | resolve | `[host family port limit]` | `[endpoints truncated]` | -| indexed-tree | read | `[reader directory limit size digest]` | decoded tree | -| file | sync | handle | unit | -| directory | sync | path | unit | -| utf8 | encode | scalar list | octet list | -| utf8 | decode | `[final octets]` | `[scalars carryOctets]` | -| indexed-tree | hash | `limit, tree` | `[size digest]` | -| indexed-tree | write | `[writer limit digest tree]` | `[size digest]` | -| indexed-tree | verify | `[reader limit tree]` | `[size digest]` | -| stream | read | reader, positive limit | `(data, nonempty bytes)` or `(eof, t)` | -| stream | write | writer, nonempty bytes | positive accepted count | -| stream | snapshot | `[reader directory limit digest]` | `[snapshot size]` | -| stream | transfer | `[reader writer count]` | unit | -| resource | close | resource | unit | -| stage | open | scope, directory | staged writer | -| stage | seal | stage | unit after flush, sync and close | -| stage | publish | stage, replacement mode, destination | `"published"` or `"exists"` | -| digest | open | scope, algorithm | digest context; currently `"sha256"` | -| digest | update | digest, nonempty bytes | unit | -| digest | finish | digest | lowercase hexadecimal digest; consumes state | - -`IO.capabilities` reports association rows `[key value]`: `schema` (currently 1), -`io-version`, `backend`, `authority`, `core` constructor names, `groups` as -`[name revision operationNames]`, `limits` (chunk bytes, transfer handles and maximum file offset and directory-name scalar limit TCP backlog, resolver input/output bounds and indexed metadata bounds), -`digest-algorithms`, and `restrictions`. It describes this backend's implementation, -not OS permissions, resource availability or a guarantee that a future call works. -For example, no-replace publication still needs filesystem hard-link support. -The native report covers native Linux capabilities. - -A well-formed named request with an unavailable group, revision or operation -returns `unsupported` **without demanding its arguments**. Unknown names do not -reserve or specify future argument schemas. Malformed headers and unknown core -constructors are terminal protocol failures; bad arguments to implemented requests -are ordinary operation errors. Protocol failures carry -`["protocol-error" "core/decode" boundedDetail "none"]` as their diagnostic. - -`IO.inspect action` returns `["core" constructorName]` or -`["facility" group revision operation "available"/"unsupported"]` inside Result. -This inspects only the header: it does not validate arguments, inspect payloads, -follow bind actions, apply callbacks, resolve handles or predict success. Even a -malformed bind payload can have a recognizable bind header. An invalid queried -header returns an ordinary `invalid-argument` error from `runtime/inspect`. -This is distinct from the richer read-only **indexed bundle inspector**, which -follows explicit bind action structure under depth/occurrence limits while keeping -v2 request arguments and callbacks opaque. Its JSON schema is -`tricu.interaction-inspection.v2` with `supportedVersions: [1,2]`; offline inspection -never infers a deployment backend's support from an artifact's named header. - -Spawn flags are canonical 0/1. A non-result child has `t` in the ticket position; -its task control handle is still available while active. Transfer lists contain -at most 256 handles. Task and join references use distinct markers, run identity -and monotonic keys; they are not portable resources or secret capabilities. - -Joined outcomes are lists: `["completed" value]`, `["failed" diagnostic cleanup]`, -or `["cancelled" reason cleanup]`. Cancellation is not an ordinary operation error -inside the cancelled child. A supervisor can observe its final outcome as data. -The CLI reports terminal failure/cancellation without forcing arbitrary diagnostic -payloads. `runDetailed`/`runInRuntime` retain structured outcomes for embedding; -a supplied runtime belongs to that execution and closes on exit. - -Ordinary errors are four-element lists: category, operation, bounded detail and -progress. Wire operation errors now use the requested `group/operation`, e.g. -`file/open-reader`, `stage/publish`, or `core/join`, rather than leaking incidental -backend labels such as `open` or `task`. Category/detail/progress are preserved. -Terminal user diagnostics remain arbitrary trees; lifecycle cleanup diagnostics -retain their resource-release context and are not fabricated operation replies. Canonical byte chunks/read bounds are 4096 bytes; paths are canonical -Unicode scalar lists without NUL. Empty read means EOF, never transient progress. -The file backend accepts the whole chunk on successful writes; source `writeAll` -also handles partial positive acknowledgements. Its failures are -`["write-all" confirmedBefore originalProblem]`: confirmed bytes from earlier -successful calls, plus the failing operation's unchanged diagnostic. They do not -claim the failing operation accepted zero bytes. This deliberately replaces raw -error forwarding, which lost earlier progress. Native write errors can have unknown -progress, so blindly retrying an entire chunk is unsafe. Acknowledgement is not -durability. Unknown/malformed computation constructors fail the task; invalid -arguments to recognized operations return ordinary errors. - -## Lifetimes, admission and handoff - -Scopes own resources and child tasks. Descendants borrow ancestor resources; -returning a handle does not extend its lifetime. Normal exit closes admission, -joins attached children, then releases resources. Existing descendants keep their -borrowing rights and may acquire into their own still-open scopes. Failure or -cancellation requests child cancellation before waiting. There are no detached -tasks. An endless child makes normal scope exit wait until explicitly stopped. - -Operations hold leases. Closing rejects new operations and waits for an existing -lease before releasing; file cursor overlap returns `busy`. Repeated explicit -close reports stale/closed, without retained tombstones. Scope cleanup finishes -inner scopes first and releases resources in reverse acquisition order. - -Spawn creates a gated worker before commitment. Resource reservation, transfer, -task registration and opening its gate occur in one STM transaction. Invalid -handle sets start no child and move nothing. Only idle resources owned by the -attaching scope move; borrowed ancestor resources remain borrowed. Old aliases -become stale; fresh handles preserve input order, while release follows original -acquisition order. Explicit close claims closing atomically with token validation, -so an old alias cannot close a newly transferred resource. Once published, a -worker startup/execution fault is a task failure, not a rollback of successful -spawn. OS/thread allocation before publication must succeed before any transfer. - -## Outcomes, cancellation and cleanup - -Ordinary Results remain values. `IO.require` explicitly promotes a selected -operation error to terminal failure. Uncontained child failure records the group's -failure and cancellation of siblings transactionally, then wakes the body. -Supervision must be selected at spawn, before the child runs. Joining later does -not retroactively contain a fault; discarding a ticket does not disable supervision. - -The first recorded stop request wins. Completion linearizes only after child and -resource settlement, so a cancellation during normal joining/release can still -win over a successful body. Cancellation after final completion reports stale or -completed-task. A task has one consumable ticket: claiming join removes it before -waiting; concurrent/duplicate claims fail. An interrupted join does not restore -the ticket. Explicit and implicit joins participate in wait-cycle detection; -an explicit cycle returns an ordinary error, while an unavoidable implicit scope -cycle fails the group. Non-result and discarded completions leave no result -archive. Unconsumed tickets deliberately retain their results until scope exit. - -Controllers do not run user computation. A body worker executes the interaction -tree; sticky cancellation is checked before interpreting each action. Waking a -worker cannot replace its recorded reason or turn cancellation into a recoverable -Result. Even a backend that catches the wakeup cannot resume the next source -continuation. Nested lexical controllers receive cancellation through group state, -not an exception injected into their resource-release protocol. - -Cancellation waits for pending work to settle; it cannot undo bytes written or -other external effects. Cleanup attempts remaining releases after synchronous -failures, preserves an existing failure/cancellation, and bounds diagnostics to -sixteen plus an omitted-count marker. Successful execution with failed required -cleanup becomes failure. A reported failed release is not proof the OS reclaimed -a broken backend resource. Host interruption of the outer runner is rethrown only -after subtree settlement; repeated external interruption/process death remains -outside the cleanup guarantee. - -`Runtime.Tree.apply` is a lazy recursive evaluator, not an explicit budgeted -reduction machine. The driver supplies action-boundary safe points; native pure -reduction relies on GHC scheduling/allocation safe points. No prompt cancellation -or hard deadline is promised for arbitrary divergent pure calculations, foreign -calls or OS cleanup. Pending-IO cancellation tests do not establish those claims. - -## Handler bridge - -`H.scopeV2`, `H.spawnV2`, and `H.runIOV2` are concurrency-aware source boundaries. -Normalization snapshots the actual lexical service environment and labels for the -child, with an empty child continuation stack. A handler callback's child inherits -the callback's outer environment, not the original requester's inner overrides. -Child-local handler changes do not mutate parent routing. Label list spines are -settled at boundaries without forcing payloads or unselected continuations. - -`H.runIO` aliases `H.runIOV2`, which returns successful values directly and maps -handler failures/unhandled requests to terminal failures. -Service-level Results remain ordinary values. `H.liftIO (IO.spawn ...)` alone does -not capture handler routing: use `H.spawnV2` for handler computations. - -`H.collect` is a state-threading transformer, not shared mutable state. It rejects -v2 scope/spawn boundaries rather than silently sharing or dropping its state; -install child-local collection inside the child body explicitly. General shared -cells/channels and implicit child-state snapshots are not implemented. Captured -services that refer to resources still obey the real resource scopes. - -The handled copier exercises the current host bridge and lexical service routing. - -## Staged publication and independent digests - -`IO.openStage` creates a mode-private temporary file in an existing directory -(nonempty path; use `.` explicitly for the working directory). -It is not visible at the destination name, but its temporary name is discoverable -by the OS owner; this is not a sandbox. `IO.writeSome`/`writeAll` work on stages. -`IO.sealStage` flushes, synchronizes the actual file descriptor and closes it; -subsequent writes fail. On sync failure/interruption, the descriptor remains -registered for scope cleanup, so release failure cannot replace the original -sync failure. Directory synchronization uses the same ownership discipline. -Failed descriptor closes are not blindly retried against a possibly -reused OS descriptor; other cleanup is still attempted. No length, hash, artifact name or upload-session policy is -hidden in the native staged writer. - -Publication may cross directories on the same filesystem. Destination directories -must already exist. Atomic rename/hard-link syscalls enforce the filesystem boundary; -there is no copy fallback or automatic retry on cross-filesystem failure. This -replaces the earlier same-directory-only restriction. Basenames containing -`.tricu-stage-` are reserved for temporary files, preventing publication over -this or another stage's private name. `publishReplace` atomically renames over a destination; -`publishNew` atomically creates a hard link without replacement, then removes the -private name. The latter requires filesystem hard-link support. Both synchronize -the destination directory after successful publication, then the staging directory -when it is distinct. Same-directory publication synchronizes the directory once. -A failure during either synchronization or descriptor release can follow visible -publication; it is not rollback permission. Synchronization stops at failure, and -owned descriptor/private-name cleanup still runs. These are POSIX filesystem mechanics, -not race-resistant confinement against other actors changing managed directories. -Already-open readers of a replaced destination can still see its old inode. - -Mode 0 means no-replace; mode 1 means replacement. `"exists"` leaves the stage -sealed and makes no claim about the winner's contents or durability. `"published"` -means this backend's file/directory synchronization requests completed, not a -universal crash-consistency guarantee across every OS/filesystem/storage device. - -Write, seal and publication execution failures freeze the stage against further -writes/publication. Publication errors classify visibility in the progress field: -`not-published` before the namespace operation, `unknown` on an ambiguous namespace -failure, or `published` after successful link/rename but failed removal/directory -sync/descriptor release. A cancellation after namespace change may also leave output visible. -**Neither failure nor cancellation authorizes retrying publication or deleting -the destination.** Explicit close/scope cleanup only closes the owned handle and -removes the private name. A failed release can leave private staging files and is -reported; automatic crash recovery/pruning is not implemented. Invalid decoded -arguments never reach the backend; phase errors and publication execution errors -are distinct from those argument checks. - -Digest contexts have the same ownership, borrowing, transfer, lease and close -rules, independently of files. Updates accept bounded nonempty canonical chunks; -finish consumes the context, including the valid empty-input digest. Updates or -another finish after finish report `invalid-state`; close releases the registry -entry. Unsupported algorithm names return `unsupported`, not protocol failure. -The native implementation forces each updated context so it does not retain a -chain of old chunks. - -### Source-owned artifact policy - -`io_v2_artifacts` (`lib/io-v2/artifacts.tri`) composes stages and digests through -handlers. Its producer submits `write` requests and finally returns Result unit. -The boundary owns the remaining count: early success, excess bytes, a producer -ignoring failed writes, or an inner override forging acknowledgements cannot -advance that count or authorize publication. A failed step ends the boundary -without resuming the producer. The adapter's declared-length limit is 64 MiB; -it is application policy, not a native staging quota. An excess chunk may have -been written privately before its excess is detected (at most one bounded chunk); -it is never published and is removed on scope exit. - -The adapter validates the finished digest before deriving a flat destination -name. An existing winner is independently read and hashed, with exact-length -checking, before returning success. This is point-in-time content verification, -not certification of another writer's crash durability or protection against -subsequent external modification. It returns an ordinary Result digest; handler -protocol failures remain terminal failures. The directory must already exist. -Producer code remains trusted process-authorized code: this boundary does not -constrain direct native IO or external filesystem mutation. It checks submitted -chunks and acknowledgements, not arbitrary malicious programs. -The producer is sequential: other requests are forwarded with lexical labels, -but nested producer scope/spawn views are rejected instead of duplicating count -state. The whole publication computation can itself run in a child task. - -`demos/io-v2/publish.tri` is a bounded file producer. The default source registry -uses the streaming composition described below for socket uploads and publication. - -`IOV2StorageTests.hs` independently covers SHA-256 vectors, phase/kind/bound checks, -atomic no-replace races, transfer, pre/post-publication faults, cancellation during -write/seal/publication, descriptor settlement, release failures and repeated -scope reclamation. `io_v2_publication.py` checks complete files and independent -Python SHA-256 expectations through 1 MiB, Unicode paths, private file modes, -existing-winner verification, corrupt -winners, counter/acknowledgement controls, cancellation, and digest/path validation. -It records allocation separately from the same workload-specific RSS/live-heap -guards used by the first slice. Separate write/digest calls deliberately decode -each chunk twice; no fusion or routing-only performance improvement is claimed. - -## Explicit compiler invocation boundary - -The native supervisor defaults to IO v2 and accepts an explicit -`--toolchain-io-version 2`. Selected compilers execute with OS process authority. -Canonical envelope validation precedes action demand; v1 selection requires a -retained host. Returned manifests and receipts are authenticated. Startup loads -and verifies the worker inertly. - -```sh -python3 test/compiler_io_version.py result/bin/tricu --directory /tmp/new-compiler-version-check -``` - -Independent native workers check framing, inert startup, retired-grant rejection, -invalid selections and authenticated outputs. The eleven CLI controls cover -packaged and explicit selection. Full generation acceptance uses the separate -[reproduction workflow](../../docs/compiler-rebuilding.md.html). - -## Source storage and isolated compiler projection - -`io_v2_storage` composes native mechanisms without adding a native store service: - -- `readBytes path limit` returns a whole byte list, with bounded reads and a one-byte - EOF probe at the budget boundary. Excess input can consume that one byte before - failure. It is not framed input or a constant-memory whole-file representation. -- `hashBytes limit bytes` validates canonical octets and the byte budget while - feeding bounded chunks to a scoped SHA-256 context. Empty bytes are valid. -- `publishBytes directory destination replace limit bytes` preflights before stage - creation. Canonical flag 0 publishes without replacement and verifies **every** - winner byte-for-byte through EOF, then synchronizes its open inode and directory. - Flag 1 atomically replaces a mutable name after sealing; it does not race-read a - subsequent legitimate alias replacement. Both return Result Boolean: false only - for a verified existing immutable winner. Publication errors do not authorize - rollback or retry; a destination can already be visible when synchronization fails. -- `publishTree directory destination limit digest tree` preflights canonical identity, - uses native indexed transport, and verifies/synchronizes every no-replace winner. - `readTree directory path limit digest` authenticates a snapshot before decoding; - the explicit snapshot directory must be writable. -- `parentPath` is a POSIX lexical parent helper, including trailing separators and - leading root separators. It does not resolve symlinks or `..`, validate a path, - or establish confinement. Directory creation/durability remains caller policy. - -Limits are canonical `[0,2^63-1]` encoded-byte budgets, not graph/heap or CPU quotas. -Resident caller byte lists remain resident values; these helpers bound transport, -not all application memory. Failures preserve the underlying operation problem or -writeAll diagnostic. They do not provide a transactional whole-operation progress -count or safe implicit retry. Immutable-store policy assumes cooperating writers; -there is no transaction against external mutation or universal crash guarantee. - -`lib/source/io-v2.tri` is the **compiler storage facade**, imported as -`compiler_io`. It constructs v2 actions for the operations used by the source compiler. -It preserves Result continuation/remainder conventions, uses a declared 64-MiB -source/metadata/encoded-tree budget, and requires parent-first directory creation. -Missing ancestors fail. Directory -entries are synchronized before dependent publication. File-existence queries -return false for missing paths and non-files, but propagate other operation errors. -Indexed reads spool in the object's parent directory, suitable for the writable -compiler application store. Callers must supply a writable parent directory. - -Both compiler source maps select `compiler_io` and its v2 dependencies. The -general application module `io` reexports the complete `io_v2` facade. - -```sh -python3 test/io_v2_compiler_store.py result/bin/tricu \ - --directory /tmp/new-compiler-store-check --compiler-smoke -``` - -The focused Nix gate compiles current storage source, exercises exact chunk and -budget boundaries, rejects malformed tails and octets before publication, preserves -failed replacement targets, and checks corrupt immutable winners through EOF: - -```sh -nix build .#ioV2StorageTest --no-link -``` - -Its `cost/report.json` records three-run median invocation time and cumulative -allocation for 1-KiB, 4-KiB and 64-KiB hashes with exact and roomy byte budgets. -The 4-KiB roomy-budget hash probe has a 160-MB allocation ceiling. The report also -records 4095/4096-byte file writes; the full-bound write has a 32-MB allocation -ceiling. Native acknowledgement of all 4096 bytes exhausts the validated input -chunk, allowing `writeAll` to finish directly. Short acknowledgements retain the -ordinary suffix/progress path. Wall time is reported rather than gated. This measures resident byte-list processing, not a constant- -memory stream or peak RSS. To compare an explicitly selected historical storage -source under the same compiler and workload, use: - -```sh -nix shell --inputs-from . nixpkgs#python3 -c python3 test/io_v2_storage_cost.py \ - result/bin/tricu --directory /tmp/storage-reference-new \ - --reference-storage /path/to/retained-storage.tri -``` - -Reference-storage runs record the source identity and bypass the candidate hash -allocation ceiling. Write probes still select the current IO library and guard. The public `base.length` contract remains unchanged; internal storage -counting operates on validated proper chunks. - -Without `--compiler-smoke`, the test exercises actual `lib/source/object.tri` -callers and storage policy. With it, the retained compiler builds a candidate -stable `sourceBuildGraph`; explicit IO-version 2 selection then compiles an actual -application dependency graph with cold and warm caches. Controls compare complete -bundle bytes with the retained compiler, evaluate independent numeric and combining- -character expectations, inspect exact raw UTF-8 provenance digests, and preserve -prior output on BOM/malformed-UTF-8 failures. CRLF source rejection is compared -with the retained compiler: preserving bytes is not a promise that the parser -accepts every Unicode scalar as source whitespace. - -Reports record projected input hashes, candidate bundle identity and IO version, -commands and routine RTS observations. This procedure does **not** produce or accept -an entire self-rebuilt compiler generation pair. Generation/reproduction metadata, -backend support, deployment/default selection and rolling retention acceptance -remain separate gates. Source bundle encoding and resident source/metadata values -are not claimed to have become bounded-memory operations. - -## Artifact handler filesystem adapter - -`handler_artifacts.withFilesystemV2 root byteLimit action` and -`withFilesystemForV2 service root byteLimit action` interpret the existing artifact -request interface using native v2. Run the computation through `handlers.runIOV2`. -`prepareFilesystemV2 root` explicitly initializes the registry layout; the root's -parent must already exist. Lexical overrides, named captured services and unknown-operation -forwarding retain the interface's ordinary handler behavior. - -The caller selects a byte limit. Heap and execution-time limits require separate controls. These operations -retain whole byte lists. Loads authenticate the returned bytes against the requested -raw SHA-256 name. Publication verifies and synchronizes every immutable winner -through EOF; corrupt existing objects remain intact and produce a failure. -Synchronization follows host-filesystem guarantees; objects can remain visible -after a post-publication failure. Callers provide bundle validation and publisher -authentication when required. Paths follow OS process authority. Invalid digest paths and malformed raw -publication requests are rejected at the adapter boundary too. - -```sh -python3 test/io_v2_artifact_service.py "$TRICU" --directory /tmp/new-artifact-service -python3 test/handler_library.py "$TRICU" --directory /tmp/new-handler-controls -``` - -Controls include exact binary data across chunk boundaries, corrupt/trailing -winners, byte budgets, startup failures, raw request validation, lexical and captured -routing, forwarding, concurrent publication, stage cleanup and rejection of v1 -grants before effects. These controls exercise the storage service; the HTTP -listener has separate socket-level checks below. - -`arboricx_handlers.routerV2 root byteLimit sink method target headers body` reuses -registry routing/response policy with those services. `packageLookupV2` captures its -root independently of artifact overrides; `stdoutLogV2` writes status-only access -logs to the borrowed standard output. Each request interpretation runs in a -supervised child whose join ticket is consumed. Interpretation/logger failures yield -a redacted 500 after child cleanup; cancellation remains cancellation. Publication -can precede either outcome and is not undone. Byte budgets also apply to captured -package reads. This router expects the HTTP transport to validate framing and own -the connection; it neither accepts sockets nor makes request deadlines available. -The source probe checks complete HTTP bytes, captured package roots, authentication -failures, logging failure after publication, ordinary status logging and cancellation. - -### Digest-selected streaming destinations - -`io_v2_artifacts.publishWith stagingDirectory expected destination producer` uses -the existing sequential producer interface. After exact-length completion, digest -validation and sealing, `destination digest` returns a handler Result containing -the final path. Destination policy may initialize a digest-derived directory; -it remains source code, not native upload/store policy. Callback failure abandons -the stage. The existing `publish` helper retains its flat-directory behavior. -Existing winners are length/digest verified through EOF and their opened inode and -parent directory are synchronized before success. - -`handler_artifacts.publishStreamV2 root expected producer` uses this mechanism for -the registry layout: stage under `root/tmp`, then publish without replacement under -`root/objects/<first-three-digest-characters>/<digest>.arboricx`. Initialization is -explicit via `prepareFilesystemV2`; the temporary and object directories must be on -the same filesystem. There is no whole-body buffer or second file copy. The source -probe exercises new/existing winners through 1 MiB, short/excess streams, corrupted -winners and private-stage cleanup. The 64-KiB/1-MiB checks retain generous 128-MiB RSS -and 16-MiB live-heap regression guards for these workloads. - -### Native v2 HTTP registry - -`io_v2_http` reuses the existing pure HTTP grammar, validation precedence, -Content-Length decoding, 65,536-byte accumulated header-buffer limit (including -prefetch), and 1,048,576-byte body limit. Transfer coding and duplicate lengths -remain rejected. HEAD suppresses bodies only after the historical trusted parsing -boundary. Each connection serves one request; surplus/pipelined bytes are ignored -on close. Response writes are explicitly split into chunks of at most 4096 bytes: -Each `IO.writeAll` call completes one bounded chunk. - -`arboricx_streaming_v2` connects this transport to `routerV2` and -`publishStreamV2`. POST bundle input is sequential and acknowledged before the next -read. Other request bodies, artifact downloads and package downloads remain -whole-value operations whose memory cost grows with the retained values. -Upload interpretation/logging runs in a supervised child and is joined before the -response. Cancellation remains cancellation, and post-publication logging failure -cannot undo publication. Connection ownership transfers atomically into its worker -scope, which closes the socket after success, failure, reset or cancellation. - -`serve` and `serveCount` take a canonical artifact byte limit (also enforced on -uploads, in addition to the HTTP body limit) and an explicit connection window in -[1,256]. Admission -consumes the oldest join ticket before exceeding that window. This bounds active -connections and retained outcomes but deliberately permits head-of-line blocking: -a slow oldest connection delays new admission even if later workers finish. No -request deadline, idle timeout, completion-order scheduling or slow-client -availability guarantee is implemented. Excess connections wait in the OS backlog, -whose capacity and behavior follow host configuration. `serveCount` drains its finite selection before returning; explicit -scope cancellation cancels/joins active workers and cleans incomplete stages. - -Build/run the native-OS-authority streaming runner: - -```sh -nix build .#arboricxStreamingV2Runner -./result/bin/arboricx-streaming-v2 -nix build .#arboricxStreamingV2Test -python3 test/io_v2_http.py /path/to/nix-built/tricu --directory /new/output -``` - -The runner binds port 8080 and uses `./store` with a 16-connection FIFO window. -Its packaged runtime store needs no compiler cache in the working directory. -The default `arboricxServerRunner` and image use the same v2 composition. Symlinks -follow OS authority; wrong-digest bytes fail authentication. Deployment tests use -v2 and retain retired-grant rejection controls. Public deployment is a separate -operator action. - -Socket controls include complete HTTP-byte expectations, binary upload boundaries -through 1 MiB, authenticated downloads, corrupt winners, header/body rejection, -prefetch/surplus, half-close/reset, logger failure after publication, bounded -concurrent admission, repeated join-ticket consumption, startup failure, v1 grant -rejection before effects, and explicit cancellation of active uploads. Streaming, -admission and lifecycle jobs enforce 128-MiB RSS/16-MiB live-heap regression guards; -these are test guards, not process-wide service capacity promises. The separate -packaged deployment test also exercises whole-value 1-MiB download/persistence. - -### Monotonic observations, relative waits and scheduler yield - -Native `clock` revision 1 provides `monotonic` (unit argument) and `sleep` -(canonical milliseconds in [0,9223372036854775807]); `scheduler` revision 1 provides -`yield` (unit argument). Source helpers are `IO.monotonicNanoseconds`, -`IO.sleepMilliseconds` and `IO.yield`. All return ordinary Results. Clock samples -are the host's unsigned 64-bit monotonic nanoseconds, with an unspecified local -origin. Use differences within that clock domain to measure elapsed time. -The host counter's representation/wrap limitations remain applicable. - -Relative waits split host delays into at most one second, avoiding conversion of -an enormous millisecond value to a bounded host microsecond integer. They are -interruptible, may finish late, and make no hard deadline promise. Zero wait is a -no-op, not an implicit scheduling barrier. Yield offers a scheduling opportunity; -it does not guarantee alternation, fairness, ordering or message atomicity. No -wall-clock reads, timeout/race combinator or deadline API is implied by this group. - -`demos/interactionTrees.tri` now uses v2 scoped children and consumed join tickets; -the parent alone writes stdout. `demos/interactionTrees/yield.tri` installs child- -local log collection and prints the collected results from the parent. This -explicitly retires the old demo's incorrect claim that yield guarantees alternating -writes and avoids concurrent stdout lease conflicts. Published historical fixture -bundles retain their original provenance alongside current v2 publications. - -```sh -python3 test/io_v2_clock.py result/bin/tricu --directory /new/clock-controls -python3 test/io_v2_protocol.py result/bin/tricu --directory /new/protocol-controls -``` - -Native controls independently check capability data, canonical arguments, inert -inspection, monotonic ordering, minimum elapsed sleep, exact waiter chunking and -maximum-duration interruption. Actual source probes check zero/short waits, -normal scope joining, explicit cancellation of a maximum-duration child wait, -invalid inputs, and both migrated demos' complete output. Test timeouts are harness -guards, not runtime deadline guarantees. - -### Direct-child subprocesses and bounded pipes (native POSIX) - -`IO.spawnProcess executable arguments directory environment graceMilliseconds` -uses the `process` revision 1 `spawn` operation. Its payload is the five-element -list of those arguments; its success value is -`[task joinTicket stdinWriter stdoutReader stderrReader]`. Pipes belong to the -launching scope and support ordinary stream operations, borrowing, handoff and -close. A failed/cancelled pipe operation poisons that endpoint against replay. -Other runtime descriptors are not deliberately inherited (`close_fds` is enabled). - -Directory selection is `["inherit"]` or `["at" path]`; environment selection is -`["inherit"]` or `["replace" [[key value] ...]]`. Empty replacement means an empty -environment. Duplicate/empty keys and keys containing `=` are rejected. Executable -must be nonempty. Arguments are passed as a vector, not parsed as a shell command; -executable lookup, script loading, path resolution and encoding follow the host -process library/OS. An explicitly named shell can of course interpret its arguments. -Execution uses OS process authority. - -Preflight limits are 256 arguments, 256 environment entries, 4096 Unicode scalars -per text field and 65536 scalars across executable, arguments, explicit cwd, keys -and values. Text must be canonical, NUL-free Unicode scalars. These are not encoded -OS argument-size guarantees: host limits/errors still apply. Grace is canonical -milliseconds in [0,9223372036854775807], explicitly selected by the caller. Individual -field/list bounds are checked before constructing host values; the aggregate bound -is then checked before spawning. Excess elements beyond individual bounds are inert. - -The process is monitored as an unsupervised structured child task. Normal scope -exit joins it, even without consuming its ticket. Explicit `IO.join` returns the -usual Result outcome; completion contains `["exit" code]` or `["signal" number]`. -**Exit status is data, including nonzero exits.** Callers must consume and check -the join outcome when command success matters. -Launch errors are ordinary Results, acknowledged after internal task settlement. -Unexpected monitor/cleanup failures propagate through the existing task machinery. - -`IO.cancel task reason`, owning-scope cancellation or failure terminates the direct -child with TERM, allows the selected grace, escalates to KILL if necessary, and -reaps. Normal waiting stays in cancellable task work, including after the parent's -body has completed. It does not block protected resource release. Status observation -uses nonblocking host polling with nominal 10-ms pauses, not a detached waiter. -Termination/reaping also attempts subsequent cleanup after an earlier release -error; errors are retained with bounded detail and an explicit additional-error -count when needed. Very long selected grace periods can delay cancellation settlement. -Scheduling, OS calls and cleanup provide **no hard deadline guarantee**. Host process -death, repeated external interruption and power loss are not cleanup guarantees. - -Only the direct child is owned: no process-group signalling, descendant confinement, -job control or automatic grandchild reaping is claimed. A descendant can retain a -pipe writer after the direct child exits. This can prevent EOF indefinitely. - -**Pipe policy is explicit:** close stdin to deliver EOF; drain stdout and stderr -concurrently when either may fill. Waiting before doing that can deadlock. No -implicit whole-output buffering, automatic input close on normal scope joining, -rollback of external effects, or automatic command-success checking is supplied. -The source demo `demos/io-v2/process.tri` pumps a file into stdin while two scoped -children drain stdout/stderr into files, then checks task completion and returns -command status for its caller to judge. Files can contain partial output on failure. - -```sh -nix flake check -nix build .# -python3 test/io_v2_process.py result/bin/tricu --directory /new/process-controls -python3 test/io_v2_protocol.py result/bin/tricu --directory /new/process-protocol -python3 test/io_v2_tasks.py result/bin/tricu --directory /new/task-controls -``` - -Native controls cover real exit/argv/env/cwd behavior, binary backpressure, missing -executables, implicit joins, failure, cancellation after observing closed scope -admission with a live child, TERM-ignoring escalation/reaping, repeated lifecycle -registry counts, failed-pipe poisoning, bounded/non-demanding protocol validation, -and injected termination/cleanup failures and clock wrap. Source controls compare -complete binary outputs through 1 MiB, including simultaneously full stdout/stderr, -normal joining without an explicit ticket wait, literal arguments and nonzero -status, and malformed bounds. Runtime RSS/maximum-live guards exclude child-process -memory and are regression guards, not capacity or throughput promises. This slice -is validated on native Linux; browser process support and scheduling changes require separate validation; it does not change -compiler fixtures, deployment defaults, or accepted compiler generations. - -### Bounded, incremental line reading - -`io_v2_text.lineCursor reader` starts a single-consumer source cursor. -`Text.readLine cursor byteLimit` returns an ordinary Result containing -`[event nextCursor]`. Events are `["line" text "lf"]`, `["line" text "eof"]` -(for a nonempty final unterminated line), or `["eof"]`. An empty LF-terminated -line is a line, not EOF; a trailing LF does not manufacture an extra EOF line. - -The explicit canonical limit is0..9223372036854775807 **encoded bytes per line, -excluding LF**. CR, NUL, BOM and all valid Unicode scalars remain content; no CRLF -normalization, BOM stripping or Unicode normalization occurs. This is generic text, -not `readSource`'s stricter compiler-source policy. UTF-8 decoding is strict and -incremental with at most three carry bytes. Framing splits at LF before decoding, -so malformed text in a later buffered line does not invalidate an earlier line. -Byte bounds are checked before decoding each framed piece. - -Reads request at most4093 bytes and preserve bounded read-ahead in the next cursor. -Each returned line is a whole value, occupying space proportional to its selected -limit; processing many lines need not retain the entire stream. This is not a -constant-space arbitrary-length-line facility. No aggregate input, CPU or wall-time -limit is implied. `demos/io-v2/lines.tri` includes both collecting and discarding -consumers; only the latter is used for repeated-line retention controls. - -**Thread only the returned cursor forward.** Serialize cursor use with all other -reads on the same reader. The cursor is ordinary source data; native leases cover -individual reads. Buffered bytes and observed EOF are cached data that can survive -resource closure. A fresh read requires a live reader handle. Cursor representation -is an internal library detail. - -Read/UTF-8/line-size failures return `["read-line" observedLineBytes cause]`, without -a next cursor. Retire that cursor on failure or cancellation; automatic draining, -resynchronization and recovery are not provided. The count includes the current -framed piece on a bound/decode failure, excludes LF/read-ahead belonging to later -lines, and is **not** a file offset or safe retry position. Native decoder byte -locations are relative to that decoder invocation. The underlying stream may already -have consumed read-ahead; reconstructing a fresh cursor is not lossless recovery. -Invalid limits reject before reading, including when the reader is stale. - -The migrated `getLineAsync` demo starts a child read, writes prompts from the parent, -then consumes the child's join ticket. EOF and an empty name differ, and write/read -failures are checked. `greet` and `forkAwait` also use v2; the parent serializes -`forkAwait` output rather than assuming concurrent writes are atomic. Historical -published v1 fixtures remain frozen alongside the current v2 releases. - -```sh -python3 test/io_v2_lines.py result/bin/tricu --directory /new/line-controls -python3 test/io_v2_text.py result/bin/tricu --directory /new/text-controls -``` - -Independent controls cover LF/EOF/CR/BOM/NUL, multibyte carry at the4093-byte read -boundary, malformed and incomplete UTF-8, line limits, a valid line before malformed -buffered data, stale readers, preflight, cancellation with stdin held open, and full -interactive output. Split pipe writes exercise fragmentation but may be coalesced -by the OS; stateless UTF-8 controls independently cover every split. Prompts must -arrive before the harness supplies input. Repeated-line RSS/maximum-live guards are -regression bounds, not capacity guarantees; whole-line collection remains bounded -by the caller's selected per-line budget. - -### Read-only process context - -`IO.workingDirectory` (unit payload, `process/working-directory`) returns Result -host cwd. `IO.environmentVariable name` (`process/environment-variable`) returns -Result `["missing"]` or `["value" text]`; an empty value is `["value" ""]`. Names are -nonempty, NUL-free Unicode scalar text, exclude `=`, and fit4096 scalars. Returned -cwd/values have the same scalar bound and strict text requirements; oversized or -unrepresentable values fail rather than truncate or normalize. Host acquisition -may materialize a value before checking its size; this is not an allocation quota. - -These are read-only, point-in-time OS observations under OS process authority. -Application arguments are explicit exported-function parameters. Subprocess -cwd/env selection is supplied explicitly when spawning each process. - -```sh -python3 test/io_v2_context.py result/bin/tricu --directory /new/context-controls -``` - -Native controls check actual cwd/PATH observations, malformed names/payloads, -strict scalar/size validation and inert inspection. Independent source controls -run in a selected cwd and distinguish absent, empty, Unicode, boundary-size and -oversized environment values. These facilities expose only a requested variable; -value-validation diagnostics use fixed error messages. - -### Basic callers: lexical configuration, state, files and bundle bytes - -`Text.readText reader byteLimit` is a bounded whole-text convenience using the strict -incremental decoder without compiler-source BOM rejection. Initial BOM, NUL and CRLF -are preserved. It shares the whole-source accumulation machinery and retains -the complete decoded text. `readSource` retains its separate raw-byte and source -policy contract unchanged. - -The `environment` demo now uses lexical configuration handlers and demonstrates -restoration of the outer lexical value. -The `state` demo explicitly passes state through a sequential state machine rather -than recreating a global host get/put slot. Neither implies shared mutable cells. -`safeRead` and `shout` use scoped generic UTF-8 reads with an explicit1-MiB budget. -`writeThenRead` checks write and close results before opening the reader. Its main -requires `TRICU_DEMO_OUTPUT` instead of overwriting a shared fixed `/tmp` filename; -its exported `run path text` entrypoint also accepts an explicit path. - -`demos/runArboricxBundle.tri` reads bounded binary bytes and uses the existing portable reader to select/apply the default export. -Validation is not authentication, sandboxing, or a computation-resource quota. -The portable reader permits bytes outside declared sections; its Result remainder -is an internal directory cursor, **not** an EOF indicator. Invalid/truncated sections -and trailing bytes *inside the manifest section* still reject. This migration does -not silently tighten the independently documented container profile. - -```sh -python3 test/io_v2_callers.py result/bin/tricu --directory /new/caller-controls -``` - -Controls compare complete output/results for generic text/BOM/CRLF/NUL, missing and -malformed input, read budgets, failed writes, explicit output-path selection, -lexical restoration, state transitions, binary bundle execution, permitted external -padding and malformed section/container negatives. Frozen historical fixtures and -published package releases remain separate from these live-source migrations. - -### Offline v2 package inspector - -`lib/packages/io.tri` and `demos/package-inspector/` are the canonical native-v2 -package facade and consumer. Reads and hashes take explicit byte budgets; the -inspector selects1MiB and hashes the same byte value it validates. Native failures -remain structured diagnostics. The `arboricx.inspect.v1` report format is unchanged. -Inspection validates graphs and exports without executing them. - -```sh -nix build .# .#inspectorOracle --no-link -HOST=$(nix path-info .#)/bin/tricu -ORACLE=$(nix path-info .#inspectorOracle)/bin/inspector-oracle -nix shell --inputs-from . nixpkgs#python3 -c python3 test/library_inspector.py "$HOST" \ - --oracle "$ORACLE" --directory /new/native-package-acceptance -nix shell --inputs-from . nixpkgs#python3 -c python3 test/provenance.py "$HOST" \ - --accepted /new/native-package-acceptance --directory /new/package-replay -``` - -The first command publishes into a new local registry under default label -`0.5.0-io-v2`, fetches and binds a clean consumer, stops the registry, then compiles -and runs offline. Complete reports are compared with the independent Haskell -oracle. Controls cover malformed and unselected exports, sharing, inert IO, -immutable publication, isolated dependency updates, missing files, OS permission -denial, byte budgets, and rejection of both read grants and `--unsafe-io`. Run as a -non-root user for the OS permission check. Explicit `--io-version 2` is accepted; -current-source publication no longer offers v1 selection. - -The second command captures four input packs, removes its disposable producer, -and rebuilds the facade, foundation, inspector library and consumer from retained -inputs. It checks exact modules/receipts, distinct source histories, and rejects an -authenticated false output claim and producer-cache poisoning. - -Existing published releases retain their bytes. Acceptance writes to the -caller-selected new directory. The optional `--stage-site` operation adds accepted -bundles/releases to an exclusively owned local static-site directory; network -deployment remains a separate operator action. - -The remaining `demos/interactionTrees/{echoServer,httpServer,arboricxServer}.tri` -entrypoints now explicitly construct v2 programs. Echo and simple HTTP are -sequential demonstrations: one scoped connection at a time, no idle timeout or -availability guarantee. Echo pumps bounded chunks until EOF instead of truncating -at the first receive. HTTP retains the existing framing/body limits and HEAD rules. -The registry demo uses the already validated streaming service with FIFO16 -admission and a source-selected1-MiB budget. It initializes `./store`, not a shared -fixed `/tmp/store`, and announces its port only after successful startup. Each -exposes a counted `server` entrypoint for reproducible tests; `main` serves forever. -The packaged registry application and image also default to v2. Historical images -and their authenticated inputs remain available for explicit rollback. - -`python3 test/io_v2_demo_servers.py result/bin/tricu --directory /new/demo-servers` -checks real binary echo, HTTP method/framing/HEAD responses, binary registry -publication/download, and that listeners no longer accept after their scopes finish. -The larger native HTTP/streaming controls remain the service's independent coverage. - -### Validated program/backend boundary - -IO envelope versions, compiler entrypoints and native embedding ABI versions are -separate contracts. Native embedding uses Haskell ABI 1 and IO protocol 2. - -| Program/operation | Haskell native (validated Linux) | Haskell browser | -| --- | --- | --- | -| Pure tree evaluation / stored definitions | Yes | Yes | -| Pure stable module-compiler entrypoint | Yes | Independently pinned browser compiler | -| Filesystem compiler workflow | IO v2 | Pure compilation over supplied sources | -| Historical IO v1 | Retained runtime required | Inert inspection only | -| IO v2 envelope/core | Structured core | Structured core and terminal profile | -| Named facilities | Query `IO.capabilities` | Runtime, standard streams, strict UTF-8 | -| Applications | Files, pipelines, tasks, processes and registry | Structured tasks and terminal pipelines | - -Browser terminal input uses UTF-8 line/EOF messages. Stop requests structured -cancellation and waits for admitted operations, tasks and leases to settle. -Emergency disposal terminates the Worker without promising resource settlement. -A non-returning reduction can delay structured cancellation; external supervision -bounds native work. Canonical v2 framing is checked before action demand. The JS -reference graph supplies independent pure conformance checks. - -Browser compilation invokes `source_unit.compileStableModuleDefinitions` from -its independently authenticated compiler library. Published assets retain their -own source and compiler provenance. Reproduce native and browser boundaries with: - -```sh -nix build .#tricu-native-bindings-integration .#arboricxSite --no-link -nix shell --inputs-from . nixpkgs#nodejs -c \ - node --test ext/demo-site/test/io-version-boundary.test.mjs -SITE=$(nix path-info .#arboricxSite) -TEST_IO_VERSION=1 TEST_BACKEND=haskell-wasm ARBORICX_SITE_ROOT="$SITE" \ - nix shell --inputs-from . nixpkgs#nodejs nixpkgs#chromium -c \ - node ext/demo-site/test/backend-browser.mjs "$SITE/runtime/haskell" chromium -``` - -Independent Haskell task/resource/file/indexed/text controls retain malformed -framing, non-demand, cancellation/cleanup, exact byte and publication oracles. -Shared-library generation tests compare complete inventories with the CLI; full -compiler-pair acceptance and retention remain separate. See the -[browser IO contract](../../ext/demo-site/README.md#io-version-boundary) and -[compiler rebuilding](../../docs/compiler-rebuilding.md#execution-interface-selection). diff --git a/test/io-v2/artifact-service.tri b/test/io-v2/artifact-service.tri @@ -1,63 +0,0 @@ -!import base as B -!import io_v2 as IO -!import handlers as H -!import handler_artifacts as A -!export prepare -!export publish -!export load -!export rawLoad -!export rawPublish -!export routed -!export captured -!export forwarded -!export rejectedView -!import arboricx_handlers as Registry -!import handler_log as Log -!export route -!export routeFailedLog -!export capturedPackage -!export routeStdout -!export routeCancelledLog -!import io_v2_artifacts as Stream -!export streamPublish - -prepare root = IO.io (H.runIOV2 (A.prepareFilesystemV2 root)) -publish root limit bytes = IO.io (H.runIOV2 (A.withFilesystemV2 root limit (A.publish bytes))) -load root limit digest = IO.io (H.runIOV2 (A.withFilesystemV2 root limit (A.load digest))) -rawLoad root limit digest = IO.io (H.runIOV2 (A.withFilesystemV2 root limit (H.request "artifacts" "load" digest))) -rawPublish root limit bytes = IO.io (H.runIOV2 (A.withFilesystemV2 root limit (H.request "artifacts" "publish" bytes))) -routed outer inner limit bytes = IO.io (H.runIOV2 - (A.withFilesystemV2 outer limit (A.withFilesystemV2 inner limit (A.publish bytes)))) -captured outer inner limit bytes = IO.io (H.runIOV2 - (A.withFilesystemForV2 "captured" outer limit - (A.withFilesystemV2 inner limit (A.publishTo "captured" bytes)))) -forwarded root = IO.io (H.runIOV2 - (H.handle "artifacts" (operation payload : H.pure [operation payload]) - (A.withFilesystemV2 root 0 (H.request "artifacts" "other" 42)))) - -rejectedView root limit bytes = H.view (A.withFilesystemV2 root limit (H.request "artifacts" "publish" bytes)) - -silent _ = H.pure (B.ok t t) -failedLog _ = H.pure (B.err "private logger diagnostic" t) -route root limit method target body = IO.io (Registry.routerV2 root limit silent method target [] body) -routeFailedLog root limit body = IO.io (Registry.routerV2 root limit failedLog "POST" "/_arboricx/bundle" [] body) -capturedPackage root other limit target = IO.io (H.runIOV2 - (Registry.request (Registry.packageLookupV2 root limit) "GET" target [] [] - |> A.withFilesystemV2 other limit - |> Log.withSink silent)) -cancelledLog _ = H.liftIO (IO.cancelled "logger stopped") -routeStdout root limit = IO.io (Registry.routerV2 root limit Registry.stdoutLogV2 "GET" "/_arboricx/health" [] []) -routeCancelledLog root limit body = IO.io (IO.scope (_ : do IO.require - spawned <- IO.spawn 1 1 [] (_ : Registry.routerV2 root limit cancelledLog "POST" "/_arboricx/bundle" [] body) - case spawned of - [task ticket] = IO.require (IO.join ticket) IO.pure - _ = IO.fail "Invalid test spawn reply")) -pump reader = H.bind (H.liftIO (IO.readSome reader 4096)) (reply : case reply of - ((t t), (("eof", t), t)) = H.pure (B.ok t t) - ((t t), (("data", bytes), t)) = H.bind (Stream.write bytes) (written : case written of - ((t t), (t, t)) = pump reader - _ = H.pure written) - (t, (_, t)) = H.pure reply - _ = H.fail "invalid-response" "reader" "read" "Invalid read reply") -streamPublish root source expected = IO.io (IO.scope (owner : IO.require - (IO.openReader owner source) (reader : H.runIOV2 (A.publishStreamV2 root expected (pump reader))))) diff --git a/test/io-v2/browser.tri b/test/io-v2/browser.tri @@ -1,64 +0,0 @@ -!import io_v2 as IO -!import io_v2_text as Text -!import handlers as H -!export direct -!export handled -!export roundTrip -!export cancellation -!export main -!export binary -!export invalidUTF8 -!export invalidScalar -!export publicHandled -!export publicRoundTrip -!export publicCancellation -!export publicBinary -!export returnValue - --- Bare computations exercise the private bridge; explicit envelopes exercise --- the public RuntimeSession and worker/terminal routing. -direct = do IO.require - writer <- IO.stdout - Text.writeText writer "λ😀é\r\n" - IO.pure 42 -handled = H.runIOV2 (H.liftIO direct) -roundTrip = do IO.require - reader <- IO.stdin - writer <- IO.stdout - chars <- Text.readText reader 4096 - Text.writeText writer chars - IO.pure 42 -cancellation = IO.scope (owner : do IO.require - reader <- IO.stdin - writer <- IO.stdout - spawned <- IO.spawn 1 1 [] (_ : do IO.bind - IO.readSome reader 8 - IO.fail "abandoned continuation resumed") - case spawned of - [task ticket] = do IO.require - Text.writeText writer "ready" - IO.cancel task "stop reader" - outcome <- IO.join ticket - case outcome of - ["cancelled" _ _] = IO.pure 42 - _ = IO.fail "wrong child outcome" - _ = IO.fail "invalid spawn result") -main = IO.io direct - -invalidUTF8 = IO.bind (IO.decodeUTF8 0 [237 160]) (reply : case reply of - (t, (["invalid-data" "utf8/decode" "invalid UTF-8 sequence at byte 0" "none"], t)) = IO.pure 42 - _ = IO.fail "invalid UTF8 prefix was accepted or misclassified") -invalidScalar = IO.bind (IO.encodeUTF8 [55296]) (reply : case reply of - (t, (["invalid-argument" "utf8/encode" "" "none"], t)) = IO.pure 42 - _ = IO.fail "surrogate was accepted or misclassified") - -binary = do IO.require - writer <- IO.stdout - IO.writeAll writer [92 120 102 102 255 240 159] - IO.pure 42 - -publicHandled = IO.io handled -publicRoundTrip = IO.io roundTrip -publicCancellation = IO.io cancellation -publicBinary = IO.io binary -returnValue value = IO.io (IO.pure value) diff --git a/test/io-v2/core-controls.tri b/test/io-v2/core-controls.tri @@ -1,58 +0,0 @@ -!import base as B -!import io_v2 as IO -!export cancelChild -!export currentScopes -!export badFlags -!export badTransfer -!export discardedTicket -!export selfCycle -!export selfCycleWrong -!export pureDomainError -!export rootFailure -!export rootCancelled -!export malformedBind -!export unknownWithPoison -!export legacyEnvelope - -spin seed = IO.bind (IO.pure seed) (_ : spin seed) -diverge seed = diverge seed -joinSpawn reply = case reply of - ((t t), ([task ticket], t)) = IO.require (IO.join ticket) IO.pure - _ = IO.fail "invalid spawn reply" -cancelChild = IO.io <| IO.require (IO.spawn 1 1 [] (_ : spin t)) (spawned : case spawned of - [task ticket] = do IO.bind - _ <- IO.require (IO.cancel task "stop") IO.pure - IO.require (IO.join ticket) IO.pure - _ = IO.fail "invalid spawn reply") -currentScopes = IO.io <| do IO.bind - before <- IO.currentScope - inside <- IO.scope (_ : IO.currentScope) - after <- IO.currentScope - case after of - ^before = case inside of - ^before = IO.pure 0 - _ = IO.pure 1 - _ = IO.pure 0 -badFlags seed = IO.io (IO.spawn 2 1 [] (diverge seed)) -badTransfer seed = IO.io (IO.spawn 1 1 [t] (diverge seed)) -discardedTicket = IO.io <| IO.require (IO.spawn 1 1 [] (_ : IO.pure 5)) (spawned : case spawned of - [task ticket] = IO.bind (IO.discard ticket) (_ : IO.join ticket) - _ = IO.fail "invalid spawn reply") -selfCheck expected = IO.bind IO.currentScope (scope : case scope of - [_ identity key] = IO.scope (_ : IO.bind (IO.join ["io.v2.join" identity key]) (reply : case reply of - (t, ([category _ _ _], t)) = case category of - ^expected = IO.pure t - _ = IO.fail "wrong cycle category" - _ = IO.fail "self join succeeded")) - _ = IO.fail "invalid scope") --- Parent never claims this ticket. Normal-exit joining must wait for the child's --- assertion; the wrong-expectation control must fail, not merely lose its result. -selfRun expected = IO.io <| IO.bind (IO.spawn 0 1 [] (_ : selfCheck expected)) (_ : IO.pure 42) -selfCycle = selfRun "join-cycle" -selfCycleWrong = selfRun "not-the-category" -pureDomainError = IO.io (IO.pure (B.err "ordinary" t)) -rootFailure seed = IO.io (IO.fail (diverge seed)) -rootCancelled seed = IO.io (IO.cancelled (diverge seed)) -malformedBind = IO.io (B.pair 1 t) -unknownWithPoison seed = IO.io (IO.request "future" 3 "call" (diverge seed)) -legacyEnvelope seed = B.pair "tricuIO" (B.pair 1 (diverge seed)) diff --git a/test/io-v2/core-retention.tri b/test/io-v2/core-retention.tri @@ -1,26 +0,0 @@ -!import base as B -!import native_math as N -!import io_v2 as IO -!export joined -!export discarded - --- Each child creates a different, forced 4096-cell payload. A constant or --- unevaluated payload would conceal an archive of completed task results. -make 0 _ = [] -make count value = B.pair value (make (N.predecessor count) value) -count [] acc = acc -count [_ | rest] acc = count rest (N.successor acc) -count _ _ = 0 -checked payload = case N.equal (count payload 0) 4096 of - (t t) = IO.pure payload - _ = IO.fail "incomplete fresh payload" -step mode seed = IO.scope (_ : IO.require - (IO.spawn 0 1 [] (_ : checked (make 4096 seed))) (spawned : case spawned of - [task ticket] = case mode of - 0 = IO.require (IO.join ticket) (_ : IO.pure t) - _ = IO.require (IO.discard ticket) (_ : IO.pure t) - _ = IO.fail "invalid spawn reply")) -loop _ 0 = IO.pure t -loop mode left = IO.bind (step mode left) (_ : loop mode (N.predecessor left)) -joined iterations = IO.io (loop 0 iterations) -discarded iterations = IO.io (loop 1 iterations) diff --git a/test/io-v2/indexed-transport.tri b/test/io-v2/indexed-transport.tri @@ -1,14 +0,0 @@ -!import io_v2 as IO -!export verifyTail -!export wrongKind - -verifyTail path limit tree = IO.io <| IO.scope (owner : do IO.onOk - reader <- IO.openReader owner path - IO.verifyIndexedTree reader limit tree - IO.readSome reader 1) - --- A bad resource kind is rejected before demanding the value to encode. -diverge seed = diverge seed -wrongKind seed = IO.io <| IO.scope (owner : do IO.onOk - digest <- IO.openDigest owner "sha256" - IO.verifyIndexedTree digest 65536 (diverge seed)) diff --git a/test/io-v2/protocol.tri b/test/io-v2/protocol.tri @@ -1,24 +0,0 @@ -!import base as B -!import io_v2 as IO -!import handlers as H -!export capabilities -!export handledCapabilities -!export inspectWriter -!export inspectLazyBind -!export inspectLazyScope -!export inspectFuture -!export unsupported -!export invalidHeader -!export retired - -capabilities = IO.io IO.capabilities -handledCapabilities = IO.io <| H.runIOV2 (H.liftIO IO.capabilities) -inspectWriter path = IO.io <| IO.bind IO.currentScope - (owner : IO.inspect (IO.openWriter owner path)) -diverge x = diverge x -inspectLazyBind seed = IO.io <| IO.inspect (B.pair 1 (diverge seed)) -inspectLazyScope seed = IO.io <| IO.inspect (IO.scope (diverge seed)) -inspectFuture seed = IO.io <| IO.inspect (IO.request "future-tcp" 1 "connect" (diverge seed)) -unsupported seed = IO.io <| IO.request "future-tcp" 1 "connect" (diverge seed) -invalidHeader = IO.io <| IO.request "file" 0 "open-reader" t -retired = IO.io (B.pair 21 t) diff --git a/test/io-v2/registry-log-failure.tri b/test/io-v2/registry-log-failure.tri @@ -1,10 +0,0 @@ -!import base as B -!import io_v2 as IO -!import handlers as H -!import arboricx_streaming_v2 as Server -!export main - --- Exercise the production request path with an ordinary failed logging sink. -failedLog _ = H.pure (B.err "private logger failure" t) -main = IO.io (Server.serve "./store" 1048576 failedLog - (IO.ipv4 [0 0 0 0] 8080) 16 (_ : IO.pure t)) diff --git a/test/io-v2/tasks.tri b/test/io-v2/tasks.tri @@ -1,129 +0,0 @@ -!import http as Http -!import native_math as N -!import base as B -!import io_v2 as IO -!import handlers as H -!export churnJoined -!export churnDiscarded -!export acknowledgedWrites -!export partialWrites -!export failedPartialWrite -!export joined -!export consumed -!export supervised -!export cancelled -!export ordinary -!export inherited -!export changed -!export callback -!export labelledFailure -!export labelledStateBoundary -!export lazyLabels -!export stateBoundary - -joinSpawn reply = case reply of - ((t t), ([task ticket], t)) = IO.require (IO.join ticket) IO.pure - _ = IO.fail "Invalid spawn reply" -runChild action = IO.io <| IO.bind (IO.spawn 1 1 [] (_ : action)) joinSpawn -joined = runChild (IO.pure 42) -supervised = runChild (IO.fail "fault") -cancelled = runChild (IO.cancelled "shutdown") -ordinary = runChild (IO.pure (B.err "domain" t)) -consumed = IO.io <| IO.require (IO.spawn 1 1 [] (_ : IO.pure 42)) (spawned : case spawned of - [task ticket] = do IO.bind - first <- IO.join ticket - second <- IO.join ticket - IO.pure [first second] - _ = IO.fail "Invalid spawn reply") - -value n _ _ = H.pure n -child action = H.bind (H.spawnV2 1 1 [] (_ : action)) (reply : H.liftIO (joinSpawn reply)) -readValue = H.request "value" "get" t -inherited = IO.io <| H.runIOV2 (H.handle "value" (value 7) (H.scopeV2 (owner : do H.bind - answer <- child readValue - parent <- readValue - H.pure [answer parent]))) -changed = IO.io <| H.runIOV2 (H.handle "value" (value 7) (do H.bind - answer <- child (H.handle "value" (value 9) readValue) - parent <- readValue - H.pure [answer parent])) -spawner _ _ = child readValue -callback = IO.io <| H.runIOV2 (H.handle "value" (value 7) - (H.handle "spawn" spawner (H.handle "value" (value 9) (do H.bind - answer <- H.request "spawn" "go" t - parent <- readValue - H.pure [answer parent])))) -diverge x = diverge x -lazyLabels seed = IO.io <| H.runIOV2 (H.label (diverge seed) (child (H.pure 7))) -stateBoundary = runChild (H.runIOV2 (H.collect "log" "write" B.anyC t (child (H.pure 7)))) - --- Each child obtains a fresh host chunk. Constant/shared payloads would conceal --- a completion archive in an RSS-only test. Scope exit settles discarded work. -churnStep mode reader = IO.scope (_ : IO.require - (IO.spawn 0 1 [] (_ : IO.require (IO.readSome reader 4096) checkedChunk)) - (spawned : case spawned of - [task ticket] = case mode of - 0 = IO.require (IO.join ticket) (_ : IO.pure t) - _ = IO.require (IO.discard ticket) (_ : IO.pure t) - _ = IO.fail "Invalid spawn reply")) -churnLoop 0 _ _ = IO.pure t -churnLoop count mode reader = IO.bind (churnStep mode reader) - (_ : churnLoop (N.predecessor count) mode reader) -churn mode count path = IO.io <| IO.scope (owner : - IO.require (IO.openReader owner path) (reader : churnLoop count mode reader)) -churnJoined count path = churn 0 count path -churnDiscarded count path = churn 1 count path - -checkedChunk ("data", bytes) = case Http.httpBodyLength bytes of - 4096 = IO.pure bytes - _ = IO.fail "Incomplete retention input" -checkedChunk _ = IO.fail "Unexpected retention EOF" - -labelledFailure = IO.io <| H.runIOV2 (H.label "outer" - (child (H.label "inner" (H.fail "problem" "service" "op" "detail")))) -labelledStateBoundary = runChild (H.runIOV2 (H.label "outer" - (H.collect "log" "write" B.anyC t (H.label "inner" (child (H.pure 7)))))) - --- Test-only interpreter for the actual library convenience, not a reimplementation --- of its chunk dropping. Each successful mock accepts at most two bytes; fuel --- exhaustion fails before any further acceptance. Python specifies full traces. -mockWrites fuel action frames accepted = let - resume value = case frames of - [] = [value accepted] - [next | rest] = mockWrites fuel (next value) rest accepted - _ = ["invalid frames"] - in case action of - (0, value) = resume value - (1, (first, next)) = mockWrites fuel first (B.pair next frames) accepted - (10, ("stream", (1, ("write", (writer, bytes))))) = case fuel of - 0 = resume (B.err "write fault" t) - _ = case bytes of - [a b | rest] = mockWrites (N.predecessor fuel) (IO.pure (B.ok 2 t)) frames - (B.append accepted [a b]) - [a] = mockWrites (N.predecessor fuel) (IO.pure (B.ok 1 t)) frames - (B.append accepted [a]) - _ = ["invalid chunk"] - _ = ["invalid action"] -partialWrites bytes = IO.io <| IO.pure (mockWrites 32 (IO.writeAll t bytes) [] []) -failedPartialWrite bytes = IO.io <| IO.pure (mockWrites 1 (IO.writeAll t bytes) [] []) - --- Caller-specified positive acknowledgements exercise full and partial chunks. --- Record actual submitted suffixes; Python checks them independently. Exhausted --- acknowledgements inject an uncertain operation failure before further progress. -mockAcknowledgements counts action frames requests = let - resume value = case frames of - [] = [value (B.reverse requests)] - [next | rest] = mockAcknowledgements counts (next value) rest requests - _ = ["invalid frames"] - in case action of - (0, value) = resume value - (1, (first, next)) = mockAcknowledgements counts first (B.pair next frames) requests - (10, ("stream", (1, ("write", (writer, bytes))))) = case counts of - [count | rest] = mockAcknowledgements rest (IO.pure (B.ok count t)) frames - (B.pair bytes requests) - [] = mockAcknowledgements [] (IO.pure (B.err "write fault" t)) frames - (B.pair bytes requests) - _ = ["invalid acknowledgements"] - _ = ["invalid action"] -acknowledgedWrites counts bytes = - IO.io <| IO.pure (mockAcknowledgements counts (IO.writeAll t bytes) [] []) diff --git a/test/io-v2/utf8-boundaries.tri b/test/io-v2/utf8-boundaries.tri @@ -1,17 +0,0 @@ -!import base as B -!import native_math as N -!import io_v2 as IO -!export excessEncode -!export excessDecode -!export excessWrite -!export excessDigest - -zeros 0 tail = tail -zeros n tail = B.pair 0 (zeros (N.predecessor n) tail) -diverge seed = diverge seed -excessEncode seed = IO.io (IO.encodeUTF8 (zeros 1024 (B.pair (diverge seed) (diverge seed)))) -excessDecode seed = IO.io (IO.decodeUTF8 0 (zeros 4096 (B.pair (diverge seed) (diverge seed)))) -excessWrite path seed = IO.io <| IO.scope (owner : IO.require (IO.openWriter owner path) (writer : - IO.writeSome writer (zeros 4096 (B.pair (diverge seed) (diverge seed))))) -excessDigest seed = IO.io <| IO.scope (owner : IO.require (IO.openDigest owner "sha256") (digest : - IO.updateDigest digest (zeros 4096 (B.pair (diverge seed) (diverge seed))))) diff --git a/test/io/README.md b/test/io/README.md @@ -0,0 +1,1475 @@ +# IO: scoped tasks, streams, files and publication + +Current hosts execute canonical IO envelopes. Native execution uses OS process +authority; retired grant arguments and unsupported envelope versions fail before +effects. Haskell provides filesystem, TCP/resolver, process/context and clock +facilities. The Haskell browser host provides +structured core/task execution and terminal streams. Capability queries report +the selected backend's facilities. TLS, UDP and hard deadlines require additional +facilities. Historical v1 execution requires a retained runtime. + +The operation layout is a research protocol subject to revision. + +## Reproduce + +```sh +nix flake check +nix build .# +PYTHONDONTWRITEBYTECODE=1 python3 test/io_protocol.py result/bin/tricu \ + --directory /tmp/new-v2-protocol-check +PYTHONDONTWRITEBYTECODE=1 python3 test/interaction_inspection.py result/bin/tricu +PYTHONDONTWRITEBYTECODE=1 python3 test/io_streams.py result/bin/tricu \ + --directory /tmp/new-v2-stream-check +PYTHONDONTWRITEBYTECODE=1 python3 test/io_tcp.py result/bin/tricu \ + --directory /tmp/new-v2-tcp-check +PYTHONDONTWRITEBYTECODE=1 python3 test/io_directory.py result/bin/tricu \ + --directory /tmp/new-v2-directory-check +PYTHONDONTWRITEBYTECODE=1 python3 test/io_positioned.py result/bin/tricu \ + --directory /tmp/new-v2-positioned-check +PYTHONDONTWRITEBYTECODE=1 python3 test/io_files.py result/bin/tricu \ + --directory /tmp/new-v2-file-check +PYTHONDONTWRITEBYTECODE=1 python3 test/io_tasks.py result/bin/tricu \ + --directory /tmp/new-v2-task-check +PYTHONDONTWRITEBYTECODE=1 python3 test/io_publication.py result/bin/tricu \ + --directory /tmp/new-v2-publication-check +``` + +Use new output directories. The scripts compile current source through the +packaged v2 compiler, execute under OS process authority, and record source hashes, +commands, full results and measurements. File checks cover complete binary output +through 1 MiB, direct and handled borrowing, ownership transfer, implicit joining, +acquisition failures, and cancellation after an acknowledged chunk. Copying uses +**direct truncating output**. Inputs and outputs must be +distinct even through hard links/symlinks; interruption can leave partial output. + +Task source oracles independently check the three outcomes, consumed tickets, +ordinary domain errors, partial-write progress/failure traces for the actual +`IO.writeAll` helper (including full 4096-byte acknowledgements, partial suffixes, +and failures after confirmed progress), child handler inheritance, contextual overrides, callback +routing, lazy label payloads, and explicit state-boundary rejection. Retention +workloads run 16/256/1024 children over fresh 4096-byte chunks in one process. They +force each chunk's list spine before returning it, then join or discard the result; +using only constant or unforced payloads would conceal important retention bugs. + +The file/task/publication scripts enforce workload-specific 128-MiB sampled RSS and 16-MiB GC maximum +live-heap guards. Allocation volume is recorded separately. Shared runner reports now also include +`rts_process`: whole-process allocated/copied bytes, MUT and GC CPU/elapsed time, +and CPU/elapsed productivity from GHC's `-s` output. Missing measurements are +absent, not invented zeros. These include loading, rendering and profiling's +forced collections; they are **not** the separately measured invocation deltas. +MUT includes allocation, interpreter and native work, not just useful reductions. These are sequential +fresh-process observations, not concurrent capacity guarantees or hard runtime +resource bounds. RSS polling can miss short peaks; RTS residency is GC-sampled. + +`IOTests.hs` exercises the resource registry and driver independently of the +compiler. `IOTaskTests.hs` adds deterministic blocking/failure controls, lease +cancellation, fail-fast supervision, lexical cleanup, completion races, join +consumption/cycles, transfer publication, and registry reclamation. Fake backends +supply controls without production fault-injection flags. + +## Binary standard streams and reusable copying + +`IO.stdin`, `IO.stdout` and `IO.stderr` return ordinary Results containing borrowed +stream references. `IO.copyStream reader writer` composes bounded `readSome` and +`writeAll`, stopping at explicit EOF or the first error. It acquires/closes neither +endpoint and adds no host copy primitive. It expects the native read/progress +protocol; it does not validate arbitrary replacement interpreters. Error values +preserve `writeAll`'s confirmed prefix and original uncertain progress; copying is +not a transaction, a whole-message atomic write, or a whole-file retry operation. +These APIs use octet lists, **not arbitrary Unicode strings**. + +Each native run starts with exactly three inherited root entries. Repeated lookup +returns the same reference/lease for that slot, without adding registry history. +Descendants borrow them, including a first lookup while their parent is joining; +child scope exit does not revoke them. Explicit close returns `borrowed-resource`. +Root-owned handoff also returns `borrowed-resource`; descendant attempts can fail +`not-owner` first. No transfer occurs on rejection. Root settlement drains leases +and removes the references but **never closes, flushes or changes the mode of the +process handles**. Run-local references become stale afterwards. + +Each successful output operation flushes the host handle before acknowledging its +byte count. That is not durability, peer consumption or transactionality. Broken +pipes are ordinary `io-error` operation failures with `unknown` progress. A failed +or cancelled inherited write poisons that slot for the rest of the run: subsequent +writes return `invalid-state` with unknown progress rather than implicitly retrying +bytes left in a failed host buffer. The host still owns that buffer and may flush +it independently, including during process shutdown. Embedders must perform their +own recovery before reuse in a new run; teardown does not discard/rollback bytes. + +Lease exclusion is **per standard slot per run**. It does not coordinate separate +runs, arbitrary host IO, or stdout/stderr redirected to aliases of the same OS +stream. Avoid unsynchronized mixing. Terminal modes/line discipline are unchanged; +this is not raw terminal IO or a text/line codec. Existing cancellation and foreign +call limits still apply. No deadlines or platform-wide descriptor isolation exist. + +`demos/io/streams.tri` provides direct, handled and attached-child pipelines. +Compile/import it as usual, then run its stored export with **`--io --no-result`** +to reserve stdout for program bytes (no `--repeat` framing): + +```sh +printf 'hello\n' | result/bin/tricu arboricx run --module "$APP_MANIFEST" \ + --store "$WORK/runtime" --name main --io --no-result > "$WORK/output" +``` + +The stream harness compares entire binary outputs and stderr, exercises actual +pipes plus file redirects, empty/boundary inputs through 1 MiB, broken pipes, +close/handoff rejection, handlers and attached children. It enforces 128-MiB RSS +and 16-MiB GC residency guards. These runs have **no final rendering or forced +profile collections**: their RTS totals are whole-process observations, not +matched comparisons with the publication/profile workloads. Native tests also +exercise shared blocked-read leases, read/write cancellation, poisoned writers, +host buffering preservation and non-closing settlement. All seven inherited-stream +native cases are included in the Nix suite. + +## Positioned files, updates and append + +`IO.openReader` opens a readable file; `IO.openWriter` creates/truncates a writable +file. `IO.openUpdate` opens a readable/writable file at position zero without +truncating an existing file, creating it if absent. `IO.openAppender` creates if +absent and writes using the host's append mode. None provides exclusive creation, +atomic replacement, snapshot isolation or publication durability. + +`IO.seekFile file offset` uses an **absolute byte offset**; `IO.filePosition file` +queries the logical cursor, including read-ahead adjustment. `IO.fileSize file` +queries the opened file, not its potentially replaced pathname. `IO.resizeFile +file size` may shrink or grow a writable non-append file, preserving the logical +cursor even beyond the new EOF. Regular-file growth reads as zero-filled space; +seeking beyond EOF alone does not extend the file. These operations use canonical +naturals through **9223372036854775807** (reported as `file-offset-max`). Smaller +filesystem/backend constraints can still fail. Malformed/out-of-range wire +arguments are rejected before that operation's backend IO; negative or excessive +host query results cannot escape as invalid naturals. + +All byte and position/size operations on a file share **one lease**. They reject +concurrent use of the same registry entry with `busy`; handoff preserves the cursor +and invalidates old aliases. A `seek` followed by a `read` is **not an atomic +compound operation**. Coordinate borrowers, transfer ownership, or use independent +read handles; no positioned `read-at`/`write-at` operation is promised here. +Separate OS handles/processes can change file contents or size independently. +Queries are point-in-time observations, not a race-free verification policy. + +Append handles have no seek, position or resize method: those calls return +`unsupported`. Reads on writers/appenders return `wrong-kind`; resize on a reader +returns `unsupported`. Standard streams and stages are not positioned-file handles, +even if their underlying storage happens to be a file. Non-seekable/special files +can report `unsupported` or backend IO errors. Haskell's process-local Handle +locking may reject conflicting file opens that POSIX alone would permit; this +restriction is advertised. Do not infer filesystem confinement or external-writer +serialization from that host behavior. + +Append placement is at the backend's current EOF on writes, not an open-time seek. +This is not a whole-record atomicity guarantee: partial writes, interleaving with +other writers and failure remain possible. IO exceptions during seek/resize report +`unknown` progress/state, not a claim that nothing changed. There is no automatic +retry, rollback, restoration after an operation failure, or new durability promise. +Ordinary Results and scope cleanup retain the existing rules. + +`demos/io/positioned.tri` composes bounded patching, resizing, prefix inspection +and append. It is deliberately **in-place editing**, not a safe publication +replacement. The focused harness compares complete outputs/results, including +Unicode paths, empty files, shrinking/growth, updates/appends against 1-MiB files, +mode errors and malformed offsets. A wide-offset control writes a sparse byte +above 4 GiB and shrinks before comparing its entire final output; it does not +materialize a multi-GiB source value or assert multi-GiB streaming performance. +Native tests cover cursor mode switches, read-ahead invalidation, current-inode +size, external-fd growth before append, bounds, cancellation/lease exclusion, +transfer, host locking and uncertain mutation errors. + +## Paths and directory cursors + +`IO.statPath` follows symlinks; `IO.lstatPath` observes a final link itself. Both +use normal host resolution of intermediate components and trailing slashes. They +return a regular-file size only for a regular file; other tags deliberately do +not invent a zero byte length. Missing paths and permission errors remain ordinary +errors, not a false existence Boolean. These observations do not pin later opens +or constitute race-free authorization, immutable verification or confinement. + +`IO.createDirectory` creates one directory with host mode 0777 subject to umask; +it fails with `already-exists` rather than silently accepting another object. +`IO.ensureDirectory` is a **source** convenience: attempt creation, and on +`already-exists` use stat to check for a directory. Existing symlinks to directories +are accepted. It is nonrecursive; caller-ordered initialization fits store/cache +layouts. The check is only a point-in-time observation. Direct files retain their +host creation modes/umask, not the private-stage permission contract. + +`IO.removeFile` unlinks a file/link, `IO.removeDirectory` removes an empty directory, +and `IO.renamePath` performs the host rename/replacement operation. No recursive +removal, cross-filesystem copy fallback, no-replace rename, sync, rollback, or +transaction is supplied. Path mutations persist after scope exit: scopes own +handles, not arbitrary pathname changes. Mutation IO errors conservatively report +`unknown` progress/state. Failed close IO now also reports `unknown` (correcting +its former `none` report); this does not authorize retrying raw release operations. + +`IO.openDirectory owner path` creates a scoped cursor over the opened directory. +`IO.nextDirectory` yields **one** name at a time, skipping `.` and `..`, with an +explicit, sticky EOF. It never sorts/materializes a complete listing. Host internal +buffering is not specified. Enumeration order and concurrent directory mutation +are not snapshot guarantees. Renaming the directory does not retarget the cursor, +but later path operations built from its original pathname can target another +object; no dirfd-relative child operations are provided here. + +The cursor uses one exclusive lease and the ordinary lifetime/transfer/close +rules. Failed or cancelled advancement poisons it: subsequent next calls return +`invalid-state`; close or explicitly reopen instead of implicitly replaying an +uncertain advance. EOF does not implicitly close the cursor. Closing attempts +`closedir` once, never retrying a possibly freed native directory pointer. + +Paths use canonical Unicode-scalar text with no NUL, mapped through the host's +filesystem encoding. Directory names must be Unicode-scalar single components, +up to **4096 scalars** (advertised as `directory-name-scalars`). Undecodable names +are not silently replaced or skipped: they fail and poison enumeration. This is +not an arbitrary raw-byte POSIX pathname interface. Empty path arguments are +rejected by these path/directory operations; use `.` for the current directory. +Native capability restrictions expose host encoding, unsorted/non-snapshot +iteration and cursor poisoning. No Unicode normalization is performed. + +`demos/io/directory.tri` provides caller-ordered initialization and an inventory +that intentionally accumulates entries, alongside a count that discards names and +forces its small numeric accumulator before each request. The latter avoids +retaining a lazy arithmetic chain; bounded native enumeration alone would not +make arbitrary source accumulation bounded. The focused test compares complete +inventories without assuming order, checks symlink/dangling-link and mutation +behavior, rejects a real invalid-encoding name, and consumes up to 16,384 entries +under RSS/live-heap guards. Native controls exercise ownership transfer, renamed +open directories, cancellation/busy exclusion, EOF, invalid names, poisoning and +once-only close, including uncertain/async release failures. + +## TCP listeners and duplex streams + +Endpoints are canonical tagged lists: + +- `["ipv4" [a b c d] port]`: four octets and a 16-bit port. +- `["ipv6" [a b c d e f g h] scope port]`: eight 16-bit words, a 32-bit + interface scope ID and a 16-bit port. Flow information is zero. + +`IO.ipv4` / `IO.ipv6` construct these values; they do not resolve names. +`IO.listenTCP owner endpoint backlog` acquires an already bound/listening socket. +Backlog must be 1–4096; OS clamping/queue policy still applies. Port zero requests +an ephemeral listening port; query `IO.localAddress` to learn it. IPv6 listeners +are explicitly IPv6-only, not silently dual-stack. Reuse-address is enabled; +there is no reuse-port, ownership or exclusive-bind security guarantee. +`IO.connectTCP owner endpoint` connects exactly that numeric address; zero peer +port is rejected. There is no hidden DNS, address fallback or reconnect policy. +`tcp-address-families` reports implemented encodings, not OS/network availability. + +`IO.acceptTCP listener owner` leases the listener and reserves acquisition in the +selected live ancestor/current scope before accepting. It publishes the connection +before acknowledging success; cancellation cannot leave an unregistered accepted +socket. The caller can use the existing atomic spawn-with-transfer operation to +move it to a worker, with fresh handles and stale old aliases. Listener acceptance +is exclusive; multiple pending accepts return `busy`. Acquiring into an explicitly +selected ancestor retains that ancestor's lifetime, just as file factories do. + +A connected stream permits **one read and one write concurrently**. Another read +or another write returns `busy`. Read EOF is sticky and does not close the write +direction. Write acknowledgements are positive accepted prefixes, not complete +messages, delivery, peer consumption or transaction boundaries. `IO.writeAll` +retains its ordinary partial/uncertain-progress rules. Source multi-call writes +are not whole-message atomic. TCP has no datagram/message framing here. + +`IO.shutdownWrite` leases the write direction, sends the local write half-close +and preserves reading; successful repeats are harmless. Writes afterwards fail +with `invalid-state` and no progress. Failed/cancelled reads or writes poison only +the affected direction; later use reports `invalid-state`/unknown progress rather +than replaying ambiguous work. Failed shutdown poisons writing too. Socket IO +errors include stable categories such as `connection-refused`, `connection-reset`, +`address-in-use`, `broken-pipe` and `timed-out` when the host supplies those errno +values; otherwise the ordinary generic categories remain. An OS timeout error is +not a new language deadline facility. + +Address queries currently lease the whole resource. Transfer and close require +**both** directions idle. Close prevents new leases and waits for pending ones; +it does not close/reuse the descriptor beneath a blocked read/accept to wake it. +Cancel the owning IO task to interrupt supported pending work, then settle/close. +Scope failure/cancellation does that through the structured-task machinery. +There are no hard deadlines; OS/kernel queued output can still reach a peer after +local acknowledgement/close. Cancellation does not undo network effects. +Files, standard streams and directories retain their existing single-lease rules. + +`demos/io/tcp.tri` composes finite concurrent echo servers, lexical child handler +inheritance, and a file/socket client with simultaneous sending and receiving to +avoid bidirectional backpressure deadlock. The handled server's binary banner is +source policy, not a native operation. Default workers are fail-fast; an explicitly +supervised variant tests containment and discards outcomes intentionally, not as +a production logging recommendation. These are raw TCP demonstrations, not HTTP, +TLS or hardened public services. Registry deployments remain unchanged. + +The focused real-socket suite compares every echoed/output byte through 1 MiB, +fragmented sends, IPv4/IPv6, concurrent clients, repeated handoff, EOF/half-close, +refusal/malformed-address errors, and accepted-peer resets with both fail-fast and +contained workers. Native controls additionally cover directional lease exclusion, +close/transfer while only one direction remains busy, blocked receive/write +cancellation, listener reuse after cancelled accept, scope admission, structured +settlement, endpoint bounds and non-demand of excess components. RSS/live-heap +guards and ordinary RTS observations are regression controls, not throughput or +maximum-concurrency guarantees. + +## Explicit host name resolution + +`IO.resolveTCP host family port limit` uses the host TCP name resolver. Family is +`"ipv4"`, `"ipv6"` or `"any"`; port is 0–65535 and result limit is 1–64. Host input +is 1–253 ASCII letters/digits/dots/hyphens. This admits ordinary host names and +IPv4 literals accepted by the host; it is not strict DNS-label validation, IDNA +conversion, hostname authorization or a numeric-address parser. Use the numeric +endpoint constructors for IPv6 literals. Host search rules, hosts/NSS configuration, +ordering, duplicates and address availability remain host-defined. + +The result is `[endpoints truncated]`, with a canonical natural 0/1 truncation flag. +Only the bounded prefix is published; excess endpoint values are not demanded, +and the returned prefix does not retain the discarded host list. **The host +resolver may internally buffer more results and delay cancellation**. There is +no detached resolver worker, guaranteed prompt interruption, public DNS deadline, +exhaustive-answer claim, implicit connection, deduplication or address preference. +OS resolution failures return ordinary `resolution-failed` with bounded diagnostic +text; cancellation/unexpected faults are not converted to that Result. + +`IO.connectFirstTCP owner endpoints` is an explicit **source** policy that tries +candidates sequentially, stops at the first success and returns the last ordinary +failure unchanged if none succeeds. An empty list returns `not-found`. It does not +race candidates, add deadlines or elevate the selected endpoint into authorization. +The named client demo chooses the resolver's bounded prefix, then uses that policy. +Applications needing other retry, selection or authentication policies should +compose their own. TLS peer identity is not supplied by successful resolution. + +Native controls cover local/numeric host lookup, bounds/non-demand, host ordering, +truncation, ordinary errors and cancellation propagation without requiring public +DNS. The real socket source suite also resolves numeric/localhost names, checks +complete output, and exercises failed-first/successful-second source fallback, +including non-execution of a malformed later candidate and scope-bound lifetime. + +## Authenticated native byte snapshots and transfer + +The Haskell native host implements two revision-1 `stream` operations: + +- `IO.snapshotBytes reader directory limit digest` consumes the reader through + EOF under one read lease and returns `[snapshot size]` inside a Result. `digest` + is exactly 64 lowercase hexadecimal SHA-256 digits over the raw bytes. `limit` + is a canonical natural in `[0,2^63-1]`. The EOF probe can consume one excess byte + on a limit failure. A digest mismatch returns `integrity-failed`. +- `IO.transferBytes reader writer count` transfers exactly `count` bytes from the + current cursor with both directional leases held. Positive short writes are + completed without replaying accepted prefixes. Early EOF is `invalid-data`; + an error can follow partial output, so callers close rather than retrying an + uncertain transfer. A zero count still validates and leases both handles. + +Snapshot preparation registers an anonymous temporary file before consuming +input. Only authenticated snapshots are returned; they are read-only file +resources owned by the current scope, with read/seek/position/size operations. +They may be closed or handed off under the ordinary ownership rules. Their +private writable handles remain native. Transfer reads the verified snapshot, +so changes to the original file after authentication cannot change its output. +Concurrent source mutation is accepted only if the captured bytes match the +expected digest. This is integrity checking, not OS-process confinement. + +Both operations use native chunks of at most 4096 bytes and constant-size hash +state, avoiding tree-encoded bulk byte lists. Temporary payload disk is at most +`limit` per snapshot, plus filesystem overhead. Source readers can still block; +operation cancellation is interruptible, with no hard deadline guarantee. Errors +and cancellation retain scoped cleanup and diagnostic precedence. These APIs +are native-host facilities; other backends report their own capabilities. + +The registry's bundle GET/HEAD path verifies before headers, logs the selected +status, then sends the known length and verified body (HEAD sends headers only). +Missing/corrupt/oversized objects return 404; malformed digests return 400. Logger +failure before headers returns 500. Transfer failure after headers closes the +connection. Whole-value artifact APIs and package metadata keep their existing +resident-value contracts. + +`IOByteStreamTests` covers boundaries through 1 MiB, binary/partial transfers, +read-only snapshots, corruption, size limits, source mutation, cancellation, +cleanup faults, early EOF and disconnected writers. `test/io_http.py` exercises +framing, configured limits and failed logging. The production-runner acceptance +gate additionally runs `test/registry_download.py` on the actual compiler bundle +and bounds both upload/download retention in `test/body_retention.py`. + +## Authenticated indexed-tree reads + +`IO.readIndexedTree reader directory limit size digest` consumes exactly `size` +bytes from the current reader position, under one read lease. `limit` is the +caller's maximum encoded-frame size; both are canonical integers in `[0,2^63-1]` +and size must not exceed limit. Digest is exactly 64 lowercase hexadecimal digits, +using the existing `arboricx.indexed-tree.v1` SHA-256 domain (UTF-8 domain, NUL, +encoded bytes). The reply is an ordinary Result containing the decoded tree. +No tree is evaluated, exposed, or decoded before authentication succeeds. +The caller supplies the expected digest: this checks content integrity, not +publisher identity or source provenance. + +The native transport creates a temporary file in the explicitly supplied directory +and unlinks its name before receiving input. It copies/hashes bounded chunks and +then decodes **that same private snapshot**, not a second pass over the public +source. Changing the original inode cannot change the snapshot. This is not a +point-in-time filesystem snapshot: authentication checks the bytes actually +received. A mutation during copying ordinarily makes the expected digest fail. +The directory must exist; empty directory strings are rejected (use `"."`). + +Temporary payload storage is at most `size` bytes, plus filesystem bookkeeping: +no expanded-tree serialization. Storage is reclaimed before ordinary completion; +on terminal fault/cancellation, its registered resource is released during lexical +settlement, with the existing primary-error/cleanup-diagnostic precedence. The +snapshot is private transport, not a source-visible handle, durable publication, +or recovery archive. Guessed registry keys cannot read, explicitly close, or +transfer private spool entries; active private leases still delay scope release. +This is lifecycle protection, not confinement against OS process authority. No fsync or publication is performed. A tmpfs directory uses +RAM-backed storage; ordinary filesystems may cache writes too. Choose the directory +and frame budget accordingly. There is **no aggregate disk quota across tasks**. + +Framing is explicit: no EOF probe consumes bytes of the following frame. The source +reader is not closed or rewound. Digest mismatch, truncation, or invalid format +may have consumed input; their `unknown` progress is not authorization to retry. +Validation of bounds and digest spelling precedes operation IO. An unauthenticated +malformed frame reports digest mismatch, never a parser error. Snapshot acquisition +errors and resource errors remain ordinary Results; unexpected faults/cancellation +remain terminal. Ordinary primary failures retain bounded cleanup detail if release +also fails. + +Decoded nodes preserve sharing and the existing reader's structural/manifest checks, +including backward references and duplicate-node rejection. Additional metadata +limits are 64 section entries and 65,536 manifest bytes. Noncritical opaque sections +remain supported and are authenticated even though not decoded. These are reader +semantics, not a claim that every accepted encoding equals the canonical writer's +bytes. Offset arithmetic is checked without integer wraparound. Transfer buffers +are bounded, but **the indexed DAG and verification tables are resident and grow +with node count**; the caller's frame budget must allow for that amplification. +There is no hard parser CPU deadline or universal safe maximum DAG size. + +The facility works on readable files, borrowed input and duplex streams. File and +borrowed-input leases stay exclusive; duplex connections permit concurrent writing +but not another reader. Other operations cannot interleave reads between chunks. +The reader alone implies no compiler caller migration or deployment/backend switch; +canonical writing and comparison are described below. + +```sh +python3 test/io_indexed.py result/bin/tricu --directory /tmp/new-indexed-check +``` + +`demos/io/indexed-read.tri` demonstrates source-selected framing and spool policy. +Native `SnapshotTests` cover authentication ordering, immutable-source isolation, +shared DAGs, malformed offsets/nodes, metadata bounds, cleanup faults, cancellation, +lease exclusion and wire non-demand. The independent source suite compares complete +results and framing through 64-MiB encoded inputs. Its large inputs grow an opaque +section while keeping the decoded DAG tiny: they test bounded transport, **not** +large-DAG capacity. RTS reports and sampled RSS are included; very short runs may +exit before a representative RSS sample. The 64-MiB transfer has 48-MiB sampled-RSS +and 16-MiB maximum-live regression guards, not service guarantees. + +## Canonical indexed writing and exact comparison + +`IO.hashIndexedTree limit tree` returns Result `[size digest]` for the canonical, +name-neutral indexed encoding. `IO.writeIndexedTree writer limit digest tree` +prepares the same encoding and checks its size and expected digest **before any +writer call**; success returns `[size digest]`. It holds one write lease for the +whole operation and handles positive partial acknowledgements without replaying +acknowledged bytes. Reads may proceed concurrently on duplex connections; files, +stages and borrowed outputs remain exclusive. Cancellation and host faults retain +the existing structured-settlement rules. + +Transport buffers are at most 4096 bytes. The native encoder retains the DAG and +exact interning tables, not an expanded tree or whole encoded ByteString. Hash and +write passes regenerate chunks rather than retaining a shared lazy chunk history. +Bytes and digest domains match the existing canonical indexed writer. The size +budget bounds **encoded output**, not tree evaluation, graph construction memory, +CPU time or an aggregate quota. Preparation can allocate before discovering that +output exceeds the limit; divergent source trees have no hard deadline. + +Preflight failures have `none` writer progress. Once sending starts, IO failures +have `unknown` aggregate progress: no atomicity, rollback, automatic retry, or +resumption promise. A caller opening a truncating writer has already truncated +its file before invoking this operation. Use a stage for publication safety. + +`IO.verifyIndexedTree reader limit tree` compares one canonical frame byte-for-byte +against a known tree and returns `[size digest]` on success. It does not merely +compare hashes, decode untrusted bytes, or probe EOF. Whole-file winner verification +must additionally check EOF; `demos/io/indexed-write.tri` does so. Its publication +composition leaves a corrupt/excess existing winner untouched. An `exists` result +is exact verification at observation time, **not** existing-winner durability or +protection from subsequent external mutation. Compiler publication requires additional synchronization and ordering; the +source-owned object library below supplies that policy without changing this demo +into a durability claim. + +```sh +python3 test/io_indexed_write.py result/bin/tricu --directory /tmp/new-indexed-write +``` + +Native tests cover canonical byte equivalence at varying chunk boundaries, physical +sharing, partial progress, preflight non-effects/non-demand, interrupted leases, +uncertain failures and exact comparison. Independent source checks cover hashes, +complete output bytes, staged publication and corrupt/excess winners, plus a DAG +with 200 shared fork levels and chains through 100,000 nodes. Those graphs remain +indexed during round-trip; their node-table memory is expected to grow. Reported +RSS is sampled and tiny runs may finish before representative samples. + +## Synchronization and source-owned indexed objects + +`IO.syncFile handle` flushes writable file buffering and requests filesystem +synchronization of the **opened inode**. It shares the file's exclusive cursor +lease, preserves its logical position, and supports read/update/write/append file +modes. It is not a standard-stream, stage, socket or directory-cursor operation. +`IO.syncDirectory path` opens and validates a directory, then requests synchronization +of that directory itself (not recursively). Paths follow the existing host/symlink +rules. Nonblocking acquisition avoids hanging on a FIFO substituted for a directory. + +Both operations return ordinary Results. Success means the host synchronization +request succeeded, not a universal crash/power-loss guarantee, peer consumption, +atomicity with another operation, or protection against subsequent mutation. +Filesystem/device behavior can be weaker; some filesystems reject synchronization. +Failures during sync have unknown durability/progress and do not imply rollback. +Transient descriptor resources are registered before use and have exclusive private +leases. Fault/cancellation cleanup follows lexical settlement; failed raw closes are +not retried against potentially reused descriptor numbers. Ordinary sync failures +retain their primary detail and bounded release diagnostics if both fail. + +`io_objects` supplies source-owned `putTree root limit tree` and +`getTree root limit digest` computations. These are library policy, not native +object-store services. The store root must already exist; its creation and parent +namespace durability belong to the caller. Put creates/synchronizes `objects` and +three-character shard directories, uses a scoped stage and no-replace publication, +then verifies **every** winner byte-for-byte through EOF. It synchronizes the +verified open inode and shard directory before returning the digest. Callers must +publish manifests/locks/aliases only after that successful return. + +A mismatch or synchronization failure never deletes/overwrites a winning name and +can occur after a new object became visible. There is no crash pruning or rollback. +Root paths are trusted: this is not symlink confinement or a transaction against +external mutable writers. Get validates lowercase digest spelling before path IO, +gets the opened-file size, and uses the authenticated snapshot reader with the +caller's encoded-size budget. Object domains, paths, canonical bytes and sharding +match the existing compiler store. Returning an authenticated tree does not execute +it or establish publisher identity. + +```sh +python3 test/io_objects.py result/bin/tricu --directory /tmp/new-object-check +``` + +`demos/io/objects.tri` exercises these policies. Independent controls cover +complete object bytes/results, fresh/existing/corrupt/excess winners, malformed +digests, absent/blocked roots, limits, exponentially shared cross-store copying, +and actual concurrent publishers. Native synchronization tests cover opened-inode +identity/cursor behavior, modes, non-directories/FIFOs, leases, cancellation, +primary/cleanup faults and once-only descriptor release. Power-loss durability is +outside these tests. The packaged compiler uses this source-owned v2 storage +policy; full reproduction is covered by the compiler rebuilding guide. + +## Strict UTF-8 and source-text conveniences + +`IO.encodeUTF8 text` accepts at most 1024 canonical Unicode scalars and returns at +most 4096 octets. `IO.decodeUTF8 final bytes` accepts at most 4096 canonical octets +and returns `[text carry]`. Final is canonical 0/1. A nonfinal valid incomplete +suffix is returned as at most three octets; supply it before the next input chunk. +Final input rejects incomplete suffixes. Invalid prefixes fail immediately, even +when incomplete. Overlong encodings, surrogate code points, stray continuation +bytes and values above U+10FFFF are rejected; no replacement decoding occurs. +Empty chunks, NUL, BOM and Unicode noncharacters are valid codec data. No BOM +stripping, newline folding or Unicode normalization occurs. Errors report offsets +within this supplied chunk and return no partial decoded output. + +These bounded codecs are stateless. Carry is ordinary source data, not hidden +per-stream state; callers own sequencing and can use any byte transport. At most +4093 fresh bytes plus a three-byte carry fit a decode request. Bounds and canonical +scalar/octet validation precede conversion; excess elements are not demanded. + +`io_text` composes them into: + +- `readSource reader limit`: whole-source Result `(rawOctets, scalars)`, preserving + exactly the bytes used for decoding and rejecting an initial BOM. The reader is + borrowed, not closed. The byte limit is canonical `[0,2^63-1]`; a one-byte EOF + probe at the boundary may consume one excess byte before rejecting oversize input. + Failure is `["read-source" confirmedInput cause]`; a failing read's own uncertain + progress remains in the unchanged cause. No automatic retry or cursor rewind. +- `encodeText limit scalars`: whole-value encoded octets under a byte budget, without + source-specific BOM rejection. It does not write a resource. +- `writeText writer scalars`: bounded sequential encoding/writing with no aggregate + output quota or ownership transfer. Failure is `["write-text" confirmedBefore + cause]`; writeAll's partial-progress diagnostic is preserved when applicable. + +Read/encode conveniences retain whole values and are **not** constant-memory source +compilation. Budgets bound bytes, not all graph/interpreter memory or CPU. Individual +read/write operations are leased; these source conveniences do not acquire one +whole-text lease, guarantee whole-record atomicity, or make concurrent cursor use +safe. The low-level codecs remain usable in bounded streaming applications instead. + +```sh +python3 test/io_text.py result/bin/tricu --directory /tmp/new-text-check +``` + +Native tests independently exercise all 1,112,064 Unicode scalars against standard +UTF-8 encoding, boundary/invalid vectors, every split of mixed text, canonical +wire bounds and non-demand. Source checks compare complete encoded bytes, raw source +provenance and scalar values across chunk boundaries; retain CR/LF, NUL, combining +characters and interior BOM; reject initial BOM, malformed/incomplete input, excess +budgets and surrogates; preserve an acknowledged prefix on later encoding failure. +Whole-source pipelines through 64 KiB record RTS and sampled RSS observations +for their retained source/decoded values. + +## Internal protocol + +Envelope: `("tricuIO", (2, action))`. Action is a pair of canonical numeric tag and +payload. Comma-separated payload fields below are right-nested pairs, except +where a list is explicitly shown. Pure/bind do not add Result envelopes. + +| Tag | Payload | Reply | +| --- | --- | --- | +| 0 | value | raw value | +| 1 | action, continuation | sequencing | +| 2 | diagnostic | terminal failure | +| 3 | callback | new scope supplied to callback; returns body value after children and release | +| 4 | ignored | current scope reference | +| 5 | supervised, keepResult, handles, callback | Result `[task joinTicket]`; callback receives fresh handle list | +| 6 | join ticket | Result terminal outcome; consumes ticket at claim | +| 7 | join ticket | Result unit; discards the result, not the child | +| 8 | task, reason | Result unit acknowledging cancellation request | +| 9 | reason | cancel this computation, without resuming its continuation | +| 10 | facility name, group revision, operation name, arguments | Result (named external operation) | + +Current core tags are listed above. Facility calls use named requests. Earlier +v2 tags 20–42 reject; historical v1 actions require a retained runtime: + +``` +IO.request group revision operation arguments +-- (10, (group, (revision, (operation, arguments)))) +``` + +Group/operation names are nonempty ASCII identifiers of at most 64 characters: +an initial lowercase letter followed by lowercase letters, digits or hyphens. +Revisions are positive canonical 16-bit naturals. The outer envelope version is +also a canonical 16-bit natural, and its sentinel is exactly canonical `tricuIO`. +Native execution and indexed inspection enforce these envelope checks for both +versions; noncanonical envelope aliases are deliberately rejected. + +Implemented group revisions are all **1**: + +| Group | Operation | Arguments | Reply inside Result | +| --- | --- | --- | --- | +| runtime | capabilities | unit `t` | capability association rows | +| runtime | inspect | action | shallow header description | +| standard | input / output / error | unit `t` | borrowed stdin reader / stdout writer / stderr writer | +| file | open-reader | scope, path | reader | +| file | open-writer | scope, path | writer (create/truncate) | +| file | open-appender | scope, path | append-only writer (create if absent) | +| file | open-update | scope, path | readable/writable file (create if absent, no truncation) | +| file | seek | file, absolute byte offset | unit | +| file | position | file | current logical byte offset | +| file | size | file | observed byte length of the opened file | +| file | resize | file, byte length | unit | +| path | stat | path | `["file" bytes]`, `["directory"]`, `["symlink"]`, or `["other"]` | +| path | lstat | path | same tags, without following final symlink (host path resolution rules apply) | +| path | remove-file | path | unit; unlink, not recursive deletion | +| path | rename | source, destination | unit; host replacement semantics | +| directory | create | path | unit; strict nonrecursive creation | +| directory | remove | path | unit; empty-directory removal | +| directory | open | scope, path | directory cursor | +| directory | next | cursor | pair `("entry", name)` or `("eof", t)` | +| tcp | listen | scope, (endpoint, backlog) | listener | +| tcp | connect | scope, endpoint | duplex stream | +| tcp | accept | listener, scope | duplex stream owned by the selected scope | +| tcp | local-address | listener or duplex stream | endpoint | +| tcp | peer-address | duplex stream | endpoint | +| tcp | shutdown-write | duplex stream | unit | +| resolver | resolve | `[host family port limit]` | `[endpoints truncated]` | +| indexed-tree | read | `[reader directory limit size digest]` | decoded tree | +| file | sync | handle | unit | +| directory | sync | path | unit | +| utf8 | encode | scalar list | octet list | +| utf8 | decode | `[final octets]` | `[scalars carryOctets]` | +| indexed-tree | hash | `limit, tree` | `[size digest]` | +| indexed-tree | write | `[writer limit digest tree]` | `[size digest]` | +| indexed-tree | verify | `[reader limit tree]` | `[size digest]` | +| stream | read | reader, positive limit | `(data, nonempty bytes)` or `(eof, t)` | +| stream | write | writer, nonempty bytes | positive accepted count | +| stream | snapshot | `[reader directory limit digest]` | `[snapshot size]` | +| stream | transfer | `[reader writer count]` | unit | +| resource | close | resource | unit | +| stage | open | scope, directory | staged writer | +| stage | seal | stage | unit after flush, sync and close | +| stage | publish | stage, replacement mode, destination | `"published"` or `"exists"` | +| digest | open | scope, algorithm | digest context; currently `"sha256"` | +| digest | update | digest, nonempty bytes | unit | +| digest | finish | digest | lowercase hexadecimal digest; consumes state | + +`IO.capabilities` reports association rows `[key value]`: `schema` (currently 1), +`io-version`, `backend`, `authority`, `core` constructor names, `groups` as +`[name revision operationNames]`, `limits` (chunk bytes, transfer handles and maximum file offset and directory-name scalar limit TCP backlog, resolver input/output bounds and indexed metadata bounds), +`digest-algorithms`, and `restrictions`. It describes this backend's implementation, +not OS permissions, resource availability or a guarantee that a future call works. +For example, no-replace publication still needs filesystem hard-link support. +The native report covers native Linux capabilities. + +A well-formed named request with an unavailable group, revision or operation +returns `unsupported` **without demanding its arguments**. Unknown names do not +reserve or specify future argument schemas. Malformed headers and unknown core +constructors are terminal protocol failures; bad arguments to implemented requests +are ordinary operation errors. Protocol failures carry +`["protocol-error" "core/decode" boundedDetail "none"]` as their diagnostic. + +`IO.inspect action` returns `["core" constructorName]` or +`["facility" group revision operation "available"/"unsupported"]` inside Result. +This inspects only the header: it does not validate arguments, inspect payloads, +follow bind actions, apply callbacks, resolve handles or predict success. Even a +malformed bind payload can have a recognizable bind header. An invalid queried +header returns an ordinary `invalid-argument` error from `runtime/inspect`. +This is distinct from the richer read-only **indexed bundle inspector**, which +follows explicit bind action structure under depth/occurrence limits while keeping +v2 request arguments and callbacks opaque. Its JSON schema is +`tricu.interaction-inspection.v2` with `supportedVersions: [1,2]`; offline inspection +never infers a deployment backend's support from an artifact's named header. + +Spawn flags are canonical 0/1. A non-result child has `t` in the ticket position; +its task control handle is still available while active. Transfer lists contain +at most 256 handles. Task and join references use distinct markers, run identity +and monotonic keys; they are not portable resources or secret capabilities. + +Joined outcomes are lists: `["completed" value]`, `["failed" diagnostic cleanup]`, +or `["cancelled" reason cleanup]`. Cancellation is not an ordinary operation error +inside the cancelled child. A supervisor can observe its final outcome as data. +The CLI reports terminal failure/cancellation without forcing arbitrary diagnostic +payloads. `runDetailed`/`runInRuntime` retain structured outcomes for embedding; +a supplied runtime belongs to that execution and closes on exit. + +Ordinary errors are four-element lists: category, operation, bounded detail and +progress. Wire operation errors now use the requested `group/operation`, e.g. +`file/open-reader`, `stage/publish`, or `core/join`, rather than leaking incidental +backend labels such as `open` or `task`. Category/detail/progress are preserved. +Terminal user diagnostics remain arbitrary trees; lifecycle cleanup diagnostics +retain their resource-release context and are not fabricated operation replies. Canonical byte chunks/read bounds are 4096 bytes; paths are canonical +Unicode scalar lists without NUL. Empty read means EOF, never transient progress. +The file backend accepts the whole chunk on successful writes; source `writeAll` +also handles partial positive acknowledgements. Its failures are +`["write-all" confirmedBefore originalProblem]`: confirmed bytes from earlier +successful calls, plus the failing operation's unchanged diagnostic. They do not +claim the failing operation accepted zero bytes. This deliberately replaces raw +error forwarding, which lost earlier progress. Native write errors can have unknown +progress, so blindly retrying an entire chunk is unsafe. Acknowledgement is not +durability. Unknown/malformed computation constructors fail the task; invalid +arguments to recognized operations return ordinary errors. + +## Lifetimes, admission and handoff + +Scopes own resources and child tasks. Descendants borrow ancestor resources; +returning a handle does not extend its lifetime. Normal exit closes admission, +joins attached children, then releases resources. Existing descendants keep their +borrowing rights and may acquire into their own still-open scopes. Failure or +cancellation requests child cancellation before waiting. There are no detached +tasks. An endless child makes normal scope exit wait until explicitly stopped. + +Operations hold leases. Closing rejects new operations and waits for an existing +lease before releasing; file cursor overlap returns `busy`. Repeated explicit +close reports stale/closed, without retained tombstones. Scope cleanup finishes +inner scopes first and releases resources in reverse acquisition order. + +Spawn creates a gated worker before commitment. Resource reservation, transfer, +task registration and opening its gate occur in one STM transaction. Invalid +handle sets start no child and move nothing. Only idle resources owned by the +attaching scope move; borrowed ancestor resources remain borrowed. Old aliases +become stale; fresh handles preserve input order, while release follows original +acquisition order. Explicit close claims closing atomically with token validation, +so an old alias cannot close a newly transferred resource. Once published, a +worker startup/execution fault is a task failure, not a rollback of successful +spawn. OS/thread allocation before publication must succeed before any transfer. + +## Outcomes, cancellation and cleanup + +Ordinary Results remain values. `IO.require` explicitly promotes a selected +operation error to terminal failure. Uncontained child failure records the group's +failure and cancellation of siblings transactionally, then wakes the body. +Supervision must be selected at spawn, before the child runs. Joining later does +not retroactively contain a fault; discarding a ticket does not disable supervision. + +The first recorded stop request wins. Completion linearizes only after child and +resource settlement, so a cancellation during normal joining/release can still +win over a successful body. Cancellation after final completion reports stale or +completed-task. A task has one consumable ticket: claiming join removes it before +waiting; concurrent/duplicate claims fail. An interrupted join does not restore +the ticket. Explicit and implicit joins participate in wait-cycle detection; +an explicit cycle returns an ordinary error, while an unavoidable implicit scope +cycle fails the group. Non-result and discarded completions leave no result +archive. Unconsumed tickets deliberately retain their results until scope exit. + +Controllers do not run user computation. A body worker executes the interaction +tree; sticky cancellation is checked before interpreting each action. Waking a +worker cannot replace its recorded reason or turn cancellation into a recoverable +Result. Even a backend that catches the wakeup cannot resume the next source +continuation. Nested lexical controllers receive cancellation through group state, +not an exception injected into their resource-release protocol. + +Cancellation waits for pending work to settle; it cannot undo bytes written or +other external effects. Cleanup attempts remaining releases after synchronous +failures, preserves an existing failure/cancellation, and bounds diagnostics to +sixteen plus an omitted-count marker. Successful execution with failed required +cleanup becomes failure. A reported failed release is not proof the OS reclaimed +a broken backend resource. Host interruption of the outer runner is rethrown only +after subtree settlement; repeated external interruption/process death remains +outside the cleanup guarantee. + +`Runtime.Tree.apply` is a lazy recursive evaluator, not an explicit budgeted +reduction machine. The driver supplies action-boundary safe points; native pure +reduction relies on GHC scheduling/allocation safe points. No prompt cancellation +or hard deadline is promised for arbitrary divergent pure calculations, foreign +calls or OS cleanup. Pending-IO cancellation tests do not establish those claims. + +## Handler bridge + +`H.scope`, `H.spawn`, and `H.runIO` are concurrency-aware source boundaries. +Normalization snapshots the actual lexical service environment and labels for the +child, with an empty child continuation stack. A handler callback's child inherits +the callback's outer environment, not the original requester's inner overrides. +Child-local handler changes do not mutate parent routing. Label list spines are +settled at boundaries without forcing payloads or unselected continuations. + +`H.runIO` aliases `H.runIO`, which returns successful values directly and maps +handler failures/unhandled requests to terminal failures. +Service-level Results remain ordinary values. `H.liftIO (IO.spawn ...)` alone does +not capture handler routing: use `H.spawn` for handler computations. + +`H.collect` is a state-threading transformer, not shared mutable state. It rejects +v2 scope/spawn boundaries rather than silently sharing or dropping its state; +install child-local collection inside the child body explicitly. General shared +cells/channels and implicit child-state snapshots are not implemented. Captured +services that refer to resources still obey the real resource scopes. + +The handled copier exercises the current host bridge and lexical service routing. + +## Staged publication and independent digests + +`IO.openStage` creates a mode-private temporary file in an existing directory +(nonempty path; use `.` explicitly for the working directory). +It is not visible at the destination name, but its temporary name is discoverable +by the OS owner; this is not a sandbox. `IO.writeSome`/`writeAll` work on stages. +`IO.sealStage` flushes, synchronizes the actual file descriptor and closes it; +subsequent writes fail. On sync failure/interruption, the descriptor remains +registered for scope cleanup, so release failure cannot replace the original +sync failure. Directory synchronization uses the same ownership discipline. +Failed descriptor closes are not blindly retried against a possibly +reused OS descriptor; other cleanup is still attempted. No length, hash, artifact name or upload-session policy is +hidden in the native staged writer. + +Publication may cross directories on the same filesystem. Destination directories +must already exist. Atomic rename/hard-link syscalls enforce the filesystem boundary; +there is no copy fallback or automatic retry on cross-filesystem failure. This +replaces the earlier same-directory-only restriction. Basenames containing +`.tricu-stage-` are reserved for temporary files, preventing publication over +this or another stage's private name. `publishReplace` atomically renames over a destination; +`publishNew` atomically creates a hard link without replacement, then removes the +private name. The latter requires filesystem hard-link support. Both synchronize +the destination directory after successful publication, then the staging directory +when it is distinct. Same-directory publication synchronizes the directory once. +A failure during either synchronization or descriptor release can follow visible +publication; it is not rollback permission. Synchronization stops at failure, and +owned descriptor/private-name cleanup still runs. These are POSIX filesystem mechanics, +not race-resistant confinement against other actors changing managed directories. +Already-open readers of a replaced destination can still see its old inode. + +Mode 0 means no-replace; mode 1 means replacement. `"exists"` leaves the stage +sealed and makes no claim about the winner's contents or durability. `"published"` +means this backend's file/directory synchronization requests completed, not a +universal crash-consistency guarantee across every OS/filesystem/storage device. + +Write, seal and publication execution failures freeze the stage against further +writes/publication. Publication errors classify visibility in the progress field: +`not-published` before the namespace operation, `unknown` on an ambiguous namespace +failure, or `published` after successful link/rename but failed removal/directory +sync/descriptor release. A cancellation after namespace change may also leave output visible. +**Neither failure nor cancellation authorizes retrying publication or deleting +the destination.** Explicit close/scope cleanup only closes the owned handle and +removes the private name. A failed release can leave private staging files and is +reported; automatic crash recovery/pruning is not implemented. Invalid decoded +arguments never reach the backend; phase errors and publication execution errors +are distinct from those argument checks. + +Digest contexts have the same ownership, borrowing, transfer, lease and close +rules, independently of files. Updates accept bounded nonempty canonical chunks; +finish consumes the context, including the valid empty-input digest. Updates or +another finish after finish report `invalid-state`; close releases the registry +entry. Unsupported algorithm names return `unsupported`, not protocol failure. +The native implementation forces each updated context so it does not retain a +chain of old chunks. + +### Source-owned artifact policy + +`io_artifacts` (`lib/io/artifacts.tri`) composes stages and digests through +handlers. Its producer submits `write` requests and finally returns Result unit. +The boundary owns the remaining count: early success, excess bytes, a producer +ignoring failed writes, or an inner override forging acknowledgements cannot +advance that count or authorize publication. A failed step ends the boundary +without resuming the producer. The adapter's declared-length limit is 64 MiB; +it is application policy, not a native staging quota. An excess chunk may have +been written privately before its excess is detected (at most one bounded chunk); +it is never published and is removed on scope exit. + +The adapter validates the finished digest before deriving a flat destination +name. An existing winner is independently read and hashed, with exact-length +checking, before returning success. This is point-in-time content verification, +not certification of another writer's crash durability or protection against +subsequent external modification. It returns an ordinary Result digest; handler +protocol failures remain terminal failures. The directory must already exist. +Producer code remains trusted process-authorized code: this boundary does not +constrain direct native IO or external filesystem mutation. It checks submitted +chunks and acknowledgements, not arbitrary malicious programs. +The producer is sequential: other requests are forwarded with lexical labels, +but nested producer scope/spawn views are rejected instead of duplicating count +state. The whole publication computation can itself run in a child task. + +`demos/io/publish.tri` is a bounded file producer. The default source registry +uses the streaming composition described below for socket uploads and publication. + +`IOStorageTests.hs` independently covers SHA-256 vectors, phase/kind/bound checks, +atomic no-replace races, transfer, pre/post-publication faults, cancellation during +write/seal/publication, descriptor settlement, release failures and repeated +scope reclamation. `io_publication.py` checks complete files and independent +Python SHA-256 expectations through 1 MiB, Unicode paths, private file modes, +existing-winner verification, corrupt +winners, counter/acknowledgement controls, cancellation, and digest/path validation. +It records allocation separately from the same workload-specific RSS/live-heap +guards used by the first slice. Separate write/digest calls deliberately decode +each chunk twice; no fusion or routing-only performance improvement is claimed. + +## Explicit compiler invocation boundary + +The native supervisor defaults to IO and accepts an explicit +`--toolchain-io-version 2`. Selected compilers execute with OS process authority. +Canonical envelope validation precedes action demand; v1 selection requires a +retained host. Returned manifests and receipts are authenticated. Startup loads +and verifies the worker inertly. + +```sh +python3 test/compiler_io_version.py result/bin/tricu --directory /tmp/new-compiler-version-check +``` + +Independent native workers check framing, inert startup, retired-grant rejection, +invalid selections and authenticated outputs. The eleven CLI controls cover +packaged and explicit selection. Full generation acceptance uses the separate +[reproduction workflow](../../docs/compiler-rebuilding.md.html). + +## Source storage and isolated compiler projection + +`io_storage` composes native mechanisms without adding a native store service: + +- `readBytes path limit` returns a whole byte list, with bounded reads and a one-byte + EOF probe at the budget boundary. Excess input can consume that one byte before + failure. It is not framed input or a constant-memory whole-file representation. +- `hashBytes limit bytes` validates canonical octets and the byte budget while + feeding bounded chunks to a scoped SHA-256 context. Empty bytes are valid. +- `publishBytes directory destination replace limit bytes` preflights before stage + creation. Canonical flag 0 publishes without replacement and verifies **every** + winner byte-for-byte through EOF, then synchronizes its open inode and directory. + Flag 1 atomically replaces a mutable name after sealing; it does not race-read a + subsequent legitimate alias replacement. Both return Result Boolean: false only + for a verified existing immutable winner. Publication errors do not authorize + rollback or retry; a destination can already be visible when synchronization fails. +- `publishTree directory destination limit digest tree` preflights canonical identity, + uses native indexed transport, and verifies/synchronizes every no-replace winner. + `readTree directory path limit digest` authenticates a snapshot before decoding; + the explicit snapshot directory must be writable. +- `parentPath` is a POSIX lexical parent helper, including trailing separators and + leading root separators. It does not resolve symlinks or `..`, validate a path, + or establish confinement. Directory creation/durability remains caller policy. + +Limits are canonical `[0,2^63-1]` encoded-byte budgets, not graph/heap or CPU quotas. +Resident caller byte lists remain resident values; these helpers bound transport, +not all application memory. Failures preserve the underlying operation problem or +writeAll diagnostic. They do not provide a transactional whole-operation progress +count or safe implicit retry. Immutable-store policy assumes cooperating writers; +there is no transaction against external mutation or universal crash guarantee. + +`lib/source/io.tri` is the **compiler storage facade**, imported as +`compiler_io`. It constructs v2 actions for the operations used by the source compiler. +It preserves Result continuation/remainder conventions, uses a declared 64-MiB +source/metadata/encoded-tree budget, and requires parent-first directory creation. +Missing ancestors fail. Directory +entries are synchronized before dependent publication. File-existence queries +return false for missing paths and non-files, but propagate other operation errors. +Indexed reads spool in the object's parent directory, suitable for the writable +compiler application store. Callers must supply a writable parent directory. + +Both compiler source maps select `compiler_io` and its v2 dependencies. The +general application module `io` reexports the complete `io` facade. + +```sh +python3 test/io_compiler_store.py result/bin/tricu \ + --directory /tmp/new-compiler-store-check --compiler-smoke +``` + +The focused Nix gate compiles current storage source, exercises exact chunk and +budget boundaries, rejects malformed tails and octets before publication, preserves +failed replacement targets, and checks corrupt immutable winners through EOF: + +```sh +nix build .#ioStorageTest --no-link +``` + +Its `cost/report.json` records three-run median invocation time and cumulative +allocation for 1-KiB, 4-KiB and 64-KiB hashes with exact and roomy byte budgets. +The 4-KiB roomy-budget hash probe has a 160-MB allocation ceiling. The report also +records 4095/4096-byte file writes; the full-bound write has a 32-MB allocation +ceiling. Native acknowledgement of all 4096 bytes exhausts the validated input +chunk, allowing `writeAll` to finish directly. Short acknowledgements retain the +ordinary suffix/progress path. Wall time is reported rather than gated. This measures resident byte-list processing, not a constant- +memory stream or peak RSS. To compare an explicitly selected historical storage +source under the same compiler and workload, use: + +```sh +nix shell --inputs-from . nixpkgs#python3 -c python3 test/io_storage_cost.py \ + result/bin/tricu --directory /tmp/storage-reference-new \ + --reference-storage /path/to/retained-storage.tri +``` + +Reference-storage runs record the source identity and bypass the candidate hash +allocation ceiling. Write probes still select the current IO library and guard. The public `base.length` contract remains unchanged; internal storage +counting operates on validated proper chunks. + +Without `--compiler-smoke`, the test exercises actual `lib/source/object.tri` +callers and storage policy. With it, the retained compiler builds a candidate +stable `sourceBuildGraph`; explicit IO-version 2 selection then compiles an actual +application dependency graph with cold and warm caches. Controls compare complete +bundle bytes with the retained compiler, evaluate independent numeric and combining- +character expectations, inspect exact raw UTF-8 provenance digests, and preserve +prior output on BOM/malformed-UTF-8 failures. CRLF source rejection is compared +with the retained compiler: preserving bytes is not a promise that the parser +accepts every Unicode scalar as source whitespace. + +Reports record projected input hashes, candidate bundle identity and IO version, +commands and routine RTS observations. This procedure does **not** produce or accept +an entire self-rebuilt compiler generation pair. Generation/reproduction metadata, +backend support, deployment/default selection and rolling retention acceptance +remain separate gates. Source bundle encoding and resident source/metadata values +are not claimed to have become bounded-memory operations. + +## Artifact handler filesystem adapter + +`handler_artifacts.withFilesystem root byteLimit action` and +`withFilesystemFor service root byteLimit action` interpret the existing artifact +request interface using native. Run the computation through `handlers.runIO`. +`prepareFilesystem root` explicitly initializes the registry layout; the root's +parent must already exist. Lexical overrides, named captured services and unknown-operation +forwarding retain the interface's ordinary handler behavior. + +The caller selects a byte limit. Heap and execution-time limits require separate controls. These operations +retain whole byte lists. Loads authenticate the returned bytes against the requested +raw SHA-256 name. Publication verifies and synchronizes every immutable winner +through EOF; corrupt existing objects remain intact and produce a failure. +Synchronization follows host-filesystem guarantees; objects can remain visible +after a post-publication failure. Callers provide bundle validation and publisher +authentication when required. Paths follow OS process authority. Invalid digest paths and malformed raw +publication requests are rejected at the adapter boundary too. + +```sh +python3 test/io_artifact_service.py "$TRICU" --directory /tmp/new-artifact-service +python3 test/handler_library.py "$TRICU" --directory /tmp/new-handler-controls +``` + +Controls include exact binary data across chunk boundaries, corrupt/trailing +winners, byte budgets, startup failures, raw request validation, lexical and captured +routing, forwarding, concurrent publication, stage cleanup and rejection of v1 +grants before effects. These controls exercise the storage service; the HTTP +listener has separate socket-level checks below. + +`arboricx_handlers.router root byteLimit sink method target headers body` reuses +registry routing/response policy with those services. `packageLookup` captures its +root independently of artifact overrides; `stdoutLog` writes status-only access +logs to the borrowed standard output. Each request interpretation runs in a +supervised child whose join ticket is consumed. Interpretation/logger failures yield +a redacted 500 after child cleanup; cancellation remains cancellation. Publication +can precede either outcome and is not undone. Byte budgets also apply to captured +package reads. This router expects the HTTP transport to validate framing and own +the connection; it neither accepts sockets nor makes request deadlines available. +The source probe checks complete HTTP bytes, captured package roots, authentication +failures, logging failure after publication, ordinary status logging and cancellation. + +### Digest-selected streaming destinations + +`io_artifacts.publishWith stagingDirectory expected destination producer` uses +the existing sequential producer interface. After exact-length completion, digest +validation and sealing, `destination digest` returns a handler Result containing +the final path. Destination policy may initialize a digest-derived directory; +it remains source code, not native upload/store policy. Callback failure abandons +the stage. The existing `publish` helper retains its flat-directory behavior. +Existing winners are length/digest verified through EOF and their opened inode and +parent directory are synchronized before success. + +`handler_artifacts.publishStream root expected producer` uses this mechanism for +the registry layout: stage under `root/tmp`, then publish without replacement under +`root/objects/<first-three-digest-characters>/<digest>.arboricx`. Initialization is +explicit via `prepareFilesystem`; the temporary and object directories must be on +the same filesystem. There is no whole-body buffer or second file copy. The source +probe exercises new/existing winners through 1 MiB, short/excess streams, corrupted +winners and private-stage cleanup. The 64-KiB/1-MiB checks retain generous 128-MiB RSS +and 16-MiB live-heap regression guards for these workloads. + +### Native HTTP registry + +`io_http` reuses the existing pure HTTP grammar, validation precedence, +Content-Length decoding, 65,536-byte accumulated header-buffer limit (including +prefetch), and 1,048,576-byte body limit. Transfer coding and duplicate lengths +remain rejected. HEAD suppresses bodies only after the historical trusted parsing +boundary. Each connection serves one request; surplus/pipelined bytes are ignored +on close. Response writes are explicitly split into chunks of at most 4096 bytes: +Each `IO.writeAll` call completes one bounded chunk. + +`arboricx_streaming` connects this transport to `router` and +`publishStream`. POST bundle input is sequential and acknowledged before the next +read. Other request bodies, artifact downloads and package downloads remain +whole-value operations whose memory cost grows with the retained values. +Upload interpretation/logging runs in a supervised child and is joined before the +response. Cancellation remains cancellation, and post-publication logging failure +cannot undo publication. Connection ownership transfers atomically into its worker +scope, which closes the socket after success, failure, reset or cancellation. + +`serve` and `serveCount` take a canonical artifact byte limit (also enforced on +uploads, in addition to the HTTP body limit) and an explicit connection window in +[1,256]. Admission +consumes the oldest join ticket before exceeding that window. This bounds active +connections and retained outcomes but deliberately permits head-of-line blocking: +a slow oldest connection delays new admission even if later workers finish. No +request deadline, idle timeout, completion-order scheduling or slow-client +availability guarantee is implemented. Excess connections wait in the OS backlog, +whose capacity and behavior follow host configuration. `serveCount` drains its finite selection before returning; explicit +scope cancellation cancels/joins active workers and cleans incomplete stages. + +Build/run the native-OS-authority streaming runner: + +```sh +nix build .#arboricxStreamingRunner +./result/bin/arboricx-streaming-v2 +nix build .#arboricxStreamingTest +python3 test/io_http.py /path/to/nix-built/tricu --directory /new/output +``` + +The runner binds port 8080 and uses `./store` with a 16-connection FIFO window. +Its packaged runtime store needs no compiler cache in the working directory. +The default `arboricxServerRunner` and image use the same v2 composition. Symlinks +follow OS authority; wrong-digest bytes fail authentication. Deployment tests use +v2 and retain retired-grant rejection controls. Public deployment is a separate +operator action. + +Socket controls include complete HTTP-byte expectations, binary upload boundaries +through 1 MiB, authenticated downloads, corrupt winners, header/body rejection, +prefetch/surplus, half-close/reset, logger failure after publication, bounded +concurrent admission, repeated join-ticket consumption, startup failure, v1 grant +rejection before effects, and explicit cancellation of active uploads. Streaming, +admission and lifecycle jobs enforce 128-MiB RSS/16-MiB live-heap regression guards; +these are test guards, not process-wide service capacity promises. The separate +packaged deployment test also exercises whole-value 1-MiB download/persistence. + +### Monotonic observations, relative waits and scheduler yield + +Native `clock` revision 1 provides `monotonic` (unit argument) and `sleep` +(canonical milliseconds in [0,9223372036854775807]); `scheduler` revision 1 provides +`yield` (unit argument). Source helpers are `IO.monotonicNanoseconds`, +`IO.sleepMilliseconds` and `IO.yield`. All return ordinary Results. Clock samples +are the host's unsigned 64-bit monotonic nanoseconds, with an unspecified local +origin. Use differences within that clock domain to measure elapsed time. +The host counter's representation/wrap limitations remain applicable. + +Relative waits split host delays into at most one second, avoiding conversion of +an enormous millisecond value to a bounded host microsecond integer. They are +interruptible, may finish late, and make no hard deadline promise. Zero wait is a +no-op, not an implicit scheduling barrier. Yield offers a scheduling opportunity; +it does not guarantee alternation, fairness, ordering or message atomicity. No +wall-clock reads, timeout/race combinator or deadline API is implied by this group. + +`demos/interactionTrees.tri` now uses v2 scoped children and consumed join tickets; +the parent alone writes stdout. `demos/interactionTrees/yield.tri` installs child- +local log collection and prints the collected results from the parent. This +explicitly retires the old demo's incorrect claim that yield guarantees alternating +writes and avoids concurrent stdout lease conflicts. Published historical fixture +bundles retain their original provenance alongside current v2 publications. + +```sh +python3 test/io_clock.py result/bin/tricu --directory /new/clock-controls +python3 test/io_protocol.py result/bin/tricu --directory /new/protocol-controls +``` + +Native controls independently check capability data, canonical arguments, inert +inspection, monotonic ordering, minimum elapsed sleep, exact waiter chunking and +maximum-duration interruption. Actual source probes check zero/short waits, +normal scope joining, explicit cancellation of a maximum-duration child wait, +invalid inputs, and both migrated demos' complete output. Test timeouts are harness +guards, not runtime deadline guarantees. + +### Direct-child subprocesses and bounded pipes (native POSIX) + +`IO.spawnProcess executable arguments directory environment graceMilliseconds` +uses the `process` revision 1 `spawn` operation. Its payload is the five-element +list of those arguments; its success value is +`[task joinTicket stdinWriter stdoutReader stderrReader]`. Pipes belong to the +launching scope and support ordinary stream operations, borrowing, handoff and +close. A failed/cancelled pipe operation poisons that endpoint against replay. +Other runtime descriptors are not deliberately inherited (`close_fds` is enabled). + +Directory selection is `["inherit"]` or `["at" path]`; environment selection is +`["inherit"]` or `["replace" [[key value] ...]]`. Empty replacement means an empty +environment. Duplicate/empty keys and keys containing `=` are rejected. Executable +must be nonempty. Arguments are passed as a vector, not parsed as a shell command; +executable lookup, script loading, path resolution and encoding follow the host +process library/OS. An explicitly named shell can of course interpret its arguments. +Execution uses OS process authority. + +Preflight limits are 256 arguments, 256 environment entries, 4096 Unicode scalars +per text field and 65536 scalars across executable, arguments, explicit cwd, keys +and values. Text must be canonical, NUL-free Unicode scalars. These are not encoded +OS argument-size guarantees: host limits/errors still apply. Grace is canonical +milliseconds in [0,9223372036854775807], explicitly selected by the caller. Individual +field/list bounds are checked before constructing host values; the aggregate bound +is then checked before spawning. Excess elements beyond individual bounds are inert. + +The process is monitored as an unsupervised structured child task. Normal scope +exit joins it, even without consuming its ticket. Explicit `IO.join` returns the +usual Result outcome; completion contains `["exit" code]` or `["signal" number]`. +**Exit status is data, including nonzero exits.** Callers must consume and check +the join outcome when command success matters. +Launch errors are ordinary Results, acknowledged after internal task settlement. +Unexpected monitor/cleanup failures propagate through the existing task machinery. + +`IO.cancel task reason`, owning-scope cancellation or failure terminates the direct +child with TERM, allows the selected grace, escalates to KILL if necessary, and +reaps. Normal waiting stays in cancellable task work, including after the parent's +body has completed. It does not block protected resource release. Status observation +uses nonblocking host polling with nominal 10-ms pauses, not a detached waiter. +Termination/reaping also attempts subsequent cleanup after an earlier release +error; errors are retained with bounded detail and an explicit additional-error +count when needed. Very long selected grace periods can delay cancellation settlement. +Scheduling, OS calls and cleanup provide **no hard deadline guarantee**. Host process +death, repeated external interruption and power loss are not cleanup guarantees. + +Only the direct child is owned: no process-group signalling, descendant confinement, +job control or automatic grandchild reaping is claimed. A descendant can retain a +pipe writer after the direct child exits. This can prevent EOF indefinitely. + +**Pipe policy is explicit:** close stdin to deliver EOF; drain stdout and stderr +concurrently when either may fill. Waiting before doing that can deadlock. No +implicit whole-output buffering, automatic input close on normal scope joining, +rollback of external effects, or automatic command-success checking is supplied. +The source demo `demos/io/process.tri` pumps a file into stdin while two scoped +children drain stdout/stderr into files, then checks task completion and returns +command status for its caller to judge. Files can contain partial output on failure. + +```sh +nix flake check +nix build .# +python3 test/io_process.py result/bin/tricu --directory /new/process-controls +python3 test/io_protocol.py result/bin/tricu --directory /new/process-protocol +python3 test/io_tasks.py result/bin/tricu --directory /new/task-controls +``` + +Native controls cover real exit/argv/env/cwd behavior, binary backpressure, missing +executables, implicit joins, failure, cancellation after observing closed scope +admission with a live child, TERM-ignoring escalation/reaping, repeated lifecycle +registry counts, failed-pipe poisoning, bounded/non-demanding protocol validation, +and injected termination/cleanup failures and clock wrap. Source controls compare +complete binary outputs through 1 MiB, including simultaneously full stdout/stderr, +normal joining without an explicit ticket wait, literal arguments and nonzero +status, and malformed bounds. Runtime RSS/maximum-live guards exclude child-process +memory and are regression guards, not capacity or throughput promises. This slice +is validated on native Linux; browser process support and scheduling changes require separate validation; it does not change +compiler fixtures, deployment defaults, or accepted compiler generations. + +### Bounded, incremental line reading + +`io_text.lineCursor reader` starts a single-consumer source cursor. +`Text.readLine cursor byteLimit` returns an ordinary Result containing +`[event nextCursor]`. Events are `["line" text "lf"]`, `["line" text "eof"]` +(for a nonempty final unterminated line), or `["eof"]`. An empty LF-terminated +line is a line, not EOF; a trailing LF does not manufacture an extra EOF line. + +The explicit canonical limit is0..9223372036854775807 **encoded bytes per line, +excluding LF**. CR, NUL, BOM and all valid Unicode scalars remain content; no CRLF +normalization, BOM stripping or Unicode normalization occurs. This is generic text, +not `readSource`'s stricter compiler-source policy. UTF-8 decoding is strict and +incremental with at most three carry bytes. Framing splits at LF before decoding, +so malformed text in a later buffered line does not invalidate an earlier line. +Byte bounds are checked before decoding each framed piece. + +Reads request at most4093 bytes and preserve bounded read-ahead in the next cursor. +Each returned line is a whole value, occupying space proportional to its selected +limit; processing many lines need not retain the entire stream. This is not a +constant-space arbitrary-length-line facility. No aggregate input, CPU or wall-time +limit is implied. `demos/io/lines.tri` includes both collecting and discarding +consumers; only the latter is used for repeated-line retention controls. + +**Thread only the returned cursor forward.** Serialize cursor use with all other +reads on the same reader. The cursor is ordinary source data; native leases cover +individual reads. Buffered bytes and observed EOF are cached data that can survive +resource closure. A fresh read requires a live reader handle. Cursor representation +is an internal library detail. + +Read/UTF-8/line-size failures return `["read-line" observedLineBytes cause]`, without +a next cursor. Retire that cursor on failure or cancellation; automatic draining, +resynchronization and recovery are not provided. The count includes the current +framed piece on a bound/decode failure, excludes LF/read-ahead belonging to later +lines, and is **not** a file offset or safe retry position. Native decoder byte +locations are relative to that decoder invocation. The underlying stream may already +have consumed read-ahead; reconstructing a fresh cursor is not lossless recovery. +Invalid limits reject before reading, including when the reader is stale. + +The migrated `getLineAsync` demo starts a child read, writes prompts from the parent, +then consumes the child's join ticket. EOF and an empty name differ, and write/read +failures are checked. `greet` and `forkAwait` also use v2; the parent serializes +`forkAwait` output rather than assuming concurrent writes are atomic. Historical +published v1 fixtures remain frozen alongside the current v2 releases. + +```sh +python3 test/io_lines.py result/bin/tricu --directory /new/line-controls +python3 test/io_text.py result/bin/tricu --directory /new/text-controls +``` + +Independent controls cover LF/EOF/CR/BOM/NUL, multibyte carry at the4093-byte read +boundary, malformed and incomplete UTF-8, line limits, a valid line before malformed +buffered data, stale readers, preflight, cancellation with stdin held open, and full +interactive output. Split pipe writes exercise fragmentation but may be coalesced +by the OS; stateless UTF-8 controls independently cover every split. Prompts must +arrive before the harness supplies input. Repeated-line RSS/maximum-live guards are +regression bounds, not capacity guarantees; whole-line collection remains bounded +by the caller's selected per-line budget. + +### Read-only process context + +`IO.workingDirectory` (unit payload, `process/working-directory`) returns Result +host cwd. `IO.environmentVariable name` (`process/environment-variable`) returns +Result `["missing"]` or `["value" text]`; an empty value is `["value" ""]`. Names are +nonempty, NUL-free Unicode scalar text, exclude `=`, and fit4096 scalars. Returned +cwd/values have the same scalar bound and strict text requirements; oversized or +unrepresentable values fail rather than truncate or normalize. Host acquisition +may materialize a value before checking its size; this is not an allocation quota. + +These are read-only, point-in-time OS observations under OS process authority. +Application arguments are explicit exported-function parameters. Subprocess +cwd/env selection is supplied explicitly when spawning each process. + +```sh +python3 test/io_context.py result/bin/tricu --directory /new/context-controls +``` + +Native controls check actual cwd/PATH observations, malformed names/payloads, +strict scalar/size validation and inert inspection. Independent source controls +run in a selected cwd and distinguish absent, empty, Unicode, boundary-size and +oversized environment values. These facilities expose only a requested variable; +value-validation diagnostics use fixed error messages. + +### Basic callers: lexical configuration, state, files and bundle bytes + +`Text.readText reader byteLimit` is a bounded whole-text convenience using the strict +incremental decoder without compiler-source BOM rejection. Initial BOM, NUL and CRLF +are preserved. It shares the whole-source accumulation machinery and retains +the complete decoded text. `readSource` retains its separate raw-byte and source +policy contract unchanged. + +The `environment` demo now uses lexical configuration handlers and demonstrates +restoration of the outer lexical value. +The `state` demo explicitly passes state through a sequential state machine rather +than recreating a global host get/put slot. Neither implies shared mutable cells. +`safeRead` and `shout` use scoped generic UTF-8 reads with an explicit1-MiB budget. +`writeThenRead` checks write and close results before opening the reader. Its main +requires `TRICU_DEMO_OUTPUT` instead of overwriting a shared fixed `/tmp` filename; +its exported `run path text` entrypoint also accepts an explicit path. + +`demos/runArboricxBundle.tri` reads bounded binary bytes and uses the existing portable reader to select/apply the default export. +Validation is not authentication, sandboxing, or a computation-resource quota. +The portable reader permits bytes outside declared sections; its Result remainder +is an internal directory cursor, **not** an EOF indicator. Invalid/truncated sections +and trailing bytes *inside the manifest section* still reject. This migration does +not silently tighten the independently documented container profile. + +```sh +python3 test/io_callers.py result/bin/tricu --directory /new/caller-controls +``` + +Controls compare complete output/results for generic text/BOM/CRLF/NUL, missing and +malformed input, read budgets, failed writes, explicit output-path selection, +lexical restoration, state transitions, binary bundle execution, permitted external +padding and malformed section/container negatives. Frozen historical fixtures and +published package releases remain separate from these live-source migrations. + +### Offline v2 package inspector + +`lib/packages/io.tri` and `demos/package-inspector/` are the canonical native-v2 +package facade and consumer. Reads and hashes take explicit byte budgets; the +inspector selects1MiB and hashes the same byte value it validates. Native failures +remain structured diagnostics. The `arboricx.inspect.v1` report format is unchanged. +Inspection validates graphs and exports without executing them. + +```sh +nix build .# .#inspectorOracle --no-link +HOST=$(nix path-info .#)/bin/tricu +ORACLE=$(nix path-info .#inspectorOracle)/bin/inspector-oracle +nix shell --inputs-from . nixpkgs#python3 -c python3 test/library_inspector.py "$HOST" \ + --oracle "$ORACLE" --directory /new/native-package-acceptance +nix shell --inputs-from . nixpkgs#python3 -c python3 test/provenance.py "$HOST" \ + --accepted /new/native-package-acceptance --directory /new/package-replay +``` + +The first command publishes into a new local registry under default label +`0.5.0-io-v2`, fetches and binds a clean consumer, stops the registry, then compiles +and runs offline. Complete reports are compared with the independent Haskell +oracle. Controls cover malformed and unselected exports, sharing, inert IO, +immutable publication, isolated dependency updates, missing files, OS permission +denial, byte budgets, and rejection of both read grants and `--unsafe-io`. Run as a +non-root user for the OS permission check. Explicit `--io-version 2` is accepted; +current-source publication no longer offers v1 selection. + +The second command captures four input packs, removes its disposable producer, +and rebuilds the facade, foundation, inspector library and consumer from retained +inputs. It checks exact modules/receipts, distinct source histories, and rejects an +authenticated false output claim and producer-cache poisoning. + +Existing published releases retain their bytes. Acceptance writes to the +caller-selected new directory. The optional `--stage-site` operation adds accepted +bundles/releases to an exclusively owned local static-site directory; network +deployment remains a separate operator action. + +The remaining `demos/interactionTrees/{echoServer,httpServer,arboricxServer}.tri` +entrypoints now explicitly construct v2 programs. Echo and simple HTTP are +sequential demonstrations: one scoped connection at a time, no idle timeout or +availability guarantee. Echo pumps bounded chunks until EOF instead of truncating +at the first receive. HTTP retains the existing framing/body limits and HEAD rules. +The registry demo uses the already validated streaming service with FIFO16 +admission and a source-selected1-MiB budget. It initializes `./store`, not a shared +fixed `/tmp/store`, and announces its port only after successful startup. Each +exposes a counted `server` entrypoint for reproducible tests; `main` serves forever. +The packaged registry application and image also default to v2. Historical images +and their authenticated inputs remain available for explicit rollback. + +`python3 test/io_demo_servers.py result/bin/tricu --directory /new/demo-servers` +checks real binary echo, HTTP method/framing/HEAD responses, binary registry +publication/download, and that listeners no longer accept after their scopes finish. +The larger native HTTP/streaming controls remain the service's independent coverage. + +### Validated program/backend boundary + +IO envelope versions, compiler entrypoints and native embedding ABI versions are +separate contracts. Native embedding uses Haskell ABI 1 and IO protocol 2. + +| Program/operation | Haskell native (validated Linux) | Haskell browser | +| --- | --- | --- | +| Pure tree evaluation / stored definitions | Yes | Yes | +| Pure stable module-compiler entrypoint | Yes | Independently pinned browser compiler | +| Filesystem compiler workflow | IO | Pure compilation over supplied sources | +| Historical IO v1 | Retained runtime required | Inert inspection only | +| IO envelope/core | Structured core | Structured core and terminal profile | +| Named facilities | Query `IO.capabilities` | Runtime, standard streams, strict UTF-8 | +| Applications | Files, pipelines, tasks, processes and registry | Structured tasks and terminal pipelines | + +Browser terminal input uses UTF-8 line/EOF messages. Stop requests structured +cancellation and waits for admitted operations, tasks and leases to settle. +Emergency disposal terminates the Worker without promising resource settlement. +A non-returning reduction can delay structured cancellation; external supervision +bounds native work. Canonical v2 framing is checked before action demand. The JS +reference graph supplies independent pure conformance checks. + +Browser compilation invokes `source_unit.compileStableModuleDefinitions` from +its independently authenticated compiler library. Published assets retain their +own source and compiler provenance. Reproduce native and browser boundaries with: + +```sh +nix build .#tricu-native-bindings-integration .#arboricxSite --no-link +nix shell --inputs-from . nixpkgs#nodejs -c \ + node --test ext/demo-site/test/io-version-boundary.test.mjs +SITE=$(nix path-info .#arboricxSite) +TEST_IO_VERSION=1 TEST_BACKEND=haskell-wasm ARBORICX_SITE_ROOT="$SITE" \ + nix shell --inputs-from . nixpkgs#nodejs nixpkgs#chromium -c \ + node ext/demo-site/test/backend-browser.mjs "$SITE/runtime/haskell" chromium +``` + +Independent Haskell task/resource/file/indexed/text controls retain malformed +framing, non-demand, cancellation/cleanup, exact byte and publication oracles. +Shared-library generation tests compare complete inventories with the CLI; full +compiler-pair acceptance and retention remain separate. See the +[browser IO contract](../../ext/demo-site/README.md#io-version-boundary) and +[compiler rebuilding](../../docs/compiler-rebuilding.md#execution-interface-selection). diff --git a/test/io/artifact-service.tri b/test/io/artifact-service.tri @@ -0,0 +1,63 @@ +!import base as B +!import io as IO +!import handlers as H +!import handler_artifacts as A +!export prepare +!export publish +!export load +!export rawLoad +!export rawPublish +!export routed +!export captured +!export forwarded +!export rejectedView +!import arboricx_handlers as Registry +!import handler_log as Log +!export route +!export routeFailedLog +!export capturedPackage +!export routeStdout +!export routeCancelledLog +!import io_artifacts as Stream +!export streamPublish + +prepare root = IO.io (H.runIO (A.prepareFilesystem root)) +publish root limit bytes = IO.io (H.runIO (A.withFilesystem root limit (A.publish bytes))) +load root limit digest = IO.io (H.runIO (A.withFilesystem root limit (A.load digest))) +rawLoad root limit digest = IO.io (H.runIO (A.withFilesystem root limit (H.request "artifacts" "load" digest))) +rawPublish root limit bytes = IO.io (H.runIO (A.withFilesystem root limit (H.request "artifacts" "publish" bytes))) +routed outer inner limit bytes = IO.io (H.runIO + (A.withFilesystem outer limit (A.withFilesystem inner limit (A.publish bytes)))) +captured outer inner limit bytes = IO.io (H.runIO + (A.withFilesystemFor "captured" outer limit + (A.withFilesystem inner limit (A.publishTo "captured" bytes)))) +forwarded root = IO.io (H.runIO + (H.handle "artifacts" (operation payload : H.pure [operation payload]) + (A.withFilesystem root 0 (H.request "artifacts" "other" 42)))) + +rejectedView root limit bytes = H.view (A.withFilesystem root limit (H.request "artifacts" "publish" bytes)) + +silent _ = H.pure (B.ok t t) +failedLog _ = H.pure (B.err "private logger diagnostic" t) +route root limit method target body = IO.io (Registry.router root limit silent method target [] body) +routeFailedLog root limit body = IO.io (Registry.router root limit failedLog "POST" "/_arboricx/bundle" [] body) +capturedPackage root other limit target = IO.io (H.runIO + (Registry.request (Registry.packageLookup root limit) "GET" target [] [] + |> A.withFilesystem other limit + |> Log.withSink silent)) +cancelledLog _ = H.liftIO (IO.cancelled "logger stopped") +routeStdout root limit = IO.io (Registry.router root limit Registry.stdoutLog "GET" "/_arboricx/health" [] []) +routeCancelledLog root limit body = IO.io (IO.scope (_ : do IO.require + spawned <- IO.spawn 1 1 [] (_ : Registry.router root limit cancelledLog "POST" "/_arboricx/bundle" [] body) + case spawned of + [task ticket] = IO.require (IO.join ticket) IO.pure + _ = IO.fail "Invalid test spawn reply")) +pump reader = H.bind (H.liftIO (IO.readSome reader 4096)) (reply : case reply of + ((t t), (("eof", t), t)) = H.pure (B.ok t t) + ((t t), (("data", bytes), t)) = H.bind (Stream.write bytes) (written : case written of + ((t t), (t, t)) = pump reader + _ = H.pure written) + (t, (_, t)) = H.pure reply + _ = H.fail "invalid-response" "reader" "read" "Invalid read reply") +streamPublish root source expected = IO.io (IO.scope (owner : IO.require + (IO.openReader owner source) (reader : H.runIO (A.publishStream root expected (pump reader))))) diff --git a/test/io/browser.tri b/test/io/browser.tri @@ -0,0 +1,64 @@ +!import io as IO +!import io_text as Text +!import handlers as H +!export direct +!export handled +!export roundTrip +!export cancellation +!export main +!export binary +!export invalidUTF8 +!export invalidScalar +!export publicHandled +!export publicRoundTrip +!export publicCancellation +!export publicBinary +!export returnValue + +-- Bare computations exercise the private bridge; explicit envelopes exercise +-- the public RuntimeSession and worker/terminal routing. +direct = do IO.require + writer <- IO.stdout + Text.writeText writer "λ😀é\r\n" + IO.pure 42 +handled = H.runIO (H.liftIO direct) +roundTrip = do IO.require + reader <- IO.stdin + writer <- IO.stdout + chars <- Text.readText reader 4096 + Text.writeText writer chars + IO.pure 42 +cancellation = IO.scope (owner : do IO.require + reader <- IO.stdin + writer <- IO.stdout + spawned <- IO.spawn 1 1 [] (_ : do IO.bind + IO.readSome reader 8 + IO.fail "abandoned continuation resumed") + case spawned of + [task ticket] = do IO.require + Text.writeText writer "ready" + IO.cancel task "stop reader" + outcome <- IO.join ticket + case outcome of + ["cancelled" _ _] = IO.pure 42 + _ = IO.fail "wrong child outcome" + _ = IO.fail "invalid spawn result") +main = IO.io direct + +invalidUTF8 = IO.bind (IO.decodeUTF8 0 [237 160]) (reply : case reply of + (t, (["invalid-data" "utf8/decode" "invalid UTF-8 sequence at byte 0" "none"], t)) = IO.pure 42 + _ = IO.fail "invalid UTF8 prefix was accepted or misclassified") +invalidScalar = IO.bind (IO.encodeUTF8 [55296]) (reply : case reply of + (t, (["invalid-argument" "utf8/encode" "" "none"], t)) = IO.pure 42 + _ = IO.fail "surrogate was accepted or misclassified") + +binary = do IO.require + writer <- IO.stdout + IO.writeAll writer [92 120 102 102 255 240 159] + IO.pure 42 + +publicHandled = IO.io handled +publicRoundTrip = IO.io roundTrip +publicCancellation = IO.io cancellation +publicBinary = IO.io binary +returnValue value = IO.io (IO.pure value) diff --git a/test/io/core-controls.tri b/test/io/core-controls.tri @@ -0,0 +1,58 @@ +!import base as B +!import io as IO +!export cancelChild +!export currentScopes +!export badFlags +!export badTransfer +!export discardedTicket +!export selfCycle +!export selfCycleWrong +!export pureDomainError +!export rootFailure +!export rootCancelled +!export malformedBind +!export unknownWithPoison +!export legacyEnvelope + +spin seed = IO.bind (IO.pure seed) (_ : spin seed) +diverge seed = diverge seed +joinSpawn reply = case reply of + ((t t), ([task ticket], t)) = IO.require (IO.join ticket) IO.pure + _ = IO.fail "invalid spawn reply" +cancelChild = IO.io <| IO.require (IO.spawn 1 1 [] (_ : spin t)) (spawned : case spawned of + [task ticket] = do IO.bind + _ <- IO.require (IO.cancel task "stop") IO.pure + IO.require (IO.join ticket) IO.pure + _ = IO.fail "invalid spawn reply") +currentScopes = IO.io <| do IO.bind + before <- IO.currentScope + inside <- IO.scope (_ : IO.currentScope) + after <- IO.currentScope + case after of + ^before = case inside of + ^before = IO.pure 0 + _ = IO.pure 1 + _ = IO.pure 0 +badFlags seed = IO.io (IO.spawn 2 1 [] (diverge seed)) +badTransfer seed = IO.io (IO.spawn 1 1 [t] (diverge seed)) +discardedTicket = IO.io <| IO.require (IO.spawn 1 1 [] (_ : IO.pure 5)) (spawned : case spawned of + [task ticket] = IO.bind (IO.discard ticket) (_ : IO.join ticket) + _ = IO.fail "invalid spawn reply") +selfCheck expected = IO.bind IO.currentScope (scope : case scope of + [_ identity key] = IO.scope (_ : IO.bind (IO.join ["io.v2.join" identity key]) (reply : case reply of + (t, ([category _ _ _], t)) = case category of + ^expected = IO.pure t + _ = IO.fail "wrong cycle category" + _ = IO.fail "self join succeeded")) + _ = IO.fail "invalid scope") +-- Parent never claims this ticket. Normal-exit joining must wait for the child's +-- assertion; the wrong-expectation control must fail, not merely lose its result. +selfRun expected = IO.io <| IO.bind (IO.spawn 0 1 [] (_ : selfCheck expected)) (_ : IO.pure 42) +selfCycle = selfRun "join-cycle" +selfCycleWrong = selfRun "not-the-category" +pureDomainError = IO.io (IO.pure (B.err "ordinary" t)) +rootFailure seed = IO.io (IO.fail (diverge seed)) +rootCancelled seed = IO.io (IO.cancelled (diverge seed)) +malformedBind = IO.io (B.pair 1 t) +unknownWithPoison seed = IO.io (IO.request "future" 3 "call" (diverge seed)) +legacyEnvelope seed = B.pair "tricuIO" (B.pair 1 (diverge seed)) diff --git a/test/io/core-retention.tri b/test/io/core-retention.tri @@ -0,0 +1,26 @@ +!import base as B +!import native_math as N +!import io as IO +!export joined +!export discarded + +-- Each child creates a different, forced 4096-cell payload. A constant or +-- unevaluated payload would conceal an archive of completed task results. +make 0 _ = [] +make count value = B.pair value (make (N.predecessor count) value) +count [] acc = acc +count [_ | rest] acc = count rest (N.successor acc) +count _ _ = 0 +checked payload = case N.equal (count payload 0) 4096 of + (t t) = IO.pure payload + _ = IO.fail "incomplete fresh payload" +step mode seed = IO.scope (_ : IO.require + (IO.spawn 0 1 [] (_ : checked (make 4096 seed))) (spawned : case spawned of + [task ticket] = case mode of + 0 = IO.require (IO.join ticket) (_ : IO.pure t) + _ = IO.require (IO.discard ticket) (_ : IO.pure t) + _ = IO.fail "invalid spawn reply")) +loop _ 0 = IO.pure t +loop mode left = IO.bind (step mode left) (_ : loop mode (N.predecessor left)) +joined iterations = IO.io (loop 0 iterations) +discarded iterations = IO.io (loop 1 iterations) diff --git a/test/io/indexed-transport.tri b/test/io/indexed-transport.tri @@ -0,0 +1,14 @@ +!import io as IO +!export verifyTail +!export wrongKind + +verifyTail path limit tree = IO.io <| IO.scope (owner : do IO.onOk + reader <- IO.openReader owner path + IO.verifyIndexedTree reader limit tree + IO.readSome reader 1) + +-- A bad resource kind is rejected before demanding the value to encode. +diverge seed = diverge seed +wrongKind seed = IO.io <| IO.scope (owner : do IO.onOk + digest <- IO.openDigest owner "sha256" + IO.verifyIndexedTree digest 65536 (diverge seed)) diff --git a/test/io/protocol.tri b/test/io/protocol.tri @@ -0,0 +1,24 @@ +!import base as B +!import io as IO +!import handlers as H +!export capabilities +!export handledCapabilities +!export inspectWriter +!export inspectLazyBind +!export inspectLazyScope +!export inspectFuture +!export unsupported +!export invalidHeader +!export retired + +capabilities = IO.io IO.capabilities +handledCapabilities = IO.io <| H.runIO (H.liftIO IO.capabilities) +inspectWriter path = IO.io <| IO.bind IO.currentScope + (owner : IO.inspect (IO.openWriter owner path)) +diverge x = diverge x +inspectLazyBind seed = IO.io <| IO.inspect (B.pair 1 (diverge seed)) +inspectLazyScope seed = IO.io <| IO.inspect (IO.scope (diverge seed)) +inspectFuture seed = IO.io <| IO.inspect (IO.request "future-tcp" 1 "connect" (diverge seed)) +unsupported seed = IO.io <| IO.request "future-tcp" 1 "connect" (diverge seed) +invalidHeader = IO.io <| IO.request "file" 0 "open-reader" t +retired = IO.io (B.pair 21 t) diff --git a/test/io/registry-log-failure.tri b/test/io/registry-log-failure.tri @@ -0,0 +1,10 @@ +!import base as B +!import io as IO +!import handlers as H +!import arboricx_streaming as Server +!export main + +-- Exercise the production request path with an ordinary failed logging sink. +failedLog _ = H.pure (B.err "private logger failure" t) +main = IO.io (Server.serve "./store" 1048576 failedLog + (IO.ipv4 [0 0 0 0] 8080) 16 (_ : IO.pure t)) diff --git a/test/io/tasks.tri b/test/io/tasks.tri @@ -0,0 +1,129 @@ +!import http as Http +!import native_math as N +!import base as B +!import io as IO +!import handlers as H +!export churnJoined +!export churnDiscarded +!export acknowledgedWrites +!export partialWrites +!export failedPartialWrite +!export joined +!export consumed +!export supervised +!export cancelled +!export ordinary +!export inherited +!export changed +!export callback +!export labelledFailure +!export labelledStateBoundary +!export lazyLabels +!export stateBoundary + +joinSpawn reply = case reply of + ((t t), ([task ticket], t)) = IO.require (IO.join ticket) IO.pure + _ = IO.fail "Invalid spawn reply" +runChild action = IO.io <| IO.bind (IO.spawn 1 1 [] (_ : action)) joinSpawn +joined = runChild (IO.pure 42) +supervised = runChild (IO.fail "fault") +cancelled = runChild (IO.cancelled "shutdown") +ordinary = runChild (IO.pure (B.err "domain" t)) +consumed = IO.io <| IO.require (IO.spawn 1 1 [] (_ : IO.pure 42)) (spawned : case spawned of + [task ticket] = do IO.bind + first <- IO.join ticket + second <- IO.join ticket + IO.pure [first second] + _ = IO.fail "Invalid spawn reply") + +value n _ _ = H.pure n +child action = H.bind (H.spawn 1 1 [] (_ : action)) (reply : H.liftIO (joinSpawn reply)) +readValue = H.request "value" "get" t +inherited = IO.io <| H.runIO (H.handle "value" (value 7) (H.scope (owner : do H.bind + answer <- child readValue + parent <- readValue + H.pure [answer parent]))) +changed = IO.io <| H.runIO (H.handle "value" (value 7) (do H.bind + answer <- child (H.handle "value" (value 9) readValue) + parent <- readValue + H.pure [answer parent])) +spawner _ _ = child readValue +callback = IO.io <| H.runIO (H.handle "value" (value 7) + (H.handle "spawn" spawner (H.handle "value" (value 9) (do H.bind + answer <- H.request "spawn" "go" t + parent <- readValue + H.pure [answer parent])))) +diverge x = diverge x +lazyLabels seed = IO.io <| H.runIO (H.label (diverge seed) (child (H.pure 7))) +stateBoundary = runChild (H.runIO (H.collect "log" "write" B.anyC t (child (H.pure 7)))) + +-- Each child obtains a fresh host chunk. Constant/shared payloads would conceal +-- a completion archive in an RSS-only test. Scope exit settles discarded work. +churnStep mode reader = IO.scope (_ : IO.require + (IO.spawn 0 1 [] (_ : IO.require (IO.readSome reader 4096) checkedChunk)) + (spawned : case spawned of + [task ticket] = case mode of + 0 = IO.require (IO.join ticket) (_ : IO.pure t) + _ = IO.require (IO.discard ticket) (_ : IO.pure t) + _ = IO.fail "Invalid spawn reply")) +churnLoop 0 _ _ = IO.pure t +churnLoop count mode reader = IO.bind (churnStep mode reader) + (_ : churnLoop (N.predecessor count) mode reader) +churn mode count path = IO.io <| IO.scope (owner : + IO.require (IO.openReader owner path) (reader : churnLoop count mode reader)) +churnJoined count path = churn 0 count path +churnDiscarded count path = churn 1 count path + +checkedChunk ("data", bytes) = case Http.httpBodyLength bytes of + 4096 = IO.pure bytes + _ = IO.fail "Incomplete retention input" +checkedChunk _ = IO.fail "Unexpected retention EOF" + +labelledFailure = IO.io <| H.runIO (H.label "outer" + (child (H.label "inner" (H.fail "problem" "service" "op" "detail")))) +labelledStateBoundary = runChild (H.runIO (H.label "outer" + (H.collect "log" "write" B.anyC t (H.label "inner" (child (H.pure 7)))))) + +-- Test-only interpreter for the actual library convenience, not a reimplementation +-- of its chunk dropping. Each successful mock accepts at most two bytes; fuel +-- exhaustion fails before any further acceptance. Python specifies full traces. +mockWrites fuel action frames accepted = let + resume value = case frames of + [] = [value accepted] + [next | rest] = mockWrites fuel (next value) rest accepted + _ = ["invalid frames"] + in case action of + (0, value) = resume value + (1, (first, next)) = mockWrites fuel first (B.pair next frames) accepted + (10, ("stream", (1, ("write", (writer, bytes))))) = case fuel of + 0 = resume (B.err "write fault" t) + _ = case bytes of + [a b | rest] = mockWrites (N.predecessor fuel) (IO.pure (B.ok 2 t)) frames + (B.append accepted [a b]) + [a] = mockWrites (N.predecessor fuel) (IO.pure (B.ok 1 t)) frames + (B.append accepted [a]) + _ = ["invalid chunk"] + _ = ["invalid action"] +partialWrites bytes = IO.io <| IO.pure (mockWrites 32 (IO.writeAll t bytes) [] []) +failedPartialWrite bytes = IO.io <| IO.pure (mockWrites 1 (IO.writeAll t bytes) [] []) + +-- Caller-specified positive acknowledgements exercise full and partial chunks. +-- Record actual submitted suffixes; Python checks them independently. Exhausted +-- acknowledgements inject an uncertain operation failure before further progress. +mockAcknowledgements counts action frames requests = let + resume value = case frames of + [] = [value (B.reverse requests)] + [next | rest] = mockAcknowledgements counts (next value) rest requests + _ = ["invalid frames"] + in case action of + (0, value) = resume value + (1, (first, next)) = mockAcknowledgements counts first (B.pair next frames) requests + (10, ("stream", (1, ("write", (writer, bytes))))) = case counts of + [count | rest] = mockAcknowledgements rest (IO.pure (B.ok count t)) frames + (B.pair bytes requests) + [] = mockAcknowledgements [] (IO.pure (B.err "write fault" t)) frames + (B.pair bytes requests) + _ = ["invalid acknowledgements"] + _ = ["invalid action"] +acknowledgedWrites counts bytes = + IO.io <| IO.pure (mockAcknowledgements counts (IO.writeAll t bytes) [] []) diff --git a/test/io/utf8-boundaries.tri b/test/io/utf8-boundaries.tri @@ -0,0 +1,17 @@ +!import base as B +!import native_math as N +!import io as IO +!export excessEncode +!export excessDecode +!export excessWrite +!export excessDigest + +zeros 0 tail = tail +zeros n tail = B.pair 0 (zeros (N.predecessor n) tail) +diverge seed = diverge seed +excessEncode seed = IO.io (IO.encodeUTF8 (zeros 1024 (B.pair (diverge seed) (diverge seed)))) +excessDecode seed = IO.io (IO.decodeUTF8 0 (zeros 4096 (B.pair (diverge seed) (diverge seed)))) +excessWrite path seed = IO.io <| IO.scope (owner : IO.require (IO.openWriter owner path) (writer : + IO.writeSome writer (zeros 4096 (B.pair (diverge seed) (diverge seed))))) +excessDigest seed = IO.io <| IO.scope (owner : IO.require (IO.openDigest owner "sha256") (digest : + IO.updateDigest digest (zeros 4096 (B.pair (diverge seed) (diverge seed))))) diff --git a/test/io_artifact_service.py b/test/io_artifact_service.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 +"""Actual v2 artifact handlers: exact bytes, integrity, lexical routing and limits.""" +import argparse +import hashlib +import json +from pathlib import Path +import subprocess +from source_order import Runner, field +from http_headers import number, result, tree_list, pair +from io_objects import text, problem + +ROOT = Path(__file__).resolve().parent.parent + + +def octets(data): + return tree_list([number(v) for v in data]) + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument('tricu', type=Path) + p.add_argument('--directory', type=Path, required=True) + a = p.parse_args() + root = a.directory.resolve(); root.mkdir(parents=True, exist_ok=False) + runner = Runner(a.tricu, root) + bundle = root/'service.arboricx' + runner.run('compile', ['compile', '--file', ROOT/'test/io/artifact-service.tri', + '--store', runner.store, '--output', bundle, '--compile-timeout', '600'], seconds=660) + pin = runner.imported(bundle, 'import') + checks = [] + + def arguments(label, values): + args = ['--io'] + for i, value in enumerate(values): + path = root/f'{label}-{i}.tree'; path.write_bytes(value) + args += ['--arg-tree-file', path] + return args + + def check(label, name, values, expected): + actual = runner.invoke(label, pin, name, arguments(label, values)) + assert actual == expected, label + checks.append(label) + + def path(directory, data): + digest = hashlib.sha256(data).hexdigest() + return directory/'objects'/digest[:3]/(digest+'.arboricx') + + left, right = root/'left', root/'right' + for directory in [left, right]: + for suffix in ['new', 'existing']: + check(directory.name+'-'+suffix, 'prepare', [text(str(directory))], result(True, b'\0')) + limit = number(8192) + for index, data in enumerate([b'', b'\0\xff\x80\r\n', bytes(range(256))*16+b'end']): + digest = text(hashlib.sha256(data).hexdigest()) + for suffix in ['new', 'existing']: + check(f'publish-{index}-{suffix}', 'publish', [text(str(left)), limit, octets(data)], result(True, digest)) + assert path(left, data).read_bytes() == data + check(f'load-{index}', 'load', [text(str(left)), limit, digest], result(True, octets(data))) + corrupt = b'X'+data[1:] if data else b'X' + path(left, data).write_bytes(corrupt) + check(f'corrupt-load-{index}', 'load', [text(str(left)), limit, digest], + problem('integrity-failed', 'artifacts/load', 'digest mismatch', 'unknown')) + check(f'corrupt-winner-{index}', 'publish', [text(str(left)), limit, octets(data)], + problem('invalid-data', 'storage/verify', 'different bytes', 'unknown')) + assert path(left, data).read_bytes() == corrupt + path(left, data).write_bytes(data+b'extra') + check(f'trailing-winner-{index}', 'publish', [text(str(left)), limit, octets(data)], + problem('invalid-data', 'storage/verify', 'different bytes', 'unknown')) + path(left, data).write_bytes(data) + data = b'lexical' + check('nearest-route', 'routed', [text(str(left)), text(str(right)), limit, octets(data)], + result(True, text(hashlib.sha256(data).hexdigest()))) + assert path(right, data).read_bytes() == data and not path(left, data).exists() + data = b'captured' + check('captured-route', 'captured', [text(str(left)), text(str(right)), limit, octets(data)], + result(True, text(hashlib.sha256(data).hexdigest()))) + assert path(left, data).read_bytes() == data and not path(right, data).exists() + check('forward-unknown', 'forwarded', [text(str(left))], tree_list([text('other'), number(42)])) + absent = root/'untouched' + diagnostic = tree_list([text('invalid-digest'), text('expected 64 lowercase hexadecimal hash characters')]) + for index, invalid in enumerate(['../outside', 'A'*64, '0'*63, '0'*65, '\0'*64]): + for name in ['load', 'rawLoad']: + check(f'{name}-invalid-{index}', name, [text(str(absent)), limit, text(invalid)], result(False, diagnostic)) + check('budget-before-paths', 'publish', [text(str(absent)), number(0), octets(b'x')], + problem('invalid-bound', 'storage/hash', 'bytes exceed limit', 'none')) + assert not absent.exists() + data = b'captured' + check('read-budget', 'load', [text(str(left)), number(1), text(hashlib.sha256(data).hexdigest())], + problem('invalid-bound', 'storage/read', 'file exceeds byte limit', 'unknown')) + for label, malformed in [('wide', tree_list([number(256)])), ('improper', b'\1\0')]: + args = arguments('raw-'+label, [text(str(absent)), limit, malformed]) + command = [str(a.tricu.resolve()), 'arboricx', 'run', '--store', str(runner.store), + '--module', pin, '--name', 'rawPublish', *args] + reply = subprocess.run(command, capture_output=True, text=True, timeout=30) + assert reply.returncode != 0 and 'IO task failed' in reply.stderr, reply + actual = runner.invoke('view-'+label, pin, 'rejectedView', args[1:]) + wanted = pair(number(103), tree_list([text('invalid-request'), text('artifacts'), text('publish'), + text('expected octet' if label == 'wide' else 'not a list'), tree_list([])])) + assert actual == wanted, label + checks.append('raw-'+label) + assert not absent.exists() + blocked = root/'blocked'; blocked.write_bytes(b'keep') + actual = runner.invoke('blocked-root', pin, 'prepare', arguments('blocked', [text(str(blocked))])) + assert field(actual, 1, 0, 0) == text('not-directory') + assert blocked.read_bytes() == b'keep' + checks.append('blocked-root') + data = b'concurrent winner' + args = arguments('race', [text(str(right)), limit, octets(data)]) + command = [str(a.tricu.resolve()), 'arboricx', 'run', '--store', str(runner.store), + '--module', pin, '--name', 'publish', '--format', 'ternary', *args, + '+RTS', '-N1', '-M2g', '-RTS'] + processes = [subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE) for _ in range(2)] + try: + for process in processes: + stdout, stderr = process.communicate(timeout=60) + assert process.returncode == 0, stderr + assert bytes(int(c) for c in stdout.strip().decode()) == result(True, text(hashlib.sha256(data).hexdigest())) + finally: + for process in processes: + if process.poll() is None: + process.kill(); process.wait() + assert path(right, data).read_bytes() == data + checks.append('concurrent-winner') + data = b'must not write with old grants' + args = arguments('grants', [text(str(right)), limit, octets(data)]) + reply = subprocess.run([str(a.tricu.resolve()), 'arboricx', 'run', '--store', str(runner.store), + '--module', pin, '--name', 'publish', *args, '--allow-write', str(right)], + capture_output=True, text=True, timeout=30) + assert reply.returncode != 0 and 'v1 permission grants are not accepted' in reply.stderr + assert not path(right, data).exists() + checks.append('v1-grants-rejected-before-effects') + def response(status, body, mime='text/plain; charset=utf-8'): + phrase = {200:'OK', 201:'Created', 400:'Bad Request', 404:'Not Found', 500:'Internal Server Error'}[status] + return octets((f'HTTP/1.1 {status} {phrase}\r\nContent-Type: {mime}\r\n' + f'Content-Length: {len(body)}\r\nConnection: close\r\n\r\n').encode()+body) + def route(label, method, target, body, expected): + check(label, 'route', [text(str(left)), limit, text(method), text(target), octets(body)], expected) + route('health', 'GET', '/_arboricx/health', b'', response(200, b'OK\n')) + data = b'route binary\0\xff' + digest = hashlib.sha256(data).hexdigest() + route('route-publish', 'POST', '/_arboricx/bundle', data, response(201, digest.encode())) + route('route-get', 'GET', '/_arboricx/bundle/hash/'+digest, b'', + response(200, data, 'application/vnd.arboricx.bundle')) + path(left, data).write_bytes(b'corrupt') + route('route-corrupt-get', 'GET', '/_arboricx/bundle/hash/'+digest, b'', response(404, b'Artifact unavailable\n')) + route('route-corrupt-put', 'POST', '/_arboricx/bundle', data, response(400, b'Upload failed\n')) + assert path(left, data).read_bytes() == b'corrupt' + data = b'published despite logger failure' + check('failed-log-after-publication', 'routeFailedLog', [text(str(left)), limit, octets(data)], + response(500, b'Internal Server Error\n')) + assert path(left, data).read_bytes() == data + route('after-failed-request', 'GET', '/_arboricx/health', b'', response(200, b'OK\n')) + package = b'captured package bytes' + (left/'packages'/'example').mkdir(parents=True) + (left/'packages'/'example'/'1').write_bytes(package) + (right/'packages'/'example').mkdir(parents=True) + (right/'packages'/'example'/'1').write_bytes(b'wrong root') + target = '/_arboricx/package/example/1' + check('captured-package', 'capturedPackage', [text(str(left)), text(str(right)), limit, text(target)], + response(200, package, 'application/vnd.arboricx.package-release')) + route('package-route', 'GET', target, b'', response(200, package, 'application/vnd.arboricx.package-release')) + route('package-invalid', 'GET', '/_arboricx/package/../escape', b'', response(400, b'Invalid package name/version')) + check('stdout-status', 'routeStdout', [text(str(left)), limit], response(200, b'OK\n')) + assert 'registry status=200\n' in (root/'stdout-status.log').read_text() + data = b'published before cancellation' + check('request-cancellation', 'routeCancelledLog', [text(str(left)), limit, octets(data)], + tree_list([text('cancelled'), text('logger stopped'), tree_list([])])) + assert path(left, data).read_bytes() == data + for size in [0, 1, 4095, 4096, 4097, 65536, 1048576]: + data = (bytes(range(256))*((size+255)//256))[:size] + source = root/f'stream-source-{size}'; source.write_bytes(data) + digest = hashlib.sha256(data).hexdigest() + for mode in ['new', 'existing']: + check(f'stream-{size}-{mode}', 'streamPublish', [text(str(right)), text(str(source)), number(size)], + result(True, text(digest))) + assert path(right, data).read_bytes() == data + assert list((right/'tmp').iterdir()) == [] + if size >= 65536: + record = runner.records[-1] + assert record['peak_kib'] < 128*1024, 'stream RSS retention regression' + assert record['rts_process']['maximum_live_bytes'] < 16*1024*1024, 'stream heap retention regression' + data = b'stream corruption test' + source = root/'stream-negative'; source.write_bytes(data) + check('stream-short', 'streamPublish', [text(str(right)), text(str(source)), number(len(data)+1)], + result(False, text('Incomplete artifact stream'))) + check('stream-excess', 'streamPublish', [text(str(right)), text(str(source)), number(len(data)-1)], + result(False, text('Artifact exceeds declared length'))) + assert not path(right, data).exists() + path(right, data).parent.mkdir(exist_ok=True) + path(right, data).write_bytes(b'X'+data[1:]) + check('stream-corrupt-winner', 'streamPublish', [text(str(right)), text(str(source)), number(len(data))], + result(False, text('Existing artifact digest mismatch'))) + assert path(right, data).read_bytes() == b'X'+data[1:] + assert list((right/'tmp').iterdir()) == [] + assert not list(left.rglob('.tricu-*')) and not list(right.rglob('.tricu-*')) + (root/'report.json').write_text(json.dumps(dict(checks=checks, records=runner.records), indent=2)+'\n') + print(f'PASS {len(checks)} v2 artifact service controls; deployment unchanged') + + +if __name__ == '__main__': + main() diff --git a/test/io_callers.py b/test/io_callers.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""Real migrated reader/state/file/bundle callers, with independent outputs.""" +import argparse,json,os,subprocess,struct +from pathlib import Path +from source_order import Runner +from http_headers import result,parse_tree_output +from io_text import text +ROOT=Path(__file__).resolve().parent.parent + +def main(): + p=argparse.ArgumentParser(description=__doc__);p.add_argument('tricu',type=Path);p.add_argument('--directory',type=Path,required=True) + a=p.parse_args();d=a.directory.resolve();d.mkdir(parents=True,exist_ok=False);r=Runner(a.tricu,d);checks=[];pins={} + for name in ['safeRead','shout','writeThenRead','environment','state','bundle']: + source=ROOT/'demos/runArboricxBundle.tri' if name=='bundle' else ROOT/f'demos/interactionTrees/{name}.tri' + bundle=d/(name+'.arboricx');r.run(name+'-compile',['compile','--file',source,'--store',r.store,'--output',bundle,'--compile-timeout','600'],seconds=660);pins[name]=r.imported(bundle,name+'-import') + def call(label,module,name,args=(),env=None,good=True,output=False): + command=[str(r.exe),'arboricx','run','--module',pins[module],'--name',name,'--store',str(r.store),'--io','--format','ternary'] + if output:command+=['--no-result'] + for i,arg in enumerate(args): + f=d/f'{label}-{i}.tree';f.write_bytes(text(arg));command+=['--arg-tree-file',str(f)] + proc=subprocess.run(command,capture_output=True,timeout=60,env=env) + (d/(label+'.stdout')).write_bytes(proc.stdout);(d/(label+'.stderr')).write_bytes(proc.stderr) + assert (proc.returncode==0)==good,(label,proc.stderr) + checks.append(label) + return proc.stdout if output or not good else parse_tree_output(proc.stdout.decode()) + for i,value in enumerate(['','\ufeffλ\r\n\0😀','a'*4092+'😀']): + f=d/f'text-{i}.txt';f.write_bytes(value.encode()) + assert call(f'read-{i}','safeRead','run',[str(f)])==text(value) + assert call(f'shout-{i}','shout','run',[str(f)])==text(value+'!!!') + out=d/f'write-{i}.txt' + assert call(f'roundtrip-{i}','writeThenRead','run',[str(out),value])==result(True,text(value)) + assert out.read_bytes()==value.encode() + missing=d/'missing' + assert call('missing-read','safeRead','run',[str(missing)])==text('ERROR: Unable to read file') + assert call('missing-shout','shout','run',[str(missing)])==text('missing!!!') + invalid=d/'invalid';invalid.write_bytes(b'\xff') + assert call('invalid-text','safeRead','run',[str(invalid)])==text('ERROR: Unable to read file') + over=d/'overlong';over.write_bytes(b'a'*1048577) + assert call('read-budget','safeRead','run',[str(over)])==text('ERROR: Unable to read file') + failure=call('write-failed','writeThenRead','run',[str(missing/'output'),'never']) + assert failure[:2]==b'\2\0' and not missing.exists(),failure + env=dict(os.environ);env.pop('TRICU_DEMO_OUTPUT',None) + call('explicit-output-required','writeThenRead','main',env=env,good=False) + out=d/'configured.txt' + assert call('configured-output','writeThenRead','main',env=dict(env,TRICU_DEMO_OUTPUT=str(out)),output=True)==b'hello from tricu\n' + assert out.read_bytes()==b'hello from tricu' + assert call('lexical-configuration','environment','main',env=dict(env,TRICU_DEMO_OUTPUT='unrelated'),output=True)==b'working in env: demo\nlocal returned: inside-done\noutside env: outer\n' + assert call('explicit-state','state','main',output=True)==b'state: idle\nstate: running\nstate: done\n' + assert call('bundle-main','bundle','main',output=True)==b'hi\n' + # Main must check its final write even when --no-result suppresses values. + for name in ['shout','writeThenRead','environment','state','bundle']: + with open('/dev/full','wb') as full: + run=subprocess.run([str(r.exe),'arboricx','run','--module',pins[name],'--name','main','--store',str(r.store),'--io','--no-result'],stdout=full,stderr=subprocess.PIPE,env=dict(env,TRICU_DEMO_OUTPUT=str(out)),timeout=60) + assert run.returncode!=0,(name,run.stderr) + checks.append(name+'-failed-final-output') + original=(ROOT/'test/fixtures/id.arboricx').read_bytes() + padding=d/'padding.arboricx';padding.write_bytes(original+b'x') + assert call('bundle-permitted-padding','bundle','run',[str(padding),'hi'],output=True)==b'hi\n' + offset=struct.unpack_from('>Q',original,80)[0] + bad=bytearray(original[:offset]+b'x'+original[offset:]) + struct.pack_into('>Q',bad,56,struct.unpack_from('>Q',original,56)[0]+1) + struct.pack_into('>Q',bad,80,offset+1) + for label,data in [('truncated',original[:-1]),('trailing-manifest',bad),('bad-magic',b'X'+original[1:])]: + f=d/(label+'.arboricx');f.write_bytes(data) + assert call('bundle-'+label,'bundle','run',[str(f),'never'],good=False,output=True)==b'' + (d/'report.json').write_text(json.dumps(dict(passed=checks,records=r.records),indent=2)+'\n');print(f'PASS {len(checks)} migrated caller controls') +if __name__=='__main__':main() diff --git a/test/io_clock.py b/test/io_clock.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +"""Actual source timing, scoped waits/cancellation, and migrated async demos.""" +import argparse +import json +from pathlib import Path +import subprocess +from source_order import Runner +from http_headers import number, result, tree_list +from io_objects import text +from io_directory import parse_tree, natural + +ROOT=Path(__file__).resolve().parent.parent + +def main(): + p=argparse.ArgumentParser(description=__doc__) + p.add_argument('tricu',type=Path);p.add_argument('--directory',type=Path,required=True) + a=p.parse_args();root=a.directory.resolve();root.mkdir(parents=True,exist_ok=False) + runner=Runner(a.tricu,root);checks=[] + def compile(label,path): + bundle=root/(label+'.arboricx') + runner.run(label+'-compile',['compile','--file',path,'--store',runner.store,'--output',bundle,'--compile-timeout','600'],seconds=660) + return runner.imported(bundle,label+'-import') + pin=compile('clock',ROOT/'demos/io/clock.tri') + def invoke(label,name,value=None): + args=['--io'] + if value is not None: + path=root/(label+'.tree');path.write_bytes(value);args+=['--arg-tree-file',path] + return runner.invoke(label,pin,name,args) + assert invoke('yield','yielded')==result(True,b'\0');checks.append('yield-unit') + assert invoke('cancelled','cancelledWait')==tree_list([text('cancelled'),text('timer-stop'),b'\0']);checks.append('cancel-max-duration') + for milliseconds in [0,1,20]: + raw=invoke(f'elapsed-{milliseconds}','elapsed',number(milliseconds)) + # Decode the binary kernel tree with the existing reader; compare to an + # independent host duration bound, never a self-derived expected value. + elapsed=natural(parse_tree(raw)) + assert elapsed>=milliseconds*1000000,(milliseconds,elapsed) + checks.append(f'elapsed-{milliseconds}') + assert natural(parse_tree(invoke('scope-wait','scopeWait')))>=20000000 + checks.append('normal-scope-waits') + problem=tree_list([text('invalid-argument'),text('clock/sleep'),b'\0',text('none')]) + for i,value in enumerate([number(9223372036854775808),b'\1\0',b'\2\0\0']): + assert invoke(f'invalid-{i}','invalidSleep',value)==result(False,problem) + checks.append(f'invalid-{i}') + for label,path,wanted in [ + ('async',ROOT/'demos/interactionTrees.tri',b'Parent first!\n2000ms done sleeping!\n5000ms done sleeping!\n'), + ('yield-demo',ROOT/'demos/interactionTrees/yield.tri',b'A says 1\nA says 2\nA says 3\nB says 1\nB says 2\nB says 3\nFinished: doneA doneB\n')]: + demo=compile(label,path) + command=[str(runner.exe),'arboricx','run','--module',demo,'--name','main','--store',str(runner.store),'--io','--no-result'] + run=subprocess.run(command,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=30) + (root/(label+'.stdout')).write_bytes(run.stdout);(root/(label+'.stderr')).write_bytes(run.stderr) + assert run.returncode==0,(label,run.stderr) + assert run.stdout==wanted,(label,run.stdout) + checks.append(label) + (root/'report.json').write_text(json.dumps(dict(passed=checks,records=runner.records),indent=2)+'\n') + print(f'PASS {len(checks)} clock/caller controls') +if __name__=='__main__':main() diff --git a/test/io_compiler_store.py b/test/io_compiler_store.py @@ -0,0 +1,277 @@ +#!/usr/bin/env python3 +"""Run actual source_object callers with an isolated v2 IO source projection. +No compiler generations, frozen-input edits or default workspace replacement. +""" +import argparse +import hashlib +import json +import subprocess +from pathlib import Path +from source_order import Runner +from process_rss import run_guarded +from stable_bundle import byte_tree +from native_indexed_store import indexed_bytes, digest +from executable_object import INDEXED +from io_indexed_write import dag +from io_objects import text, problem +from http_headers import number, result, tree_list + +ROOT = Path(__file__).resolve().parent.parent +PROBE = '''!import source_object as O +!import compiler_io as IO +!import io_storage as S +!export putRaw +!export getRaw +!export putTree +!export getTree +!export copyTree +!export replace +!export read +!export hash +!export publish +!export parent +!export exists +putRaw root domain bytes = IO.io (O.sourceObjectStore root domain bytes) +getRaw root domain digest = IO.io (O.sourceObjectGet root domain digest) +putTree root tree = IO.io (O.sourceObjectPutTree root tree) +getTree root digest = IO.io (O.sourceObjectGetTree root digest) +copyTree source target digest = IO.io <| IO.onOk_ (O.sourceObjectGetTree source digest) + (tree : O.sourceObjectPutTree target tree) +replace path bytes = IO.io (IO.atomicWriteBytes path bytes) +read path limit = IO.io (S.readBytes path limit) +hash limit bytes = IO.io (S.hashBytes limit bytes) +publish directory path mode limit bytes = IO.io (S.publishBytes directory path mode limit bytes) +parent path = S.parentPath path +exists path = IO.io (IO.fileExists path) +''' + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('tricu',type=Path) + parser.add_argument('--directory',type=Path,required=True) + parser.add_argument('--compiler-smoke',action='store_true',help='also build a candidate stable sourceBuildGraph and compile a real dependency graph; not a generation pair') + args=parser.parse_args() + root=args.directory.resolve(); root.mkdir(parents=True,exist_ok=False) + projection=root/'projection'; projection.mkdir() + (projection/'inputs').mkdir() + rows=['tricu.workspace.v1'] + for line in (ROOT/'tricu.workspace').read_text().splitlines()[1:]: + name,path=line.split('\t') + selected=ROOT/path + relative='inputs/'+name+'.tri' + (projection/relative).write_bytes(selected.read_bytes()) + rows.append(name+'\t'+relative) + (projection/'tricu.workspace').write_text('\n'.join(rows)+'\n') + source=projection/'probe.tri'; source.write_text(PROBE) + runner=Runner(args.tricu,root) + bundle=root/'probe.arboricx' + runner.run('compile',['compile','--file',source,'--store',runner.store,'--output',bundle, + '--compile-timeout','300'],seconds=330) + pin=runner.imported(bundle,'import') + checks=[] + candidate=None + def invoke(label,name,values,expected,io=True): + argv=['--io'] if io else [] + for i,value in enumerate(values): + path=root/f'{label}-{i}.tree'; path.write_bytes(value) + argv+=['--arg-tree-file',path] + assert runner.invoke(label,pin,name,argv)==expected,label + checks.append(label) + def clean(path): + assert not list(path.rglob('.tricu-*')),path + for i,payload in enumerate([b'',bytes(range(256)),b'a'*4096+b'last\0\xff']): + store=root/f'raw-{i}' + expected=hashlib.sha256(b'test.raw\0'+payload).hexdigest() + path=store/'objects'/expected[:3]/expected + for status in ['new','existing']: + invoke(f'raw-{i}-{status}','putRaw',[text(str(store)),text('test.raw'),byte_tree(payload)],result(True,text(expected))) + assert path.read_bytes()==payload + clean(store) + invoke(f'raw-get-{i}','getRaw',[text(str(store)),text('test.raw'),text(expected)],result(True,byte_tree(payload))) + path.write_bytes(payload+b'excess') + invoke(f'raw-corrupt-{i}','putRaw',[text(str(store)),text('test.raw'),byte_tree(payload)], + problem('invalid-data','storage/verify','different bytes','unknown')) + assert path.read_bytes()==payload+b'excess'; clean(store) + invoke(f'raw-corrupt-get-{i}','getRaw',[text(str(store)),text('test.raw'),text(expected)], + result(False,text('CAS digest mismatch'))) + for i,tree in enumerate([b'\0',number(42)]): + store=root/f'tree-{i}' + payload=indexed_bytes(tree); expected=digest(INDEXED,payload) + path=store/'objects'/expected[:3]/expected + for status in ['new','existing']: + invoke(f'tree-{i}-{status}','putTree',[text(str(store)),tree],result(True,text(expected))) + assert path.read_bytes()==payload; clean(store) + invoke(f'tree-get-{i}','getTree',[text(str(store)),text(expected)],result(True,tree)) + path.write_bytes(payload+b'excess') + invoke(f'tree-excess-{i}','putTree',[text(str(store)),tree], + problem('invalid-data','storage/verify','trailing bytes','unknown')) + assert path.read_bytes()==payload+b'excess'; clean(store) + invoke(f'tree-corrupt-get-{i}','getTree',[text(str(store)),text(expected)], + problem('integrity-failed','indexed-tree/read','CAS digest mismatch','unknown')) + shared=dag(200,True); expected=digest(INDEXED,shared) + store=root/'shared'; shard=store/'objects'/expected[:3]; shard.mkdir(parents=True) + (shard/expected).write_bytes(shared) + target=root/'shared-copy' + invoke('shared-copy','copyTree',[text(str(store)),text(str(target)),text(expected)],result(True,text(expected))) + assert (target/'objects'/expected[:3]/expected).read_bytes()==shared; clean(target) + alias=root/'alias' + for i,payload in enumerate([b'old',b'new\0\xff',b'']): + invoke(f'replace-{i}','replace',[text(str(alias)),byte_tree(payload)],result(True,b'\1\0')) + assert alias.read_bytes()==payload + for i,path in enumerate(['name','/name','/','a/b','a//b','../name','λ/name','name/','/name///','//name','///']): + expected_parent=['.','/','/','a','a/','..','λ','.','/','//','///'][i] + invoke(f'parent-{i}','parent',[text(path)],text(expected_parent),False) + data=root/'bytes'; data.write_bytes(b'ab\0\xff') + invoke('read-exact','read',[text(str(data)),number(4)],result(True,byte_tree(data.read_bytes()))) + invoke('read-limit','read',[text(str(data)),number(3)],problem('invalid-bound','storage/read','file exceeds byte limit','unknown')) + for i,payload in enumerate([b'',b'a'*4096+b'last']): + invoke(f'hash-{i}','hash',[number(len(payload)),byte_tree(payload)],result(True,text(hashlib.sha256(payload).hexdigest()))) + # Exact chunk boundaries, short final chunks, and the final budget/EOF probe. + for size in [0,1,4095,4096,4097,8191,8192,8193]: + payload=bytes(i % 256 for i in range(size)) + encoded=byte_tree(payload) + data.write_bytes(payload) + expected_hash=result(True,text(hashlib.sha256(payload).hexdigest())) + for limit in sorted({size, max(size,4096), 67108864}): + invoke(f'hash-boundary-{size}-{limit}','hash',[number(limit),encoded],expected_hash) + invoke(f'read-boundary-{size}','read',[text(str(data)),number(size)],result(True,encoded)) + if size: + invoke(f'hash-short-budget-{size}','hash',[number(size-1),encoded], + problem('invalid-bound','storage/hash','bytes exceed limit','none')) + invoke(f'read-short-budget-{size}','read',[text(str(data)),number(size-1)], + problem('invalid-bound','storage/read','file exceeds byte limit','unknown')) + # Host octet checks and source proper-list checks remain pre-publication, + # including faults after one or two complete chunks were already hashed. + for size in [0,4095,4096,4097,8192]: + prefix=byte_tree(b'a'*size)[:-1] + for kind,tail,expected in [ + ('octet',tree_list([number(256)]),problem('invalid-argument','digest/update','','none')), + ('stem',b'\1\0',problem('invalid-argument','storage/hash','improper byte list','none'))]: + malformed=prefix+tail + invoke(f'hash-invalid-{kind}-{size}','hash',[number(67108864),malformed],expected) + for mode in [0,1]: + destination=root/f'invalid-{kind}-{size}-{mode}' + # A failed replacement must preserve an already existing alias. + if mode: destination.write_bytes(b'prior alias') + invoke(f'publish-invalid-{kind}-{size}-{mode}','publish', + [text(str(root)),text(str(destination)),number(mode),number(67108864),malformed],expected) + assert destination.read_bytes()==b'prior alias' if mode else not destination.exists() + clean(root) + for label,limit in [('stem',b'\1\0'),('too-large',number(9223372036854775808)), + ('noncanonical',b'\2\0\0')]: + expected=problem('invalid-bound','storage/limit','invalid byte limit','none') + invoke('hash-invalid-limit-'+label,'hash',[limit,byte_tree(b'')],expected) + invoke('read-invalid-limit-'+label,'read',[text(str(data)),limit],expected) + # Existing immutable winners must match every byte and EOF, independently of + # reader chunking. Retain corrupt objects for diagnosis rather than repair. + payload=bytes(i % 256 for i in range(8193)) + destination=root/'winner-boundaries' + for label,winner in [('equal',payload),('short',payload[:-1]),('long',payload+b'x'), + *[(f'changed-{at}',payload[:at]+bytes([payload[at]^255])+payload[at+1:]) + for at in [0,4095,4096,8192]]]: + destination.write_bytes(winner) + expected=result(True,b'\0') if label=='equal' else problem('invalid-data','storage/verify','different bytes','unknown') + invoke('winner-'+label,'publish',[text(str(root)),text(str(destination)),number(0),number(len(payload)),byte_tree(payload)],expected) + assert destination.read_bytes()==winner + clean(root) + for label,path,wanted in [('file',data,True),('directory',root,False),('missing',root/'absent',False)]: + invoke('exists-'+label,'exists',[text(str(path))],result(True,b'\1\0' if wanted else b'\0')) + invoke('exists-invalid','exists',[text('bad\0path')],problem('invalid-argument','path/stat','','none')) + for mode in [0,1]: + destination=root/f'invalid-octet-{mode}' + invoke(f'invalid-octet-{mode}','publish',[text(str(root)),text(str(destination)),number(mode),number(4096),tree_list([number(256)])], + problem('invalid-argument','digest/update','','none')) + assert not destination.exists() + # Legitimate concurrent mutable aliases are last-writer-wins. Neither caller + # should spuriously verify another writer's replacement against its own bytes. + alias_arg=root/'race-path.tree'; alias_arg.write_bytes(text(str(alias))) + jobs=[]; payloads=[b'A'*4096+b'first',b'B'*4096+b'second'] + for i,payload in enumerate(payloads): + argument=root/f'race-{i}.tree'; argument.write_bytes(byte_tree(payload)) + command=[str(runner.exe),'arboricx','run','--module',pin,'--name','replace','--store',str(runner.store), + '--io','--format','ternary','--arg-tree-file',str(alias_arg),'--arg-tree-file',str(argument), + '+RTS','-N1','-M512m','-RTS'] + jobs.append(subprocess.Popen(command,stdout=subprocess.PIPE,stderr=subprocess.PIPE)) + try: + for process in jobs: + out,err=process.communicate(timeout=60) + assert process.returncode==0,(out,err) + assert out.strip()==''.join(map(str,result(True,b'\1\0'))).encode(),out + finally: + for process in jobs: + if process.poll() is None: process.kill() + process.wait() + assert alias.read_bytes() in payloads + checks.append('concurrent-mutable-alias-replacement') + missing=root/'must-not-exist' + invoke('preflight-limit','publish',[text(str(root)),text(str(missing)),number(0),number(1),byte_tree(b'ab')], + problem('invalid-bound','storage/hash','bytes exceed limit','none')) + assert not missing.exists(); clean(root) + if args.compiler_smoke: + compiler_source=projection/'candidate.tri' + compiler_source.write_text('!import source_build as Build\n!import compiler_io as IO\n!export sourceBuildGraph\n!export main\nsourceBuildGraph = Build.sourceBuildGraphStable\nmain workspace root identity name = IO.io (Build.sourceBuildGraphStable workspace root identity name)\n') + compiler_bundle=root/'candidate.arboricx' + runner.run('compile-candidate',['compile','--file',compiler_source,'--store',runner.store, + '--output',compiler_bundle,'--compile-timeout','600'],seconds=660) + compiler=runner.imported(compiler_bundle,'import-candidate') + candidate=dict(manifest=compiler,io_version=2,bundle_sha256=hashlib.sha256(compiler_bundle.read_bytes()).hexdigest()) + app=root/'app'; app.mkdir() + (app/'dependency.tri').write_text('!export pick\n-- λ 😀 combining: é\npick [x | _] = x\npick _ = 0\n') + (app/'main.tri').write_text('!import dependency as D\n!export main\n!export accent\nmain = D.pick [42 7]\naccent = "é"\n') + application=root/'application' + compiled=root/'application.arboricx' + for label in ['cold','warm']: + runner.run('candidate-'+label,['compile','--file',app/'main.tri','--store',application, + '--output',compiled,'--toolchain',compiler,'--tool-store',runner.store, + '--toolchain-io-version','2','--compile-timeout','300'],seconds=330) + program=runner.imported(compiled,'import-'+label) + assert runner.invoke('run-'+label,program,'main',[])==number(42) + assert runner.invoke('accent-'+label,program,'accent',[])==tree_list([number(101),number(769)]) + checks.append('actual-v2-compiler-'+label) + retained_bundle=root/'retained-application.arboricx' + runner.run('retained-compile',['compile','--file',app/'main.tri','--store',root/'retained-application', + '--output',retained_bundle,'--compile-timeout','300'],seconds=330) + assert compiled.read_bytes()==retained_bundle.read_bytes() + checks.append('complete-application-bundle-identity') + # Exactly received UTF-8 provenance, not re-encoded/normalized text. + # Receipts bind these digests even when source bytes are not retained as + # separate objects by the current source build policy. + all_objects=[p.read_bytes() for p in (application/'objects').glob('*/*') if p.is_file()] + for file in [app/'main.tri',app/'dependency.tri']: + expected=hashlib.sha256(b'arboricx.source.v1\0'+file.read_bytes()).hexdigest().encode() + assert any(expected in payload for payload in all_objects),file + clean(application) + original=(app/'main.tri').read_bytes() + accepted_output=compiled.read_bytes() + for label,bad,diagnostic in [('bom',b'\xef\xbb\xbf'+original,'initial source BOM is not permitted'), + ('utf8',b'\xff','utf8/decode'), + ('crlf',original.replace(b'\n',b'\r\n'),'Portable compilation failed: [40, "invalid import reference or qualifier",')]: + (app/'main.tri').write_bytes(bad) + command=[str(runner.exe),'arboricx','compile','--file',str(app/'main.tri'),'--store',str(application), + '--output',str(compiled),'--toolchain',compiler,'--tool-store',str(runner.store), + '--toolchain-io-version','2','--compile-timeout','60','+RTS','-N1','-M512m','-RTS'] + rejected=subprocess.run(command,capture_output=True,text=True,timeout=90) + assert rejected.returncode!=0,(label,rejected.stdout) + assert diagnostic in rejected.stdout+rejected.stderr,(label,rejected.stderr) + assert compiled.read_bytes()==accepted_output + (root/f'rejected-{label}.log').write_text(rejected.stdout+rejected.stderr) + if label=='crlf': + # Preserve the existing language rejection, rather than hide it + # by normalizing line endings in the new transport. + reference=[str(runner.exe),'arboricx','compile','--file',str(app/'main.tri'), + '--store',str(root/'crlf-reference'),'--output',str(root/'crlf.arboricx'), + '+RTS','-N1','-M512m','-RTS'] + legacy=subprocess.run(reference,capture_output=True,text=True,timeout=90) + assert legacy.returncode!=0 + assert legacy.stdout+legacy.stderr==rejected.stdout+rejected.stderr + checks.append('actual-v2-compiler-rejects-'+label) + (app/'main.tri').write_bytes(original) + clean(application) + files=['lib/source/object.tri','lib/source/io.tri','lib/io/storage.tri','lib/io.tri','lib/io/text.tri','test/io_compiler_store.py','bootstrap/build-stable.arboricx'] + (root/'report.json').write_text(json.dumps(dict(checks=checks,records=runner.records,candidate=candidate, + + projected_inputs={p.relative_to(projection).as_posix():hashlib.sha256(p.read_bytes()).hexdigest() for p in projection.rglob('*.tri')}, + source_hashes={p:hashlib.sha256((ROOT/p).read_bytes()).hexdigest() for p in files}),indent=2)+'\n') + print(f'{len(checks)} actual compiler-store and storage controls passed') + +if __name__=='__main__': main() diff --git a/test/io_context.py b/test/io_context.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +"""Actual read-only cwd/environment observations, independent of source handlers.""" +import argparse +import json +import os +from pathlib import Path +import subprocess +from source_order import Runner +from http_headers import result, tree_list, parse_tree_output +from io_text import text, problem +ROOT=Path(__file__).resolve().parent.parent + +def main(): + p=argparse.ArgumentParser(description=__doc__);p.add_argument('tricu',type=Path);p.add_argument('--directory',type=Path,required=True) + a=p.parse_args();d=a.directory.resolve();d.mkdir(parents=True,exist_ok=False);r=Runner(a.tricu,d);bundle=d/'context.arboricx' + r.run('compile',['compile','--file',ROOT/'demos/io/context.tri','--store',r.store,'--output',bundle]);pin=r.imported(bundle,'import');checks=[] + key='TRICU_CONTEXT_TEST_VALUE' + def run(label,name,argument,env,wanted): + cmd=[str(r.exe),'arboricx','run','--module',pin,'--name',name,'--store',str(r.store),'--io','--format','ternary'] + if argument is not None: + arg=d/(label+'.arg');arg.write_bytes(text(argument));cmd+=['--arg-tree-file',str(arg)] + proc=subprocess.run(cmd,cwd=d,env=env,capture_output=True,timeout=20) + (d/(label+'.stdout')).write_bytes(proc.stdout);(d/(label+'.stderr')).write_bytes(proc.stderr) + assert proc.returncode==0,(label,proc.stderr) + assert parse_tree_output(proc.stdout.decode())==wanted,label + checks.append(label) + env=dict(os.environ);env.pop(key,None) + run('cwd','directory',None,env,result(True,text(str(d)))) + run('missing','variable',key,env,result(True,tree_list([text('missing')]))) + for i,value in enumerate(['','λ\n\ufeff','x'*4096]): + run(f'value-{i}','variable',key,dict(env,**{key:value}),result(True,tree_list([text('value'),text(value)]))) + run('oversized','variable',key,dict(env,**{key:'x'*4097}),result(False,problem('invalid-bound','process/environment-variable','host context text exceeds scalar limit'))) + for i,name in enumerate(['','A=B','A\0B']): + run(f'invalid-{i}','variable',name,env,result(False,problem('invalid-argument','process/environment-variable',''))) + (d/'report.json').write_text(json.dumps(dict(passed=checks,records=r.records),indent=2)+'\n') + print(f'PASS {len(checks)} host-context controls') +if __name__=='__main__':main() diff --git a/test/io_demo_servers.py b/test/io_demo_servers.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +"""Actual migrated TCP/HTTP/registry demo lifetimes and independent wire bytes.""" +import argparse,json,socket,subprocess,threading,hashlib,selectors +from pathlib import Path +from source_order import Runner +from http_headers import number,tree_list +from io_text import text +ROOT=Path(__file__).resolve().parent.parent + +def main(): + p=argparse.ArgumentParser(description=__doc__);p.add_argument('tricu',type=Path);p.add_argument('--directory',type=Path,required=True) + a=p.parse_args();d=a.directory.resolve();d.mkdir(parents=True,exist_ok=False);r=Runner(a.tricu,d);checks=[];pins={} + def build(name): + bundle=d/(name+'.arboricx');r.run(name+'-compile',['compile','--file',ROOT/f'demos/interactionTrees/{name}.tri','--store',r.store,'--output',bundle,'--compile-timeout','600'],seconds=660) + pins[name]=r.imported(bundle,name+'-import');return pins[name] + def command(name,pin,values): + command=[str(r.exe),'arboricx','run','--module',pin,'--name','server','--store',str(r.store),'--io','--no-result'] + for i,value in enumerate(values): + f=d/f'{name}-{i}.arg';f.write_bytes(value);command+=['--arg-tree-file',str(f)] + return command + def start(name,pin,values): + proc=subprocess.Popen(command(name,pin,values),stdout=subprocess.PIPE,stderr=subprocess.PIPE) + with selectors.DefaultSelector() as sel: + sel.register(proc.stdout,selectors.EVENT_READ);assert sel.select(15),'no listen announcement' + port=int(proc.stdout.readline());return proc,port + def close(proc,port): + out,err=proc.communicate(timeout=20);assert proc.returncode==0,(out,err) + try: socket.create_connection(('127.0.0.1',port),timeout=1).close() + except OSError: return + raise AssertionError('listener outlived owning scope') + def endpoint(port=0):return tree_list([text('ipv4'),tree_list([number(x) for x in [127,0,0,1]]),number(port)]) + def request(port,data): + with socket.create_connection(('127.0.0.1',port),timeout=20) as s: + s.sendall(data);s.shutdown(socket.SHUT_WR);parts=[] + while True: + b=s.recv(4096) + if not b:return b''.join(parts) + parts.append(b) + proc,port=start('echo',build('echoServer'),[endpoint(),number(2)]) + try: + for data in [b'',bytes(range(256))*1024]: + with socket.create_connection(('127.0.0.1',port),timeout=20) as s: + failures=[] + def send(): + try:s.sendall(data);s.shutdown(socket.SHUT_WR) + except Exception as e:failures.append(e) + thread=threading.Thread(target=send);thread.start();out=[] + while True: + chunk=s.recv(4096) + if not chunk:break + out.append(chunk) + thread.join(5);assert not thread.is_alive() and not failures + assert b''.join(out)==data + close(proc,port);checks.append('echo-binary-and-scope-close') + finally: + if proc.poll() is None:proc.kill();proc.wait() + cases=[(b'GET /hello HTTP/1.1\r\nHost: test\r\n\r\n',200,b'Hello from /hello\n'),(b'HEAD /hello HTTP/1.1\r\nHost: test\r\n\r\n',200,b''),(b'POST /hello HTTP/1.1\r\nHost: test\r\nContent-Length: 0\r\n\r\n',405,b'Method not allowed\n'),(b'bad\r\n\r\n',400,b'Bad Request\n')] + proc,port=start('http',build('httpServer'),[endpoint(),number(len(cases))]) + try: + for payload,status,body in cases: + reply=request(port,payload);head,actual=reply.split(b'\r\n\r\n',1) + assert head.startswith(f'HTTP/1.1 {status} '.encode()) and actual==body,(head,actual) + length=len(b'Hello from /hello\n') if payload.startswith(b'HEAD') else len(body) + assert f'Content-Length: {length}\r\n'.encode() in head+b'\r\n' + close(proc,port);checks.append('http-methods-framing-head-and-scope-close') + finally: + if proc.poll() is None:proc.kill();proc.wait() + root=d/'registry';data=b'\0\xffregistry-demo';digest=hashlib.sha256(data).hexdigest() + proc,port=start('registry',build('arboricxServer'),[text(str(root)),endpoint(),number(3)]) + try: + assert request(port,b'GET /_arboricx/health HTTP/1.1\r\nHost: test\r\n\r\n').endswith(b'OK\n') + response=request(port,b'POST /_arboricx/bundle HTTP/1.1\r\nHost: test\r\nContent-Length: '+str(len(data)).encode()+b'\r\n\r\n'+data) + assert response.startswith(b'HTTP/1.1 201 ') and response.endswith(digest.encode()),response + assert request(port,f'GET /_arboricx/bundle/hash/{digest} HTTP/1.1\r\nHost: test\r\n\r\n'.encode()).endswith(b'\r\n\r\n'+data) + close(proc,port);checks.append('registry-binary-upload-download-and-scope-close') + finally: + if proc.poll() is None:proc.kill();proc.wait() + for name,values in [('echoServer',[endpoint(),number(0)]),('httpServer',[endpoint(),number(0)]),('arboricxServer',[text(str(d/'failed-ready-store')),endpoint(),number(0)])]: + with open('/dev/full','wb') as full: + failed=subprocess.run(command(name+'-failed-ready',pins[name],values),stdout=full,stderr=subprocess.PIPE,timeout=20) + assert failed.returncode!=0,(name,failed.stderr) + checks.append(name+'-announcement-failure') + (d/'report.json').write_text(json.dumps(dict(passed=checks,records=r.records),indent=2)+'\n');print('PASS migrated server demo wire/lifecycle controls') +if __name__=='__main__':main() diff --git a/test/io_dependencies.py b/test/io_dependencies.py @@ -20,13 +20,13 @@ def main(): host = str(args.tricu.resolve()) with tempfile.TemporaryDirectory(prefix='tricu-io-dependencies-') as temporary: workspace = Path(temporary) - for name, relative in [('base', 'lib/base.tri'), ('io_v2', 'lib/io-v2.tri'), + for name, relative in [('base', 'lib/base.tri'), ('io', 'lib/io.tri'), ('native_math', 'lib/math/native.tri')]: shutil.copyfile(ROOT / relative, workspace / (name + '.tri')) source = workspace / 'main.tri' # Exercise actual IO definitions in a workspace lacking patterns. source.write_text('''!import base * -!import io_v2 as IO +!import io as IO !export main main = if (and? (equal? (IO.pure 7) (pair 0 7)) (and? (equal? (IO.io (IO.pure 7)) (pair "tricuIO" (pair 2 (pair 0 7)))) diff --git a/test/io_directory.py b/test/io_directory.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +"""Complete independent directory inventory and path-operation oracles.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +from source_order import Runner +from http_headers import number, pair, result, tree_list +from io_positioned import text +ROOT = Path(__file__).resolve().parent.parent + +def parse_tree(data): + # Iterative independent decoder; tolerate large inventories without Python + # recursion limits. This parses structure, not the native result's text view. + frames = [] + completed = None + for tag in data: + assert tag in (0, 1, 2), tag + if tag: + frames.append([tag, []]) + continue + value = (0,) + while frames: + frame = frames[-1] + frame[1].append(value) + if len(frame[1]) != frame[0]: + break + frames.pop() + value = (frame[0], *frame[1]) + else: + assert completed is None + completed = value + assert not frames and completed is not None + return completed + +def items(value): + output = [] + while value != (0,): + assert value[0] == 2 + output.append(value[1]) + value = value[2] + return output + +def natural(value): + n, place = 0, 1 + while value != (0,): + assert value[0] == 2 and value[1] in ((0,), (1,(0,))) + n += place * (value[1] != (0,)) + place *= 2 + value = value[2] + return n + +def string(value): + return ''.join(chr(natural(c)) for c in items(value)) + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('tricu', type=Path) + parser.add_argument('--directory', type=Path, required=True) + args = parser.parse_args() + directory = args.directory.resolve() + directory.mkdir(parents=True, exist_ok=False) + runner = Runner(args.tricu, directory) + source = ROOT/'demos/io/directory.tri' + bundle = directory/'directory.arboricx' + runner.run('compile', ['compile', '--file', source, '--store', runner.store, '--output', bundle]) + pin = runner.imported(bundle, 'import') + checks = [] + def invoke(label, name, *paths): + argv = ['--io'] + for index, value in enumerate(paths): + path = directory/f'{label}-{index}.tree' + path.write_bytes(text(str(value))) + argv += ['--arg-tree-file', path] + return runner.invoke(label, pin, name, argv) + unit = result(True, b'\0') + root = directory/'store-λ' + assert invoke('initialize', 'initialize', root) == unit + assert invoke('initialize-again', 'initialize', root) == unit + assert sorted(p.name for p in root.iterdir()) == ['cache'] + checks += ['initialize', 'initialize-again'] + (root/'file').write_bytes(b'a\0\xff') + (root/'link').symlink_to('file') + (root/'dangling').symlink_to('absent') + for name, expected in [('file',('file',3)),('cache',('directory',)),('link',('symlink',))]: + actual = parse_tree(invoke('metadata-'+name,'metadata',root/name)) + followed, unfollowed = items(actual) + wanted = tree_list([text(expected[0])] + ([number(expected[1])] if len(expected)>1 else [])) + assert unfollowed == parse_tree(result(True,wanted)) + if name != 'link': + assert followed == unfollowed + else: + assert followed == parse_tree(result(True,tree_list([text('file'),number(3)]))) + checks.append('metadata-'+name) + actual = items(parse_tree(invoke('inventory','inventory',root))) + observed = {} + for row in actual: + name, status = items(row) + key = string(name) + assert key not in observed + parts = items(status) + observed[key] = [string(parts[0])] + [natural(p) for p in parts[1:]] + assert observed == {'cache':['directory'],'file':['file',3],'link':['symlink'],'dangling':['symlink']} + checks.append('inventory') + (root/'replacement').write_bytes(b'new\x80') + assert invoke('rename','rename',root/'replacement',root/'file') == unit + assert (root/'file').read_bytes() == b'new\x80' and not (root/'replacement').exists() + assert invoke('unlink','removeFile',root/'link') == unit + assert (root/'file').read_bytes() == b'new\x80' and not (root/'link').is_symlink() + assert invoke('rmdir','removeDirectory',root/'cache') == unit + checks += ['rename','unlink','rmdir'] + reply = items(parse_tree(invoke('nonempty','removeDirectory',root))) + assert reply[0] == (0,) + problem = items(reply[1]) + assert string(problem[1]) == 'directory/remove' and string(problem[3]) == 'unknown' + assert (root/'file').read_bytes() == b'new\x80' + conflict = directory/'not-a-directory' + conflict.write_bytes(b'keep') + expected = result(False,tree_list([text(s) for s in ['not-directory','directory/ensure','','none']])) + assert invoke('ensure-file','initialize',conflict) == expected + assert conflict.read_bytes() == b'keep' + checks += ['nonempty','ensure-file'] + alias = directory/'directory-alias' + alias.symlink_to(root, target_is_directory=True) + assert invoke('ensure-symlink','initialize',alias) == unit + assert (root/'cache').is_dir() + checks.append('ensure-symlink') + followed, unfollowed = items(parse_tree(invoke('dangling','metadata',root/'dangling'))) + assert unfollowed == parse_tree(result(True,tree_list([text('symlink')]))) + failed = items(followed) + assert failed[0] == (0,) + problem = items(failed[1]) + assert [string(problem[i]) for i in [0,1,3]] == ['not-found','path/stat','none'] + checks.append('dangling') + # Real host-invalid name: must fail, never silently replace characters/skip it. + invalid = directory/'invalid' + invalid.mkdir() + os.symlink(b'absent',os.fsencode(invalid)+b'/\xff') + try: + invoke('invalid-encoding','count',invalid) + except Exception as error: + assert 'IO task failed' in str(getattr(error,'output',error)), error + else: + raise AssertionError('invalid name was silently accepted') + checks.append('invalid-encoding') + for count in [0,16,256,4096,16384]: + path = directory/f'count-{count}' + path.mkdir() + for index in range(count): + (path/f'{index:06}-entry-λ').touch() + assert invoke(f'count-{count}','count',path) == number(count) + checks.append(f'count-{count}') + observations = [r for r in runner.records if r['label'].startswith('count-')] + for row in observations: + assert row['peak_kib'] <= 128*1024, row + assert row['rts_process']['maximum_live_bytes'] <= 2*1024*1024, row + files = [source,Path(__file__).resolve(),ROOT/'lib/io.tri',ROOT/'src/IO/Filesystem.hs', + ROOT/'src/IO/Resources.hs',ROOT/'src/IO/Driver.hs',ROOT/'src/IO/Protocol.hs', + ROOT/'test/source_order.py',ROOT/'test/rts_metrics.py',ROOT/'bootstrap/build-stable.arboricx'] + (directory/'report.json').write_text(json.dumps(dict(checks=checks,observations=observations, + sources={str(p.relative_to(ROOT)):hashlib.sha256(p.read_bytes()).hexdigest() for p in files}),indent=2)+'\n') + print(f'PASS: {len(checks)} directory/path controls') +if __name__ == '__main__': + main() diff --git a/test/io_files.py b/test/io_files.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +"""Compile/run the experimental v2 copier through the retained v1 compiler. + +Nix-built host required. No grants are passed to the v2 application. Compares +complete output files and result trees for direct and handler-backed compositions. +""" +import argparse +import hashlib +import json +import re +from pathlib import Path + +from source_order import Runner, field +from stable_bundle import byte_tree +from http_headers import result, tree_list + +ROOT = Path(__file__).resolve().parent.parent + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('tricu', type=Path) + parser.add_argument('--directory', type=Path, required=True) + args = parser.parse_args() + args.directory.mkdir(parents=True, exist_ok=False) + directory = args.directory.resolve() + runner = Runner(args.tricu, directory) + bundle = directory/'copy.arboricx' + runner.run('compile', ['compile', '--file', ROOT/'demos/io/copy.tri', + '--store', runner.store, '--output', bundle]) + pin = runner.imported(bundle, 'import') + checks = [] + for name in ('copy', 'handledCopy', 'borrowedCopy', 'transferredCopy', 'joinedOnExitCopy', 'handledChildCopy'): + for size in (0, 1, 4095, 4096, 4097, 65536, 1048576): + label = f'{name}-{size}' + source, target = directory/(label+'.in'), directory/(label+'.out') + body = (bytes(range(256))*((size+255)//256))[:size] + source.write_bytes(body) + argv = ['--io'] + for index, path in enumerate((source, target)): + argument = directory/f'{label}-{index}.tree' + argument.write_bytes(byte_tree(str(path).encode())) + argv += ['--arg-tree-file', argument] + actual = runner.invoke(label, pin, name, argv) + expected = result(True, b'\0') + if name == 'handledCopy': + expected = result(True, expected) + assert actual == expected, label + assert target.read_bytes() == body, label + checks.append({'name': label, 'sha256': hashlib.sha256(body).hexdigest()}) + for name in ('copy', 'handledCopy'): + # Independent failure controls: no destination on a missing source, and + # a failed writer acquisition after a successful reader acquisition. + for case in ('missing-source', 'missing-parent'): + source = directory/(name+'-failure.in') + source.write_bytes(b'not copied') + target = directory/(name+'-failure.out') + if case == 'missing-source': + source = directory/'NO-SUCH-SOURCE' + else: + target = directory/'NO-SUCH-DIRECTORY'/'out' + label = name+'-'+case + argv = ['--io'] + for index, path in enumerate((source, target)): + argument = directory/f'{label}-{index}.tree' + argument.write_bytes(byte_tree(str(path).encode())) + argv += ['--arg-tree-file', argument] + actual = runner.invoke(label, pin, name, argv) + assert field(actual, 0) == b'\0', label + diagnostic = field(actual, 1, 0) + assert field(diagnostic, 0) == byte_tree(b'not-found'), label + assert field(diagnostic, 1, 0) == byte_tree(b'file/open-reader' if case == 'missing-source' else b'file/open-writer'), label + assert not target.exists(), label + checks.append({'name': label}) + for size in (0, 1, 4096, 4097, 65536): + label = f'cancelledCopy-{size}' + source, target = directory/(label+'.in'), directory/(label+'.out') + body = (bytes(range(256))*((size+255)//256))[:size] + source.write_bytes(body) + argv = ['--io'] + for index, path in enumerate((source, target)): + argument = directory/f'{label}-{index}.tree' + argument.write_bytes(byte_tree(str(path).encode())) + argv += ['--arg-tree-file', argument] + actual = runner.invoke(label, pin, 'cancelledCopy', argv) + assert actual == tree_list([byte_tree(b'cancelled'), byte_tree(b'requested'), b'\0']), label + assert target.read_bytes() == body[:4096], label + checks.append({'name': label, 'sha256': hashlib.sha256(target.read_bytes()).hexdigest()}) + # Regression guards for this selected copier, not general resource SLAs. + observations = [row for row in runner.records if row['label'].endswith(('-65536', '-1048576'))] + for row in observations: + assert row['peak_kib'] is not None and row['peak_kib'] <= 128*1024, row + match = re.search(r'([0-9,]+) bytes maximum residency', (directory/(row['label']+'.log')).read_text()) + assert match, row + row['maximum_live_bytes'] = int(match[1].replace(',', '')) + assert row['maximum_live_bytes'] <= 16*1024*1024, row + report = {'checks': checks, 'sources': {str(p.relative_to(ROOT)): hashlib.sha256(p.read_bytes()).hexdigest() + for p in [ROOT/'src/IO/Resources.hs', ROOT/'src/IO/Driver.hs', ROOT/'src/IO/Protocol.hs', + ROOT/'test/source_order.py', ROOT/'test/rts_metrics.py', ROOT/'src/IO/Tasks.hs', ROOT/'lib/io.tri', + ROOT/'demos/io/copy.tri', ROOT/'lib/handlers.tri']}} + report['observations'] = observations + (directory/'report.json').write_text(json.dumps(report, indent=2)+'\n') + print(f'PASS: {len(checks)} binary-copy and failure checks, including handler composition') + + +if __name__ == '__main__': + main() diff --git a/test/io_generation.py b/test/io_generation.py @@ -0,0 +1,209 @@ +#!/usr/bin/env python3 +"""Prepare an explicit v2 map-builder kit and exercise a scoped generation. + +The current compiler prepares v2 actions. The real generation runner +then stages all mapped sources and builds native_math over v2. This is deliberately +NOT a full compiler generation pair or deployment/retention acceptance. +""" +import argparse +import hashlib +import json +from pathlib import Path +import subprocess +import sys +from source_order import Runner +from bootstrap_generations import source_map +from bootstrap_inputs import load_kit +from generation_io import produced_adapter, versions + +ROOT = Path(__file__).resolve().parent.parent + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument('tricu', type=Path) + p.add_argument('--kit', type=Path, help='validate an explicitly prepared v2 kit instead of preparing a current candidate') + p.add_argument('--directory', type=Path, required=True) + p.add_argument('--haskell-library',type=Path,help='also run the explicit v2 executor and compare complete inventories') + args = p.parse_args() + root = args.directory.resolve(); root.mkdir(parents=True, exist_ok=False) + mapping = ROOT/'bootstrap/compiler-source-map.v1' + runner = Runner(args.tricu, root) + if args.kit: + kit = args.kit.resolve() + raw, _, _ = load_kit(kit, 'stable') + metadata = json.loads(raw) + from bootstrap_inputs import kit_io_version + if kit_io_version(metadata) != 2: + raise ValueError('this probe requires an explicit v2 kit') + else: + projection = root/'projection'; projection.mkdir() + (projection/'inputs').mkdir() + _, entries, _ = source_map(mapping, ROOT, 'source_map') + workspace = ['tricu.workspace.v1'] + for alias, (_, relative, _) in entries.items(): + target = 'inputs/'+alias+'.tri' + (projection/target).write_bytes((ROOT/relative).read_bytes()) + workspace.append(alias+'\t'+target) + (projection/'tricu.workspace').write_text('\n'.join(workspace)+'\n') + worker = projection/'worker.tri' + worker.write_text('!import source_map as M\n!export sourceMapBuildStable\nsourceMapBuildStable = M.sourceMapBuildStable\n') + adapter = projection/'adapter.tri' + adapter.write_text('!import io as IO\n!export main\nmain build source overlay store compiler mapping root = IO.io (build source overlay store compiler mapping root)\n') + kit = root/'kit'; kit.mkdir() + for label, source, destination in [('worker',worker,'rebuilt-compiler.arboricx'),('adapter',adapter,'io-adapter.arboricx')]: + runner.run('compile-'+label,['compile','--file',source,'--store',runner.store, + '--output',kit/destination,'--compile-timeout','600'],seconds=660) + metadata = dict(schema='tricu.bootstrap-kit.v2', backend='stable', io_version=2, artifacts={}) + for role, filename, export in [('compiler','rebuilt-compiler.arboricx','sourceMapBuildStable'),('adapter','io-adapter.arboricx','main')]: + metadata['artifacts'][role]=dict(file=filename,export=export,sha256=hashlib.sha256((kit/filename).read_bytes()).hexdigest()) + (kit/'bootstrap-kit.json').write_text(json.dumps(metadata,indent=2)+'\n') + load_kit(kit,'stable') + checks=[] + def generation(label, extra=(), success=True, selected_kit=kit, preparation_failure=False): + output=root/label + cmd=[sys.executable,str(ROOT/'test/bootstrap_generations.py'),str(output), + '--bootstrap-kit',str(selected_kit),'--tricu',str(args.tricu.resolve()), + '--map',str(mapping),'--source-root',str(ROOT),'--root','native_math', + '--backend','stable','--memory-mib','8192','--wall-seconds','600',*extra] + reply=subprocess.run(cmd,capture_output=True,text=True,timeout=660) + (root/(label+'.log')).write_text(reply.stdout+reply.stderr) + assert (reply.returncode==0)==success,(label,reply.stdout[-2000:],reply.stderr) + if success: + status=json.loads((output/'status.json').read_text()) + assert status['complete'] and versions(status)==(2,2) + assert produced_adapter(output,status)==(kit/'io-adapter.arboricx').read_bytes() + invocation=json.loads((output/'invocation.json').read_text()) + assert '--unsafe-io' not in invocation + assert not list(output.rglob('.tricu-*')) + elif preparation_failure: + assert not json.loads((output/'status.json').read_text())['complete'] + assert not (output/'cas').exists(), 'invalid adapter started compilation' + else: + assert not output.exists(), 'selection failure created output' + checks.append(label) + return output + generation('wrong-version',('--execution-io-version','1'),False) + generation('missing-produced-adapter',('--produced-io-version','1'),False) + generation('noncanonical-version',('--execution-io-version','02'),False) + fake_library=root/'not-an-embedding-library'; fake_library.write_bytes(b'not loaded') + generation('invalid-embedding-runtime',('--execution-host','haskell-ffi','--haskell-library',str(fake_library)),False) + generation('invalid-produced-adapter',('--produced-io-version','1','--produced-adapter',str(fake_library)),False,preparation_failure=True) + output=generation('scoped') + # Independent existing store/receipt audit runs inside the generation runner. + # Compare actual complete native_math manifest with the authenticated packaged v2 kit. + control=root/'packaged-control' + command=[sys.executable,str(ROOT/'test/bootstrap_generations.py'),str(control), + '--bootstrap-kit',str(ROOT/'bootstrap/rebuild'),'--tricu',str(args.tricu.resolve()), + '--map',str(mapping),'--source-root',str(ROOT),'--root','native_math', + '--backend','stable','--memory-mib','8192','--wall-seconds','600', + '--produced-io-version','2','--produced-adapter',str(kit/'io-adapter.arboricx')] + reply=subprocess.run(command,capture_output=True,text=True,timeout=660) + (root/'packaged-control.log').write_text(reply.stdout+reply.stderr) + assert reply.returncode==0,reply.stderr + def manifest(directory): + return (directory/'cas/cache/source-build/native_math').read_text().split('\t')[2] + assert manifest(output)==manifest(control) + assert versions(json.loads((control/'status.json').read_text()))==(2,2) + checks.append('selected-packaged-complete-manifest-equality') + # Exercise both unchanged native bytes and rewritten unit/reexport overlays. + # The independent generation audit checks both source-domain digests in every + # map receipt; exact overlay expectations also detect accidentally reused raw + # digests on the rewritten branch. + fixture=root/'mixed-source'; fixture.mkdir() + originals={'dep':b'answer = 42\n', + 'native_math':b'!import "dependency" !Local\nlocal = answer\n', + 'native':b'!export value\n-- UTF-8: \xce\xbb\nvalue = 7\n'} + for name,contents in originals.items(): + (fixture/(name+'.tri')).write_bytes(contents) + mixed_map=fixture/'map.v1' + mixed_map.write_text('arboricx.source-map.v1\n' + 'unit\tdep\tdependency\tdep.tri\t-\n' + 'reexport\tnative_math\tmain\tnative_math.tri\tdep\n' + 'native\tnative\tnative\tnative.tri\t-\n') + mixed_manifests=[] + selections=[('candidate',kit,[]),('packaged',ROOT/'bootstrap/rebuild',[])] + if args.haskell_library: + selections.append(('haskell-ffi',kit,['--execution-host','haskell-ffi','--haskell-library',str(args.haskell_library.resolve())])) + for label,selected,extra in selections: + destination=root/('mixed-'+label) + command=[sys.executable,str(ROOT/'test/bootstrap_generations.py'),str(destination), + '--bootstrap-kit',str(selected),'--tricu',str(args.tricu.resolve()), + '--map',str(mixed_map),'--source-root',str(fixture),'--root','native_math', + '--backend','stable','--capabilities','1','--memory-mib','8192','--wall-seconds','180',*extra] + reply=subprocess.run(command,capture_output=True,text=True,timeout=210) + (root/('mixed-'+label+'.log')).write_text(reply.stdout+reply.stderr) + assert reply.returncode==0,(label,reply.stdout[-2000:],reply.stderr) + # Legacy lines/unlines emits the trailing empty line as a newline. + expected={'dep':b'!export *\nanswer = 42\n\n', + 'native_math':b'!import dep *\n!export *\n!export * from dep\nlocal = answer\n\n', + 'native':originals['native']} + for name,contents in expected.items(): + assert (destination/'overlay'/(name+'.tri')).read_bytes()==contents,(label,name) + mixed_manifests.append(manifest(destination)) + checks.append('mixed-staging-digests-and-overlays-'+label) + assert len(set(mixed_manifests))==1,mixed_manifests + ffi_output = None + if args.haskell_library: + ffi_output = generation('ffi-scoped',('--execution-host','haskell-ffi','--haskell-library',str(args.haskell_library.resolve()))) + def inventory(directory): + return {p.name:hashlib.sha256(p.read_bytes()).hexdigest() for p in (directory/'cas/objects').glob('*/*')} + assert inventory(ffi_output)==inventory(output) + assert manifest(ffi_output)==manifest(output) + checks.append('cli-ffi-complete-inventory-equality') + from haskell_generation_executor import validate_library + try: + validate_library(args.haskell_library.resolve(), 1) + except (ValueError, AttributeError): + checks.append('current-embedding-rejects-v1-before-preparation') + else: + raise AssertionError('current runtime still exposes v1 execution') + # Captured request tampering must fail before any IO execution, not select + # another interpreter or silently reinterpret absent/Boolean metadata. + request_path=ffi_output/'execution-request.json' + original=request_path.read_bytes() + map_path=ffi_output/'source-map.v1' + original_map=map_path.read_bytes() + before=inventory(ffi_output) + command=json.loads((ffi_output/'invocation.json').read_text()) + try: + for label,value in [('wrong',1),('boolean',True),('future',3),('missing',None)]: + request=json.loads(original) + if value is None: request.pop('execution_io_version') + else: request['execution_io_version']=value + request_path.write_text(json.dumps(request)) + reply=subprocess.run(command,capture_output=True,text=True,timeout=30) + assert reply.returncode!=0,(label,reply.stdout) + assert 'IO version' in reply.stderr,(label,reply.stderr) + assert inventory(ffi_output)==before + checks.append('ffi-request-'+label) + for key in ['worker_export', 'compiler_bundle_sha256', 'adapter_sha256', 'arguments', 'extra']: + request=json.loads(original) + if key=='arguments': + request[key][2]=str(root/'undeclared-store') + elif key=='extra': + request[key]=True + else: + request[key]='0'*64 + request_path.write_text(json.dumps(request)) + reply=subprocess.run(command,capture_output=True,text=True,timeout=30) + assert reply.returncode!=0 and 'identity/arguments mismatch' in reply.stderr,(key,reply.stderr) + assert inventory(ffi_output)==before and not (root/'undeclared-store').exists() + checks.append('ffi-request-'+key) + request=json.loads(original) + request['arguments'][4]+='\n' + map_path.write_bytes(original_map+b'\n') + request_path.write_text(json.dumps(request)) + reply=subprocess.run(command,capture_output=True,text=True,timeout=30) + assert reply.returncode!=0 and 'execution map identity mismatch' in reply.stderr + assert inventory(ffi_output)==before + checks.append('ffi-captured-map-tampering') + finally: + map_path.write_bytes(original_map) + request_path.write_bytes(original) + (root/'report.json').write_text(json.dumps(dict(checks=checks,records=runner.records, + kit=metadata,scoped_generation=str(output),ffi_generation=str(ffi_output) if ffi_output else None,generation_pair=False),indent=2)+'\n') + print(f'{len(checks)} generation boundary controls passed; no full compiler pair built') + + +if __name__=='__main__': main() diff --git a/test/io_http.py b/test/io_http.py @@ -0,0 +1,243 @@ +#!/usr/bin/env python3 +"""Independent real-socket v2 registry framing, streaming, admission and cleanup.""" +import argparse +import concurrent.futures +from contextlib import contextmanager +import hashlib +import json +from pathlib import Path +import select +import socket +import struct +import subprocess +import threading +import time +from source_order import Runner +from http_headers import number +from io_objects import text +from process_rss import ChildRSS +from rts_metrics import parse_rts_stats + +ROOT = Path(__file__).resolve().parent.parent +PHRASE = {200:'OK',201:'Created',400:'Bad Request',404:'Not Found',413:'Content Too Large',431:'Request Header Fields Too Large',500:'Internal Server Error',505:'HTTP Version Not Supported'} +def expected(status, body, mime='text/plain; charset=utf-8', head=False): + return (f'HTTP/1.1 {status} {PHRASE[status]}\r\nContent-Type: {mime}\r\nContent-Length: {len(body)}\r\nConnection: close\r\n\r\n').encode()+(b'' if head else body) +def header(length): + return f'POST /_arboricx/bundle HTTP/1.1\r\nHost: local\r\nContent-Length: {length}\r\n\r\n'.encode() +def receive(peer): + chunks=[] + while True: + chunk=peer.recv(65536) + if not chunk: return b''.join(chunks) + chunks.append(chunk) +def wait(predicate): + until=time.monotonic()+15 + while time.monotonic()<until: + if predicate(): return + time.sleep(.02) + raise AssertionError('condition timeout') + +def main(): + p=argparse.ArgumentParser(description=__doc__) + p.add_argument('tricu',type=Path); p.add_argument('--directory',type=Path,required=True) + a=p.parse_args(); directory=a.directory.resolve(); directory.mkdir(parents=True,exist_ok=False) + runner=Runner(a.tricu,directory) + bundle=directory/'registry.arboricx' + runner.run('compile',['compile','--file',ROOT/'demos/io/registry.tri','--store',runner.store,'--output',bundle,'--compile-timeout','600'],seconds=660) + pin=runner.imported(bundle,'import'); checks=[]; observations=[] + @contextmanager + def server(label,count,window=4,name='server',root=None): + root=root or directory/(label+'-files') + argv=[str(runner.exe),'arboricx','run','--module',pin,'--name',name,'--store',str(runner.store),'--io','--no-result'] + for i,value in enumerate([text(str(root)),number(count),number(window)]): + path=directory/f'{label}-{i}.tree';path.write_bytes(value);argv+=['--arg-tree-file',str(path)] + argv+=['+RTS','-N2','-M256m','-T','-s','-RTS'] + log=(directory/(label+'.stderr')).open('wb') + proc=subprocess.Popen(argv,stdin=subprocess.PIPE if name=='cancelledServer' else subprocess.DEVNULL,stdout=subprocess.PIPE,stderr=log) + stopped=threading.Event(); row=dict(label=label,command=argv,peak_kib=0) + def monitor(): + try: + rss=ChildRSS(proc) + while not stopped.is_set(): + value=rss.sample() + if value is not None: + row['peak_kib']=max(row['peak_kib'],value) + if value>256*1024: raise AssertionError('RSS guard exceeded') + stopped.wait(.02) + except Exception as error: + row['monitor_error']=repr(error);proc.kill() + thread=threading.Thread(target=monitor,daemon=True);thread.start();started=time.monotonic() + try: + assert select.select([proc.stdout],[],[],45)[0], 'startup timeout' + line=proc.stdout.readline() + assert line.strip().isdigit(), (line,(directory/(label+'.stderr')).read_text()) + port=int(line) + def connect(): + return socket.create_connection(('127.0.0.1',port),timeout=90) + def request(wire,fragment=False): + with connect() as peer: + if fragment: + for start in range(0,len(wire),997):peer.sendall(wire[start:start+997]) + else:peer.sendall(wire) + peer.shutdown(socket.SHUT_WR) + return receive(peer) + def cancel(): + proc.stdin.write(b'x');proc.stdin.flush() + request.cancel=cancel + yield root,connect,request + stdout,_=proc.communicate(timeout=120) + assert proc.returncode==0, (directory/(label+'.stderr')).read_text() + assert b'private logger detail' not in stdout + assert not list(root.rglob('.tricu-stage-*')) + row['rts_process']=parse_rts_stats((directory/(label+'.stderr')).read_text()) + assert row['rts_process']['maximum_live_bytes']<64*1024*1024,row + if label in ['binary','lifecycle','admission']: + assert row['peak_kib']<128*1024,row + assert row['rts_process']['maximum_live_bytes']<16*1024*1024,row + assert 'monitor_error' not in row,row + row['seconds']=time.monotonic()-started;observations.append(row) + finally: + if proc.poll() is None:proc.kill();proc.communicate() + stopped.set();thread.join();log.close() + def object_path(root,data): + digest=hashlib.sha256(data).hexdigest() + return digest,root/'objects'/digest[:3]/(digest+'.arboricx') + def check(label,actual,wanted): + assert actual==wanted,(label,actual[:300],wanted[:300]);checks.append(label) + + cases=[ + ('health',b'GET /_arboricx/health HTTP/1.1\r\nHost: local\r\n\r\n',200,b'OK\n',False), + ('head',b'HEAD /_arboricx/health HTTP/1.0\r\n\r\n',200,b'OK\n',True), + ('duplicate-length',header(0).replace(b'\r\n\r\n',b'\r\nContent-Length: 0\r\n\r\n'),400,b'Bad Request\n',False), + ('transfer-encoding',b'POST /_arboricx/bundle HTTP/1.1\r\nTransfer-Encoding: chunked\r\n\r\n',400,b'Bad Request\n',False), + ('oversize',header(1048577),413,b'Request body too large\n',False), + ('length-junk',header(1048577).replace(b'1048577',b'1048577x'),400,b'Bad Request\n',False), + ('bad-version',header(0).replace(b'HTTP/1.1',b'HTTP/9.0'),505,b'HTTP Version Not Supported\n',False), + ('syntax-before-version',b'POST /_arboricx/bundle HTTP/9.0\r\nbroken\r\n\r\n',400,b'Bad Request\n',False), + ('head-syntax-body',b'HEAD /_arboricx/health HTTP/1.1\r\nbroken\r\n\r\n',400,b'Bad Request\n',False), + ('head-length-error',b'HEAD /_arboricx/health HTTP/1.1\r\nContent-Length: x\r\n\r\n',400,b'Bad Request\n',True), + ('missing-length',b'POST /_arboricx/bundle HTTP/1.1\r\n\r\nignored',201,hashlib.sha256(b'').hexdigest().encode(),False), + ('short-get-body',b'GET /_arboricx/health HTTP/1.1\r\nContent-Length: 5\r\n\r\nabc',400,b'Unexpected end of request body\n',False), + ] + cases += [('header-limit', b'GET /_arboricx/health HTTP/1.1\r\nX-Long: '+b'x'*65536+b'\r\n\r\n',431,b'Request Header Fields Too Large\n',False)] + with server('framing',len(cases)) as (root,connect,request): + for label,wire,status,body,head in cases: + check(label,request(wire),expected(status,body,head=head)) + with server('binary',14) as (root,connect,request): + for size in [0,1,4095,4096,4097,65536,1048576]: + data=(bytes(range(256))*((size+255)//256))[:size] + digest,path=object_path(root,data) + for state in ['new','existing']: + check(f'binary-{size}-{state}',request(header(size)+data,True),expected(201,digest.encode())) + assert path.read_bytes()==data + assert list((root/'tmp').iterdir())==[] + with server('failures',5) as (root,connect,request): + data=b'unpublished-prefix'*200;digest,path=object_path(root,data) + with connect() as peer: + peer.sendall(header(len(data)+1)+data) + wait(lambda:len(list((root/'tmp').iterdir()))==1) + assert not path.exists() + peer.shutdown(socket.SHUT_WR) + check('early-eof',receive(peer),expected(400,b'Upload failed\n')) + assert not path.exists();assert list((root/'tmp').iterdir())==[] + path.parent.mkdir(exist_ok=True);path.write_bytes(b'X'+data[1:]) + check('corrupt-winner',request(header(len(data))+data),expected(400,b'Upload failed\n')) + assert path.read_bytes()==b'X'+data[1:] + check('corrupt-download',request(f'GET /_arboricx/bundle/hash/{digest} HTTP/1.1\r\n\r\n'.encode()),expected(404,b'Artifact unavailable\n')) + # Peer reset is a connection-local failure, not a server shutdown. + with connect() as peer: + peer.sendall(header(1048576)+b'partial') + peer.setsockopt(socket.SOL_SOCKET,socket.SO_LINGER,struct.pack('ii',1,0)) + check('healthy-after-reset',request(cases[0][1]),expected(200,b'OK\n')) + with server('downloads',7) as (root,connect,request): + data=b'authenticated binary\0\xff';digest,path=object_path(root,data) + path.parent.mkdir(exist_ok=True);path.write_bytes(data) + wire=f'GET /_arboricx/bundle/hash/{digest} HTTP/1.1\r\n\r\n'.encode() + check('download-binary',request(wire),expected(200,data,'application/vnd.arboricx.bundle')) + check('download-head',request(wire.replace(b'GET ',b'HEAD ')),expected(200,data,'application/vnd.arboricx.bundle',head=True)) + package=root/'packages'/'example';package.mkdir(parents=True) + (package/'v1').write_bytes(b'package bytes') + check('package-download',request(b'GET /_arboricx/package/example/v1 HTTP/1.1\r\n\r\n'),expected(200,b'package bytes','application/vnd.arboricx.package-release')) + for size in [4096,65536,1048576]: + data=bytes(range(256))*(size//256);digest,path=object_path(root,data) + path.parent.mkdir(exist_ok=True);path.write_bytes(data) + check(f'download-{size}',request(f'GET /_arboricx/bundle/hash/{digest} HTTP/1.1\r\n\r\n'.encode()),expected(200,data,'application/vnd.arboricx.bundle')) + # Trailing pipelined bytes never become a second request or uploaded data. + data=b'one request';digest,path=object_path(root,data) + check('surplus-body',request(header(len(data))+data+cases[0][1]),expected(201,digest.encode())) + assert path.read_bytes()==data + # Repeated handoff/join windows also exercise completed-ticket retention. + with server('lifecycle',128,window=4) as (root,connect,request): + for i in range(128): + assert request(cases[0][1])==expected(200,b'OK\n') + checks.append('128-connections-consumed-tickets') + with server('limited',4,name='limited') as (root,connect,request): + check('configured-upload-limit',request(header(5)+b'12345'),expected(413,b'Request body too large\n')) + assert not list((root/'objects').rglob('*.arboricx')) + check('configured-upload-boundary',request(header(4)+b'1234'),expected(201,hashlib.sha256(b'1234').hexdigest().encode())) + for data,status in [(b'1234',200),(b'12345',404)]: + digest,path=object_path(root,data) + path.parent.mkdir(exist_ok=True);path.write_bytes(data) + body=data if status==200 else b'Artifact unavailable\n' + mime='application/vnd.arboricx.bundle' if status==200 else 'text/plain; charset=utf-8' + check(f'configured-download-{len(data)}',request(f'GET /_arboricx/bundle/hash/{digest} HTTP/1.1\r\n\r\n'.encode()),expected(status,body,mime)) + with server('logger',2,name='badLogger') as (root,connect,request): + data=b'published before logger failure';digest,path=object_path(root,data) + check('logger-failure',request(header(len(data))+data),expected(500,b'Internal Server Error\n')) + assert path.read_bytes()==data + check('download-logger-failure',request(f'GET /_arboricx/bundle/hash/{digest} HTTP/1.1\r\n\r\n'.encode()),expected(500,b'Internal Server Error\n')) + # Two pending streams occupy exactly two admission slots. A third connection + # may enter the kernel backlog but cannot create another stage until a ticket + # is consumed. FIFO deliberately waits for the oldest, not any completed child. + with server('admission',3,window=2) as (root,connect,request): + with connect() as first,connect() as second,connect() as third: + first.sendall(header(1));second.sendall(header(1));third.sendall(header(1)) + wait(lambda:len(list((root/'tmp').iterdir()))==2) + second.sendall(b'b');second.shutdown(socket.SHUT_WR) + check('second-concurrent',receive(second),expected(201,hashlib.sha256(b'b').hexdigest().encode())) + wait(lambda:len(list((root/'tmp').iterdir()))==1) + third.settimeout(.3) + try: third.recv(1);raise AssertionError('third admitted before oldest completion') + except socket.timeout: pass + assert len(list((root/'tmp').iterdir()))==1 + first.sendall(b'a');first.shutdown(socket.SHUT_WR) + check('oldest-completes',receive(first),expected(201,hashlib.sha256(b'a').hexdigest().encode())) + third.settimeout(90);third.sendall(b'c');third.shutdown(socket.SHUT_WR) + check('queued-admitted',receive(third),expected(201,hashlib.sha256(b'c').hexdigest().encode())) + with server('cancelled',0,name='cancelledServer') as (root,connect,request): + with connect() as first,connect() as second: + first.sendall(header(1048576)+b'a'*4096) + second.sendall(header(1048576)+b'b'*4096) + wait(lambda:len(list((root/'tmp').iterdir()))==2) + request.cancel() + for peer in [first,second]: + try: assert receive(peer)==b'', 'cancellation rendered a response' + except ConnectionResetError: pass + checks.append('server-cancellation-cleans-active-streams') + def startup(label,name,values,flags=()): + argv=[str(runner.exe),'arboricx','run','--module',pin,'--name',name,'--store',str(runner.store),'--io','--no-result',*flags] + for i,value in enumerate(values): + path=directory/f'{label}-{i}.tree';path.write_bytes(value);argv+=['--arg-tree-file',str(path)] + result=subprocess.run(argv,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=30) + (directory/(label+'.startup.log')).write_bytes(result.stdout+result.stderr) + assert result.returncode!=0 and not result.stdout,(label,result.stdout,result.stderr) + checks.append(label) + for window in [0,257]: + root=directory/f'invalid-window-{window}' + startup(f'window-{window}','server',[text(str(root)),number(1),number(window)]) + assert not root.exists() + root=directory/'blocked-root';root.write_bytes(b'unchanged') + startup('blocked-root','server',[text(str(root)),number(1),number(1)]) + assert root.read_bytes()==b'unchanged' + root=directory/'grant-root' + startup('reject-v1-grants','server',[text(str(root)),number(1),number(1)],['--unsafe-io']) + assert not root.exists() + with socket.socket() as occupied: + occupied.bind(('127.0.0.1',0));occupied.listen(1) + from io_tcp import endpoint + root=directory/'bind-failure' + startup('bind-failure','listen',[text(str(root)),endpoint(4,occupied.getsockname()[1])]) + assert not list(root.rglob('.tricu-stage-*')) + (directory/'report.json').write_text(json.dumps(dict(passed=checks,records=runner.records,processes=observations),indent=2)+'\n') + print(f'PASS {len(checks)} v2 HTTP controls; deployment unchanged') +if __name__=='__main__':main() diff --git a/test/io_indexed.py b/test/io_indexed.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +"""Independent framed/authenticated indexed-read and bounded-transfer controls.""" +import argparse +import hashlib +import json +from pathlib import Path +import struct +from source_order import Runner +from process_rss import run_guarded +from native_indexed_store import indexed_bytes, digest +from executable_object import INDEXED, indexed_manifest +from http_headers import number, result, tree_list + +ROOT = Path(__file__).resolve().parent.parent + +def text(s): + return tree_list([number(ord(c)) for c in s]) + +def problem(category, detail, progress): + return result(False, tree_list([text(category), text('indexed-tree/read'), text(detail), text(progress)])) + +def padded(size): + # Valid noncritical opaque section grows TRANSPORT, not the decoded DAG. + # This is not a large-DAG memory or compiler-capacity benchmark. + manifest = indexed_manifest(0) + nodes = struct.pack('>QIB', 1, 1, 0) + def section(kind, flags, offset, count): + return struct.pack('>IHHIQQI', kind, 1, flags, 0, offset, count, 0) + offset = 128 + len(manifest) + len(nodes) + return (b'ARBORICX' + struct.pack('>HHIQQ', 1, 0, 3, 0, 32) + + section(1, 1, 128, len(manifest)) + + section(2, 1, 128 + len(manifest), len(nodes)) + + section(99, 0, offset, size) + manifest + nodes + b'x' * size) + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('tricu', type=Path) + parser.add_argument('--directory', required=True, type=Path) + args = parser.parse_args() + directory = args.directory.resolve() + directory.mkdir(parents=True, exist_ok=False) + scratch = directory/'spool-λ' + scratch.mkdir() + runner = Runner(args.tricu, directory) + source = ROOT/'demos/io/indexed-read.tri' + bundle = directory/'indexed-read.arboricx' + runner.run('compile', ['compile', '--file', source, '--store', runner.store, '--output', bundle]) + pin = runner.imported(bundle, 'import') + checks = [] + def check(label, payload, size, limit, expected_digest, expected_reply, position): + path = directory/(label+'.bin') + path.write_bytes(payload) + values = [text(str(path)), text(str(scratch)), number(limit), number(size), text(expected_digest)] + argv = ['--io'] + for i, value in enumerate(values): + argument = directory/f'{label}-{i}.tree' + argument.write_bytes(value) + argv += ['--arg-tree-file', argument] + expected = tree_list([expected_reply, number(position)]) + assert runner.invoke(label, pin, 'probe', argv) == expected, label + assert path.read_bytes() == payload, label + assert list(scratch.iterdir()) == [], label + checks.append(dict(label=label, input_bytes=len(payload), frame_bytes=size)) + for i, tree in enumerate([b'\0', b'\1\0', b'\2\1\0\0', number(42)]): + payload = indexed_bytes(tree) + check(f'tree-{i}', payload+b'next frame', len(payload), len(payload), digest(INDEXED, payload), result(True, tree), len(payload)) + payload = indexed_bytes(number(7)) + check('wrong-digest', payload, len(payload), len(payload), '0'*64, + problem('integrity-failed', 'CAS digest mismatch', 'unknown'), len(payload)) + check('truncated', payload[:-1], len(payload), len(payload), digest(INDEXED, payload), + problem('invalid-data', 'incomplete indexed frame', 'unknown'), len(payload)-1) + check('limit', payload, len(payload), len(payload)-1, digest(INDEXED, payload), + problem('invalid-bound', 'invalid snapshot size limit', 'none'), 0) + check('bad-digest', payload, len(payload), len(payload), 'A'*64, + problem('invalid-argument', '', 'none'), 0) + check('bad-bound-wire', payload, 2**63, 2**63, digest(INDEXED, payload), + problem('invalid-argument', '', 'none'), 0) + malformed = b'not a bundle' + check('malformed-authenticated', malformed, len(malformed), len(malformed), digest(INDEXED, malformed), + problem('invalid-data', 'section extends beyond bundle end', 'unknown'), len(malformed)) + check('malformed-unauthenticated', malformed, len(malformed), len(malformed), '0'*64, + problem('integrity-failed', 'CAS digest mismatch', 'unknown'), len(malformed)) + for size in [4096, 1048576, 16777216, 67108864]: + payload = padded(size) + check(f'transport-{size}', payload, len(payload), len(payload), digest(INDEXED, payload), result(True, b'\0'), len(payload)) + observations = [s for s in runner.records if s['label'].startswith('transport-')] + for sample in observations: + assert sample['peak_kib'] <= 48*1024, sample + assert sample['rts_process']['maximum_live_bytes'] <= 16*1024*1024, sample + files = ['lib/io.tri', 'demos/io/indexed-read.tri', 'test/io_indexed.py', + 'test/source_order.py', 'test/rts_metrics.py', 'test/native_indexed_store.py', + 'test/executable_object.py', 'test/stable_bundle.py', 'src/ContentStore/Snapshot.hs', + 'src/ContentStore/Indexed.hs', 'src/Wire.hs', 'src/IO/Codec.hs', + 'src/IO/Resources.hs', 'src/IO/Driver.hs', 'src/IO/Protocol.hs', + 'bootstrap/build-stable.arboricx'] + (directory/'report.json').write_text(json.dumps(dict(checks=checks, observations=observations, + + measurement_notes='Opaque extension grows transport, not DAG. RSS is sampled: tiny runs can exit before representative samples; use the 64-MiB control for the transfer RSS guard. No concurrency or large-DAG capacity claim.', + source_hashes={p:hashlib.sha256((ROOT/p).read_bytes()).hexdigest() for p in files}), indent=2)+'\n') + print(f'{len(checks)} indexed-read controls passed') + +if __name__ == '__main__': + main() diff --git a/test/io_indexed_write.py b/test/io_indexed_write.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +"""Independent canonical bytes, winner verification and shared-DAG round trips.""" +import argparse +import hashlib +import json +from pathlib import Path +import struct +from source_order import Runner +from process_rss import run_guarded +from native_indexed_store import indexed_bytes, digest +from executable_object import INDEXED, indexed_manifest +from stable_bundle import assembled +from http_headers import number, result, tree_list + +ROOT = Path(__file__).resolve().parent.parent + +def text(s): + return tree_list([number(ord(c)) for c in s]) + +def info(payload): + return tree_list([number(len(payload)), text(digest(INDEXED, payload))]) + +def problem(category, operation, detail, progress): + return result(False, tree_list([text(category), text(operation), text(detail), text(progress)])) + +def dag(depth, fork): + records = [struct.pack('>IB', 1, 0)] + for i in range(1, depth+1): + payload = struct.pack('>BII', 2, i-1, i-1) if fork else struct.pack('>BI', 1, i-1) + records.append(struct.pack('>I', len(payload))+payload) + return assembled(indexed_manifest(depth), struct.pack('>Q', len(records))+b''.join(records)) + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('tricu', type=Path) + parser.add_argument('--directory', type=Path, required=True) + args = parser.parse_args() + directory = args.directory.resolve() + directory.mkdir(parents=True, exist_ok=False) + runner = Runner(args.tricu, directory) + source = ROOT/'demos/io/indexed-write.tri' + bundle = directory/'writer.arboricx' + runner.run('compile', ['compile', '--file', source, '--store', runner.store, '--output', bundle]) + pin = runner.imported(bundle, 'import') + checks = [] + def invoke(label, name, values, expected): + argv = ['--io'] + for i, value in enumerate(values): + path = directory/f'{label}-{i}.tree' + path.write_bytes(value) + argv += ['--arg-tree-file', path] + assert runner.invoke(label, pin, name, argv) == expected, label + checks.append(label) + for i, tree in enumerate([b'\0', b'\1\0', b'\2\1\0\0', number(42)]): + payload = indexed_bytes(tree) + expected = digest(INDEXED, payload) + invoke(f'hash-{i}', 'hash', [number(len(payload)), tree], result(True, info(payload))) + path = directory/f'write-{i}.bin' + invoke(f'write-{i}', 'write', [text(str(path)), number(len(payload)), text(expected), tree], result(True, info(payload))) + assert path.read_bytes() == payload + root = directory/f'publish-{i}' + root.mkdir() + for status in ['published', 'exists']: + invoke(f'publish-{i}-{status}', 'publish', [text(str(root)), number(len(payload)), tree], + result(True, tree_list([text(status),number(len(payload)),text(expected)]))) + assert sorted(p.name for p in root.iterdir()) == [expected] + assert (root/expected).read_bytes() == payload + (root/expected).write_bytes(b'X'+payload[1:]) + invoke(f'corrupt-{i}', 'publish', [text(str(root)), number(len(payload)), tree], + problem('integrity-failed','indexed-tree/verify','indexed frame differs from canonical tree','unknown')) + assert (root/expected).read_bytes() == b'X'+payload[1:] + assert sorted(p.name for p in root.iterdir()) == [expected] + (root/expected).write_bytes(payload+b'extra') + invoke(f'excess-{i}', 'publish', [text(str(root)), number(len(payload)), tree], + problem('invalid-data','indexed-file/verify','trailing bytes','unknown')) + assert (root/expected).read_bytes() == payload+b'extra' + path = directory/'preserve.bin' + path.write_bytes(b'untouched') + tree = b'\0' + payload = indexed_bytes(tree) + for label, limit, expected, answer in [ + ('wrong-hash',65536,'0'*64,problem('integrity-failed','indexed-tree/write','CAS digest mismatch','none')), + ('limit',0,digest(INDEXED,payload),problem('invalid-bound','indexed-tree/write','encoded tree exceeds limit','none')), + ('invalid-hash',65536,'A'*64,problem('invalid-argument','indexed-tree/write','','none'))]: + invoke(label, 'write', [text(str(path)),number(limit),text(expected),tree], answer) + assert path.read_bytes() == b'untouched' + # Raw input uses linear-depth stems, not an expanded exponentially shared + # tree. Expectations come from the independent wire assembler. + for depth in [1000,10000]: + payload = dag(depth,False) + tree = b'\1'*depth+b'\0' + invoke(f'deep-hash-{depth}','hash',[number(len(payload)),tree],result(True,info(payload))) + output = directory/f'deep-{depth}.bin' + invoke(f'deep-write-{depth}','write',[text(str(output)),number(len(payload)),text(digest(INDEXED,payload)),tree],result(True,info(payload))) + assert output.read_bytes() == payload + original_bundle,original_pin = bundle,pin + bundle = directory/'transport.arboricx' + runner.run('compile-transport',['compile','--file',ROOT/'test/io/indexed-transport.tri','--store',runner.store,'--output',bundle]) + pin = runner.imported(bundle,'import-transport') + payload = indexed_bytes(b'\0') + framed = directory/'framed.bin' + framed.write_bytes(payload+b'X') + from http_headers import pair + invoke('frame-tail','verifyTail',[text(str(framed)),number(len(payload)),b'\0'],result(True,pair(text('data'),tree_list([number(88)])))) + framed.write_bytes(payload[:-1]) + invoke('frame-truncated','verifyTail',[text(str(framed)),number(len(payload)),b'\0'],problem('integrity-failed','indexed-tree/verify','indexed frame differs from canonical tree','unknown')) + invoke('wrong-kind-inert-tree','wrongKind',[number(0)],problem('wrong-kind','indexed-tree/verify','','none')) + bundle,pin = original_bundle,original_pin + scratch = directory/'spool' + scratch.mkdir() + for depth, fork in ([(200,True),(1000,False),(10000,False),(100000,False)]): + payload = dag(depth,fork) + input_path = directory/f'dag-{depth}-{fork}.bin' + output = directory/f'dag-{depth}-{fork}-output.bin' + input_path.write_bytes(payload) + invoke(f'roundtrip-{depth}-{fork}', 'roundTrip', [text(str(input_path)),text(str(output)),text(str(scratch)), + number(len(payload)),number(len(payload)),text(digest(INDEXED,payload))], result(True,info(payload))) + assert output.read_bytes() == input_path.read_bytes() == payload + assert list(scratch.iterdir()) == [] + observations = [r for r in runner.records if r['label'].startswith('roundtrip-')] + for row in observations: + # DAG/table memory is expected to grow; these are regression guards, not + # constant-memory claims or a universal safe node-count budget. + assert row['peak_kib'] <= 256*1024, row + assert row['rts_process']['maximum_live_bytes'] <= 128*1024*1024, row + paths = ['test/io/indexed-transport.tri','test/io_indexed_write.py','test/native_indexed_store.py','test/executable_object.py', + 'test/stable_bundle.py','test/source_order.py','test/rts_metrics.py','lib/io.tri', + 'demos/io/indexed-write.tri','src/IO/Codec.hs','src/IO/Resources.hs','src/Wire.hs', + 'bootstrap/build-stable.arboricx'] + (directory/'report.json').write_text(json.dumps(dict(checks=checks, observations=observations, + scope="haskell-complete", + + source_hashes={p:hashlib.sha256((ROOT/p).read_bytes()).hexdigest() for p in paths}),indent=2)+'\n') + print(f'{len(checks)} independent indexed-writer checks passed'+('')) + +if __name__ == '__main__': + main() diff --git a/test/io_lines.py b/test/io_lines.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +"""Independent LF/UTF-8/limit oracles, fragmented pipes and interactive prompts.""" +import argparse +import json +import subprocess +import selectors +import time +from pathlib import Path +from source_order import Runner +from http_headers import number, result, tree_list, parse_tree_output +from io_text import text, problem +ROOT=Path(__file__).resolve().parent.parent + +def event(s,ending): return tree_list([text('line'),text(s),text(ending)]) +def lines(data): + parts=data.split(b'\n') + return [event(p.decode('utf8'),'lf') for p in parts[:-1]] + ([event(parts[-1].decode('utf8'),'eof')] if parts[-1] else []) + +def main(): + p=argparse.ArgumentParser(description=__doc__);p.add_argument('tricu',type=Path);p.add_argument('--directory',type=Path,required=True) + a=p.parse_args();d=a.directory.resolve();d.mkdir(parents=True,exist_ok=False);runner=Runner(a.tricu,d);checks=[] + def compile(label,path): + bundle=d/(label+'.arboricx');runner.run(label+'-compile',['compile','--file',path,'--store',runner.store,'--output',bundle,'--compile-timeout','600'],seconds=660) + return runner.imported(bundle,label+'-import') + pin=compile('lines',ROOT/'demos/io/lines.tri') + def invoke(label,name,values): + args=['--io'] + for i,value in enumerate(values): + f=d/f'{label}-{i}.tree';f.write_bytes(value);args+=['--arg-tree-file',f] + return runner.invoke(label,pin,name,args) + def collect(label,data,bound,expected=None): + path=d/(label+'.input');path.write_bytes(data) + actual=invoke(label,'collect',[text(str(path)),number(bound)]) + assert actual==(expected if expected is not None else result(True,tree_list(lines(data)))),label + checks.append(label) + for i,data in enumerate([b'',b'\n',b'\n\n',b'a',b'a\n',b'a\nb',b'a\n\n',b'a\r\nb\rc\n\0', '\ufeffλ😀e\u0301\n'.encode(),b'a'*4092+'😀'.encode()+b'\nnext\n',b'a'*4093+b'\n',b'a'*8190+b'\nend']): + collect(f'valid-{i}',data,max(map(len,data.split(b'\n')))) + for carry in [1,2,3]: + data=b'a'*(4093-carry)+'😀'.encode()+b'\nnext\n' + collect(f'carry-{carry}',data,4100) + collect('zero-empty-lines',b'\n\n',0) + over=problem('invalid-bound','text/read-line','line exceeds byte limit') + collect('overlong',b'ab\n',1,result(False,tree_list([b'\0',tree_list([text('read-line'),number(2),over])]))) + invalid=problem('invalid-data','utf8/decode','invalid UTF-8 leading byte at byte 0') + collect('later-invalid',b'ok\n\xff\n',20,result(False,tree_list([tree_list([event('ok','lf')]),tree_list([text('read-line'),number(1),invalid])]))) + partial=problem('invalid-data','utf8/decode','incomplete UTF-8 sequence at byte 0') + collect('incomplete-before-lf',b'\xe2\n',20,result(False,tree_list([b'\0',tree_list([text('read-line'),number(1),partial])]))) + collect('incomplete-at-eof',b'\xe2',20,result(False,tree_list([b'\0',tree_list([text('read-line'),number(1),partial])]))) + # Returning a first line never validates unrequested later buffered text. + path=d/'later-invalid.input' + assert invoke('first-only','first',[text(str(path)),number(20)])==result(True,event('ok','lf'));checks.append('first-only') + stale=problem('stale-handle','stream/read','') + assert invoke('closed-cursor','closed',[text(str(path)),number(20)])==result(False,tree_list([text('read-line'),number(0),stale])) + badlimit=problem('invalid-bound','text/limit','invalid text byte limit') + assert invoke('invalid-bound-before-read','closed',[text(str(path)),number(9223372036854775808)])==result(False,badlimit) + checks+=['closed-cursor','invalid-bound-before-read'] + def command(module,name,values=(),no_result=False): + cmd=[str(runner.exe),'arboricx','run','--module',module,'--name',name,'--store',str(runner.store),'--io','--format','ternary'] + for i,value in enumerate(values): + f=d/f'pipe-{name}-{i}.tree';f.write_bytes(value);cmd+=['--arg-tree-file',str(f)] + if no_result:cmd+=['--no-result'] + return cmd + data='A😀λ\n\ufeff\r\nlast'.encode() + for split in range(len(data)+1): + proc=subprocess.Popen(command(pin,'stdinLines',[number(100)]),stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.PIPE) + try: + proc.stdin.write(data[:split]);proc.stdin.flush();time.sleep(.02) + proc.stdin.write(data[split:]);proc.stdin.close();proc.stdin=None + out,err=proc.communicate(timeout=20) + assert proc.returncode==0,(split,err) + assert parse_tree_output(out.decode())==result(True,tree_list(lines(data))),split + finally: + if proc.poll() is None:proc.kill();proc.wait() + checks.append('every-pipe-split') + proc=subprocess.Popen(command(pin,'cancelled'),stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.PIPE) + try: + # Do not close stdin: cancellation, rather than EOF, must release the read. + proc.wait(timeout=10);out=proc.stdout.read();err=proc.stderr.read() + assert proc.returncode==0,err + assert parse_tree_output(out.decode())==tree_list([text('cancelled'),text('line-stop'),b'\0']) + finally: + if proc.poll() is None:proc.kill();proc.wait() + proc.stdin.close();proc.stdout.close();proc.stderr.close() + checks.append('cancel-blocked-input') + demo=compile('getLineAsync',ROOT/'demos/interactionTrees/getLineAsync.tri') + prompt=b'Please enter your first name: \n(this printed before you typed anything)\n(this second line also printed before you typed anything)\n' + for label,payload,wanted in [('name','λ😀\n'.encode(),'Hello, λ😀!\n'.encode()),('empty',b'\n',b'Hello, !\n'),('eof',b'',b'End of input.\n')]: + proc=subprocess.Popen(command(demo,'main',no_result=True),stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.PIPE) + try: + collected=b'';deadline=time.monotonic()+10 + with selectors.DefaultSelector() as sel: + sel.register(proc.stdout,selectors.EVENT_READ) + while len(collected)<len(prompt): + assert time.monotonic()<deadline,'prompt blocked on input' + if sel.select(.1): collected+=__import__('os').read(proc.stdout.fileno(),4096) + assert collected==prompt,collected + out,err=proc.communicate(payload,timeout=20) + assert proc.returncode==0 and out==wanted,(label,out,err) + finally: + if proc.poll() is None:proc.kill();proc.wait() + checks.append('interactive-'+label) + for label,wanted in [('greet',b'Hello, tricu\n'),('forkAwait',b'working: job1\nworking: job2\nGot job1-result and job2-result\n')]: + demo=compile(label,ROOT/f'demos/interactionTrees/{label}.tri') + run=subprocess.run(command(demo,'main',no_result=True),capture_output=True,timeout=30) + assert run.returncode==0 and run.stdout==wanted,(label,run.stdout,run.stderr) + checks.append(label) + with open('/dev/full','wb') as full: + failed=subprocess.run(command(demo,'main',no_result=True),stdout=full,stderr=subprocess.PIPE,timeout=20) + assert failed.returncode!=0,(label,failed.stderr) + checks.append(label+'-failed-output') + for count in [1024,16384]: + path=d/f'churn-{count}.input';path.write_bytes(b'x\n'*count) + assert invoke(f'churn-{count}','count',[text(str(path)),number(1)])==number(count) + row=runner.records[-1] + assert row['peak_kib']<128*1024 and row['rts_process']['maximum_live_bytes']<16*1024*1024,row + checks.append(f'churn-{count}') + (d/'report.json').write_text(json.dumps(dict(passed=checks,records=runner.records),indent=2)+'\n') + print(f'PASS {len(checks)} line/interactive controls (including every pipe split)') +if __name__=='__main__':main() diff --git a/test/io_objects.py b/test/io_objects.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +"""Independent source-owned CAS bytes, exact winners and shared-tree transfer.""" +import argparse +import hashlib +import json +from pathlib import Path +import re +import subprocess +from source_order import Runner +from native_indexed_store import indexed_bytes, digest +from executable_object import INDEXED +from io_indexed_write import dag +from http_headers import number, result, tree_list + +ROOT = Path(__file__).resolve().parent.parent + +def text(s): + return tree_list([number(ord(c)) for c in s]) + +def problem(category, op, detail, progress): + return result(False, tree_list([text(category),text(op),text(detail),text(progress)])) + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('tricu', type=Path) + parser.add_argument('--directory', type=Path, required=True) + args = parser.parse_args() + directory = args.directory.resolve() + directory.mkdir(parents=True, exist_ok=False) + runner = Runner(args.tricu, directory) + source = ROOT/'demos/io/objects.tri' + bundle = directory/'objects.arboricx' + runner.run('compile',['compile','--file',source,'--store',runner.store,'--output',bundle]) + module = runner.imported(bundle,'import') + checks = [] + def options(label, values): + argv = ['--io'] + for i,value in enumerate(values): + path = directory/f'{label}-{i}.tree' + path.write_bytes(value) + argv += ['--arg-tree-file',path] + return argv + def invoke(label,name,values,expected): + assert runner.invoke(label,module,name,options(label,values)) == expected,label + checks.append(label) + def clean(root): + assert not list(root.rglob('.tricu-*')),root + for i,tree in enumerate([b'\0',b'\1\0',number(42)]): + root = directory/f'root-{i}' + root.mkdir() + payload = indexed_bytes(tree) + pin = digest(INDEXED,payload) + path = root/'objects'/pin[:3]/pin + for label in ['new','existing']: + invoke(f'{label}-{i}','put',[text(str(root)),number(len(payload)),tree],result(True,text(pin))) + assert path.read_bytes() == payload + assert sorted(p.relative_to(root).as_posix() for p in root.rglob('*') if p.is_file()) == ['objects/'+pin[:3]+'/'+pin] + clean(root) + invoke(f'get-{i}','get',[text(str(root)),number(len(payload)),text(pin)],result(True,tree)) + path.write_bytes(b'X'+payload[1:]) + invoke(f'corrupt-put-{i}','put',[text(str(root)),number(len(payload)),tree], + problem('integrity-failed','indexed-tree/verify','indexed frame differs from canonical tree','unknown')) + invoke(f'corrupt-get-{i}','get',[text(str(root)),number(len(payload)),text(pin)], + problem('integrity-failed','indexed-tree/read','CAS digest mismatch','unknown')) + assert path.read_bytes() == b'X'+payload[1:] + clean(root) + path.write_bytes(payload+b'extra') + invoke(f'excess-{i}','put',[text(str(root)),number(len(payload)),tree], + problem('invalid-data','objects/verify','trailing bytes','unknown')) + assert path.read_bytes() == payload+b'extra' + clean(root) + absent = directory/'must-not-be-created' + for i,invalid in enumerate(['../outside','A'*64,'0'*63,'0'*65,'λ'*64,'\0'*64]): + invoke(f'invalid-{i}','get',[text(str(absent)),number(65536),text(invalid)], + problem('invalid-argument','objects/get','invalid CAS digest','none')) + for i, invalid in enumerate([b'\1\0', tree_list([number(48)[:-1]+b'\2\0\0']*64)]): + invoke(f'malformed-digest-{i}','get',[text(str(absent)),number(65536),invalid], + problem('invalid-argument','objects/get','invalid CAS digest','none')) + assert not absent.exists() + invoke('absent-root','putCategory',[text(str(absent)),number(65536),b'\0'],text('not-found')) + blocked = directory/'blocked'; blocked.mkdir(); (blocked/'objects').write_bytes(b'keep') + invoke('blocked','putCategory',[text(str(blocked)),number(65536),b'\0'],text('not-directory')) + assert (blocked/'objects').read_bytes() == b'keep' + limited = directory/'limited'; limited.mkdir() + invoke('limit','putCategory',[text(str(limited)),number(0),b'\0'],text('invalid-bound')) + assert list(limited.iterdir()) == [] + source_root = directory/'source'; target = directory/'target' + source_root.mkdir(); target.mkdir() + payload = dag(200,True) + pin = digest(INDEXED,payload) + path = source_root/'objects'/pin[:3]/pin + path.parent.mkdir(parents=True); path.write_bytes(payload) + invoke('shared-copy','copy',[text(str(source_root)),text(str(target)),number(len(payload)),text(pin)],result(True,text(pin))) + assert (target/'objects'/pin[:3]/pin).read_bytes() == payload + clean(source_root); clean(target) + # Two independent processes publish to one new namespace. Keep complete + # output/byte checks; this is a correctness race, not a capacity benchmark. + race = directory/'race'; race.mkdir() + tree = number(19); payload = indexed_bytes(tree); pin = digest(INDEXED,payload) + argv = options('race',[text(str(race)),number(len(payload)),tree]) + command = [str(args.tricu.resolve()),'arboricx','run','--module',module,'--name','put','--store',str(runner.store),'--format','ternary',*map(str,argv),'+RTS','-N1','-M256m','-RTS'] + workers = [subprocess.Popen(command,stdout=subprocess.PIPE,stderr=subprocess.PIPE) for _ in range(2)] + try: + for worker in workers: + out,err = worker.communicate(timeout=120) + assert worker.returncode == 0,(out,err) + raw = re.search(rb'(?m)^([012]+)$',out)[1] + assert bytes(int(c)-48 for c in raw) == result(True,text(pin)),out + finally: + for worker in workers: + if worker.poll() is None: worker.kill() + worker.wait() + assert (race/'objects'/pin[:3]/pin).read_bytes() == payload + clean(race); checks.append('concurrent-publish') + files = ['lib/io/objects.tri','lib/io.tri','demos/io/objects.tri','test/io_objects.py', + 'test/native_indexed_store.py','test/io_indexed_write.py','test/executable_object.py', + 'test/source_order.py','test/rts_metrics.py','src/IO/Resources.hs','src/IO/Descriptor.hs', + 'src/IO/Filesystem.hs','src/IO/Codec.hs','bootstrap/build-stable.arboricx'] + (directory/'report.json').write_text(json.dumps(dict(checks=checks,observations=runner.records, + source_hashes={p:hashlib.sha256((ROOT/p).read_bytes()).hexdigest() for p in files}),indent=2)+'\n') + print(f'{len(checks)} source CAS controls passed') + +if __name__ == '__main__': main() diff --git a/test/io_positioned.py b/test/io_positioned.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +"""Independent complete byte/result oracles for positioned file compositions.""" +import argparse +import hashlib +import json +from pathlib import Path +from source_order import Runner +from http_headers import number, pair, result, tree_list +from stable_bundle import byte_tree +ROOT = Path(__file__).resolve().parent.parent + +def text(value): + return tree_list([number(ord(c)) for c in value]) + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('tricu', type=Path) + parser.add_argument('--directory', type=Path, required=True) + args = parser.parse_args() + directory = args.directory.resolve() + directory.mkdir(parents=True, exist_ok=False) + runner = Runner(args.tricu, directory) + source = ROOT/'demos/io/positioned.tri' + bundle = directory/'positioned.arboricx' + runner.run('compile', ['compile', '--file', source, '--store', runner.store, '--output', bundle]) + pin = runner.imported(bundle, 'import') + checks = [] + + def invoke(label, name, values): + argv = ['--io'] + for index, value in enumerate(values): + path = directory/f'{label}-{index}.tree' + path.write_bytes(value) + argv += ['--arg-tree-file', path] + return runner.invoke(label, pin, name, argv) + + for index, (original, offset, patch, length, exists) in enumerate([ + (b'abcdefgh', 2, bytes([0,255,13,10]), 8, True), + (b'', 5, b'X', 12, False), + (b'0123456789', 3, b'ABCDE', 4, True), + (b'unchanged prefix', 0, b'', 16, True), + (b'discarded', 2, b'xy', 0, True), + (bytes(range(256))*4096, 524288, bytes(range(128)), 1048585, True), + ]): + path = directory/f'patch-{index}-λ.bin' + if exists: + path.write_bytes(original) + wanted = bytearray(original) + if patch: + end = offset+len(patch) + if end > len(wanted): + wanted.extend(bytes(end-len(wanted))) + wanted[offset:end] = patch + if length > len(wanted): + wanted.extend(bytes(length-len(wanted))) + del wanted[length:] + prefix = pair(text('data'), byte_tree(wanted[:4096])) if wanted else pair(text('eof'), b'\0') + expected = tree_list([number(n) for n in + [len(original), offset, offset+len(patch), offset+len(patch), length]]+[prefix]) + actual = invoke(f'patch-{index}', 'patch', [text(str(path)), number(offset), byte_tree(patch), number(length)]) + assert actual == expected, index + assert path.read_bytes() == wanted, index + checks.append(dict(label=f'patch-{index}', sha256=hashlib.sha256(wanted).hexdigest())) + for index, original in enumerate([None, b'', b'prior\x00\xff', bytes(range(256))*4096]): + path = directory/f'append-{index}.bin' + if original is not None: + path.write_bytes(original) + body = bytes(range(256))*16 + before = original or b'' + actual = invoke(f'append-{index}', 'append', [text(str(path)), byte_tree(body)]) + assert actual == tree_list([number(len(before)),number(len(before)+len(body))]), index + assert path.read_bytes() == before+body, index + checks.append(dict(label=f'append-{index}', sha256=hashlib.sha256(before+body).hexdigest())) + # Exercise the wire's wide offset without materializing a multi-GiB value: + # write one sparse byte, shrink, and compare the entire resulting file. + path = directory/'wide.bin' + actual = invoke('wide-offset', 'patch', [text(str(path)),number(4294967296),byte_tree(b'X'),number(1)]) + assert actual == tree_list([number(n) for n in [0,4294967296,4294967297,4294967297,1]]+ + [pair(text('data'),byte_tree(b'\0'))]) + assert path.read_bytes() == b'\0' + checks.append(dict(label='wide-offset')) + path = directory/'guard.bin' + path.write_bytes(b'keep me') + def problem(category, operation, detail=''): + return result(False, tree_list([text(v) for v in [category, operation, detail, 'none']])) + expected = tree_list([problem('unsupported','file/'+op,'operation unavailable in this file mode') + for op in ['seek','position','resize']]+[problem('wrong-kind','stream/read')]) + assert invoke('append-guards', 'appendGuards', [text(str(path))]) == expected + assert path.read_bytes() == b'keep me' + checks.append(dict(label='append-guards')) + for index, value in enumerate([number(9223372036854775808), pair(b'\0',b'\0'), b'\1\0']): + assert invoke(f'offset-{index}', 'badOffset', [text(str(path)),value]) == problem('invalid-argument','file/seek') + assert path.read_bytes() == b'keep me' + checks.append(dict(label=f'offset-{index}')) + observations = [row for row in runner.records if row['label'].startswith(('patch-','append-'))] + for row in observations: + assert row['peak_kib'] <= 128*1024, row + assert row['rts_process']['maximum_live_bytes'] <= 16*1024*1024, row + paths = [source, Path(__file__).resolve(), ROOT/'lib/io.tri', ROOT/'src/IO/Resources.hs', + ROOT/'src/IO/Driver.hs', ROOT/'src/IO/Protocol.hs', ROOT/'test/source_order.py', + ROOT/'test/rts_metrics.py', ROOT/'bootstrap/build-stable.arboricx'] + (directory/'report.json').write_text(json.dumps(dict(checks=checks, observations=observations, + sources={str(p.relative_to(ROOT)):hashlib.sha256(p.read_bytes()).hexdigest() for p in paths}), indent=2)+'\n') + print(f'PASS: {len(checks)} independent positioned-file checks') + +if __name__ == '__main__': + main() diff --git a/test/io_process.py b/test/io_process.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +"""Independent actual-command, binary pipeline and normal-scope-exit controls.""" +import argparse +import json +import shutil +from pathlib import Path +from source_order import Runner +from http_headers import number, tree_list, result +from io_objects import text + +ROOT = Path(__file__).resolve().parent.parent + +def main(): + p=argparse.ArgumentParser(description=__doc__) + p.add_argument('tricu',type=Path); p.add_argument('--directory',type=Path,required=True) + a=p.parse_args(); directory=a.directory.resolve(); directory.mkdir(parents=True,exist_ok=False) + runner=Runner(a.tricu,directory); bundle=directory/'process.arboricx'; checks=[] + runner.run('compile',['compile','--file',ROOT/'demos/io/process.tri','--store',runner.store,'--output',bundle,'--compile-timeout','600'],seconds=660) + pin=runner.imported(bundle,'import') + inherit=tree_list([text('inherit')]) + def invoke(label,name,args): + path=directory/(label+'.tree');path.write_bytes(tree_list(args)) + return runner.invoke(label,pin,name,['--io','--arg-tree-file',path]) + cat=shutil.which('cat');shell=shutil.which('sh');assert cat and shell + source=directory/'input';output=directory/'output';errors=directory/'errors' + def pipeline(label,exe,argv,cwd,env,wanted,err=b'',code=0): + actual=invoke(label,'pipeline',[text(exe),tree_list([text(x) for x in argv]),cwd,env,number(20),text(str(source)),text(str(output)),text(str(errors))]) + assert actual==tree_list([text('exit'),number(code)]),(label,actual.hex()) + assert output.read_bytes()==wanted,label + assert errors.read_bytes()==err,label + checks.append(label) + for size in [0,1,4095,4096,4097,65536,1048576]: + data=bytes(i%256 for i in range(size));source.write_bytes(data) + pipeline(f'binary-{size}',cat,[],inherit,inherit,data) + tee=shutil.which('tee');assert tee + data=bytes(range(256))*4096;source.write_bytes(data) + pipeline('concurrent-stdout-stderr',tee,['/dev/stderr'],inherit,inherit,data,data) + source.write_bytes(b'') + pipeline('literal-argv-env-cwd',shell,['-c','printf "%s|%s|%s" "$1" "$VALUE" "$PWD"; printf diagnostic >&2; exit 7','test','$(not-a-command); *'],tree_list([text('at'),text(str(directory))]),tree_list([text('replace'),tree_list([tree_list([text('VALUE'),text('literal')])])]),('$(not-a-command); *|literal|'+str(directory)).encode(),b'diagnostic',7) + marker=directory/'joined' + actual=invoke('normal-exit','normalExit',[text(shell),tree_list([text('-c'),text('i=0; while [ "$i" -lt 10000 ]; do i=$((i+1)); done; printf finished > "$1"; exit 3'),text('test'),text(str(marker))])]) + assert actual==number(42) and marker.read_bytes()==b'finished' + checks.append('normal-exit-reaps-without-implying-zero-status') + for label,exe,args,cwd,env,grace in [ + ('empty-executable','',[],inherit,inherit,0), + ('duplicate-environment',cat,[],inherit,tree_list([text('replace'),tree_list([tree_list([text('X'),text('a')]),tree_list([text('X'),text('b')])])]),0), + ('excess-argv',cat,['a']*257,inherit,inherit,0), + ('excess-text',cat,['a'*4097],inherit,inherit,0), + ('excess-total',cat,['a'*4096]*16,inherit,inherit,0), + ('excess-grace',cat,[],inherit,inherit,9223372036854775808)]: + actual=invoke(label,'raw',[text(exe),tree_list([text(x) for x in args]),cwd,env,number(grace)]) + assert actual==result(False,tree_list([text('invalid-argument'),text('process/spawn'),b'\0',text('none')])),label + checks.append(label) + for row in runner.records: + if row.get('label') in ('binary-1048576','concurrent-stdout-stderr'): + # Runtime process observations, not subprocess heap or capacity promises. + assert row['rts_process']['maximum_live_bytes'] < 16*1024*1024,row + assert row['peak_kib'] < 128*1024,row + (directory/'report.json').write_text(json.dumps(dict(passed=checks,records=runner.records),indent=2)+'\n') + print(f'PASS {len(checks)} process source controls') +if __name__=='__main__':main() diff --git a/test/io_protocol.py b/test/io_protocol.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +"""Independent source oracles for v2 discovery, inspection and unsupported calls.""" +import argparse +import hashlib +import json +from pathlib import Path +from source_order import Runner +from stable_bundle import byte_tree +from http_headers import tree_list, number, result + +ROOT = Path(__file__).resolve().parent.parent + +def text(value): + return byte_tree(value.encode()) + +def strings(values): + return tree_list([text(v) for v in values]) + +def expected_capabilities(): + def row(key, value): + return tree_list([text(key), value]) + groups = [('runtime', ['capabilities', 'inspect']), ('standard', ['input', 'output', 'error']), ('file', ['open-reader', 'open-writer', 'open-appender', 'open-update', 'seek', 'position', 'size', 'resize', 'sync']), + ('path', ['stat', 'lstat', 'remove-file', 'rename']), ('directory', ['create', 'remove', 'open', 'next', 'sync']), ('tcp', ['listen', 'connect', 'accept', 'local-address', 'peer-address', 'shutdown-write']), ('resolver', ['resolve']), ('indexed-tree', ['read', 'hash', 'write', 'verify']), ('utf8', ['encode', 'decode']), ('stream', ['read', 'write']), ('resource', ['close']), + ('stage', ['open', 'seal', 'publish']), ('digest', ['open', 'update', 'finish']), ('clock', ['monotonic', 'sleep']), ('scheduler', ['yield']), ('process', ['spawn', 'working-directory', 'environment-variable'])] + return tree_list([ + row('schema', number(1)), row('io-version', number(2)), + row('backend', text('haskell-native')), row('authority', text('os-process')), + row('core', strings(['pure', 'bind', 'fail', 'scope', 'current-scope', 'spawn', 'join', 'discard', 'cancel', 'cancelled'])), + row('groups', tree_list([tree_list([text(g), number(1), strings(ops)]) for g, ops in groups])), + row('limits', tree_list([row('chunk-bytes', number(4096)), row('transfer-handles', number(256)), row('file-offset-max', number(9223372036854775807)), row('directory-name-scalars', number(4096)), row('tcp-backlog', number(4096)), row('resolver-host-characters', number(253)), row('resolver-addresses', number(64)), row('indexed-sections', number(64)), row('indexed-manifest-bytes', number(65536)), row('utf8-encode-scalars', number(1024)), row('sleep-milliseconds-max', number(9223372036854775807)), row('process-arguments', number(256)), row('process-environment-entries', number(256)), row('process-text-scalars', number(4096)), row('process-total-scalars', number(65536)), row('process-grace-milliseconds-max', number(9223372036854775807))])), + row('tcp-address-families', strings(['ipv4', 'ipv6'])), + row('digest-algorithms', strings(['sha256'])), + row('restrictions', strings(['action-boundary-cancellation', 'no-hard-deadlines', + 'stage-same-filesystem', 'stage-no-copy-fallback', 'stage-private-namespace', 'publish-new-requires-hard-links', 'no-crash-recovery', 'borrowed-standard-streams', 'standard-stream-leases-per-run', 'file-single-cursor-lease', 'append-no-position-or-resize', 'file-host-handle-locking', 'directory-unsorted-non-snapshot', 'filesystem-host-encoding', 'directory-failure-poisons-cursor', 'tcp-numeric-addresses', 'tcp-ipv6-only-listeners', 'tcp-direction-leases', 'tcp-failure-poisons-direction', 'resolver-host-order-and-buffering', 'resolver-may-delay-cancellation', 'indexed-authenticated-snapshot', 'indexed-caller-frame-and-disk-limit', 'indexed-dag-resident', 'indexed-writer-preflight', 'indexed-budget-not-heap-quota', 'sync-host-filesystem-guarantees', 'utf8-strict-scalars', 'clock-host-monotonic-word64', 'sleep-may-run-late', 'yield-no-ordering-guarantee', 'process-direct-child-only', 'process-normal-exit-joins', 'process-explicit-pipe-draining']))]) + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('tricu', type=Path) + parser.add_argument('--directory', type=Path, required=True) + args = parser.parse_args() + directory = args.directory.resolve() + directory.mkdir(parents=True, exist_ok=False) + runner = Runner(args.tricu, directory) + bundle = directory/'protocol.arboricx' + source = ROOT/'test/io/protocol.tri' + runner.run('compile', ['compile', '--file', source, '--store', runner.store, '--output', bundle]) + pin = runner.imported(bundle, 'import') + victim = directory/'must-remain' + victim.write_bytes(b'not truncated\x00\xff') + path = directory/'path.tree' + path.write_bytes(byte_tree(str(victim).encode())) + seed = directory/'seed.tree' + seed.write_bytes(number(7)) + cases = { + 'capabilities': (result(True, expected_capabilities()), []), + 'handledCapabilities': (result(True, expected_capabilities()), []), + 'inspectWriter': (result(True, tree_list([text('facility'), text('file'), number(1), + text('open-writer'), text('available')])), [path]), + 'inspectLazyBind': (result(True, strings(['core', 'bind'])), [seed]), + 'inspectLazyScope': (result(True, strings(['core', 'scope'])), [seed]), + 'inspectFuture': (result(True, tree_list([text('facility'), text('future-tcp'), number(1), + text('connect'), text('unsupported')])), [seed]), + 'unsupported': (result(False, strings(['unsupported', 'future-tcp/connect', 'group revision 1', 'none'])), [seed])} + for name, (expected, inputs) in cases.items(): + argv = ['--io'] + for arg in inputs: + argv += ['--arg-tree-file', arg] + assert runner.invoke(name, pin, name, argv) == expected, name + assert victim.read_bytes() == b'not truncated\x00\xff' + for name in ['invalidHeader', 'retired']: + try: + runner.invoke(name, pin, name, ['--io']) + except Exception as error: + assert 'IO task failed' in str(getattr(error, 'output', error)), error + else: + raise AssertionError('malformed protocol executed: ' + name) + observations = runner.records + for row in observations: + stats = row['rts_process'] + assert all(k in stats for k in ['copied_bytes', 'mutator_cpu_seconds', 'gc_cpu_seconds', + 'productivity_cpu_percent']), row + paths = [source, ROOT/'lib/io.tri', ROOT/'src/IO/Protocol.hs', ROOT/'src/IO/Driver.hs', + ROOT/'test/source_order.py', ROOT/'test/rts_metrics.py', Path(__file__).resolve()] + report = {'checks': list(cases)+['invalidHeader', 'retired'], 'observations': observations, + 'sources': {str(p.relative_to(ROOT)): hashlib.sha256(p.read_bytes()).hexdigest() for p in paths}} + (directory/'report.json').write_text(json.dumps(report, indent=2)+'\n') + print('PASS: 9 independent capability/inspection/protocol checks') + +if __name__ == '__main__': + main() diff --git a/test/io_v2_provenance.py b/test/io_provenance.py diff --git a/test/io_publication.py b/test/io_publication.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 +"""Source-owned exact length/digest policy over scoped staged writers and hashes.""" +import argparse +import hashlib +import json +from pathlib import Path +import re +import subprocess +from source_order import Runner, field +from stable_bundle import byte_tree +from http_headers import number, result, tree_list + +ROOT = Path(__file__).resolve().parent.parent + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('tricu', type=Path) + parser.add_argument('--directory', type=Path, required=True) + args = parser.parse_args() + args.directory.mkdir(parents=True, exist_ok=False) + directory = args.directory.resolve() + runner = Runner(args.tricu, directory) + bundle = directory/'publication.arboricx' + runner.run('compile', ['compile', '--file', ROOT/'demos/io/publish.tri', + '--store', runner.store, '--output', bundle]) + pin = runner.imported(bundle, 'import') + checks = [] + def invoke(label, name, values): + argv = ['--io'] + for index, value in enumerate(values): + arg = directory/f'{label}-{index}.tree' + arg.write_bytes(number(value) if isinstance(value, int) else tree_list([number(ord(c)) for c in str(value)])) + argv += ['--arg-tree-file', arg] + return runner.invoke(label, pin, name, argv) + callback = directory/'callback'; callback.mkdir() + assert invoke('rejected-destination', 'rejectedDestination', [callback]) == result(False, byte_tree(b'Destination rejected')) + failure = tree_list([byte_tree(b'invalid-response'), byte_tree(b'artifact-stream'), + byte_tree(b'destination'), byte_tree(b'Expected destination Result'), b'\0']) + assert invoke('malformed-destination', 'malformedDestination', [callback]) == tree_list([byte_tree(b'failed'), failure, b'\0']) + assert list(callback.iterdir()) == [] + checks += ['rejected-destination', 'malformed-destination'] + for size in (0, 1, 4095, 4096, 4097, 65536, 1048576): + body = (bytes(range(256))*((size+255)//256))[:size] + source = directory/f'input-{size}' + source.write_bytes(body) + target = directory/f'objects-{size}' + target.mkdir() + digest = hashlib.sha256(body).hexdigest() + for state in ('new', 'existing'): + label = f'{state}-{size}' + actual = invoke(label, 'publish', [source, target, size]) + assert actual == result(True, byte_tree(digest.encode())), label + assert (target/digest).read_bytes() == body, label + assert (target/digest).stat().st_mode & 0o077 == 0, label + assert sorted(p.name for p in target.iterdir()) == [digest], label + checks.append(label) + source, target = directory/'source-λ-树', directory/'objects-λ-树' + source.write_bytes(b'\0\xffbinary') + target.mkdir() + digest = hashlib.sha256(source.read_bytes()).hexdigest() + assert invoke('unicode-paths', 'publish', [source, target, 8]) == result(True, byte_tree(digest.encode())) + assert (target/digest).read_bytes() == source.read_bytes() + assert sorted(p.name for p in target.iterdir()) == [digest] + checks.append('unicode-paths') + source = directory/'negative-input' + source.write_bytes(b'abc') + digest = hashlib.sha256(b'abc').hexdigest() + for label, count, winner, message in ( + ('short', 4, None, 'Incomplete artifact stream'), + ('excess', 2, None, 'Artifact exceeds declared length'), + ('bad-winner', 3, b'xyz', 'Existing artifact digest mismatch'), + ('short-winner', 3, b'ab', 'Existing artifact length mismatch'), + ('long-winner', 3, b'abcd', 'Existing artifact length mismatch')): + target = directory/label + target.mkdir() + if winner is not None: + (target/digest).write_bytes(winner) + actual = invoke(label, 'publish', [source, target, count]) + assert actual == result(False, byte_tree(message.encode())), label + assert sorted(p.name for p in target.iterdir()) == ([] if winner is None else [digest]), label + if winner is not None: + assert (target/digest).read_bytes() == winner, label + checks.append(label) + for name, message in ( + ('earlySuccess', 'Incomplete artifact stream'), + ('ignoresWriteFailure', 'Artifact exceeds declared length'), + ('emptyChunk', 'Empty artifact chunk'), + ('overrideWrite', 'Incomplete artifact stream'), + ('failedAfterFull', 'Producer failed')): + target = directory/name + target.mkdir() + actual = invoke(name, name, [target]) + assert actual == result(False, byte_tree(message.encode())), name + assert list(target.iterdir()) == [], name + checks.append(name) + target = directory/'invalidChunk' + target.mkdir() + actual = invoke('invalidChunk', 'invalidChunk', [target]) + assert field(actual, 0) == b'\0' + assert field(actual, 1, 0, 0) == byte_tree(b'write-all') + assert field(actual, 1, 0, 1, 0) == number(0) + assert field(actual, 1, 0, 1, 1, 0, 0) == byte_tree(b'invalid-argument') + assert list(target.iterdir()) == [] + checks.append('invalidChunk') + for name, expected in [('cancelled', 'IO task cancelled')]: + target = directory/name + target.mkdir() + try: + invoke(name, name, [target]) + except subprocess.CalledProcessError as error: + assert expected in error.output, error.output + else: + raise AssertionError(name+' resumed normally') + assert list(target.iterdir()) == [] + assert not (directory/'escape').exists() + checks.append(name) + target = directory/'badDigest' + target.mkdir() + expected = tree_list([byte_tree(b'failed'), tree_list([ + byte_tree(b'invalid-response'), byte_tree(b'artifact-stream'), byte_tree(b'digest'), + byte_tree(b'Invalid digest reply'), b'\0']), b'\0']) + assert invoke('badDigest', 'badDigest', [target]) == expected + assert list(target.iterdir()) == [] + assert not (directory/'escape').exists() + checks.append('badDigest') + for name in ('nestedProducer', 'forkedProducer'): + target = directory/name + target.mkdir() + expected = tree_list([byte_tree(b'failed'), tree_list([ + byte_tree(b'producer-boundary'), byte_tree(b'artifact-stream'), byte_tree(b'publish'), + byte_tree(b'Producer must be sequential'), + tree_list([byte_tree(b'outer'), byte_tree(b'inner')])]), b'\0']) + assert invoke(name, name, [target]) == expected, name + assert list(target.iterdir()) == [], name + checks.append(name) + observations = [dict(row) for row in runner.records if row['label'].endswith(('-65536', '-1048576'))] + for row in observations: + log = (directory/(row['label']+'.log')).read_text() + matched = re.search(r'([0-9,]+) bytes maximum residency', log) + assert matched, row + row['maximum_live_bytes'] = int(matched[1].replace(',', '')) + assert row['maximum_live_bytes'] <= 16*1024*1024, row + assert row['peak_kib'] <= 128*1024, row + paths = [Path(__file__), ROOT/'src/IO/Resources.hs', ROOT/'src/IO/Staging.hs', + ROOT/'src/IO/Digest.hs', ROOT/'src/IO/Driver.hs', ROOT/'src/IO/Protocol.hs', + ROOT/'test/source_order.py', ROOT/'test/rts_metrics.py', ROOT/'lib/io.tri', + ROOT/'lib/io/artifacts.tri', ROOT/'lib/handlers.tri', ROOT/'demos/io/publish.tri'] + report = {'checks': checks, 'observations': observations, 'sources': { + str(path.resolve().relative_to(ROOT)): hashlib.sha256(path.read_bytes()).hexdigest() for path in paths}} + (directory/'report.json').write_text(json.dumps(report, indent=2)+'\n') + print(f'PASS: {len(checks)} publication and independent policy checks') + +if __name__ == '__main__': + main() diff --git a/test/io_storage_cost.py b/test/io_storage_cost.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +"""Isolated storage hash/write cost guards; no compiler generations or bootstrap changes. + +Optional --reference-storage compiles an explicitly selected historical source +with the same inputs and probe. Its results are measured without the candidate +allocation guard. Wall timings are observations, not portable acceptance limits. +""" +import argparse +import hashlib +import json +import statistics +from pathlib import Path + +from http_headers import number, result +from source_order import Runner +from stable_bundle import byte_tree + +ROOT = Path(__file__).resolve().parent.parent +SOURCE = '''!import io as IO +!import io_storage as S +!export hash +!export write +hash limit bytes = IO.io (S.hashBytes limit bytes) +write path bytes = IO.io <| IO.scope (owner : do IO.onOk + writer <- IO.openWriter owner path + IO.writeAll writer bytes) +''' + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('tricu', type=Path) + parser.add_argument('--directory', type=Path, required=True) + parser.add_argument('--reference-storage', type=Path) + args = parser.parse_args() + root = args.directory.resolve() + root.mkdir(parents=True, exist_ok=False) + source = root/'source' + source.mkdir() + inputs = source/'inputs' + inputs.mkdir() + paths = dict(base=ROOT/'lib/base.tri', native_math=ROOT/'lib/math/native.tri', + io=ROOT/'lib/io.tri', + io_storage=args.reference_storage or ROOT/'lib/io/storage.tri') + workspace = ['tricu.workspace.v1'] + for name, path in paths.items(): + (inputs/(name+'.tri')).write_bytes(path.read_bytes()) + workspace.append(name+'\tinputs/'+name+'.tri') + (source/'tricu.workspace').write_text('\n'.join(workspace)+'\n') + (source/'probe.tri').write_text(SOURCE) + runner = Runner(args.tricu, root) + bundle = root/'probe.arboricx' + runner.run('compile', ['compile', '--file', source/'probe.tri', '--store', runner.store, + '--output', bundle]) + pin = runner.imported(bundle, 'import') + summaries = [] + for size in [1024, 4096, 65536]: + payload = bytes(range(256))*(size//256) + argument = root/f'bytes-{size}.tree' + argument.write_bytes(byte_tree(payload)) + expected = result(True, byte_tree(hashlib.sha256(payload).hexdigest().encode())) + for label, limit in [('exact', size), ('roomy', 67108864)]: + bound = root/f'limit-{size}-{label}.tree' + bound.write_bytes(number(limit)) + rows = [] + for repeat in range(3): + actual = runner.invoke(f'hash-{size}-{label}-{repeat}', pin, 'hash', + ['--io', '--arg-tree-file', bound, '--arg-tree-file', argument]) + assert actual == expected, (size, label, repeat) + rows.append(runner.records[-1]) + summary = dict(bytes=size, limit=limit, repeats=len(rows), + median_invocation_seconds=statistics.median(r['invoke_seconds'] for r in rows), + median_invocation_allocated_bytes=statistics.median(r['allocated_bytes'] for r in rows)) + # Includes result observation. Allow ample headroom over the measured + # fixed-step helper while rejecting the numeric-countdown/list-contract + # path (~406 MB for one 4-KiB chunk on the reference compiler). + if not args.reference_storage and size == 4096 and label == 'roomy': + assert summary['median_invocation_allocated_bytes'] < 160_000_000, summary + summaries.append(summary) + writes = [] + # Full-bound native acknowledgements exhaust the validated input chunk. + # The neighboring short chunk continues to exercise ordinary dropping. + for size in [4095,4096]: + payload=(bytes(range(256))*16)[:size] + argument=root/f'write-bytes-{size}.tree' + argument.write_bytes(byte_tree(payload)) + destination=root/f'write-{size}.bin' + path=root/f'write-path-{size}.tree' + path.write_bytes(byte_tree(str(destination).encode())) + rows=[] + for repeat in range(3): + actual=runner.invoke(f'write-{size}-{repeat}',pin,'write', + ['--io','--arg-tree-file',path,'--arg-tree-file',argument]) + assert actual == result(True,b'\0') + assert destination.read_bytes() == payload + rows.append(runner.records[-1]) + summary=dict(bytes=size,repeats=len(rows), + median_invocation_seconds=statistics.median(r['invoke_seconds'] for r in rows), + median_invocation_allocated_bytes=statistics.median(r['allocated_bytes'] for r in rows)) + if size == 4096: + assert summary['median_invocation_allocated_bytes'] < 32_000_000,summary + writes.append(summary) + report = dict(reference_storage=str(args.reference_storage) if args.reference_storage else None, + source_hashes={name: hashlib.sha256(path.read_bytes()).hexdigest() for name, path in paths.items()}, + bundle_sha256=hashlib.sha256(bundle.read_bytes()).hexdigest(), + measurements=summaries, write_measurements=writes, records=runner.records) + (root/'report.json').write_text(json.dumps(report, indent=2)+'\n') + print(json.dumps(dict(hashes=summaries,writes=writes), indent=2)) + + +if __name__ == '__main__': + main() diff --git a/test/io_streams.py b/test/io_streams.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +"""Real binary pipes/redirects, ownership errors, and bounded native residency.""" +import argparse +import concurrent.futures +import hashlib +import json +import os +from pathlib import Path +import subprocess +import time +from source_order import Runner +from process_rss import ChildRSS +from rts_metrics import parse_rts_stats +from http_headers import result, tree_list +from stable_bundle import byte_tree + +ROOT = Path(__file__).resolve().parent.parent + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('tricu', type=Path) + parser.add_argument('--directory', type=Path, required=True) + args = parser.parse_args() + directory = args.directory.resolve() + directory.mkdir(parents=True, exist_ok=False) + runner = Runner(args.tricu, directory) + source = ROOT/'demos/io/streams.tri' + bundle = directory/'streams.arboricx' + runner.run('compile', ['compile', '--file', source, '--store', runner.store, '--output', bundle]) + pin = runner.imported(bundle, 'import') + checks, observations = [], [] + + def command(name, stats=True): + return [str(runner.exe), 'arboricx', 'run', '--module', pin, '--name', name, + '--store', str(runner.store), '--io', '--no-result', + '+RTS', '-N1', '-M128m', *(['-T', '-s'] if stats else []), '-RTS'] + + def invoke(label, name, body, redirected=False): + argv = command(name) + started = time.monotonic() + input_path, output_path = directory/(label+'.in'), directory/(label+'.out') + input_path.write_bytes(body) + with input_path.open('rb') as src, output_path.open('wb') as dst: + proc = subprocess.Popen(argv, stdin=src if redirected else subprocess.PIPE, + stdout=dst if redirected else subprocess.PIPE, stderr=subprocess.PIPE) + peak = None + try: + rss = ChildRSS(proc) + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: + future = pool.submit(proc.communicate, None if redirected else body, timeout=120) + while not future.done(): + current = rss.sample() + if current is not None: + peak = max(peak or 0, current) + if peak > 128*1024: + proc.kill() + raise AssertionError('RSS bound exceeded') + time.sleep(.01) + stdout, stderr = future.result() + finally: + if proc.poll() is None: + proc.kill(); proc.communicate() + assert proc.returncode == 0, stderr + if redirected: + stdout = output_path.read_bytes() + else: + output_path.write_bytes(stdout) + assert stdout == body, label + text = stderr.decode() + (directory/(label+'.log')).write_text(text) + stats = parse_rts_stats(text) + assert stats['maximum_live_bytes'] <= 16*1024*1024, stats + assert peak is not None, 'RSS unavailable' + observations.append(dict(label=label, command=argv, seconds=time.monotonic()-started, + peak_kib=peak, rts_process=stats, sha256=hashlib.sha256(stdout).hexdigest())) + checks.append(label) + + for name in ['main', 'handled', 'child']: + for size in [0, 1, 4095, 4096, 4097, 65536, 1048576]: + body = (bytes(range(256))*((size+255)//256))[:size] + invoke(f'{name}-{size}', name, body) + invoke('redirected', 'main', bytes(range(256))*257, True) + for name, operation in [('closeDenied', 'resource/close'), ('transferDenied', 'core/spawn')]: + expected = result(False, tree_list([byte_tree(x.encode()) for x in + ['borrowed-resource', operation, '', 'none']])) + assert runner.invoke(name, pin, name, ['--io']) == expected, name + checks.append(name) + run = subprocess.run(command('diagnostic', False), input=b'', capture_output=True, timeout=20) + assert (run.returncode, run.stdout, run.stderr) == (0, b'', bytes([0,255,13,10])), run + checks.append('binary-stderr') + reader, writer = os.pipe() + os.close(reader) + try: + proc = subprocess.Popen(command('broken', False), stdin=subprocess.DEVNULL, + stdout=writer, stderr=subprocess.PIPE) + finally: + os.close(writer) + _, diagnostic = proc.communicate(timeout=20) + assert (proc.returncode, diagnostic) == (0, b'failed'), (proc.returncode, diagnostic) + checks.append('broken-pipe-ordinary-result') + paths = [source, Path(__file__).resolve(), ROOT/'lib/io.tri', + ROOT/'src/IO/Resources.hs', ROOT/'src/IO/Driver.hs', ROOT/'src/IO/Protocol.hs', + ROOT/'test/rts_metrics.py', ROOT/'bootstrap/build-stable.arboricx'] + (directory/'report.json').write_text(json.dumps(dict(checks=checks, observations=observations, + sources={str(p.relative_to(ROOT)): hashlib.sha256(p.read_bytes()).hexdigest() for p in paths}), indent=2)+'\n') + print(f'PASS: {len(checks)} binary stream, lifecycle and pipe checks') + +if __name__ == '__main__': + main() diff --git a/test/io_tasks.py b/test/io_tasks.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +"""Independent source-level task/handler oracles, using a Nix-built native host.""" +import argparse +import hashlib +import json +import re +from pathlib import Path +from source_order import Runner +from stable_bundle import byte_tree +from http_headers import tree_list, number, result + +ROOT = Path(__file__).resolve().parent.parent + +def text(s): + return byte_tree(s.encode()) +def completed(value): + return tree_list([text('completed'), value]) +def failed(message): + return tree_list([text('failed'), text(message), b'\0']) + +def core_cases(): + return { + 'joined': completed(number(42)), + 'supervised': failed('fault'), + 'cancelled': tree_list([text('cancelled'), text('shutdown'), b'\0']), + 'ordinary': completed(result(False, text('domain'))), + 'consumed': tree_list([result(True, completed(number(42))), + result(False, tree_list([text('stale-task'), text('core/join'), b'\0', text('none')]))]), + 'inherited': tree_list([completed(number(7)), number(7)]), + 'changed': tree_list([completed(number(9)), number(7)]), + 'callback': tree_list([completed(number(7)), number(9)]), + 'lazyLabels': completed(number(7)), + 'labelledFailure': tree_list([text('failed'), tree_list([text('problem'), text('service'), + text('op'), text('detail'), tree_list([text('outer'), text('inner')])]), b'\0']), + 'labelledStateBoundary': tree_list([text('failed'), tree_list([text('state-boundary'), + text('log'), text('write'), text('Install child-local collection explicitly'), + tree_list([text('outer'), text('inner')])]), b'\0']), + 'stateBoundary': tree_list([text('failed'), tree_list([text('state-boundary'), + text('log'), text('write'), text('Install child-local collection explicitly'), b'\0']), b'\0']), + } + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('tricu', type=Path) + parser.add_argument('--directory', type=Path, required=True) + args = parser.parse_args() + args.directory.mkdir(parents=True, exist_ok=False) + runner = Runner(args.tricu, args.directory.resolve()) + source = ROOT/'test/io/tasks.tri' + bundle = args.directory.resolve()/'tasks.arboricx' + runner.run('compile', ['compile', '--file', source, '--store', runner.store, '--output', bundle]) + pin = runner.imported(bundle, 'import') + cases = core_cases() + for name, expected in cases.items(): + argv = ['--io'] + if name == 'lazyLabels': + arg = args.directory.resolve()/'unit.tree' + arg.write_bytes(b'\0') + argv += ['--arg-tree-file', arg] + actual = runner.invoke(name, pin, name, argv) + assert actual == expected, (name, actual.hex(), expected.hex()) + partial_checks = [] + for name in ('partialWrites', 'failedPartialWrite'): + for body in (b'', b'\xff', bytes([0, 255, 1, 128, 2, 254, 3])): + argument = args.directory.resolve()/f'{name}-{len(body)}.tree' + argument.write_bytes(byte_tree(body)) + label = f'{name}-{len(body)}' + actual = runner.invoke(label, pin, name, ['--io', '--arg-tree-file', argument]) + fails = name == 'failedPartialWrite' and len(body) > 2 + expected = tree_list([result(False, tree_list([text('write-all'), number(2), text('write fault')])) if fails else result(True, b'\0'), + byte_tree(body[:2] if fails else body)]) + assert actual == expected, label + partial_checks.append(label) + body=bytes(range(256))*16 + for i,counts in enumerate([[4096],[4095,1],[1,4095],[2048,2048], + [1,2048,2047],[],[4095],[2048],[1]]): + supplied=args.directory.resolve()/f'acks-{i}.tree' + supplied.write_bytes(tree_list([number(n) for n in counts])) + argument=args.directory.resolve()/f'ack-body-{i}.tree' + argument.write_bytes(byte_tree(body)) + offset=0; requests=[] + for count in counts: + assert 0 < count <= len(body)-offset + requests.append(byte_tree(body[offset:])) + offset+=count + if offset < len(body): + requests.append(byte_tree(body[offset:])) + answer=result(False,tree_list([text('write-all'),number(offset),text('write fault')])) + else: + answer=result(True,b'\0') + expected=tree_list([answer,tree_list(requests)]) + label=f'acknowledged-writes-{i}' + actual=runner.invoke(label,pin,'acknowledgedWrites', + ['--io','--arg-tree-file',supplied,'--arg-tree-file',argument]) + assert actual==expected,label + partial_checks.append(label) + retention = [] + for name in ('churnJoined', 'churnDiscarded'): + for count in (16, 256, 1024): + payload = args.directory.resolve()/f'{name}-{count}.bin' + payload.write_bytes(bytes(range(256))*(count*16)) + count_arg = args.directory.resolve()/f'count-{count}.tree' + count_arg.write_bytes(number(count)) + path_arg = args.directory.resolve()/f'{name}-{count}.path' + path_arg.write_bytes(byte_tree(str(payload).encode())) + label = f'{name}-{count}' + actual = runner.invoke(label, pin, name, ['--io', '--arg-tree-file', count_arg, + '--arg-tree-file', path_arg]) + assert actual == b'\0', label + row = dict(runner.records[-1]) + log = (args.directory/(label+'.log')).read_text() + match = re.search(r'([0-9,]+) bytes maximum residency', log) + assert match, label + row['maximum_live_bytes'] = int(match[1].replace(',', '')) + assert row['maximum_live_bytes'] <= 16*1024*1024, row + assert row['peak_kib'] <= 128*1024, row + retention.append(row) + paths = [source, ROOT/'lib/handlers.tri', ROOT/'lib/io.tri', + ROOT/'src/IO/Tasks.hs', ROOT/'src/IO/Driver.hs', ROOT/'src/IO/Protocol.hs', + ROOT/'test/source_order.py', ROOT/'test/rts_metrics.py', ROOT/'src/IO/Resources.hs', + ROOT/'lib/http.tri', ROOT/'lib/math/native.tri', ROOT/'lib/base.tri', + ROOT/'bootstrap/build-stable.arboricx'] + (args.directory/'report.json').write_text(json.dumps({'checks': list(cases)+partial_checks, + 'sources': {str(p.relative_to(ROOT)): hashlib.sha256(p.read_bytes()).hexdigest() for p in paths}, + 'retention': retention, 'observations': runner.records}, indent=2)+'\n') + print(f'PASS: {len(cases)+len(partial_checks)} task/handler source oracles and {len(retention)} fresh-chunk retention workloads') + +if __name__ == '__main__': + main() diff --git a/test/io_tcp.py b/test/io_tcp.py @@ -0,0 +1,296 @@ +#!/usr/bin/env python3 +"""Real socket source workflows: full binary echo, directional pipelines and handoff.""" +import argparse +import concurrent.futures +import hashlib +import json +from pathlib import Path +import socket +import subprocess +import struct +import threading +import time +from source_order import Runner +from process_rss import ChildRSS +from rts_metrics import parse_rts_stats +from http_headers import number, result, tree_list +from io_positioned import text +from io_directory import parse_tree, items, string +ROOT = Path(__file__).resolve().parent.parent + +def endpoint(family, port): + if family == 4: + return tree_list([text('ipv4'),tree_list([number(x) for x in [127,0,0,1]]),number(port)]) + return tree_list([text('ipv6'),tree_list([number(x) for x in [0,0,0,0,0,0,0,1]]),number(0),number(port)]) + +def exchange(family, port, body, prefix): + with socket.socket(socket.AF_INET if family == 4 else socket.AF_INET6) as peer: + peer.settimeout(90) + peer.connect(('127.0.0.1' if family == 4 else '::1',port)) + def send(): + for start in range(0,len(body),997): + peer.sendall(body[start:start+997]) + peer.shutdown(socket.SHUT_WR) + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: + writer = pool.submit(send) + chunks = [] + while True: + chunk = peer.recv(4096) + if not chunk: + break + chunks.append(chunk) + writer.result(timeout=90) + actual = b''.join(chunks) + assert actual == prefix+body, (len(actual),len(prefix+body)) + return hashlib.sha256(actual).hexdigest() + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('tricu', type=Path) + parser.add_argument('--directory', type=Path, required=True) + args = parser.parse_args() + directory = args.directory.resolve() + directory.mkdir(parents=True, exist_ok=False) + runner = Runner(args.tricu,directory) + source = ROOT/'demos/io/tcp.tri' + bundle = directory/'tcp.arboricx' + runner.run('compile',['compile','--file',source,'--store',runner.store,'--output',bundle]) + pin = runner.imported(bundle,'import') + checks, observations = [], [] + def command(label, name, values): + argv = [str(runner.exe),'arboricx','run','--module',pin,'--name',name,'--store',str(runner.store),'--io','--no-result'] + for index,value in enumerate(values): + path = directory/f'{label}-{index}.tree' + path.write_bytes(value) + argv += ['--arg-tree-file',str(path)] + return argv+['+RTS','-N1','-M128m','-T','-s','-RTS'] + def launch(label,name,values): + argv = command(label,name,values) + proc = subprocess.Popen(argv,stdin=subprocess.DEVNULL,stdout=subprocess.PIPE,stderr=subprocess.PIPE) + data = dict(label=label, command=argv, started=time.monotonic(), peak_kib=0) + stopped = threading.Event() + def sample(): + try: + rss = ChildRSS(proc) + while not stopped.is_set(): + current = rss.sample() + if current is not None: + data['peak_kib'] = max(data['peak_kib'],current) + if current > 128*1024: + raise AssertionError('RSS bound exceeded') + stopped.wait(.01) + except Exception as error: + data['monitor_error'] = repr(error) + try: + proc.kill() + except ProcessLookupError: + pass + thread = threading.Thread(target=sample,daemon=True) + thread.start() + return proc,data,stopped,thread + def finish(run): + proc,data,stopped,thread = run + try: + stdout,stderr = proc.communicate(timeout=120) + stopped.set(); thread.join() + assert 'monitor_error' not in data, data + assert proc.returncode == 0, stderr + assert not stdout, stdout + stats = parse_rts_stats(stderr.decode()) + assert stats['maximum_live_bytes'] <= 16*1024*1024, stats + assert 0 < data['peak_kib'] <= 128*1024, data + (directory/(data['label']+'.log')).write_bytes(stderr) + data['seconds'] = time.monotonic()-data.pop('started') + data['rts_process'] = stats + observations.append(data) + finally: + stopped.set(); thread.join() + if proc.poll() is None: + proc.kill(); proc.communicate() + def abort(run): + proc,_,stopped,thread = run + stopped.set(); thread.join() + if proc.poll() is None: + proc.kill(); proc.communicate() + def server(label, name, family, bodies, parallel=False): + run = launch(label,name,[endpoint(family,0),number(len(bodies))]) + proc = run[0] + try: + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: + # A timed readiness channel, never a probe connection consuming + # one of the finite server's accepted-connection slots. + ready = pool.submit(proc.stdout.readline) + try: + line = ready.result(timeout=20) + if not line: + _, diagnostic = proc.communicate(timeout=5) + (directory/(label+'.startup-error.log')).write_bytes(diagnostic) + raise AssertionError(diagnostic) + port = int(line) + except BaseException: + abort(run) + raise + prefix = bytes([66,0,255,10]) if name == 'handledServer' else b'' + if parallel: + with concurrent.futures.ThreadPoolExecutor(max_workers=len(bodies)) as pool: + sums = list(pool.map(lambda body: exchange(family,port,body,prefix),bodies)) + else: + sums = [exchange(family,port,body,prefix) for body in bodies] + finish(run) + checks.append(dict(label=label,sha256=sums)) + finally: + abort(run) + body = lambda size: (bytes(range(256))*((size+255)//256))[:size] + for name in ['server','handledServer']: + server(name+'-boundaries',name,4,[body(n) for n in [0,1,4095,4096,4097,65536,1048576]]) + server(name+'-ipv6',name,6,[body(4097)]) + server(name+'-concurrent',name,4,[bytes([i])+body(65535) for i in range(4)],True) + server(name+'-churn',name,4,[bytes([i])*64 for i in range(128)]) + for name in ['handledServer','supervisedHandledServer']: + label = name+'-reset' + run = launch(label,name,[endpoint(4,0),number(2)]) + proc = run[0] + try: + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: + ready = pool.submit(proc.stdout.readline) + try: + port = int(ready.result(timeout=20)) + except BaseException: + abort(run) + raise + with socket.create_connection(('127.0.0.1',port),timeout=20) as peer: + banner = b'' + while len(banner) < 4: + chunk = peer.recv(4-len(banner)) + assert chunk, banner + banner += chunk + assert banner == bytes([66,0,255,10]) + # Banner proves accept/handoff completed before aborting the peer. + peer.setsockopt(socket.SOL_SOCKET,socket.SO_LINGER,struct.pack('ii',1,0)) + if name == 'supervisedHandledServer': + exchange(4,port,body(4097),bytes([66,0,255,10])) + finish(run) + else: + stdout,stderr = proc.communicate(timeout=20) + assert proc.returncode != 0 and b'IO task failed' in stderr, stderr + assert stdout == b'' + (directory/(label+'.log')).write_bytes(stderr) + checks.append(dict(label=label)) + finally: + abort(run) + # Native source client reads/writes its socket concurrently. A real peer + # echoes with small buffers, forcing meaningful bidirectional backpressure. + for family in [4,6]: + for size in ([0,4097,1048576] if family == 4 else [4097]): + label = f'client-{family}-{size}' + incoming, outgoing = directory/(label+'.in'), directory/(label+'.out') + incoming.write_bytes(body(size)) + with socket.socket(socket.AF_INET if family == 4 else socket.AF_INET6) as listener: + listener.bind(('127.0.0.1' if family == 4 else '::1',0)); listener.listen(1); listener.settimeout(20) + port = listener.getsockname()[1] + def echo(): + peer,_ = listener.accept() + with peer: + peer.settimeout(90) + while True: + chunk = peer.recv(997) + if not chunk: + break + peer.sendall(chunk) + peer.shutdown(socket.SHUT_WR) + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: + worker = pool.submit(echo) + run = launch(label,'client',[endpoint(family,port),text(str(incoming)),text(str(outgoing))]) + try: + finish(run) + worker.result(timeout=90) + finally: + abort(run) + assert outgoing.read_bytes() == incoming.read_bytes(), label + checks.append(dict(label=label,sha256=hashlib.sha256(outgoing.read_bytes()).hexdigest())) + # Explicit host resolution plus source fallback, using only local services. + for host in ['127.0.0.1','localhost']: + label = 'named-client-'+host + incoming, outgoing = directory/(label+'.in'), directory/(label+'.out') + incoming.write_bytes(body(65536)) + with socket.socket() as listener: + listener.bind(('127.0.0.1',0)); listener.listen(1); listener.settimeout(20) + port = listener.getsockname()[1] + def echo_named(): + peer,_ = listener.accept() + with peer: + peer.settimeout(30) + while True: + chunk = peer.recv(997) + if not chunk: + break + peer.sendall(chunk) + peer.shutdown(socket.SHUT_WR) + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: + peer = pool.submit(echo_named) + run = launch(label,'namedClient',[text(host),text('any'),number(port),text(str(incoming)),text(str(outgoing))]) + try: + finish(run) + peer.result(timeout=30) + finally: + abort(run) + assert outgoing.read_bytes() == incoming.read_bytes() + checks.append(dict(label=label,sha256=hashlib.sha256(outgoing.read_bytes()).hexdigest())) + def invoke_values(label,name,values): + argv = ['--io'] + for index,value in enumerate(values): + path = directory/f'{label}-{index}.tree'; path.write_bytes(value) + argv += ['--arg-tree-file',path] + return runner.invoke(label,pin,name,argv) + actual = parse_tree(invoke_values('resolve-numeric','resolve',[text('127.0.0.1'),text('ipv4'),number(1234),number(64)])) + wrapped = items(actual) + assert wrapped[0] == (1,(0,)) + addresses,truncated = items(wrapped[1]) + assert items(addresses) and all(x == parse_tree(endpoint(4,1234)) for x in items(addresses)) + assert truncated == (0,) + checks.append(dict(label='resolve-numeric')) + for label,host,limit,category,detail in [ + ('resolve-host','bad/name',1,'invalid-argument','expected bounded ASCII host name'), + ('resolve-limit','localhost',0,'invalid-bound',''), + ]: + expected_error = result(False,tree_list([text(x) for x in [category,'resolver/resolve',detail,'none']])) + assert invoke_values(label,'resolve',[text(host),text('any'),number(80),number(limit)]) == expected_error + checks.append(dict(label=label)) + with socket.socket() as listener: + listener.bind(('127.0.0.1',0)); listener.listen(1); listener.settimeout(20) + selected = listener.getsockname()[1] + # Invalid first candidate, valid second, malformed third. Source policy + # retries ordinary failure but must stop before inspecting the third. + reply = items(parse_tree(invoke_values('candidate-fallback','candidates', + [tree_list([endpoint(4,0),endpoint(4,selected),b'\1\0'])]))) + assert reply[0] == (1,(0,)) + token = items(reply[1]) + assert len(token) == 3 and string(token[0]) == 'io.v2.resource' + peer,_ = listener.accept() + with peer: + peer.settimeout(20) + assert peer.recv(1) == b'' # returned token did not extend its scope + checks.append(dict(label='candidate-fallback')) + empty_error = result(False,tree_list([text(x) for x in ['not-found','tcp/connect-first','No endpoint candidates','none']])) + assert invoke_values('empty-candidates','candidates',[b'\0']) == empty_error + checks.append(dict(label='empty-candidates')) + expected = result(False,tree_list([text(x) for x in ['invalid-argument','tcp/connect','','none']])) + assert runner.invoke('invalid-address',pin,'invalidAddress',['--io']) == expected + checks.append(dict(label='invalid-address')) + with socket.socket() as unused: + unused.bind(('127.0.0.1',0)) + port = unused.getsockname()[1] + path = directory/'refused.tree'; path.write_bytes(endpoint(4,port)) + reply = items(parse_tree(runner.invoke('refused',pin,'connectResult',['--io','--arg-tree-file',path]))) + assert reply[0] == (0,) + problem = items(reply[1]) + assert [string(problem[i]) for i in [0,1,3]] == ['connection-refused','tcp/connect','unknown'] + checks.append(dict(label='refused')) + paths = [source,Path(__file__).resolve(),ROOT/'lib/io.tri',ROOT/'lib/handlers.tri', + ROOT/'src/IO/Network.hs',ROOT/'src/IO/Resolver.hs',ROOT/'src/IO/Resources.hs',ROOT/'src/IO/Driver.hs', + ROOT/'src/IO/Protocol.hs',ROOT/'test/rts_metrics.py',ROOT/'test/process_rss.py',ROOT/'bootstrap/build-stable.arboricx'] + (directory/'report.json').write_text(json.dumps(dict(checks=checks,observations=observations, + sources={str(p.relative_to(ROOT)):hashlib.sha256(p.read_bytes()).hexdigest() for p in paths}),indent=2)+'\n') + print(f'PASS: {len(checks)} actual TCP workflow groups') +if __name__ == '__main__': + main() diff --git a/test/io_text.py b/test/io_text.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +"""Independent UTF-8 boundaries, exact source provenance and text pipelines.""" +import argparse +import hashlib +import json +from pathlib import Path +from source_order import Runner +from http_headers import number, result, pair, tree_list +from stable_bundle import byte_tree +from process_rss import run_guarded + +ROOT = Path(__file__).resolve().parent.parent + +def text(s): + return tree_list([number(ord(c)) for c in s]) + +def problem(category, op, detail, progress='none'): + return tree_list([text(category),text(op),text(detail),text(progress)]) + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('tricu', type=Path) + parser.add_argument('--directory', type=Path, required=True) + args = parser.parse_args() + directory = args.directory.resolve() + directory.mkdir(parents=True, exist_ok=False) + runner = Runner(args.tricu,directory) + source = ROOT/'demos/io/text.tri' + bundle = directory/'text.arboricx' + runner.run('compile',['compile','--file',source,'--store',runner.store,'--output',bundle]) + pin = runner.imported(bundle,'import') + checks = [] + def invoke(label,name,values,expected): + argv = ['--io'] + for i,value in enumerate(values): + path = directory/f'{label}-{i}.tree' + path.write_bytes(value) + argv += ['--arg-tree-file',path] + assert runner.invoke(label,pin,name,argv) == expected,label + checks.append(label) + for i,value in enumerate(['','ascii\r\n\0','λ😀e\u0301é','\ufeff','\uffff\U0010ffff','😀'*1025,'a'*1024+'😀'+'b'*1024]): + payload = value.encode('utf-8') + invoke(f'encode-{i}','encode',[number(len(payload)),text(value)],result(True,byte_tree(payload))) + output = directory/f'write-{i}.bin' + invoke(f'write-{i}','write',[text(str(output)),text(value)],result(True,b'\0')) + assert output.read_bytes() == payload + for i,payload in enumerate([b'',b'a\r\n\0', 'λ😀e\u0301é'.encode(), + b'a'*4092+'😀'.encode(), b'a'*4091+'😀x'.encode(), + ('a\ufeffe\u0301\r\n'*100).encode()]): + path = directory/f'source-{i}.bin' + path.write_bytes(payload) + invoke(f'source-{i}','source',[text(str(path)),number(len(payload))], + result(True,pair(byte_tree(payload),text(payload.decode('utf-8'))))) + assert path.read_bytes() == payload + # Confirm accounting at the 4093-byte read boundary and its one-byte EOF + # probe, including multiple chunks. These bytes are independently decoded. + for size in [4092,4093,4094,8186,8187]: + payload=b'a'*size + path=directory/f'count-boundary-{size}.bin' + path.write_bytes(payload) + invoke(f'count-exact-{size}','source',[text(str(path)),number(size)], + result(True,pair(byte_tree(payload),text(payload.decode())))) + invoke(f'count-short-{size}','source',[text(str(path)),number(size-1)], + result(False,tree_list([text('read-source'),number(size), + problem('invalid-bound','text/read-source','source exceeds byte limit')]))) + payload = 'A😀λ'.encode() + # Every split through the multibyte suffix, with independent expected carry. + for i in range(len(payload)+1): + prefix = payload[:i] + import codecs + decoder = codecs.getincrementaldecoder('utf-8')('strict') + decoded = decoder.decode(prefix, final=False) + carry = decoder.getstate()[0] + invoke(f'prefix-{i}','decode',[number(0),byte_tree(prefix)], + result(True,tree_list([text(decoded),byte_tree(carry)]))) + for i,(payload,cause) in enumerate([ + (b'\xef\xbb\xbfabc',problem('invalid-data','text/read-source','initial source BOM is not permitted')), + (b'good\xff',problem('invalid-data','utf8/decode','invalid UTF-8 leading byte at byte 4')), + (b'\xe2\x82',problem('invalid-data','utf8/decode','incomplete UTF-8 sequence at byte 0')), + (b'\xed\xa0\x80',problem('invalid-data','utf8/decode','invalid UTF-8 sequence at byte 0'))]): + path = directory/f'invalid-{i}.bin'; path.write_bytes(payload) + invoke(f'invalid-source-{i}','source',[text(str(path)),number(len(payload))], + result(False,tree_list([text('read-source'),number(len(payload)),cause]))) + path = directory/'excess.bin'; path.write_bytes(b'x') + invoke('source-limit','source',[text(str(path)),number(0)],result(False,tree_list([ + text('read-source'),number(1),problem('invalid-bound','text/read-source','source exceeds byte limit')]))) + invoke('encode-limit','encode',[number(1),text('λ')],result(False, + problem('invalid-bound','text/encode','encoded text exceeds byte limit'))) + invoke('surrogate','encode',[number(4096),tree_list([number(0xd800)])],result(False, + problem('invalid-argument','utf8/encode',''))) + partial = directory/'partial.bin' + invalid_text = tree_list([number(97)]*1024+[number(0xd800)]) + invoke('write-invalid-tail','write',[text(str(partial)),invalid_text],result(False,tree_list([ + text('write-text'),number(1024),problem('invalid-argument','utf8/encode','')]))) + assert partial.read_bytes() == b'a'*1024 + for size in [4096,65536]: + payload = (b'ab\r\n\0'+ 'λ😀'.encode())*(size//11) + input_path = directory/f'pipeline-{size}.bin'; output = directory/f'pipeline-{size}-out.bin' + input_path.write_bytes(payload) + invoke(f'pipeline-{size}','roundTrip',[text(str(input_path)),text(str(output)),number(len(payload))],result(True,b'\0')) + assert output.read_bytes() == payload + # Independent raw-protocol boundaries; excess heads AND tails diverge. + # Reject without demanding excess cells or writing a prefix. + boundary = directory/'boundaries.arboricx' + runner.run('compile-boundaries',['compile','--file',ROOT/'test/io/utf8-boundaries.tri','--store',runner.store,'--output',boundary]) + original_bundle, original_pin = bundle, pin + bundle, pin = boundary, runner.imported(boundary,'import-boundaries') + for name,operation in [('excessEncode','utf8/encode'),('excessDecode','utf8/decode'),('excessDigest','digest/update')]: + invoke(name,name,[number(0)],result(False,problem('invalid-argument',operation,''))) + path = directory/'excess-write' + invoke('excessWrite','excessWrite',[text(str(path)),number(0)],result(False,problem('invalid-argument','stream/write',''))) + assert path.read_bytes() == b'' + bundle, pin = original_bundle, original_pin + observations = [r for r in runner.records if r['label'].startswith('pipeline-')] + for row in observations: + assert row['peak_kib'] <= 256*1024,row + assert row['rts_process']['maximum_live_bytes'] <= 128*1024*1024,row + files = ['lib/io.tri','lib/io/text.tri','demos/io/text.tri','test/io_text.py', + 'test/source_order.py','test/rts_metrics.py','src/IO/UTF8.hs','src/IO/Driver.hs', + 'src/IO/Protocol.hs','bootstrap/build-stable.arboricx'] + (directory/'report.json').write_text(json.dumps(dict(checks=checks,observations=observations, + + source_hashes={p:hashlib.sha256((ROOT/p).read_bytes()).hexdigest() for p in files}),indent=2)+'\n') + print(f'{len(checks)} independent text/UTF-8 checks passed'+('')) + +if __name__ == '__main__': main() diff --git a/test/io_v2_artifact_service.py b/test/io_v2_artifact_service.py @@ -1,201 +0,0 @@ -#!/usr/bin/env python3 -"""Actual v2 artifact handlers: exact bytes, integrity, lexical routing and limits.""" -import argparse -import hashlib -import json -from pathlib import Path -import subprocess -from source_order import Runner, field -from http_headers import number, result, tree_list, pair -from io_v2_objects import text, problem - -ROOT = Path(__file__).resolve().parent.parent - - -def octets(data): - return tree_list([number(v) for v in data]) - - -def main(): - p = argparse.ArgumentParser(description=__doc__) - p.add_argument('tricu', type=Path) - p.add_argument('--directory', type=Path, required=True) - a = p.parse_args() - root = a.directory.resolve(); root.mkdir(parents=True, exist_ok=False) - runner = Runner(a.tricu, root) - bundle = root/'service.arboricx' - runner.run('compile', ['compile', '--file', ROOT/'test/io-v2/artifact-service.tri', - '--store', runner.store, '--output', bundle, '--compile-timeout', '600'], seconds=660) - pin = runner.imported(bundle, 'import') - checks = [] - - def arguments(label, values): - args = ['--io'] - for i, value in enumerate(values): - path = root/f'{label}-{i}.tree'; path.write_bytes(value) - args += ['--arg-tree-file', path] - return args - - def check(label, name, values, expected): - actual = runner.invoke(label, pin, name, arguments(label, values)) - assert actual == expected, label - checks.append(label) - - def path(directory, data): - digest = hashlib.sha256(data).hexdigest() - return directory/'objects'/digest[:3]/(digest+'.arboricx') - - left, right = root/'left', root/'right' - for directory in [left, right]: - for suffix in ['new', 'existing']: - check(directory.name+'-'+suffix, 'prepare', [text(str(directory))], result(True, b'\0')) - limit = number(8192) - for index, data in enumerate([b'', b'\0\xff\x80\r\n', bytes(range(256))*16+b'end']): - digest = text(hashlib.sha256(data).hexdigest()) - for suffix in ['new', 'existing']: - check(f'publish-{index}-{suffix}', 'publish', [text(str(left)), limit, octets(data)], result(True, digest)) - assert path(left, data).read_bytes() == data - check(f'load-{index}', 'load', [text(str(left)), limit, digest], result(True, octets(data))) - corrupt = b'X'+data[1:] if data else b'X' - path(left, data).write_bytes(corrupt) - check(f'corrupt-load-{index}', 'load', [text(str(left)), limit, digest], - problem('integrity-failed', 'artifacts/load', 'digest mismatch', 'unknown')) - check(f'corrupt-winner-{index}', 'publish', [text(str(left)), limit, octets(data)], - problem('invalid-data', 'storage/verify', 'different bytes', 'unknown')) - assert path(left, data).read_bytes() == corrupt - path(left, data).write_bytes(data+b'extra') - check(f'trailing-winner-{index}', 'publish', [text(str(left)), limit, octets(data)], - problem('invalid-data', 'storage/verify', 'different bytes', 'unknown')) - path(left, data).write_bytes(data) - data = b'lexical' - check('nearest-route', 'routed', [text(str(left)), text(str(right)), limit, octets(data)], - result(True, text(hashlib.sha256(data).hexdigest()))) - assert path(right, data).read_bytes() == data and not path(left, data).exists() - data = b'captured' - check('captured-route', 'captured', [text(str(left)), text(str(right)), limit, octets(data)], - result(True, text(hashlib.sha256(data).hexdigest()))) - assert path(left, data).read_bytes() == data and not path(right, data).exists() - check('forward-unknown', 'forwarded', [text(str(left))], tree_list([text('other'), number(42)])) - absent = root/'untouched' - diagnostic = tree_list([text('invalid-digest'), text('expected 64 lowercase hexadecimal hash characters')]) - for index, invalid in enumerate(['../outside', 'A'*64, '0'*63, '0'*65, '\0'*64]): - for name in ['load', 'rawLoad']: - check(f'{name}-invalid-{index}', name, [text(str(absent)), limit, text(invalid)], result(False, diagnostic)) - check('budget-before-paths', 'publish', [text(str(absent)), number(0), octets(b'x')], - problem('invalid-bound', 'storage/hash', 'bytes exceed limit', 'none')) - assert not absent.exists() - data = b'captured' - check('read-budget', 'load', [text(str(left)), number(1), text(hashlib.sha256(data).hexdigest())], - problem('invalid-bound', 'storage/read', 'file exceeds byte limit', 'unknown')) - for label, malformed in [('wide', tree_list([number(256)])), ('improper', b'\1\0')]: - args = arguments('raw-'+label, [text(str(absent)), limit, malformed]) - command = [str(a.tricu.resolve()), 'arboricx', 'run', '--store', str(runner.store), - '--module', pin, '--name', 'rawPublish', *args] - reply = subprocess.run(command, capture_output=True, text=True, timeout=30) - assert reply.returncode != 0 and 'IO v2 task failed' in reply.stderr, reply - actual = runner.invoke('view-'+label, pin, 'rejectedView', args[1:]) - wanted = pair(number(103), tree_list([text('invalid-request'), text('artifacts'), text('publish'), - text('expected octet' if label == 'wide' else 'not a list'), tree_list([])])) - assert actual == wanted, label - checks.append('raw-'+label) - assert not absent.exists() - blocked = root/'blocked'; blocked.write_bytes(b'keep') - actual = runner.invoke('blocked-root', pin, 'prepare', arguments('blocked', [text(str(blocked))])) - assert field(actual, 1, 0, 0) == text('not-directory') - assert blocked.read_bytes() == b'keep' - checks.append('blocked-root') - data = b'concurrent winner' - args = arguments('race', [text(str(right)), limit, octets(data)]) - command = [str(a.tricu.resolve()), 'arboricx', 'run', '--store', str(runner.store), - '--module', pin, '--name', 'publish', '--format', 'ternary', *args, - '+RTS', '-N1', '-M2g', '-RTS'] - processes = [subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE) for _ in range(2)] - try: - for process in processes: - stdout, stderr = process.communicate(timeout=60) - assert process.returncode == 0, stderr - assert bytes(int(c) for c in stdout.strip().decode()) == result(True, text(hashlib.sha256(data).hexdigest())) - finally: - for process in processes: - if process.poll() is None: - process.kill(); process.wait() - assert path(right, data).read_bytes() == data - checks.append('concurrent-winner') - data = b'must not write with old grants' - args = arguments('grants', [text(str(right)), limit, octets(data)]) - reply = subprocess.run([str(a.tricu.resolve()), 'arboricx', 'run', '--store', str(runner.store), - '--module', pin, '--name', 'publish', *args, '--allow-write', str(right)], - capture_output=True, text=True, timeout=30) - assert reply.returncode != 0 and 'v1 permission grants are not accepted' in reply.stderr - assert not path(right, data).exists() - checks.append('v1-grants-rejected-before-effects') - def response(status, body, mime='text/plain; charset=utf-8'): - phrase = {200:'OK', 201:'Created', 400:'Bad Request', 404:'Not Found', 500:'Internal Server Error'}[status] - return octets((f'HTTP/1.1 {status} {phrase}\r\nContent-Type: {mime}\r\n' - f'Content-Length: {len(body)}\r\nConnection: close\r\n\r\n').encode()+body) - def route(label, method, target, body, expected): - check(label, 'route', [text(str(left)), limit, text(method), text(target), octets(body)], expected) - route('health', 'GET', '/_arboricx/health', b'', response(200, b'OK\n')) - data = b'route binary\0\xff' - digest = hashlib.sha256(data).hexdigest() - route('route-publish', 'POST', '/_arboricx/bundle', data, response(201, digest.encode())) - route('route-get', 'GET', '/_arboricx/bundle/hash/'+digest, b'', - response(200, data, 'application/vnd.arboricx.bundle')) - path(left, data).write_bytes(b'corrupt') - route('route-corrupt-get', 'GET', '/_arboricx/bundle/hash/'+digest, b'', response(404, b'Artifact unavailable\n')) - route('route-corrupt-put', 'POST', '/_arboricx/bundle', data, response(400, b'Upload failed\n')) - assert path(left, data).read_bytes() == b'corrupt' - data = b'published despite logger failure' - check('failed-log-after-publication', 'routeFailedLog', [text(str(left)), limit, octets(data)], - response(500, b'Internal Server Error\n')) - assert path(left, data).read_bytes() == data - route('after-failed-request', 'GET', '/_arboricx/health', b'', response(200, b'OK\n')) - package = b'captured package bytes' - (left/'packages'/'example').mkdir(parents=True) - (left/'packages'/'example'/'1').write_bytes(package) - (right/'packages'/'example').mkdir(parents=True) - (right/'packages'/'example'/'1').write_bytes(b'wrong root') - target = '/_arboricx/package/example/1' - check('captured-package', 'capturedPackage', [text(str(left)), text(str(right)), limit, text(target)], - response(200, package, 'application/vnd.arboricx.package-release')) - route('package-route', 'GET', target, b'', response(200, package, 'application/vnd.arboricx.package-release')) - route('package-invalid', 'GET', '/_arboricx/package/../escape', b'', response(400, b'Invalid package name/version')) - check('stdout-status', 'routeStdout', [text(str(left)), limit], response(200, b'OK\n')) - assert 'registry status=200\n' in (root/'stdout-status.log').read_text() - data = b'published before cancellation' - check('request-cancellation', 'routeCancelledLog', [text(str(left)), limit, octets(data)], - tree_list([text('cancelled'), text('logger stopped'), tree_list([])])) - assert path(left, data).read_bytes() == data - for size in [0, 1, 4095, 4096, 4097, 65536, 1048576]: - data = (bytes(range(256))*((size+255)//256))[:size] - source = root/f'stream-source-{size}'; source.write_bytes(data) - digest = hashlib.sha256(data).hexdigest() - for mode in ['new', 'existing']: - check(f'stream-{size}-{mode}', 'streamPublish', [text(str(right)), text(str(source)), number(size)], - result(True, text(digest))) - assert path(right, data).read_bytes() == data - assert list((right/'tmp').iterdir()) == [] - if size >= 65536: - record = runner.records[-1] - assert record['peak_kib'] < 128*1024, 'stream RSS retention regression' - assert record['rts_process']['maximum_live_bytes'] < 16*1024*1024, 'stream heap retention regression' - data = b'stream corruption test' - source = root/'stream-negative'; source.write_bytes(data) - check('stream-short', 'streamPublish', [text(str(right)), text(str(source)), number(len(data)+1)], - result(False, text('Incomplete artifact stream'))) - check('stream-excess', 'streamPublish', [text(str(right)), text(str(source)), number(len(data)-1)], - result(False, text('Artifact exceeds declared length'))) - assert not path(right, data).exists() - path(right, data).parent.mkdir(exist_ok=True) - path(right, data).write_bytes(b'X'+data[1:]) - check('stream-corrupt-winner', 'streamPublish', [text(str(right)), text(str(source)), number(len(data))], - result(False, text('Existing artifact digest mismatch'))) - assert path(right, data).read_bytes() == b'X'+data[1:] - assert list((right/'tmp').iterdir()) == [] - assert not list(left.rglob('.tricu-*')) and not list(right.rglob('.tricu-*')) - (root/'report.json').write_text(json.dumps(dict(checks=checks, records=runner.records), indent=2)+'\n') - print(f'PASS {len(checks)} v2 artifact service controls; deployment unchanged') - - -if __name__ == '__main__': - main() diff --git a/test/io_v2_callers.py b/test/io_v2_callers.py @@ -1,67 +0,0 @@ -#!/usr/bin/env python3 -"""Real migrated reader/state/file/bundle callers, with independent outputs.""" -import argparse,json,os,subprocess,struct -from pathlib import Path -from source_order import Runner -from http_headers import result,parse_tree_output -from io_v2_text import text -ROOT=Path(__file__).resolve().parent.parent - -def main(): - p=argparse.ArgumentParser(description=__doc__);p.add_argument('tricu',type=Path);p.add_argument('--directory',type=Path,required=True) - a=p.parse_args();d=a.directory.resolve();d.mkdir(parents=True,exist_ok=False);r=Runner(a.tricu,d);checks=[];pins={} - for name in ['safeRead','shout','writeThenRead','environment','state','bundle']: - source=ROOT/'demos/runArboricxBundle.tri' if name=='bundle' else ROOT/f'demos/interactionTrees/{name}.tri' - bundle=d/(name+'.arboricx');r.run(name+'-compile',['compile','--file',source,'--store',r.store,'--output',bundle,'--compile-timeout','600'],seconds=660);pins[name]=r.imported(bundle,name+'-import') - def call(label,module,name,args=(),env=None,good=True,output=False): - command=[str(r.exe),'arboricx','run','--module',pins[module],'--name',name,'--store',str(r.store),'--io','--format','ternary'] - if output:command+=['--no-result'] - for i,arg in enumerate(args): - f=d/f'{label}-{i}.tree';f.write_bytes(text(arg));command+=['--arg-tree-file',str(f)] - proc=subprocess.run(command,capture_output=True,timeout=60,env=env) - (d/(label+'.stdout')).write_bytes(proc.stdout);(d/(label+'.stderr')).write_bytes(proc.stderr) - assert (proc.returncode==0)==good,(label,proc.stderr) - checks.append(label) - return proc.stdout if output or not good else parse_tree_output(proc.stdout.decode()) - for i,value in enumerate(['','\ufeffλ\r\n\0😀','a'*4092+'😀']): - f=d/f'text-{i}.txt';f.write_bytes(value.encode()) - assert call(f'read-{i}','safeRead','run',[str(f)])==text(value) - assert call(f'shout-{i}','shout','run',[str(f)])==text(value+'!!!') - out=d/f'write-{i}.txt' - assert call(f'roundtrip-{i}','writeThenRead','run',[str(out),value])==result(True,text(value)) - assert out.read_bytes()==value.encode() - missing=d/'missing' - assert call('missing-read','safeRead','run',[str(missing)])==text('ERROR: Unable to read file') - assert call('missing-shout','shout','run',[str(missing)])==text('missing!!!') - invalid=d/'invalid';invalid.write_bytes(b'\xff') - assert call('invalid-text','safeRead','run',[str(invalid)])==text('ERROR: Unable to read file') - over=d/'overlong';over.write_bytes(b'a'*1048577) - assert call('read-budget','safeRead','run',[str(over)])==text('ERROR: Unable to read file') - failure=call('write-failed','writeThenRead','run',[str(missing/'output'),'never']) - assert failure[:2]==b'\2\0' and not missing.exists(),failure - env=dict(os.environ);env.pop('TRICU_DEMO_OUTPUT',None) - call('explicit-output-required','writeThenRead','main',env=env,good=False) - out=d/'configured.txt' - assert call('configured-output','writeThenRead','main',env=dict(env,TRICU_DEMO_OUTPUT=str(out)),output=True)==b'hello from tricu\n' - assert out.read_bytes()==b'hello from tricu' - assert call('lexical-configuration','environment','main',env=dict(env,TRICU_DEMO_OUTPUT='unrelated'),output=True)==b'working in env: demo\nlocal returned: inside-done\noutside env: outer\n' - assert call('explicit-state','state','main',output=True)==b'state: idle\nstate: running\nstate: done\n' - assert call('bundle-main','bundle','main',output=True)==b'hi\n' - # Main must check its final write even when --no-result suppresses values. - for name in ['shout','writeThenRead','environment','state','bundle']: - with open('/dev/full','wb') as full: - run=subprocess.run([str(r.exe),'arboricx','run','--module',pins[name],'--name','main','--store',str(r.store),'--io','--no-result'],stdout=full,stderr=subprocess.PIPE,env=dict(env,TRICU_DEMO_OUTPUT=str(out)),timeout=60) - assert run.returncode!=0,(name,run.stderr) - checks.append(name+'-failed-final-output') - original=(ROOT/'test/fixtures/id.arboricx').read_bytes() - padding=d/'padding.arboricx';padding.write_bytes(original+b'x') - assert call('bundle-permitted-padding','bundle','run',[str(padding),'hi'],output=True)==b'hi\n' - offset=struct.unpack_from('>Q',original,80)[0] - bad=bytearray(original[:offset]+b'x'+original[offset:]) - struct.pack_into('>Q',bad,56,struct.unpack_from('>Q',original,56)[0]+1) - struct.pack_into('>Q',bad,80,offset+1) - for label,data in [('truncated',original[:-1]),('trailing-manifest',bad),('bad-magic',b'X'+original[1:])]: - f=d/(label+'.arboricx');f.write_bytes(data) - assert call('bundle-'+label,'bundle','run',[str(f),'never'],good=False,output=True)==b'' - (d/'report.json').write_text(json.dumps(dict(passed=checks,records=r.records),indent=2)+'\n');print(f'PASS {len(checks)} migrated caller controls') -if __name__=='__main__':main() diff --git a/test/io_v2_clock.py b/test/io_v2_clock.py @@ -1,56 +0,0 @@ -#!/usr/bin/env python3 -"""Actual source timing, scoped waits/cancellation, and migrated async demos.""" -import argparse -import json -from pathlib import Path -import subprocess -from source_order import Runner -from http_headers import number, result, tree_list -from io_v2_objects import text -from io_v2_directory import parse_tree, natural - -ROOT=Path(__file__).resolve().parent.parent - -def main(): - p=argparse.ArgumentParser(description=__doc__) - p.add_argument('tricu',type=Path);p.add_argument('--directory',type=Path,required=True) - a=p.parse_args();root=a.directory.resolve();root.mkdir(parents=True,exist_ok=False) - runner=Runner(a.tricu,root);checks=[] - def compile(label,path): - bundle=root/(label+'.arboricx') - runner.run(label+'-compile',['compile','--file',path,'--store',runner.store,'--output',bundle,'--compile-timeout','600'],seconds=660) - return runner.imported(bundle,label+'-import') - pin=compile('clock',ROOT/'demos/io-v2/clock.tri') - def invoke(label,name,value=None): - args=['--io'] - if value is not None: - path=root/(label+'.tree');path.write_bytes(value);args+=['--arg-tree-file',path] - return runner.invoke(label,pin,name,args) - assert invoke('yield','yielded')==result(True,b'\0');checks.append('yield-unit') - assert invoke('cancelled','cancelledWait')==tree_list([text('cancelled'),text('timer-stop'),b'\0']);checks.append('cancel-max-duration') - for milliseconds in [0,1,20]: - raw=invoke(f'elapsed-{milliseconds}','elapsed',number(milliseconds)) - # Decode the binary kernel tree with the existing reader; compare to an - # independent host duration bound, never a self-derived expected value. - elapsed=natural(parse_tree(raw)) - assert elapsed>=milliseconds*1000000,(milliseconds,elapsed) - checks.append(f'elapsed-{milliseconds}') - assert natural(parse_tree(invoke('scope-wait','scopeWait')))>=20000000 - checks.append('normal-scope-waits') - problem=tree_list([text('invalid-argument'),text('clock/sleep'),b'\0',text('none')]) - for i,value in enumerate([number(9223372036854775808),b'\1\0',b'\2\0\0']): - assert invoke(f'invalid-{i}','invalidSleep',value)==result(False,problem) - checks.append(f'invalid-{i}') - for label,path,wanted in [ - ('async',ROOT/'demos/interactionTrees.tri',b'Parent first!\n2000ms done sleeping!\n5000ms done sleeping!\n'), - ('yield-demo',ROOT/'demos/interactionTrees/yield.tri',b'A says 1\nA says 2\nA says 3\nB says 1\nB says 2\nB says 3\nFinished: doneA doneB\n')]: - demo=compile(label,path) - command=[str(runner.exe),'arboricx','run','--module',demo,'--name','main','--store',str(runner.store),'--io','--no-result'] - run=subprocess.run(command,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=30) - (root/(label+'.stdout')).write_bytes(run.stdout);(root/(label+'.stderr')).write_bytes(run.stderr) - assert run.returncode==0,(label,run.stderr) - assert run.stdout==wanted,(label,run.stdout) - checks.append(label) - (root/'report.json').write_text(json.dumps(dict(passed=checks,records=runner.records),indent=2)+'\n') - print(f'PASS {len(checks)} clock/caller controls') -if __name__=='__main__':main() diff --git a/test/io_v2_compiler_store.py b/test/io_v2_compiler_store.py @@ -1,277 +0,0 @@ -#!/usr/bin/env python3 -"""Run actual source_object callers with an isolated v2 IO source projection. -No compiler generations, frozen-input edits or default workspace replacement. -""" -import argparse -import hashlib -import json -import subprocess -from pathlib import Path -from source_order import Runner -from process_rss import run_guarded -from stable_bundle import byte_tree -from native_indexed_store import indexed_bytes, digest -from executable_object import INDEXED -from io_v2_indexed_write import dag -from io_v2_objects import text, problem -from http_headers import number, result, tree_list - -ROOT = Path(__file__).resolve().parent.parent -PROBE = '''!import source_object as O -!import compiler_io as IO -!import io_v2_storage as S -!export putRaw -!export getRaw -!export putTree -!export getTree -!export copyTree -!export replace -!export read -!export hash -!export publish -!export parent -!export exists -putRaw root domain bytes = IO.io (O.sourceObjectStore root domain bytes) -getRaw root domain digest = IO.io (O.sourceObjectGet root domain digest) -putTree root tree = IO.io (O.sourceObjectPutTree root tree) -getTree root digest = IO.io (O.sourceObjectGetTree root digest) -copyTree source target digest = IO.io <| IO.onOk_ (O.sourceObjectGetTree source digest) - (tree : O.sourceObjectPutTree target tree) -replace path bytes = IO.io (IO.atomicWriteBytes path bytes) -read path limit = IO.io (S.readBytes path limit) -hash limit bytes = IO.io (S.hashBytes limit bytes) -publish directory path mode limit bytes = IO.io (S.publishBytes directory path mode limit bytes) -parent path = S.parentPath path -exists path = IO.io (IO.fileExists path) -''' - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('tricu',type=Path) - parser.add_argument('--directory',type=Path,required=True) - parser.add_argument('--compiler-smoke',action='store_true',help='also build a candidate stable sourceBuildGraph and compile a real dependency graph; not a generation pair') - args=parser.parse_args() - root=args.directory.resolve(); root.mkdir(parents=True,exist_ok=False) - projection=root/'projection'; projection.mkdir() - (projection/'inputs').mkdir() - rows=['tricu.workspace.v1'] - for line in (ROOT/'tricu.workspace').read_text().splitlines()[1:]: - name,path=line.split('\t') - selected=ROOT/path - relative='inputs/'+name+'.tri' - (projection/relative).write_bytes(selected.read_bytes()) - rows.append(name+'\t'+relative) - (projection/'tricu.workspace').write_text('\n'.join(rows)+'\n') - source=projection/'probe.tri'; source.write_text(PROBE) - runner=Runner(args.tricu,root) - bundle=root/'probe.arboricx' - runner.run('compile',['compile','--file',source,'--store',runner.store,'--output',bundle, - '--compile-timeout','300'],seconds=330) - pin=runner.imported(bundle,'import') - checks=[] - candidate=None - def invoke(label,name,values,expected,io=True): - argv=['--io'] if io else [] - for i,value in enumerate(values): - path=root/f'{label}-{i}.tree'; path.write_bytes(value) - argv+=['--arg-tree-file',path] - assert runner.invoke(label,pin,name,argv)==expected,label - checks.append(label) - def clean(path): - assert not list(path.rglob('.tricu-*')),path - for i,payload in enumerate([b'',bytes(range(256)),b'a'*4096+b'last\0\xff']): - store=root/f'raw-{i}' - expected=hashlib.sha256(b'test.raw\0'+payload).hexdigest() - path=store/'objects'/expected[:3]/expected - for status in ['new','existing']: - invoke(f'raw-{i}-{status}','putRaw',[text(str(store)),text('test.raw'),byte_tree(payload)],result(True,text(expected))) - assert path.read_bytes()==payload - clean(store) - invoke(f'raw-get-{i}','getRaw',[text(str(store)),text('test.raw'),text(expected)],result(True,byte_tree(payload))) - path.write_bytes(payload+b'excess') - invoke(f'raw-corrupt-{i}','putRaw',[text(str(store)),text('test.raw'),byte_tree(payload)], - problem('invalid-data','storage/verify','different bytes','unknown')) - assert path.read_bytes()==payload+b'excess'; clean(store) - invoke(f'raw-corrupt-get-{i}','getRaw',[text(str(store)),text('test.raw'),text(expected)], - result(False,text('CAS digest mismatch'))) - for i,tree in enumerate([b'\0',number(42)]): - store=root/f'tree-{i}' - payload=indexed_bytes(tree); expected=digest(INDEXED,payload) - path=store/'objects'/expected[:3]/expected - for status in ['new','existing']: - invoke(f'tree-{i}-{status}','putTree',[text(str(store)),tree],result(True,text(expected))) - assert path.read_bytes()==payload; clean(store) - invoke(f'tree-get-{i}','getTree',[text(str(store)),text(expected)],result(True,tree)) - path.write_bytes(payload+b'excess') - invoke(f'tree-excess-{i}','putTree',[text(str(store)),tree], - problem('invalid-data','storage/verify','trailing bytes','unknown')) - assert path.read_bytes()==payload+b'excess'; clean(store) - invoke(f'tree-corrupt-get-{i}','getTree',[text(str(store)),text(expected)], - problem('integrity-failed','indexed-tree/read','CAS digest mismatch','unknown')) - shared=dag(200,True); expected=digest(INDEXED,shared) - store=root/'shared'; shard=store/'objects'/expected[:3]; shard.mkdir(parents=True) - (shard/expected).write_bytes(shared) - target=root/'shared-copy' - invoke('shared-copy','copyTree',[text(str(store)),text(str(target)),text(expected)],result(True,text(expected))) - assert (target/'objects'/expected[:3]/expected).read_bytes()==shared; clean(target) - alias=root/'alias' - for i,payload in enumerate([b'old',b'new\0\xff',b'']): - invoke(f'replace-{i}','replace',[text(str(alias)),byte_tree(payload)],result(True,b'\1\0')) - assert alias.read_bytes()==payload - for i,path in enumerate(['name','/name','/','a/b','a//b','../name','λ/name','name/','/name///','//name','///']): - expected_parent=['.','/','/','a','a/','..','λ','.','/','//','///'][i] - invoke(f'parent-{i}','parent',[text(path)],text(expected_parent),False) - data=root/'bytes'; data.write_bytes(b'ab\0\xff') - invoke('read-exact','read',[text(str(data)),number(4)],result(True,byte_tree(data.read_bytes()))) - invoke('read-limit','read',[text(str(data)),number(3)],problem('invalid-bound','storage/read','file exceeds byte limit','unknown')) - for i,payload in enumerate([b'',b'a'*4096+b'last']): - invoke(f'hash-{i}','hash',[number(len(payload)),byte_tree(payload)],result(True,text(hashlib.sha256(payload).hexdigest()))) - # Exact chunk boundaries, short final chunks, and the final budget/EOF probe. - for size in [0,1,4095,4096,4097,8191,8192,8193]: - payload=bytes(i % 256 for i in range(size)) - encoded=byte_tree(payload) - data.write_bytes(payload) - expected_hash=result(True,text(hashlib.sha256(payload).hexdigest())) - for limit in sorted({size, max(size,4096), 67108864}): - invoke(f'hash-boundary-{size}-{limit}','hash',[number(limit),encoded],expected_hash) - invoke(f'read-boundary-{size}','read',[text(str(data)),number(size)],result(True,encoded)) - if size: - invoke(f'hash-short-budget-{size}','hash',[number(size-1),encoded], - problem('invalid-bound','storage/hash','bytes exceed limit','none')) - invoke(f'read-short-budget-{size}','read',[text(str(data)),number(size-1)], - problem('invalid-bound','storage/read','file exceeds byte limit','unknown')) - # Host octet checks and source proper-list checks remain pre-publication, - # including faults after one or two complete chunks were already hashed. - for size in [0,4095,4096,4097,8192]: - prefix=byte_tree(b'a'*size)[:-1] - for kind,tail,expected in [ - ('octet',tree_list([number(256)]),problem('invalid-argument','digest/update','','none')), - ('stem',b'\1\0',problem('invalid-argument','storage/hash','improper byte list','none'))]: - malformed=prefix+tail - invoke(f'hash-invalid-{kind}-{size}','hash',[number(67108864),malformed],expected) - for mode in [0,1]: - destination=root/f'invalid-{kind}-{size}-{mode}' - # A failed replacement must preserve an already existing alias. - if mode: destination.write_bytes(b'prior alias') - invoke(f'publish-invalid-{kind}-{size}-{mode}','publish', - [text(str(root)),text(str(destination)),number(mode),number(67108864),malformed],expected) - assert destination.read_bytes()==b'prior alias' if mode else not destination.exists() - clean(root) - for label,limit in [('stem',b'\1\0'),('too-large',number(9223372036854775808)), - ('noncanonical',b'\2\0\0')]: - expected=problem('invalid-bound','storage/limit','invalid byte limit','none') - invoke('hash-invalid-limit-'+label,'hash',[limit,byte_tree(b'')],expected) - invoke('read-invalid-limit-'+label,'read',[text(str(data)),limit],expected) - # Existing immutable winners must match every byte and EOF, independently of - # reader chunking. Retain corrupt objects for diagnosis rather than repair. - payload=bytes(i % 256 for i in range(8193)) - destination=root/'winner-boundaries' - for label,winner in [('equal',payload),('short',payload[:-1]),('long',payload+b'x'), - *[(f'changed-{at}',payload[:at]+bytes([payload[at]^255])+payload[at+1:]) - for at in [0,4095,4096,8192]]]: - destination.write_bytes(winner) - expected=result(True,b'\0') if label=='equal' else problem('invalid-data','storage/verify','different bytes','unknown') - invoke('winner-'+label,'publish',[text(str(root)),text(str(destination)),number(0),number(len(payload)),byte_tree(payload)],expected) - assert destination.read_bytes()==winner - clean(root) - for label,path,wanted in [('file',data,True),('directory',root,False),('missing',root/'absent',False)]: - invoke('exists-'+label,'exists',[text(str(path))],result(True,b'\1\0' if wanted else b'\0')) - invoke('exists-invalid','exists',[text('bad\0path')],problem('invalid-argument','path/stat','','none')) - for mode in [0,1]: - destination=root/f'invalid-octet-{mode}' - invoke(f'invalid-octet-{mode}','publish',[text(str(root)),text(str(destination)),number(mode),number(4096),tree_list([number(256)])], - problem('invalid-argument','digest/update','','none')) - assert not destination.exists() - # Legitimate concurrent mutable aliases are last-writer-wins. Neither caller - # should spuriously verify another writer's replacement against its own bytes. - alias_arg=root/'race-path.tree'; alias_arg.write_bytes(text(str(alias))) - jobs=[]; payloads=[b'A'*4096+b'first',b'B'*4096+b'second'] - for i,payload in enumerate(payloads): - argument=root/f'race-{i}.tree'; argument.write_bytes(byte_tree(payload)) - command=[str(runner.exe),'arboricx','run','--module',pin,'--name','replace','--store',str(runner.store), - '--io','--format','ternary','--arg-tree-file',str(alias_arg),'--arg-tree-file',str(argument), - '+RTS','-N1','-M512m','-RTS'] - jobs.append(subprocess.Popen(command,stdout=subprocess.PIPE,stderr=subprocess.PIPE)) - try: - for process in jobs: - out,err=process.communicate(timeout=60) - assert process.returncode==0,(out,err) - assert out.strip()==''.join(map(str,result(True,b'\1\0'))).encode(),out - finally: - for process in jobs: - if process.poll() is None: process.kill() - process.wait() - assert alias.read_bytes() in payloads - checks.append('concurrent-mutable-alias-replacement') - missing=root/'must-not-exist' - invoke('preflight-limit','publish',[text(str(root)),text(str(missing)),number(0),number(1),byte_tree(b'ab')], - problem('invalid-bound','storage/hash','bytes exceed limit','none')) - assert not missing.exists(); clean(root) - if args.compiler_smoke: - compiler_source=projection/'candidate.tri' - compiler_source.write_text('!import source_build as Build\n!import compiler_io as IO\n!export sourceBuildGraph\n!export main\nsourceBuildGraph = Build.sourceBuildGraphStable\nmain workspace root identity name = IO.io (Build.sourceBuildGraphStable workspace root identity name)\n') - compiler_bundle=root/'candidate.arboricx' - runner.run('compile-candidate',['compile','--file',compiler_source,'--store',runner.store, - '--output',compiler_bundle,'--compile-timeout','600'],seconds=660) - compiler=runner.imported(compiler_bundle,'import-candidate') - candidate=dict(manifest=compiler,io_version=2,bundle_sha256=hashlib.sha256(compiler_bundle.read_bytes()).hexdigest()) - app=root/'app'; app.mkdir() - (app/'dependency.tri').write_text('!export pick\n-- λ 😀 combining: é\npick [x | _] = x\npick _ = 0\n') - (app/'main.tri').write_text('!import dependency as D\n!export main\n!export accent\nmain = D.pick [42 7]\naccent = "é"\n') - application=root/'application' - compiled=root/'application.arboricx' - for label in ['cold','warm']: - runner.run('candidate-'+label,['compile','--file',app/'main.tri','--store',application, - '--output',compiled,'--toolchain',compiler,'--tool-store',runner.store, - '--toolchain-io-version','2','--compile-timeout','300'],seconds=330) - program=runner.imported(compiled,'import-'+label) - assert runner.invoke('run-'+label,program,'main',[])==number(42) - assert runner.invoke('accent-'+label,program,'accent',[])==tree_list([number(101),number(769)]) - checks.append('actual-v2-compiler-'+label) - retained_bundle=root/'retained-application.arboricx' - runner.run('retained-compile',['compile','--file',app/'main.tri','--store',root/'retained-application', - '--output',retained_bundle,'--compile-timeout','300'],seconds=330) - assert compiled.read_bytes()==retained_bundle.read_bytes() - checks.append('complete-application-bundle-identity') - # Exactly received UTF-8 provenance, not re-encoded/normalized text. - # Receipts bind these digests even when source bytes are not retained as - # separate objects by the current source build policy. - all_objects=[p.read_bytes() for p in (application/'objects').glob('*/*') if p.is_file()] - for file in [app/'main.tri',app/'dependency.tri']: - expected=hashlib.sha256(b'arboricx.source.v1\0'+file.read_bytes()).hexdigest().encode() - assert any(expected in payload for payload in all_objects),file - clean(application) - original=(app/'main.tri').read_bytes() - accepted_output=compiled.read_bytes() - for label,bad,diagnostic in [('bom',b'\xef\xbb\xbf'+original,'initial source BOM is not permitted'), - ('utf8',b'\xff','utf8/decode'), - ('crlf',original.replace(b'\n',b'\r\n'),'Portable compilation failed: [40, "invalid import reference or qualifier",')]: - (app/'main.tri').write_bytes(bad) - command=[str(runner.exe),'arboricx','compile','--file',str(app/'main.tri'),'--store',str(application), - '--output',str(compiled),'--toolchain',compiler,'--tool-store',str(runner.store), - '--toolchain-io-version','2','--compile-timeout','60','+RTS','-N1','-M512m','-RTS'] - rejected=subprocess.run(command,capture_output=True,text=True,timeout=90) - assert rejected.returncode!=0,(label,rejected.stdout) - assert diagnostic in rejected.stdout+rejected.stderr,(label,rejected.stderr) - assert compiled.read_bytes()==accepted_output - (root/f'rejected-{label}.log').write_text(rejected.stdout+rejected.stderr) - if label=='crlf': - # Preserve the existing language rejection, rather than hide it - # by normalizing line endings in the new transport. - reference=[str(runner.exe),'arboricx','compile','--file',str(app/'main.tri'), - '--store',str(root/'crlf-reference'),'--output',str(root/'crlf.arboricx'), - '+RTS','-N1','-M512m','-RTS'] - legacy=subprocess.run(reference,capture_output=True,text=True,timeout=90) - assert legacy.returncode!=0 - assert legacy.stdout+legacy.stderr==rejected.stdout+rejected.stderr - checks.append('actual-v2-compiler-rejects-'+label) - (app/'main.tri').write_bytes(original) - clean(application) - files=['lib/source/object.tri','lib/source/io-v2.tri','lib/io-v2/storage.tri','lib/io-v2.tri','lib/io-v2/text.tri','test/io_v2_compiler_store.py','bootstrap/build-stable.arboricx'] - (root/'report.json').write_text(json.dumps(dict(checks=checks,records=runner.records,candidate=candidate, - - projected_inputs={p.relative_to(projection).as_posix():hashlib.sha256(p.read_bytes()).hexdigest() for p in projection.rglob('*.tri')}, - source_hashes={p:hashlib.sha256((ROOT/p).read_bytes()).hexdigest() for p in files}),indent=2)+'\n') - print(f'{len(checks)} actual compiler-store and storage controls passed') - -if __name__=='__main__': main() diff --git a/test/io_v2_context.py b/test/io_v2_context.py @@ -1,37 +0,0 @@ -#!/usr/bin/env python3 -"""Actual read-only cwd/environment observations, independent of source handlers.""" -import argparse -import json -import os -from pathlib import Path -import subprocess -from source_order import Runner -from http_headers import result, tree_list, parse_tree_output -from io_v2_text import text, problem -ROOT=Path(__file__).resolve().parent.parent - -def main(): - p=argparse.ArgumentParser(description=__doc__);p.add_argument('tricu',type=Path);p.add_argument('--directory',type=Path,required=True) - a=p.parse_args();d=a.directory.resolve();d.mkdir(parents=True,exist_ok=False);r=Runner(a.tricu,d);bundle=d/'context.arboricx' - r.run('compile',['compile','--file',ROOT/'demos/io-v2/context.tri','--store',r.store,'--output',bundle]);pin=r.imported(bundle,'import');checks=[] - key='TRICU_CONTEXT_TEST_VALUE' - def run(label,name,argument,env,wanted): - cmd=[str(r.exe),'arboricx','run','--module',pin,'--name',name,'--store',str(r.store),'--io','--format','ternary'] - if argument is not None: - arg=d/(label+'.arg');arg.write_bytes(text(argument));cmd+=['--arg-tree-file',str(arg)] - proc=subprocess.run(cmd,cwd=d,env=env,capture_output=True,timeout=20) - (d/(label+'.stdout')).write_bytes(proc.stdout);(d/(label+'.stderr')).write_bytes(proc.stderr) - assert proc.returncode==0,(label,proc.stderr) - assert parse_tree_output(proc.stdout.decode())==wanted,label - checks.append(label) - env=dict(os.environ);env.pop(key,None) - run('cwd','directory',None,env,result(True,text(str(d)))) - run('missing','variable',key,env,result(True,tree_list([text('missing')]))) - for i,value in enumerate(['','λ\n\ufeff','x'*4096]): - run(f'value-{i}','variable',key,dict(env,**{key:value}),result(True,tree_list([text('value'),text(value)]))) - run('oversized','variable',key,dict(env,**{key:'x'*4097}),result(False,problem('invalid-bound','process/environment-variable','host context text exceeds scalar limit'))) - for i,name in enumerate(['','A=B','A\0B']): - run(f'invalid-{i}','variable',name,env,result(False,problem('invalid-argument','process/environment-variable',''))) - (d/'report.json').write_text(json.dumps(dict(passed=checks,records=r.records),indent=2)+'\n') - print(f'PASS {len(checks)} host-context controls') -if __name__=='__main__':main() diff --git a/test/io_v2_demo_servers.py b/test/io_v2_demo_servers.py @@ -1,84 +0,0 @@ -#!/usr/bin/env python3 -"""Actual migrated TCP/HTTP/registry demo lifetimes and independent wire bytes.""" -import argparse,json,socket,subprocess,threading,hashlib,selectors -from pathlib import Path -from source_order import Runner -from http_headers import number,tree_list -from io_v2_text import text -ROOT=Path(__file__).resolve().parent.parent - -def main(): - p=argparse.ArgumentParser(description=__doc__);p.add_argument('tricu',type=Path);p.add_argument('--directory',type=Path,required=True) - a=p.parse_args();d=a.directory.resolve();d.mkdir(parents=True,exist_ok=False);r=Runner(a.tricu,d);checks=[];pins={} - def build(name): - bundle=d/(name+'.arboricx');r.run(name+'-compile',['compile','--file',ROOT/f'demos/interactionTrees/{name}.tri','--store',r.store,'--output',bundle,'--compile-timeout','600'],seconds=660) - pins[name]=r.imported(bundle,name+'-import');return pins[name] - def command(name,pin,values): - command=[str(r.exe),'arboricx','run','--module',pin,'--name','server','--store',str(r.store),'--io','--no-result'] - for i,value in enumerate(values): - f=d/f'{name}-{i}.arg';f.write_bytes(value);command+=['--arg-tree-file',str(f)] - return command - def start(name,pin,values): - proc=subprocess.Popen(command(name,pin,values),stdout=subprocess.PIPE,stderr=subprocess.PIPE) - with selectors.DefaultSelector() as sel: - sel.register(proc.stdout,selectors.EVENT_READ);assert sel.select(15),'no listen announcement' - port=int(proc.stdout.readline());return proc,port - def close(proc,port): - out,err=proc.communicate(timeout=20);assert proc.returncode==0,(out,err) - try: socket.create_connection(('127.0.0.1',port),timeout=1).close() - except OSError: return - raise AssertionError('listener outlived owning scope') - def endpoint(port=0):return tree_list([text('ipv4'),tree_list([number(x) for x in [127,0,0,1]]),number(port)]) - def request(port,data): - with socket.create_connection(('127.0.0.1',port),timeout=20) as s: - s.sendall(data);s.shutdown(socket.SHUT_WR);parts=[] - while True: - b=s.recv(4096) - if not b:return b''.join(parts) - parts.append(b) - proc,port=start('echo',build('echoServer'),[endpoint(),number(2)]) - try: - for data in [b'',bytes(range(256))*1024]: - with socket.create_connection(('127.0.0.1',port),timeout=20) as s: - failures=[] - def send(): - try:s.sendall(data);s.shutdown(socket.SHUT_WR) - except Exception as e:failures.append(e) - thread=threading.Thread(target=send);thread.start();out=[] - while True: - chunk=s.recv(4096) - if not chunk:break - out.append(chunk) - thread.join(5);assert not thread.is_alive() and not failures - assert b''.join(out)==data - close(proc,port);checks.append('echo-binary-and-scope-close') - finally: - if proc.poll() is None:proc.kill();proc.wait() - cases=[(b'GET /hello HTTP/1.1\r\nHost: test\r\n\r\n',200,b'Hello from /hello\n'),(b'HEAD /hello HTTP/1.1\r\nHost: test\r\n\r\n',200,b''),(b'POST /hello HTTP/1.1\r\nHost: test\r\nContent-Length: 0\r\n\r\n',405,b'Method not allowed\n'),(b'bad\r\n\r\n',400,b'Bad Request\n')] - proc,port=start('http',build('httpServer'),[endpoint(),number(len(cases))]) - try: - for payload,status,body in cases: - reply=request(port,payload);head,actual=reply.split(b'\r\n\r\n',1) - assert head.startswith(f'HTTP/1.1 {status} '.encode()) and actual==body,(head,actual) - length=len(b'Hello from /hello\n') if payload.startswith(b'HEAD') else len(body) - assert f'Content-Length: {length}\r\n'.encode() in head+b'\r\n' - close(proc,port);checks.append('http-methods-framing-head-and-scope-close') - finally: - if proc.poll() is None:proc.kill();proc.wait() - root=d/'registry';data=b'\0\xffregistry-demo';digest=hashlib.sha256(data).hexdigest() - proc,port=start('registry',build('arboricxServer'),[text(str(root)),endpoint(),number(3)]) - try: - assert request(port,b'GET /_arboricx/health HTTP/1.1\r\nHost: test\r\n\r\n').endswith(b'OK\n') - response=request(port,b'POST /_arboricx/bundle HTTP/1.1\r\nHost: test\r\nContent-Length: '+str(len(data)).encode()+b'\r\n\r\n'+data) - assert response.startswith(b'HTTP/1.1 201 ') and response.endswith(digest.encode()),response - assert request(port,f'GET /_arboricx/bundle/hash/{digest} HTTP/1.1\r\nHost: test\r\n\r\n'.encode()).endswith(b'\r\n\r\n'+data) - close(proc,port);checks.append('registry-binary-upload-download-and-scope-close') - finally: - if proc.poll() is None:proc.kill();proc.wait() - for name,values in [('echoServer',[endpoint(),number(0)]),('httpServer',[endpoint(),number(0)]),('arboricxServer',[text(str(d/'failed-ready-store')),endpoint(),number(0)])]: - with open('/dev/full','wb') as full: - failed=subprocess.run(command(name+'-failed-ready',pins[name],values),stdout=full,stderr=subprocess.PIPE,timeout=20) - assert failed.returncode!=0,(name,failed.stderr) - checks.append(name+'-announcement-failure') - (d/'report.json').write_text(json.dumps(dict(passed=checks,records=r.records),indent=2)+'\n');print('PASS migrated server demo wire/lifecycle controls') -if __name__=='__main__':main() diff --git a/test/io_v2_directory.py b/test/io_v2_directory.py @@ -1,165 +0,0 @@ -#!/usr/bin/env python3 -"""Complete independent directory inventory and path-operation oracles.""" -import argparse -import hashlib -import json -import os -from pathlib import Path -from source_order import Runner -from http_headers import number, pair, result, tree_list -from io_v2_positioned import text -ROOT = Path(__file__).resolve().parent.parent - -def parse_tree(data): - # Iterative independent decoder; tolerate large inventories without Python - # recursion limits. This parses structure, not the native result's text view. - frames = [] - completed = None - for tag in data: - assert tag in (0, 1, 2), tag - if tag: - frames.append([tag, []]) - continue - value = (0,) - while frames: - frame = frames[-1] - frame[1].append(value) - if len(frame[1]) != frame[0]: - break - frames.pop() - value = (frame[0], *frame[1]) - else: - assert completed is None - completed = value - assert not frames and completed is not None - return completed - -def items(value): - output = [] - while value != (0,): - assert value[0] == 2 - output.append(value[1]) - value = value[2] - return output - -def natural(value): - n, place = 0, 1 - while value != (0,): - assert value[0] == 2 and value[1] in ((0,), (1,(0,))) - n += place * (value[1] != (0,)) - place *= 2 - value = value[2] - return n - -def string(value): - return ''.join(chr(natural(c)) for c in items(value)) - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('tricu', type=Path) - parser.add_argument('--directory', type=Path, required=True) - args = parser.parse_args() - directory = args.directory.resolve() - directory.mkdir(parents=True, exist_ok=False) - runner = Runner(args.tricu, directory) - source = ROOT/'demos/io-v2/directory.tri' - bundle = directory/'directory.arboricx' - runner.run('compile', ['compile', '--file', source, '--store', runner.store, '--output', bundle]) - pin = runner.imported(bundle, 'import') - checks = [] - def invoke(label, name, *paths): - argv = ['--io'] - for index, value in enumerate(paths): - path = directory/f'{label}-{index}.tree' - path.write_bytes(text(str(value))) - argv += ['--arg-tree-file', path] - return runner.invoke(label, pin, name, argv) - unit = result(True, b'\0') - root = directory/'store-λ' - assert invoke('initialize', 'initialize', root) == unit - assert invoke('initialize-again', 'initialize', root) == unit - assert sorted(p.name for p in root.iterdir()) == ['cache'] - checks += ['initialize', 'initialize-again'] - (root/'file').write_bytes(b'a\0\xff') - (root/'link').symlink_to('file') - (root/'dangling').symlink_to('absent') - for name, expected in [('file',('file',3)),('cache',('directory',)),('link',('symlink',))]: - actual = parse_tree(invoke('metadata-'+name,'metadata',root/name)) - followed, unfollowed = items(actual) - wanted = tree_list([text(expected[0])] + ([number(expected[1])] if len(expected)>1 else [])) - assert unfollowed == parse_tree(result(True,wanted)) - if name != 'link': - assert followed == unfollowed - else: - assert followed == parse_tree(result(True,tree_list([text('file'),number(3)]))) - checks.append('metadata-'+name) - actual = items(parse_tree(invoke('inventory','inventory',root))) - observed = {} - for row in actual: - name, status = items(row) - key = string(name) - assert key not in observed - parts = items(status) - observed[key] = [string(parts[0])] + [natural(p) for p in parts[1:]] - assert observed == {'cache':['directory'],'file':['file',3],'link':['symlink'],'dangling':['symlink']} - checks.append('inventory') - (root/'replacement').write_bytes(b'new\x80') - assert invoke('rename','rename',root/'replacement',root/'file') == unit - assert (root/'file').read_bytes() == b'new\x80' and not (root/'replacement').exists() - assert invoke('unlink','removeFile',root/'link') == unit - assert (root/'file').read_bytes() == b'new\x80' and not (root/'link').is_symlink() - assert invoke('rmdir','removeDirectory',root/'cache') == unit - checks += ['rename','unlink','rmdir'] - reply = items(parse_tree(invoke('nonempty','removeDirectory',root))) - assert reply[0] == (0,) - problem = items(reply[1]) - assert string(problem[1]) == 'directory/remove' and string(problem[3]) == 'unknown' - assert (root/'file').read_bytes() == b'new\x80' - conflict = directory/'not-a-directory' - conflict.write_bytes(b'keep') - expected = result(False,tree_list([text(s) for s in ['not-directory','directory/ensure','','none']])) - assert invoke('ensure-file','initialize',conflict) == expected - assert conflict.read_bytes() == b'keep' - checks += ['nonempty','ensure-file'] - alias = directory/'directory-alias' - alias.symlink_to(root, target_is_directory=True) - assert invoke('ensure-symlink','initialize',alias) == unit - assert (root/'cache').is_dir() - checks.append('ensure-symlink') - followed, unfollowed = items(parse_tree(invoke('dangling','metadata',root/'dangling'))) - assert unfollowed == parse_tree(result(True,tree_list([text('symlink')]))) - failed = items(followed) - assert failed[0] == (0,) - problem = items(failed[1]) - assert [string(problem[i]) for i in [0,1,3]] == ['not-found','path/stat','none'] - checks.append('dangling') - # Real host-invalid name: must fail, never silently replace characters/skip it. - invalid = directory/'invalid' - invalid.mkdir() - os.symlink(b'absent',os.fsencode(invalid)+b'/\xff') - try: - invoke('invalid-encoding','count',invalid) - except Exception as error: - assert 'IO v2 task failed' in str(getattr(error,'output',error)), error - else: - raise AssertionError('invalid name was silently accepted') - checks.append('invalid-encoding') - for count in [0,16,256,4096,16384]: - path = directory/f'count-{count}' - path.mkdir() - for index in range(count): - (path/f'{index:06}-entry-λ').touch() - assert invoke(f'count-{count}','count',path) == number(count) - checks.append(f'count-{count}') - observations = [r for r in runner.records if r['label'].startswith('count-')] - for row in observations: - assert row['peak_kib'] <= 128*1024, row - assert row['rts_process']['maximum_live_bytes'] <= 2*1024*1024, row - files = [source,Path(__file__).resolve(),ROOT/'lib/io-v2.tri',ROOT/'src/IOV2/Filesystem.hs', - ROOT/'src/IOV2/Resources.hs',ROOT/'src/IOV2/Driver.hs',ROOT/'src/IOV2/Protocol.hs', - ROOT/'test/source_order.py',ROOT/'test/rts_metrics.py',ROOT/'bootstrap/build-stable.arboricx'] - (directory/'report.json').write_text(json.dumps(dict(checks=checks,observations=observations, - sources={str(p.relative_to(ROOT)):hashlib.sha256(p.read_bytes()).hexdigest() for p in files}),indent=2)+'\n') - print(f'PASS: {len(checks)} directory/path controls') -if __name__ == '__main__': - main() diff --git a/test/io_v2_files.py b/test/io_v2_files.py @@ -1,107 +0,0 @@ -#!/usr/bin/env python3 -"""Compile/run the experimental v2 copier through the retained v1 compiler. - -Nix-built host required. No grants are passed to the v2 application. Compares -complete output files and result trees for direct and handler-backed compositions. -""" -import argparse -import hashlib -import json -import re -from pathlib import Path - -from source_order import Runner, field -from stable_bundle import byte_tree -from http_headers import result, tree_list - -ROOT = Path(__file__).resolve().parent.parent - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('tricu', type=Path) - parser.add_argument('--directory', type=Path, required=True) - args = parser.parse_args() - args.directory.mkdir(parents=True, exist_ok=False) - directory = args.directory.resolve() - runner = Runner(args.tricu, directory) - bundle = directory/'copy.arboricx' - runner.run('compile', ['compile', '--file', ROOT/'demos/io-v2/copy.tri', - '--store', runner.store, '--output', bundle]) - pin = runner.imported(bundle, 'import') - checks = [] - for name in ('copy', 'handledCopy', 'borrowedCopy', 'transferredCopy', 'joinedOnExitCopy', 'handledChildCopy'): - for size in (0, 1, 4095, 4096, 4097, 65536, 1048576): - label = f'{name}-{size}' - source, target = directory/(label+'.in'), directory/(label+'.out') - body = (bytes(range(256))*((size+255)//256))[:size] - source.write_bytes(body) - argv = ['--io'] - for index, path in enumerate((source, target)): - argument = directory/f'{label}-{index}.tree' - argument.write_bytes(byte_tree(str(path).encode())) - argv += ['--arg-tree-file', argument] - actual = runner.invoke(label, pin, name, argv) - expected = result(True, b'\0') - if name == 'handledCopy': - expected = result(True, expected) - assert actual == expected, label - assert target.read_bytes() == body, label - checks.append({'name': label, 'sha256': hashlib.sha256(body).hexdigest()}) - for name in ('copy', 'handledCopy'): - # Independent failure controls: no destination on a missing source, and - # a failed writer acquisition after a successful reader acquisition. - for case in ('missing-source', 'missing-parent'): - source = directory/(name+'-failure.in') - source.write_bytes(b'not copied') - target = directory/(name+'-failure.out') - if case == 'missing-source': - source = directory/'NO-SUCH-SOURCE' - else: - target = directory/'NO-SUCH-DIRECTORY'/'out' - label = name+'-'+case - argv = ['--io'] - for index, path in enumerate((source, target)): - argument = directory/f'{label}-{index}.tree' - argument.write_bytes(byte_tree(str(path).encode())) - argv += ['--arg-tree-file', argument] - actual = runner.invoke(label, pin, name, argv) - assert field(actual, 0) == b'\0', label - diagnostic = field(actual, 1, 0) - assert field(diagnostic, 0) == byte_tree(b'not-found'), label - assert field(diagnostic, 1, 0) == byte_tree(b'file/open-reader' if case == 'missing-source' else b'file/open-writer'), label - assert not target.exists(), label - checks.append({'name': label}) - for size in (0, 1, 4096, 4097, 65536): - label = f'cancelledCopy-{size}' - source, target = directory/(label+'.in'), directory/(label+'.out') - body = (bytes(range(256))*((size+255)//256))[:size] - source.write_bytes(body) - argv = ['--io'] - for index, path in enumerate((source, target)): - argument = directory/f'{label}-{index}.tree' - argument.write_bytes(byte_tree(str(path).encode())) - argv += ['--arg-tree-file', argument] - actual = runner.invoke(label, pin, 'cancelledCopy', argv) - assert actual == tree_list([byte_tree(b'cancelled'), byte_tree(b'requested'), b'\0']), label - assert target.read_bytes() == body[:4096], label - checks.append({'name': label, 'sha256': hashlib.sha256(target.read_bytes()).hexdigest()}) - # Regression guards for this selected copier, not general resource SLAs. - observations = [row for row in runner.records if row['label'].endswith(('-65536', '-1048576'))] - for row in observations: - assert row['peak_kib'] is not None and row['peak_kib'] <= 128*1024, row - match = re.search(r'([0-9,]+) bytes maximum residency', (directory/(row['label']+'.log')).read_text()) - assert match, row - row['maximum_live_bytes'] = int(match[1].replace(',', '')) - assert row['maximum_live_bytes'] <= 16*1024*1024, row - report = {'checks': checks, 'sources': {str(p.relative_to(ROOT)): hashlib.sha256(p.read_bytes()).hexdigest() - for p in [ROOT/'src/IOV2/Resources.hs', ROOT/'src/IOV2/Driver.hs', ROOT/'src/IOV2/Protocol.hs', - ROOT/'test/source_order.py', ROOT/'test/rts_metrics.py', ROOT/'src/IOV2/Tasks.hs', ROOT/'lib/io-v2.tri', - ROOT/'demos/io-v2/copy.tri', ROOT/'lib/handlers.tri']}} - report['observations'] = observations - (directory/'report.json').write_text(json.dumps(report, indent=2)+'\n') - print(f'PASS: {len(checks)} binary-copy and failure checks, including handler composition') - - -if __name__ == '__main__': - main() diff --git a/test/io_v2_generation.py b/test/io_v2_generation.py @@ -1,209 +0,0 @@ -#!/usr/bin/env python3 -"""Prepare an explicit v2 map-builder kit and exercise a scoped generation. - -The current compiler prepares v2 actions. The real generation runner -then stages all mapped sources and builds native_math over v2. This is deliberately -NOT a full compiler generation pair or deployment/retention acceptance. -""" -import argparse -import hashlib -import json -from pathlib import Path -import subprocess -import sys -from source_order import Runner -from bootstrap_generations import source_map -from bootstrap_inputs import load_kit -from generation_io import produced_adapter, versions - -ROOT = Path(__file__).resolve().parent.parent - - -def main(): - p = argparse.ArgumentParser(description=__doc__) - p.add_argument('tricu', type=Path) - p.add_argument('--kit', type=Path, help='validate an explicitly prepared v2 kit instead of preparing a current candidate') - p.add_argument('--directory', type=Path, required=True) - p.add_argument('--haskell-library',type=Path,help='also run the explicit v2 executor and compare complete inventories') - args = p.parse_args() - root = args.directory.resolve(); root.mkdir(parents=True, exist_ok=False) - mapping = ROOT/'bootstrap/compiler-source-map.io-v2.v1' - runner = Runner(args.tricu, root) - if args.kit: - kit = args.kit.resolve() - raw, _, _ = load_kit(kit, 'stable') - metadata = json.loads(raw) - from bootstrap_inputs import kit_io_version - if kit_io_version(metadata) != 2: - raise ValueError('this probe requires an explicit v2 kit') - else: - projection = root/'projection'; projection.mkdir() - (projection/'inputs').mkdir() - _, entries, _ = source_map(mapping, ROOT, 'source_map') - workspace = ['tricu.workspace.v1'] - for alias, (_, relative, _) in entries.items(): - target = 'inputs/'+alias+'.tri' - (projection/target).write_bytes((ROOT/relative).read_bytes()) - workspace.append(alias+'\t'+target) - (projection/'tricu.workspace').write_text('\n'.join(workspace)+'\n') - worker = projection/'worker.tri' - worker.write_text('!import source_map as M\n!export sourceMapBuildStable\nsourceMapBuildStable = M.sourceMapBuildStable\n') - adapter = projection/'adapter.tri' - adapter.write_text('!import io_v2 as IO\n!export main\nmain build source overlay store compiler mapping root = IO.io (build source overlay store compiler mapping root)\n') - kit = root/'kit'; kit.mkdir() - for label, source, destination in [('worker',worker,'rebuilt-compiler.arboricx'),('adapter',adapter,'io-adapter.arboricx')]: - runner.run('compile-'+label,['compile','--file',source,'--store',runner.store, - '--output',kit/destination,'--compile-timeout','600'],seconds=660) - metadata = dict(schema='tricu.bootstrap-kit.v2', backend='stable', io_version=2, artifacts={}) - for role, filename, export in [('compiler','rebuilt-compiler.arboricx','sourceMapBuildStable'),('adapter','io-adapter.arboricx','main')]: - metadata['artifacts'][role]=dict(file=filename,export=export,sha256=hashlib.sha256((kit/filename).read_bytes()).hexdigest()) - (kit/'bootstrap-kit.json').write_text(json.dumps(metadata,indent=2)+'\n') - load_kit(kit,'stable') - checks=[] - def generation(label, extra=(), success=True, selected_kit=kit, preparation_failure=False): - output=root/label - cmd=[sys.executable,str(ROOT/'test/bootstrap_generations.py'),str(output), - '--bootstrap-kit',str(selected_kit),'--tricu',str(args.tricu.resolve()), - '--map',str(mapping),'--source-root',str(ROOT),'--root','native_math', - '--backend','stable','--memory-mib','8192','--wall-seconds','600',*extra] - reply=subprocess.run(cmd,capture_output=True,text=True,timeout=660) - (root/(label+'.log')).write_text(reply.stdout+reply.stderr) - assert (reply.returncode==0)==success,(label,reply.stdout[-2000:],reply.stderr) - if success: - status=json.loads((output/'status.json').read_text()) - assert status['complete'] and versions(status)==(2,2) - assert produced_adapter(output,status)==(kit/'io-adapter.arboricx').read_bytes() - invocation=json.loads((output/'invocation.json').read_text()) - assert '--unsafe-io' not in invocation - assert not list(output.rglob('.tricu-*')) - elif preparation_failure: - assert not json.loads((output/'status.json').read_text())['complete'] - assert not (output/'cas').exists(), 'invalid adapter started compilation' - else: - assert not output.exists(), 'selection failure created output' - checks.append(label) - return output - generation('wrong-version',('--execution-io-version','1'),False) - generation('missing-produced-adapter',('--produced-io-version','1'),False) - generation('noncanonical-version',('--execution-io-version','02'),False) - fake_library=root/'not-an-embedding-library'; fake_library.write_bytes(b'not loaded') - generation('invalid-embedding-runtime',('--execution-host','haskell-ffi','--haskell-library',str(fake_library)),False) - generation('invalid-produced-adapter',('--produced-io-version','1','--produced-adapter',str(fake_library)),False,preparation_failure=True) - output=generation('scoped') - # Independent existing store/receipt audit runs inside the generation runner. - # Compare actual complete native_math manifest with the authenticated packaged v2 kit. - control=root/'packaged-control' - command=[sys.executable,str(ROOT/'test/bootstrap_generations.py'),str(control), - '--bootstrap-kit',str(ROOT/'bootstrap/rebuild'),'--tricu',str(args.tricu.resolve()), - '--map',str(mapping),'--source-root',str(ROOT),'--root','native_math', - '--backend','stable','--memory-mib','8192','--wall-seconds','600', - '--produced-io-version','2','--produced-adapter',str(kit/'io-adapter.arboricx')] - reply=subprocess.run(command,capture_output=True,text=True,timeout=660) - (root/'packaged-control.log').write_text(reply.stdout+reply.stderr) - assert reply.returncode==0,reply.stderr - def manifest(directory): - return (directory/'cas/cache/source-build/native_math').read_text().split('\t')[2] - assert manifest(output)==manifest(control) - assert versions(json.loads((control/'status.json').read_text()))==(2,2) - checks.append('selected-packaged-complete-manifest-equality') - # Exercise both unchanged native bytes and rewritten unit/reexport overlays. - # The independent generation audit checks both source-domain digests in every - # map receipt; exact overlay expectations also detect accidentally reused raw - # digests on the rewritten branch. - fixture=root/'mixed-source'; fixture.mkdir() - originals={'dep':b'answer = 42\n', - 'native_math':b'!import "dependency" !Local\nlocal = answer\n', - 'native':b'!export value\n-- UTF-8: \xce\xbb\nvalue = 7\n'} - for name,contents in originals.items(): - (fixture/(name+'.tri')).write_bytes(contents) - mixed_map=fixture/'map.v1' - mixed_map.write_text('arboricx.source-map.v1\n' - 'unit\tdep\tdependency\tdep.tri\t-\n' - 'reexport\tnative_math\tmain\tnative_math.tri\tdep\n' - 'native\tnative\tnative\tnative.tri\t-\n') - mixed_manifests=[] - selections=[('candidate',kit,[]),('packaged',ROOT/'bootstrap/rebuild',[])] - if args.haskell_library: - selections.append(('haskell-ffi',kit,['--execution-host','haskell-ffi','--haskell-library',str(args.haskell_library.resolve())])) - for label,selected,extra in selections: - destination=root/('mixed-'+label) - command=[sys.executable,str(ROOT/'test/bootstrap_generations.py'),str(destination), - '--bootstrap-kit',str(selected),'--tricu',str(args.tricu.resolve()), - '--map',str(mixed_map),'--source-root',str(fixture),'--root','native_math', - '--backend','stable','--capabilities','1','--memory-mib','8192','--wall-seconds','180',*extra] - reply=subprocess.run(command,capture_output=True,text=True,timeout=210) - (root/('mixed-'+label+'.log')).write_text(reply.stdout+reply.stderr) - assert reply.returncode==0,(label,reply.stdout[-2000:],reply.stderr) - # Legacy lines/unlines emits the trailing empty line as a newline. - expected={'dep':b'!export *\nanswer = 42\n\n', - 'native_math':b'!import dep *\n!export *\n!export * from dep\nlocal = answer\n\n', - 'native':originals['native']} - for name,contents in expected.items(): - assert (destination/'overlay'/(name+'.tri')).read_bytes()==contents,(label,name) - mixed_manifests.append(manifest(destination)) - checks.append('mixed-staging-digests-and-overlays-'+label) - assert len(set(mixed_manifests))==1,mixed_manifests - ffi_output = None - if args.haskell_library: - ffi_output = generation('ffi-scoped',('--execution-host','haskell-ffi','--haskell-library',str(args.haskell_library.resolve()))) - def inventory(directory): - return {p.name:hashlib.sha256(p.read_bytes()).hexdigest() for p in (directory/'cas/objects').glob('*/*')} - assert inventory(ffi_output)==inventory(output) - assert manifest(ffi_output)==manifest(output) - checks.append('cli-ffi-complete-inventory-equality') - from haskell_generation_executor import validate_library - try: - validate_library(args.haskell_library.resolve(), 1) - except (ValueError, AttributeError): - checks.append('current-embedding-rejects-v1-before-preparation') - else: - raise AssertionError('current runtime still exposes v1 execution') - # Captured request tampering must fail before any IO execution, not select - # another interpreter or silently reinterpret absent/Boolean metadata. - request_path=ffi_output/'execution-request.json' - original=request_path.read_bytes() - map_path=ffi_output/'source-map.v1' - original_map=map_path.read_bytes() - before=inventory(ffi_output) - command=json.loads((ffi_output/'invocation.json').read_text()) - try: - for label,value in [('wrong',1),('boolean',True),('future',3),('missing',None)]: - request=json.loads(original) - if value is None: request.pop('execution_io_version') - else: request['execution_io_version']=value - request_path.write_text(json.dumps(request)) - reply=subprocess.run(command,capture_output=True,text=True,timeout=30) - assert reply.returncode!=0,(label,reply.stdout) - assert 'IO version' in reply.stderr,(label,reply.stderr) - assert inventory(ffi_output)==before - checks.append('ffi-request-'+label) - for key in ['worker_export', 'compiler_bundle_sha256', 'adapter_sha256', 'arguments', 'extra']: - request=json.loads(original) - if key=='arguments': - request[key][2]=str(root/'undeclared-store') - elif key=='extra': - request[key]=True - else: - request[key]='0'*64 - request_path.write_text(json.dumps(request)) - reply=subprocess.run(command,capture_output=True,text=True,timeout=30) - assert reply.returncode!=0 and 'identity/arguments mismatch' in reply.stderr,(key,reply.stderr) - assert inventory(ffi_output)==before and not (root/'undeclared-store').exists() - checks.append('ffi-request-'+key) - request=json.loads(original) - request['arguments'][4]+='\n' - map_path.write_bytes(original_map+b'\n') - request_path.write_text(json.dumps(request)) - reply=subprocess.run(command,capture_output=True,text=True,timeout=30) - assert reply.returncode!=0 and 'execution map identity mismatch' in reply.stderr - assert inventory(ffi_output)==before - checks.append('ffi-captured-map-tampering') - finally: - map_path.write_bytes(original_map) - request_path.write_bytes(original) - (root/'report.json').write_text(json.dumps(dict(checks=checks,records=runner.records, - kit=metadata,scoped_generation=str(output),ffi_generation=str(ffi_output) if ffi_output else None,generation_pair=False),indent=2)+'\n') - print(f'{len(checks)} generation boundary controls passed; no full compiler pair built') - - -if __name__=='__main__': main() diff --git a/test/io_v2_http.py b/test/io_v2_http.py @@ -1,243 +0,0 @@ -#!/usr/bin/env python3 -"""Independent real-socket v2 registry framing, streaming, admission and cleanup.""" -import argparse -import concurrent.futures -from contextlib import contextmanager -import hashlib -import json -from pathlib import Path -import select -import socket -import struct -import subprocess -import threading -import time -from source_order import Runner -from http_headers import number -from io_v2_objects import text -from process_rss import ChildRSS -from rts_metrics import parse_rts_stats - -ROOT = Path(__file__).resolve().parent.parent -PHRASE = {200:'OK',201:'Created',400:'Bad Request',404:'Not Found',413:'Content Too Large',431:'Request Header Fields Too Large',500:'Internal Server Error',505:'HTTP Version Not Supported'} -def expected(status, body, mime='text/plain; charset=utf-8', head=False): - return (f'HTTP/1.1 {status} {PHRASE[status]}\r\nContent-Type: {mime}\r\nContent-Length: {len(body)}\r\nConnection: close\r\n\r\n').encode()+(b'' if head else body) -def header(length): - return f'POST /_arboricx/bundle HTTP/1.1\r\nHost: local\r\nContent-Length: {length}\r\n\r\n'.encode() -def receive(peer): - chunks=[] - while True: - chunk=peer.recv(65536) - if not chunk: return b''.join(chunks) - chunks.append(chunk) -def wait(predicate): - until=time.monotonic()+15 - while time.monotonic()<until: - if predicate(): return - time.sleep(.02) - raise AssertionError('condition timeout') - -def main(): - p=argparse.ArgumentParser(description=__doc__) - p.add_argument('tricu',type=Path); p.add_argument('--directory',type=Path,required=True) - a=p.parse_args(); directory=a.directory.resolve(); directory.mkdir(parents=True,exist_ok=False) - runner=Runner(a.tricu,directory) - bundle=directory/'registry.arboricx' - runner.run('compile',['compile','--file',ROOT/'demos/io-v2/registry.tri','--store',runner.store,'--output',bundle,'--compile-timeout','600'],seconds=660) - pin=runner.imported(bundle,'import'); checks=[]; observations=[] - @contextmanager - def server(label,count,window=4,name='server',root=None): - root=root or directory/(label+'-files') - argv=[str(runner.exe),'arboricx','run','--module',pin,'--name',name,'--store',str(runner.store),'--io','--no-result'] - for i,value in enumerate([text(str(root)),number(count),number(window)]): - path=directory/f'{label}-{i}.tree';path.write_bytes(value);argv+=['--arg-tree-file',str(path)] - argv+=['+RTS','-N2','-M256m','-T','-s','-RTS'] - log=(directory/(label+'.stderr')).open('wb') - proc=subprocess.Popen(argv,stdin=subprocess.PIPE if name=='cancelledServer' else subprocess.DEVNULL,stdout=subprocess.PIPE,stderr=log) - stopped=threading.Event(); row=dict(label=label,command=argv,peak_kib=0) - def monitor(): - try: - rss=ChildRSS(proc) - while not stopped.is_set(): - value=rss.sample() - if value is not None: - row['peak_kib']=max(row['peak_kib'],value) - if value>256*1024: raise AssertionError('RSS guard exceeded') - stopped.wait(.02) - except Exception as error: - row['monitor_error']=repr(error);proc.kill() - thread=threading.Thread(target=monitor,daemon=True);thread.start();started=time.monotonic() - try: - assert select.select([proc.stdout],[],[],45)[0], 'startup timeout' - line=proc.stdout.readline() - assert line.strip().isdigit(), (line,(directory/(label+'.stderr')).read_text()) - port=int(line) - def connect(): - return socket.create_connection(('127.0.0.1',port),timeout=90) - def request(wire,fragment=False): - with connect() as peer: - if fragment: - for start in range(0,len(wire),997):peer.sendall(wire[start:start+997]) - else:peer.sendall(wire) - peer.shutdown(socket.SHUT_WR) - return receive(peer) - def cancel(): - proc.stdin.write(b'x');proc.stdin.flush() - request.cancel=cancel - yield root,connect,request - stdout,_=proc.communicate(timeout=120) - assert proc.returncode==0, (directory/(label+'.stderr')).read_text() - assert b'private logger detail' not in stdout - assert not list(root.rglob('.tricu-stage-*')) - row['rts_process']=parse_rts_stats((directory/(label+'.stderr')).read_text()) - assert row['rts_process']['maximum_live_bytes']<64*1024*1024,row - if label in ['binary','lifecycle','admission']: - assert row['peak_kib']<128*1024,row - assert row['rts_process']['maximum_live_bytes']<16*1024*1024,row - assert 'monitor_error' not in row,row - row['seconds']=time.monotonic()-started;observations.append(row) - finally: - if proc.poll() is None:proc.kill();proc.communicate() - stopped.set();thread.join();log.close() - def object_path(root,data): - digest=hashlib.sha256(data).hexdigest() - return digest,root/'objects'/digest[:3]/(digest+'.arboricx') - def check(label,actual,wanted): - assert actual==wanted,(label,actual[:300],wanted[:300]);checks.append(label) - - cases=[ - ('health',b'GET /_arboricx/health HTTP/1.1\r\nHost: local\r\n\r\n',200,b'OK\n',False), - ('head',b'HEAD /_arboricx/health HTTP/1.0\r\n\r\n',200,b'OK\n',True), - ('duplicate-length',header(0).replace(b'\r\n\r\n',b'\r\nContent-Length: 0\r\n\r\n'),400,b'Bad Request\n',False), - ('transfer-encoding',b'POST /_arboricx/bundle HTTP/1.1\r\nTransfer-Encoding: chunked\r\n\r\n',400,b'Bad Request\n',False), - ('oversize',header(1048577),413,b'Request body too large\n',False), - ('length-junk',header(1048577).replace(b'1048577',b'1048577x'),400,b'Bad Request\n',False), - ('bad-version',header(0).replace(b'HTTP/1.1',b'HTTP/9.0'),505,b'HTTP Version Not Supported\n',False), - ('syntax-before-version',b'POST /_arboricx/bundle HTTP/9.0\r\nbroken\r\n\r\n',400,b'Bad Request\n',False), - ('head-syntax-body',b'HEAD /_arboricx/health HTTP/1.1\r\nbroken\r\n\r\n',400,b'Bad Request\n',False), - ('head-length-error',b'HEAD /_arboricx/health HTTP/1.1\r\nContent-Length: x\r\n\r\n',400,b'Bad Request\n',True), - ('missing-length',b'POST /_arboricx/bundle HTTP/1.1\r\n\r\nignored',201,hashlib.sha256(b'').hexdigest().encode(),False), - ('short-get-body',b'GET /_arboricx/health HTTP/1.1\r\nContent-Length: 5\r\n\r\nabc',400,b'Unexpected end of request body\n',False), - ] - cases += [('header-limit', b'GET /_arboricx/health HTTP/1.1\r\nX-Long: '+b'x'*65536+b'\r\n\r\n',431,b'Request Header Fields Too Large\n',False)] - with server('framing',len(cases)) as (root,connect,request): - for label,wire,status,body,head in cases: - check(label,request(wire),expected(status,body,head=head)) - with server('binary',14) as (root,connect,request): - for size in [0,1,4095,4096,4097,65536,1048576]: - data=(bytes(range(256))*((size+255)//256))[:size] - digest,path=object_path(root,data) - for state in ['new','existing']: - check(f'binary-{size}-{state}',request(header(size)+data,True),expected(201,digest.encode())) - assert path.read_bytes()==data - assert list((root/'tmp').iterdir())==[] - with server('failures',5) as (root,connect,request): - data=b'unpublished-prefix'*200;digest,path=object_path(root,data) - with connect() as peer: - peer.sendall(header(len(data)+1)+data) - wait(lambda:len(list((root/'tmp').iterdir()))==1) - assert not path.exists() - peer.shutdown(socket.SHUT_WR) - check('early-eof',receive(peer),expected(400,b'Upload failed\n')) - assert not path.exists();assert list((root/'tmp').iterdir())==[] - path.parent.mkdir(exist_ok=True);path.write_bytes(b'X'+data[1:]) - check('corrupt-winner',request(header(len(data))+data),expected(400,b'Upload failed\n')) - assert path.read_bytes()==b'X'+data[1:] - check('corrupt-download',request(f'GET /_arboricx/bundle/hash/{digest} HTTP/1.1\r\n\r\n'.encode()),expected(404,b'Artifact unavailable\n')) - # Peer reset is a connection-local failure, not a server shutdown. - with connect() as peer: - peer.sendall(header(1048576)+b'partial') - peer.setsockopt(socket.SOL_SOCKET,socket.SO_LINGER,struct.pack('ii',1,0)) - check('healthy-after-reset',request(cases[0][1]),expected(200,b'OK\n')) - with server('downloads',7) as (root,connect,request): - data=b'authenticated binary\0\xff';digest,path=object_path(root,data) - path.parent.mkdir(exist_ok=True);path.write_bytes(data) - wire=f'GET /_arboricx/bundle/hash/{digest} HTTP/1.1\r\n\r\n'.encode() - check('download-binary',request(wire),expected(200,data,'application/vnd.arboricx.bundle')) - check('download-head',request(wire.replace(b'GET ',b'HEAD ')),expected(200,data,'application/vnd.arboricx.bundle',head=True)) - package=root/'packages'/'example';package.mkdir(parents=True) - (package/'v1').write_bytes(b'package bytes') - check('package-download',request(b'GET /_arboricx/package/example/v1 HTTP/1.1\r\n\r\n'),expected(200,b'package bytes','application/vnd.arboricx.package-release')) - for size in [4096,65536,1048576]: - data=bytes(range(256))*(size//256);digest,path=object_path(root,data) - path.parent.mkdir(exist_ok=True);path.write_bytes(data) - check(f'download-{size}',request(f'GET /_arboricx/bundle/hash/{digest} HTTP/1.1\r\n\r\n'.encode()),expected(200,data,'application/vnd.arboricx.bundle')) - # Trailing pipelined bytes never become a second request or uploaded data. - data=b'one request';digest,path=object_path(root,data) - check('surplus-body',request(header(len(data))+data+cases[0][1]),expected(201,digest.encode())) - assert path.read_bytes()==data - # Repeated handoff/join windows also exercise completed-ticket retention. - with server('lifecycle',128,window=4) as (root,connect,request): - for i in range(128): - assert request(cases[0][1])==expected(200,b'OK\n') - checks.append('128-connections-consumed-tickets') - with server('limited',4,name='limited') as (root,connect,request): - check('configured-upload-limit',request(header(5)+b'12345'),expected(413,b'Request body too large\n')) - assert not list((root/'objects').rglob('*.arboricx')) - check('configured-upload-boundary',request(header(4)+b'1234'),expected(201,hashlib.sha256(b'1234').hexdigest().encode())) - for data,status in [(b'1234',200),(b'12345',404)]: - digest,path=object_path(root,data) - path.parent.mkdir(exist_ok=True);path.write_bytes(data) - body=data if status==200 else b'Artifact unavailable\n' - mime='application/vnd.arboricx.bundle' if status==200 else 'text/plain; charset=utf-8' - check(f'configured-download-{len(data)}',request(f'GET /_arboricx/bundle/hash/{digest} HTTP/1.1\r\n\r\n'.encode()),expected(status,body,mime)) - with server('logger',2,name='badLogger') as (root,connect,request): - data=b'published before logger failure';digest,path=object_path(root,data) - check('logger-failure',request(header(len(data))+data),expected(500,b'Internal Server Error\n')) - assert path.read_bytes()==data - check('download-logger-failure',request(f'GET /_arboricx/bundle/hash/{digest} HTTP/1.1\r\n\r\n'.encode()),expected(500,b'Internal Server Error\n')) - # Two pending streams occupy exactly two admission slots. A third connection - # may enter the kernel backlog but cannot create another stage until a ticket - # is consumed. FIFO deliberately waits for the oldest, not any completed child. - with server('admission',3,window=2) as (root,connect,request): - with connect() as first,connect() as second,connect() as third: - first.sendall(header(1));second.sendall(header(1));third.sendall(header(1)) - wait(lambda:len(list((root/'tmp').iterdir()))==2) - second.sendall(b'b');second.shutdown(socket.SHUT_WR) - check('second-concurrent',receive(second),expected(201,hashlib.sha256(b'b').hexdigest().encode())) - wait(lambda:len(list((root/'tmp').iterdir()))==1) - third.settimeout(.3) - try: third.recv(1);raise AssertionError('third admitted before oldest completion') - except socket.timeout: pass - assert len(list((root/'tmp').iterdir()))==1 - first.sendall(b'a');first.shutdown(socket.SHUT_WR) - check('oldest-completes',receive(first),expected(201,hashlib.sha256(b'a').hexdigest().encode())) - third.settimeout(90);third.sendall(b'c');third.shutdown(socket.SHUT_WR) - check('queued-admitted',receive(third),expected(201,hashlib.sha256(b'c').hexdigest().encode())) - with server('cancelled',0,name='cancelledServer') as (root,connect,request): - with connect() as first,connect() as second: - first.sendall(header(1048576)+b'a'*4096) - second.sendall(header(1048576)+b'b'*4096) - wait(lambda:len(list((root/'tmp').iterdir()))==2) - request.cancel() - for peer in [first,second]: - try: assert receive(peer)==b'', 'cancellation rendered a response' - except ConnectionResetError: pass - checks.append('server-cancellation-cleans-active-streams') - def startup(label,name,values,flags=()): - argv=[str(runner.exe),'arboricx','run','--module',pin,'--name',name,'--store',str(runner.store),'--io','--no-result',*flags] - for i,value in enumerate(values): - path=directory/f'{label}-{i}.tree';path.write_bytes(value);argv+=['--arg-tree-file',str(path)] - result=subprocess.run(argv,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=30) - (directory/(label+'.startup.log')).write_bytes(result.stdout+result.stderr) - assert result.returncode!=0 and not result.stdout,(label,result.stdout,result.stderr) - checks.append(label) - for window in [0,257]: - root=directory/f'invalid-window-{window}' - startup(f'window-{window}','server',[text(str(root)),number(1),number(window)]) - assert not root.exists() - root=directory/'blocked-root';root.write_bytes(b'unchanged') - startup('blocked-root','server',[text(str(root)),number(1),number(1)]) - assert root.read_bytes()==b'unchanged' - root=directory/'grant-root' - startup('reject-v1-grants','server',[text(str(root)),number(1),number(1)],['--unsafe-io']) - assert not root.exists() - with socket.socket() as occupied: - occupied.bind(('127.0.0.1',0));occupied.listen(1) - from io_v2_tcp import endpoint - root=directory/'bind-failure' - startup('bind-failure','listen',[text(str(root)),endpoint(4,occupied.getsockname()[1])]) - assert not list(root.rglob('.tricu-stage-*')) - (directory/'report.json').write_text(json.dumps(dict(passed=checks,records=runner.records,processes=observations),indent=2)+'\n') - print(f'PASS {len(checks)} v2 HTTP controls; deployment unchanged') -if __name__=='__main__':main() diff --git a/test/io_v2_indexed.py b/test/io_v2_indexed.py @@ -1,103 +0,0 @@ -#!/usr/bin/env python3 -"""Independent framed/authenticated indexed-read and bounded-transfer controls.""" -import argparse -import hashlib -import json -from pathlib import Path -import struct -from source_order import Runner -from process_rss import run_guarded -from native_indexed_store import indexed_bytes, digest -from executable_object import INDEXED, indexed_manifest -from http_headers import number, result, tree_list - -ROOT = Path(__file__).resolve().parent.parent - -def text(s): - return tree_list([number(ord(c)) for c in s]) - -def problem(category, detail, progress): - return result(False, tree_list([text(category), text('indexed-tree/read'), text(detail), text(progress)])) - -def padded(size): - # Valid noncritical opaque section grows TRANSPORT, not the decoded DAG. - # This is not a large-DAG memory or compiler-capacity benchmark. - manifest = indexed_manifest(0) - nodes = struct.pack('>QIB', 1, 1, 0) - def section(kind, flags, offset, count): - return struct.pack('>IHHIQQI', kind, 1, flags, 0, offset, count, 0) - offset = 128 + len(manifest) + len(nodes) - return (b'ARBORICX' + struct.pack('>HHIQQ', 1, 0, 3, 0, 32) - + section(1, 1, 128, len(manifest)) - + section(2, 1, 128 + len(manifest), len(nodes)) - + section(99, 0, offset, size) + manifest + nodes + b'x' * size) - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('tricu', type=Path) - parser.add_argument('--directory', required=True, type=Path) - args = parser.parse_args() - directory = args.directory.resolve() - directory.mkdir(parents=True, exist_ok=False) - scratch = directory/'spool-λ' - scratch.mkdir() - runner = Runner(args.tricu, directory) - source = ROOT/'demos/io-v2/indexed-read.tri' - bundle = directory/'indexed-read.arboricx' - runner.run('compile', ['compile', '--file', source, '--store', runner.store, '--output', bundle]) - pin = runner.imported(bundle, 'import') - checks = [] - def check(label, payload, size, limit, expected_digest, expected_reply, position): - path = directory/(label+'.bin') - path.write_bytes(payload) - values = [text(str(path)), text(str(scratch)), number(limit), number(size), text(expected_digest)] - argv = ['--io'] - for i, value in enumerate(values): - argument = directory/f'{label}-{i}.tree' - argument.write_bytes(value) - argv += ['--arg-tree-file', argument] - expected = tree_list([expected_reply, number(position)]) - assert runner.invoke(label, pin, 'probe', argv) == expected, label - assert path.read_bytes() == payload, label - assert list(scratch.iterdir()) == [], label - checks.append(dict(label=label, input_bytes=len(payload), frame_bytes=size)) - for i, tree in enumerate([b'\0', b'\1\0', b'\2\1\0\0', number(42)]): - payload = indexed_bytes(tree) - check(f'tree-{i}', payload+b'next frame', len(payload), len(payload), digest(INDEXED, payload), result(True, tree), len(payload)) - payload = indexed_bytes(number(7)) - check('wrong-digest', payload, len(payload), len(payload), '0'*64, - problem('integrity-failed', 'CAS digest mismatch', 'unknown'), len(payload)) - check('truncated', payload[:-1], len(payload), len(payload), digest(INDEXED, payload), - problem('invalid-data', 'incomplete indexed frame', 'unknown'), len(payload)-1) - check('limit', payload, len(payload), len(payload)-1, digest(INDEXED, payload), - problem('invalid-bound', 'invalid snapshot size limit', 'none'), 0) - check('bad-digest', payload, len(payload), len(payload), 'A'*64, - problem('invalid-argument', '', 'none'), 0) - check('bad-bound-wire', payload, 2**63, 2**63, digest(INDEXED, payload), - problem('invalid-argument', '', 'none'), 0) - malformed = b'not a bundle' - check('malformed-authenticated', malformed, len(malformed), len(malformed), digest(INDEXED, malformed), - problem('invalid-data', 'section extends beyond bundle end', 'unknown'), len(malformed)) - check('malformed-unauthenticated', malformed, len(malformed), len(malformed), '0'*64, - problem('integrity-failed', 'CAS digest mismatch', 'unknown'), len(malformed)) - for size in [4096, 1048576, 16777216, 67108864]: - payload = padded(size) - check(f'transport-{size}', payload, len(payload), len(payload), digest(INDEXED, payload), result(True, b'\0'), len(payload)) - observations = [s for s in runner.records if s['label'].startswith('transport-')] - for sample in observations: - assert sample['peak_kib'] <= 48*1024, sample - assert sample['rts_process']['maximum_live_bytes'] <= 16*1024*1024, sample - files = ['lib/io-v2.tri', 'demos/io-v2/indexed-read.tri', 'test/io_v2_indexed.py', - 'test/source_order.py', 'test/rts_metrics.py', 'test/native_indexed_store.py', - 'test/executable_object.py', 'test/stable_bundle.py', 'src/ContentStore/Snapshot.hs', - 'src/ContentStore/Indexed.hs', 'src/Wire.hs', 'src/IOV2/Codec.hs', - 'src/IOV2/Resources.hs', 'src/IOV2/Driver.hs', 'src/IOV2/Protocol.hs', - 'bootstrap/build-stable.arboricx'] - (directory/'report.json').write_text(json.dumps(dict(checks=checks, observations=observations, - - measurement_notes='Opaque extension grows transport, not DAG. RSS is sampled: tiny runs can exit before representative samples; use the 64-MiB control for the transfer RSS guard. No concurrency or large-DAG capacity claim.', - source_hashes={p:hashlib.sha256((ROOT/p).read_bytes()).hexdigest() for p in files}), indent=2)+'\n') - print(f'{len(checks)} indexed-read controls passed') - -if __name__ == '__main__': - main() diff --git a/test/io_v2_indexed_write.py b/test/io_v2_indexed_write.py @@ -1,137 +0,0 @@ -#!/usr/bin/env python3 -"""Independent canonical bytes, winner verification and shared-DAG round trips.""" -import argparse -import hashlib -import json -from pathlib import Path -import struct -from source_order import Runner -from process_rss import run_guarded -from native_indexed_store import indexed_bytes, digest -from executable_object import INDEXED, indexed_manifest -from stable_bundle import assembled -from http_headers import number, result, tree_list - -ROOT = Path(__file__).resolve().parent.parent - -def text(s): - return tree_list([number(ord(c)) for c in s]) - -def info(payload): - return tree_list([number(len(payload)), text(digest(INDEXED, payload))]) - -def problem(category, operation, detail, progress): - return result(False, tree_list([text(category), text(operation), text(detail), text(progress)])) - -def dag(depth, fork): - records = [struct.pack('>IB', 1, 0)] - for i in range(1, depth+1): - payload = struct.pack('>BII', 2, i-1, i-1) if fork else struct.pack('>BI', 1, i-1) - records.append(struct.pack('>I', len(payload))+payload) - return assembled(indexed_manifest(depth), struct.pack('>Q', len(records))+b''.join(records)) - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('tricu', type=Path) - parser.add_argument('--directory', type=Path, required=True) - args = parser.parse_args() - directory = args.directory.resolve() - directory.mkdir(parents=True, exist_ok=False) - runner = Runner(args.tricu, directory) - source = ROOT/'demos/io-v2/indexed-write.tri' - bundle = directory/'writer.arboricx' - runner.run('compile', ['compile', '--file', source, '--store', runner.store, '--output', bundle]) - pin = runner.imported(bundle, 'import') - checks = [] - def invoke(label, name, values, expected): - argv = ['--io'] - for i, value in enumerate(values): - path = directory/f'{label}-{i}.tree' - path.write_bytes(value) - argv += ['--arg-tree-file', path] - assert runner.invoke(label, pin, name, argv) == expected, label - checks.append(label) - for i, tree in enumerate([b'\0', b'\1\0', b'\2\1\0\0', number(42)]): - payload = indexed_bytes(tree) - expected = digest(INDEXED, payload) - invoke(f'hash-{i}', 'hash', [number(len(payload)), tree], result(True, info(payload))) - path = directory/f'write-{i}.bin' - invoke(f'write-{i}', 'write', [text(str(path)), number(len(payload)), text(expected), tree], result(True, info(payload))) - assert path.read_bytes() == payload - root = directory/f'publish-{i}' - root.mkdir() - for status in ['published', 'exists']: - invoke(f'publish-{i}-{status}', 'publish', [text(str(root)), number(len(payload)), tree], - result(True, tree_list([text(status),number(len(payload)),text(expected)]))) - assert sorted(p.name for p in root.iterdir()) == [expected] - assert (root/expected).read_bytes() == payload - (root/expected).write_bytes(b'X'+payload[1:]) - invoke(f'corrupt-{i}', 'publish', [text(str(root)), number(len(payload)), tree], - problem('integrity-failed','indexed-tree/verify','indexed frame differs from canonical tree','unknown')) - assert (root/expected).read_bytes() == b'X'+payload[1:] - assert sorted(p.name for p in root.iterdir()) == [expected] - (root/expected).write_bytes(payload+b'extra') - invoke(f'excess-{i}', 'publish', [text(str(root)), number(len(payload)), tree], - problem('invalid-data','indexed-file/verify','trailing bytes','unknown')) - assert (root/expected).read_bytes() == payload+b'extra' - path = directory/'preserve.bin' - path.write_bytes(b'untouched') - tree = b'\0' - payload = indexed_bytes(tree) - for label, limit, expected, answer in [ - ('wrong-hash',65536,'0'*64,problem('integrity-failed','indexed-tree/write','CAS digest mismatch','none')), - ('limit',0,digest(INDEXED,payload),problem('invalid-bound','indexed-tree/write','encoded tree exceeds limit','none')), - ('invalid-hash',65536,'A'*64,problem('invalid-argument','indexed-tree/write','','none'))]: - invoke(label, 'write', [text(str(path)),number(limit),text(expected),tree], answer) - assert path.read_bytes() == b'untouched' - # Raw input uses linear-depth stems, not an expanded exponentially shared - # tree. Expectations come from the independent wire assembler. - for depth in [1000,10000]: - payload = dag(depth,False) - tree = b'\1'*depth+b'\0' - invoke(f'deep-hash-{depth}','hash',[number(len(payload)),tree],result(True,info(payload))) - output = directory/f'deep-{depth}.bin' - invoke(f'deep-write-{depth}','write',[text(str(output)),number(len(payload)),text(digest(INDEXED,payload)),tree],result(True,info(payload))) - assert output.read_bytes() == payload - original_bundle,original_pin = bundle,pin - bundle = directory/'transport.arboricx' - runner.run('compile-transport',['compile','--file',ROOT/'test/io-v2/indexed-transport.tri','--store',runner.store,'--output',bundle]) - pin = runner.imported(bundle,'import-transport') - payload = indexed_bytes(b'\0') - framed = directory/'framed.bin' - framed.write_bytes(payload+b'X') - from http_headers import pair - invoke('frame-tail','verifyTail',[text(str(framed)),number(len(payload)),b'\0'],result(True,pair(text('data'),tree_list([number(88)])))) - framed.write_bytes(payload[:-1]) - invoke('frame-truncated','verifyTail',[text(str(framed)),number(len(payload)),b'\0'],problem('integrity-failed','indexed-tree/verify','indexed frame differs from canonical tree','unknown')) - invoke('wrong-kind-inert-tree','wrongKind',[number(0)],problem('wrong-kind','indexed-tree/verify','','none')) - bundle,pin = original_bundle,original_pin - scratch = directory/'spool' - scratch.mkdir() - for depth, fork in ([(200,True),(1000,False),(10000,False),(100000,False)]): - payload = dag(depth,fork) - input_path = directory/f'dag-{depth}-{fork}.bin' - output = directory/f'dag-{depth}-{fork}-output.bin' - input_path.write_bytes(payload) - invoke(f'roundtrip-{depth}-{fork}', 'roundTrip', [text(str(input_path)),text(str(output)),text(str(scratch)), - number(len(payload)),number(len(payload)),text(digest(INDEXED,payload))], result(True,info(payload))) - assert output.read_bytes() == input_path.read_bytes() == payload - assert list(scratch.iterdir()) == [] - observations = [r for r in runner.records if r['label'].startswith('roundtrip-')] - for row in observations: - # DAG/table memory is expected to grow; these are regression guards, not - # constant-memory claims or a universal safe node-count budget. - assert row['peak_kib'] <= 256*1024, row - assert row['rts_process']['maximum_live_bytes'] <= 128*1024*1024, row - paths = ['test/io-v2/indexed-transport.tri','test/io_v2_indexed_write.py','test/native_indexed_store.py','test/executable_object.py', - 'test/stable_bundle.py','test/source_order.py','test/rts_metrics.py','lib/io-v2.tri', - 'demos/io-v2/indexed-write.tri','src/IOV2/Codec.hs','src/IOV2/Resources.hs','src/Wire.hs', - 'bootstrap/build-stable.arboricx'] - (directory/'report.json').write_text(json.dumps(dict(checks=checks, observations=observations, - scope="haskell-complete", - - source_hashes={p:hashlib.sha256((ROOT/p).read_bytes()).hexdigest() for p in paths}),indent=2)+'\n') - print(f'{len(checks)} independent indexed-writer checks passed'+('')) - -if __name__ == '__main__': - main() diff --git a/test/io_v2_lines.py b/test/io_v2_lines.py @@ -1,119 +0,0 @@ -#!/usr/bin/env python3 -"""Independent LF/UTF-8/limit oracles, fragmented pipes and interactive prompts.""" -import argparse -import json -import subprocess -import selectors -import time -from pathlib import Path -from source_order import Runner -from http_headers import number, result, tree_list, parse_tree_output -from io_v2_text import text, problem -ROOT=Path(__file__).resolve().parent.parent - -def event(s,ending): return tree_list([text('line'),text(s),text(ending)]) -def lines(data): - parts=data.split(b'\n') - return [event(p.decode('utf8'),'lf') for p in parts[:-1]] + ([event(parts[-1].decode('utf8'),'eof')] if parts[-1] else []) - -def main(): - p=argparse.ArgumentParser(description=__doc__);p.add_argument('tricu',type=Path);p.add_argument('--directory',type=Path,required=True) - a=p.parse_args();d=a.directory.resolve();d.mkdir(parents=True,exist_ok=False);runner=Runner(a.tricu,d);checks=[] - def compile(label,path): - bundle=d/(label+'.arboricx');runner.run(label+'-compile',['compile','--file',path,'--store',runner.store,'--output',bundle,'--compile-timeout','600'],seconds=660) - return runner.imported(bundle,label+'-import') - pin=compile('lines',ROOT/'demos/io-v2/lines.tri') - def invoke(label,name,values): - args=['--io'] - for i,value in enumerate(values): - f=d/f'{label}-{i}.tree';f.write_bytes(value);args+=['--arg-tree-file',f] - return runner.invoke(label,pin,name,args) - def collect(label,data,bound,expected=None): - path=d/(label+'.input');path.write_bytes(data) - actual=invoke(label,'collect',[text(str(path)),number(bound)]) - assert actual==(expected if expected is not None else result(True,tree_list(lines(data)))),label - checks.append(label) - for i,data in enumerate([b'',b'\n',b'\n\n',b'a',b'a\n',b'a\nb',b'a\n\n',b'a\r\nb\rc\n\0', '\ufeffλ😀e\u0301\n'.encode(),b'a'*4092+'😀'.encode()+b'\nnext\n',b'a'*4093+b'\n',b'a'*8190+b'\nend']): - collect(f'valid-{i}',data,max(map(len,data.split(b'\n')))) - for carry in [1,2,3]: - data=b'a'*(4093-carry)+'😀'.encode()+b'\nnext\n' - collect(f'carry-{carry}',data,4100) - collect('zero-empty-lines',b'\n\n',0) - over=problem('invalid-bound','text/read-line','line exceeds byte limit') - collect('overlong',b'ab\n',1,result(False,tree_list([b'\0',tree_list([text('read-line'),number(2),over])]))) - invalid=problem('invalid-data','utf8/decode','invalid UTF-8 leading byte at byte 0') - collect('later-invalid',b'ok\n\xff\n',20,result(False,tree_list([tree_list([event('ok','lf')]),tree_list([text('read-line'),number(1),invalid])]))) - partial=problem('invalid-data','utf8/decode','incomplete UTF-8 sequence at byte 0') - collect('incomplete-before-lf',b'\xe2\n',20,result(False,tree_list([b'\0',tree_list([text('read-line'),number(1),partial])]))) - collect('incomplete-at-eof',b'\xe2',20,result(False,tree_list([b'\0',tree_list([text('read-line'),number(1),partial])]))) - # Returning a first line never validates unrequested later buffered text. - path=d/'later-invalid.input' - assert invoke('first-only','first',[text(str(path)),number(20)])==result(True,event('ok','lf'));checks.append('first-only') - stale=problem('stale-handle','stream/read','') - assert invoke('closed-cursor','closed',[text(str(path)),number(20)])==result(False,tree_list([text('read-line'),number(0),stale])) - badlimit=problem('invalid-bound','text/limit','invalid text byte limit') - assert invoke('invalid-bound-before-read','closed',[text(str(path)),number(9223372036854775808)])==result(False,badlimit) - checks+=['closed-cursor','invalid-bound-before-read'] - def command(module,name,values=(),no_result=False): - cmd=[str(runner.exe),'arboricx','run','--module',module,'--name',name,'--store',str(runner.store),'--io','--format','ternary'] - for i,value in enumerate(values): - f=d/f'pipe-{name}-{i}.tree';f.write_bytes(value);cmd+=['--arg-tree-file',str(f)] - if no_result:cmd+=['--no-result'] - return cmd - data='A😀λ\n\ufeff\r\nlast'.encode() - for split in range(len(data)+1): - proc=subprocess.Popen(command(pin,'stdinLines',[number(100)]),stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.PIPE) - try: - proc.stdin.write(data[:split]);proc.stdin.flush();time.sleep(.02) - proc.stdin.write(data[split:]);proc.stdin.close();proc.stdin=None - out,err=proc.communicate(timeout=20) - assert proc.returncode==0,(split,err) - assert parse_tree_output(out.decode())==result(True,tree_list(lines(data))),split - finally: - if proc.poll() is None:proc.kill();proc.wait() - checks.append('every-pipe-split') - proc=subprocess.Popen(command(pin,'cancelled'),stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.PIPE) - try: - # Do not close stdin: cancellation, rather than EOF, must release the read. - proc.wait(timeout=10);out=proc.stdout.read();err=proc.stderr.read() - assert proc.returncode==0,err - assert parse_tree_output(out.decode())==tree_list([text('cancelled'),text('line-stop'),b'\0']) - finally: - if proc.poll() is None:proc.kill();proc.wait() - proc.stdin.close();proc.stdout.close();proc.stderr.close() - checks.append('cancel-blocked-input') - demo=compile('getLineAsync',ROOT/'demos/interactionTrees/getLineAsync.tri') - prompt=b'Please enter your first name: \n(this printed before you typed anything)\n(this second line also printed before you typed anything)\n' - for label,payload,wanted in [('name','λ😀\n'.encode(),'Hello, λ😀!\n'.encode()),('empty',b'\n',b'Hello, !\n'),('eof',b'',b'End of input.\n')]: - proc=subprocess.Popen(command(demo,'main',no_result=True),stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.PIPE) - try: - collected=b'';deadline=time.monotonic()+10 - with selectors.DefaultSelector() as sel: - sel.register(proc.stdout,selectors.EVENT_READ) - while len(collected)<len(prompt): - assert time.monotonic()<deadline,'prompt blocked on input' - if sel.select(.1): collected+=__import__('os').read(proc.stdout.fileno(),4096) - assert collected==prompt,collected - out,err=proc.communicate(payload,timeout=20) - assert proc.returncode==0 and out==wanted,(label,out,err) - finally: - if proc.poll() is None:proc.kill();proc.wait() - checks.append('interactive-'+label) - for label,wanted in [('greet',b'Hello, tricu\n'),('forkAwait',b'working: job1\nworking: job2\nGot job1-result and job2-result\n')]: - demo=compile(label,ROOT/f'demos/interactionTrees/{label}.tri') - run=subprocess.run(command(demo,'main',no_result=True),capture_output=True,timeout=30) - assert run.returncode==0 and run.stdout==wanted,(label,run.stdout,run.stderr) - checks.append(label) - with open('/dev/full','wb') as full: - failed=subprocess.run(command(demo,'main',no_result=True),stdout=full,stderr=subprocess.PIPE,timeout=20) - assert failed.returncode!=0,(label,failed.stderr) - checks.append(label+'-failed-output') - for count in [1024,16384]: - path=d/f'churn-{count}.input';path.write_bytes(b'x\n'*count) - assert invoke(f'churn-{count}','count',[text(str(path)),number(1)])==number(count) - row=runner.records[-1] - assert row['peak_kib']<128*1024 and row['rts_process']['maximum_live_bytes']<16*1024*1024,row - checks.append(f'churn-{count}') - (d/'report.json').write_text(json.dumps(dict(passed=checks,records=runner.records),indent=2)+'\n') - print(f'PASS {len(checks)} line/interactive controls (including every pipe split)') -if __name__=='__main__':main() diff --git a/test/io_v2_objects.py b/test/io_v2_objects.py @@ -1,123 +0,0 @@ -#!/usr/bin/env python3 -"""Independent source-owned CAS bytes, exact winners and shared-tree transfer.""" -import argparse -import hashlib -import json -from pathlib import Path -import re -import subprocess -from source_order import Runner -from native_indexed_store import indexed_bytes, digest -from executable_object import INDEXED -from io_v2_indexed_write import dag -from http_headers import number, result, tree_list - -ROOT = Path(__file__).resolve().parent.parent - -def text(s): - return tree_list([number(ord(c)) for c in s]) - -def problem(category, op, detail, progress): - return result(False, tree_list([text(category),text(op),text(detail),text(progress)])) - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('tricu', type=Path) - parser.add_argument('--directory', type=Path, required=True) - args = parser.parse_args() - directory = args.directory.resolve() - directory.mkdir(parents=True, exist_ok=False) - runner = Runner(args.tricu, directory) - source = ROOT/'demos/io-v2/objects.tri' - bundle = directory/'objects.arboricx' - runner.run('compile',['compile','--file',source,'--store',runner.store,'--output',bundle]) - module = runner.imported(bundle,'import') - checks = [] - def options(label, values): - argv = ['--io'] - for i,value in enumerate(values): - path = directory/f'{label}-{i}.tree' - path.write_bytes(value) - argv += ['--arg-tree-file',path] - return argv - def invoke(label,name,values,expected): - assert runner.invoke(label,module,name,options(label,values)) == expected,label - checks.append(label) - def clean(root): - assert not list(root.rglob('.tricu-*')),root - for i,tree in enumerate([b'\0',b'\1\0',number(42)]): - root = directory/f'root-{i}' - root.mkdir() - payload = indexed_bytes(tree) - pin = digest(INDEXED,payload) - path = root/'objects'/pin[:3]/pin - for label in ['new','existing']: - invoke(f'{label}-{i}','put',[text(str(root)),number(len(payload)),tree],result(True,text(pin))) - assert path.read_bytes() == payload - assert sorted(p.relative_to(root).as_posix() for p in root.rglob('*') if p.is_file()) == ['objects/'+pin[:3]+'/'+pin] - clean(root) - invoke(f'get-{i}','get',[text(str(root)),number(len(payload)),text(pin)],result(True,tree)) - path.write_bytes(b'X'+payload[1:]) - invoke(f'corrupt-put-{i}','put',[text(str(root)),number(len(payload)),tree], - problem('integrity-failed','indexed-tree/verify','indexed frame differs from canonical tree','unknown')) - invoke(f'corrupt-get-{i}','get',[text(str(root)),number(len(payload)),text(pin)], - problem('integrity-failed','indexed-tree/read','CAS digest mismatch','unknown')) - assert path.read_bytes() == b'X'+payload[1:] - clean(root) - path.write_bytes(payload+b'extra') - invoke(f'excess-{i}','put',[text(str(root)),number(len(payload)),tree], - problem('invalid-data','objects/verify','trailing bytes','unknown')) - assert path.read_bytes() == payload+b'extra' - clean(root) - absent = directory/'must-not-be-created' - for i,invalid in enumerate(['../outside','A'*64,'0'*63,'0'*65,'λ'*64,'\0'*64]): - invoke(f'invalid-{i}','get',[text(str(absent)),number(65536),text(invalid)], - problem('invalid-argument','objects/get','invalid CAS digest','none')) - for i, invalid in enumerate([b'\1\0', tree_list([number(48)[:-1]+b'\2\0\0']*64)]): - invoke(f'malformed-digest-{i}','get',[text(str(absent)),number(65536),invalid], - problem('invalid-argument','objects/get','invalid CAS digest','none')) - assert not absent.exists() - invoke('absent-root','putCategory',[text(str(absent)),number(65536),b'\0'],text('not-found')) - blocked = directory/'blocked'; blocked.mkdir(); (blocked/'objects').write_bytes(b'keep') - invoke('blocked','putCategory',[text(str(blocked)),number(65536),b'\0'],text('not-directory')) - assert (blocked/'objects').read_bytes() == b'keep' - limited = directory/'limited'; limited.mkdir() - invoke('limit','putCategory',[text(str(limited)),number(0),b'\0'],text('invalid-bound')) - assert list(limited.iterdir()) == [] - source_root = directory/'source'; target = directory/'target' - source_root.mkdir(); target.mkdir() - payload = dag(200,True) - pin = digest(INDEXED,payload) - path = source_root/'objects'/pin[:3]/pin - path.parent.mkdir(parents=True); path.write_bytes(payload) - invoke('shared-copy','copy',[text(str(source_root)),text(str(target)),number(len(payload)),text(pin)],result(True,text(pin))) - assert (target/'objects'/pin[:3]/pin).read_bytes() == payload - clean(source_root); clean(target) - # Two independent processes publish to one new namespace. Keep complete - # output/byte checks; this is a correctness race, not a capacity benchmark. - race = directory/'race'; race.mkdir() - tree = number(19); payload = indexed_bytes(tree); pin = digest(INDEXED,payload) - argv = options('race',[text(str(race)),number(len(payload)),tree]) - command = [str(args.tricu.resolve()),'arboricx','run','--module',module,'--name','put','--store',str(runner.store),'--format','ternary',*map(str,argv),'+RTS','-N1','-M256m','-RTS'] - workers = [subprocess.Popen(command,stdout=subprocess.PIPE,stderr=subprocess.PIPE) for _ in range(2)] - try: - for worker in workers: - out,err = worker.communicate(timeout=120) - assert worker.returncode == 0,(out,err) - raw = re.search(rb'(?m)^([012]+)$',out)[1] - assert bytes(int(c)-48 for c in raw) == result(True,text(pin)),out - finally: - for worker in workers: - if worker.poll() is None: worker.kill() - worker.wait() - assert (race/'objects'/pin[:3]/pin).read_bytes() == payload - clean(race); checks.append('concurrent-publish') - files = ['lib/io-v2/objects.tri','lib/io-v2.tri','demos/io-v2/objects.tri','test/io_v2_objects.py', - 'test/native_indexed_store.py','test/io_v2_indexed_write.py','test/executable_object.py', - 'test/source_order.py','test/rts_metrics.py','src/IOV2/Resources.hs','src/IOV2/Descriptor.hs', - 'src/IOV2/Filesystem.hs','src/IOV2/Codec.hs','bootstrap/build-stable.arboricx'] - (directory/'report.json').write_text(json.dumps(dict(checks=checks,observations=runner.records, - source_hashes={p:hashlib.sha256((ROOT/p).read_bytes()).hexdigest() for p in files}),indent=2)+'\n') - print(f'{len(checks)} source CAS controls passed') - -if __name__ == '__main__': main() diff --git a/test/io_v2_positioned.py b/test/io_v2_positioned.py @@ -1,107 +0,0 @@ -#!/usr/bin/env python3 -"""Independent complete byte/result oracles for positioned file compositions.""" -import argparse -import hashlib -import json -from pathlib import Path -from source_order import Runner -from http_headers import number, pair, result, tree_list -from stable_bundle import byte_tree -ROOT = Path(__file__).resolve().parent.parent - -def text(value): - return tree_list([number(ord(c)) for c in value]) - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('tricu', type=Path) - parser.add_argument('--directory', type=Path, required=True) - args = parser.parse_args() - directory = args.directory.resolve() - directory.mkdir(parents=True, exist_ok=False) - runner = Runner(args.tricu, directory) - source = ROOT/'demos/io-v2/positioned.tri' - bundle = directory/'positioned.arboricx' - runner.run('compile', ['compile', '--file', source, '--store', runner.store, '--output', bundle]) - pin = runner.imported(bundle, 'import') - checks = [] - - def invoke(label, name, values): - argv = ['--io'] - for index, value in enumerate(values): - path = directory/f'{label}-{index}.tree' - path.write_bytes(value) - argv += ['--arg-tree-file', path] - return runner.invoke(label, pin, name, argv) - - for index, (original, offset, patch, length, exists) in enumerate([ - (b'abcdefgh', 2, bytes([0,255,13,10]), 8, True), - (b'', 5, b'X', 12, False), - (b'0123456789', 3, b'ABCDE', 4, True), - (b'unchanged prefix', 0, b'', 16, True), - (b'discarded', 2, b'xy', 0, True), - (bytes(range(256))*4096, 524288, bytes(range(128)), 1048585, True), - ]): - path = directory/f'patch-{index}-λ.bin' - if exists: - path.write_bytes(original) - wanted = bytearray(original) - if patch: - end = offset+len(patch) - if end > len(wanted): - wanted.extend(bytes(end-len(wanted))) - wanted[offset:end] = patch - if length > len(wanted): - wanted.extend(bytes(length-len(wanted))) - del wanted[length:] - prefix = pair(text('data'), byte_tree(wanted[:4096])) if wanted else pair(text('eof'), b'\0') - expected = tree_list([number(n) for n in - [len(original), offset, offset+len(patch), offset+len(patch), length]]+[prefix]) - actual = invoke(f'patch-{index}', 'patch', [text(str(path)), number(offset), byte_tree(patch), number(length)]) - assert actual == expected, index - assert path.read_bytes() == wanted, index - checks.append(dict(label=f'patch-{index}', sha256=hashlib.sha256(wanted).hexdigest())) - for index, original in enumerate([None, b'', b'prior\x00\xff', bytes(range(256))*4096]): - path = directory/f'append-{index}.bin' - if original is not None: - path.write_bytes(original) - body = bytes(range(256))*16 - before = original or b'' - actual = invoke(f'append-{index}', 'append', [text(str(path)), byte_tree(body)]) - assert actual == tree_list([number(len(before)),number(len(before)+len(body))]), index - assert path.read_bytes() == before+body, index - checks.append(dict(label=f'append-{index}', sha256=hashlib.sha256(before+body).hexdigest())) - # Exercise the wire's wide offset without materializing a multi-GiB value: - # write one sparse byte, shrink, and compare the entire resulting file. - path = directory/'wide.bin' - actual = invoke('wide-offset', 'patch', [text(str(path)),number(4294967296),byte_tree(b'X'),number(1)]) - assert actual == tree_list([number(n) for n in [0,4294967296,4294967297,4294967297,1]]+ - [pair(text('data'),byte_tree(b'\0'))]) - assert path.read_bytes() == b'\0' - checks.append(dict(label='wide-offset')) - path = directory/'guard.bin' - path.write_bytes(b'keep me') - def problem(category, operation, detail=''): - return result(False, tree_list([text(v) for v in [category, operation, detail, 'none']])) - expected = tree_list([problem('unsupported','file/'+op,'operation unavailable in this file mode') - for op in ['seek','position','resize']]+[problem('wrong-kind','stream/read')]) - assert invoke('append-guards', 'appendGuards', [text(str(path))]) == expected - assert path.read_bytes() == b'keep me' - checks.append(dict(label='append-guards')) - for index, value in enumerate([number(9223372036854775808), pair(b'\0',b'\0'), b'\1\0']): - assert invoke(f'offset-{index}', 'badOffset', [text(str(path)),value]) == problem('invalid-argument','file/seek') - assert path.read_bytes() == b'keep me' - checks.append(dict(label=f'offset-{index}')) - observations = [row for row in runner.records if row['label'].startswith(('patch-','append-'))] - for row in observations: - assert row['peak_kib'] <= 128*1024, row - assert row['rts_process']['maximum_live_bytes'] <= 16*1024*1024, row - paths = [source, Path(__file__).resolve(), ROOT/'lib/io-v2.tri', ROOT/'src/IOV2/Resources.hs', - ROOT/'src/IOV2/Driver.hs', ROOT/'src/IOV2/Protocol.hs', ROOT/'test/source_order.py', - ROOT/'test/rts_metrics.py', ROOT/'bootstrap/build-stable.arboricx'] - (directory/'report.json').write_text(json.dumps(dict(checks=checks, observations=observations, - sources={str(p.relative_to(ROOT)):hashlib.sha256(p.read_bytes()).hexdigest() for p in paths}), indent=2)+'\n') - print(f'PASS: {len(checks)} independent positioned-file checks') - -if __name__ == '__main__': - main() diff --git a/test/io_v2_process.py b/test/io_v2_process.py @@ -1,61 +0,0 @@ -#!/usr/bin/env python3 -"""Independent actual-command, binary pipeline and normal-scope-exit controls.""" -import argparse -import json -import shutil -from pathlib import Path -from source_order import Runner -from http_headers import number, tree_list, result -from io_v2_objects import text - -ROOT = Path(__file__).resolve().parent.parent - -def main(): - p=argparse.ArgumentParser(description=__doc__) - p.add_argument('tricu',type=Path); p.add_argument('--directory',type=Path,required=True) - a=p.parse_args(); directory=a.directory.resolve(); directory.mkdir(parents=True,exist_ok=False) - runner=Runner(a.tricu,directory); bundle=directory/'process.arboricx'; checks=[] - runner.run('compile',['compile','--file',ROOT/'demos/io-v2/process.tri','--store',runner.store,'--output',bundle,'--compile-timeout','600'],seconds=660) - pin=runner.imported(bundle,'import') - inherit=tree_list([text('inherit')]) - def invoke(label,name,args): - path=directory/(label+'.tree');path.write_bytes(tree_list(args)) - return runner.invoke(label,pin,name,['--io','--arg-tree-file',path]) - cat=shutil.which('cat');shell=shutil.which('sh');assert cat and shell - source=directory/'input';output=directory/'output';errors=directory/'errors' - def pipeline(label,exe,argv,cwd,env,wanted,err=b'',code=0): - actual=invoke(label,'pipeline',[text(exe),tree_list([text(x) for x in argv]),cwd,env,number(20),text(str(source)),text(str(output)),text(str(errors))]) - assert actual==tree_list([text('exit'),number(code)]),(label,actual.hex()) - assert output.read_bytes()==wanted,label - assert errors.read_bytes()==err,label - checks.append(label) - for size in [0,1,4095,4096,4097,65536,1048576]: - data=bytes(i%256 for i in range(size));source.write_bytes(data) - pipeline(f'binary-{size}',cat,[],inherit,inherit,data) - tee=shutil.which('tee');assert tee - data=bytes(range(256))*4096;source.write_bytes(data) - pipeline('concurrent-stdout-stderr',tee,['/dev/stderr'],inherit,inherit,data,data) - source.write_bytes(b'') - pipeline('literal-argv-env-cwd',shell,['-c','printf "%s|%s|%s" "$1" "$VALUE" "$PWD"; printf diagnostic >&2; exit 7','test','$(not-a-command); *'],tree_list([text('at'),text(str(directory))]),tree_list([text('replace'),tree_list([tree_list([text('VALUE'),text('literal')])])]),('$(not-a-command); *|literal|'+str(directory)).encode(),b'diagnostic',7) - marker=directory/'joined' - actual=invoke('normal-exit','normalExit',[text(shell),tree_list([text('-c'),text('i=0; while [ "$i" -lt 10000 ]; do i=$((i+1)); done; printf finished > "$1"; exit 3'),text('test'),text(str(marker))])]) - assert actual==number(42) and marker.read_bytes()==b'finished' - checks.append('normal-exit-reaps-without-implying-zero-status') - for label,exe,args,cwd,env,grace in [ - ('empty-executable','',[],inherit,inherit,0), - ('duplicate-environment',cat,[],inherit,tree_list([text('replace'),tree_list([tree_list([text('X'),text('a')]),tree_list([text('X'),text('b')])])]),0), - ('excess-argv',cat,['a']*257,inherit,inherit,0), - ('excess-text',cat,['a'*4097],inherit,inherit,0), - ('excess-total',cat,['a'*4096]*16,inherit,inherit,0), - ('excess-grace',cat,[],inherit,inherit,9223372036854775808)]: - actual=invoke(label,'raw',[text(exe),tree_list([text(x) for x in args]),cwd,env,number(grace)]) - assert actual==result(False,tree_list([text('invalid-argument'),text('process/spawn'),b'\0',text('none')])),label - checks.append(label) - for row in runner.records: - if row.get('label') in ('binary-1048576','concurrent-stdout-stderr'): - # Runtime process observations, not subprocess heap or capacity promises. - assert row['rts_process']['maximum_live_bytes'] < 16*1024*1024,row - assert row['peak_kib'] < 128*1024,row - (directory/'report.json').write_text(json.dumps(dict(passed=checks,records=runner.records),indent=2)+'\n') - print(f'PASS {len(checks)} process source controls') -if __name__=='__main__':main() diff --git a/test/io_v2_protocol.py b/test/io_v2_protocol.py @@ -1,90 +0,0 @@ -#!/usr/bin/env python3 -"""Independent source oracles for v2 discovery, inspection and unsupported calls.""" -import argparse -import hashlib -import json -from pathlib import Path -from source_order import Runner -from stable_bundle import byte_tree -from http_headers import tree_list, number, result - -ROOT = Path(__file__).resolve().parent.parent - -def text(value): - return byte_tree(value.encode()) - -def strings(values): - return tree_list([text(v) for v in values]) - -def expected_capabilities(): - def row(key, value): - return tree_list([text(key), value]) - groups = [('runtime', ['capabilities', 'inspect']), ('standard', ['input', 'output', 'error']), ('file', ['open-reader', 'open-writer', 'open-appender', 'open-update', 'seek', 'position', 'size', 'resize', 'sync']), - ('path', ['stat', 'lstat', 'remove-file', 'rename']), ('directory', ['create', 'remove', 'open', 'next', 'sync']), ('tcp', ['listen', 'connect', 'accept', 'local-address', 'peer-address', 'shutdown-write']), ('resolver', ['resolve']), ('indexed-tree', ['read', 'hash', 'write', 'verify']), ('utf8', ['encode', 'decode']), ('stream', ['read', 'write']), ('resource', ['close']), - ('stage', ['open', 'seal', 'publish']), ('digest', ['open', 'update', 'finish']), ('clock', ['monotonic', 'sleep']), ('scheduler', ['yield']), ('process', ['spawn', 'working-directory', 'environment-variable'])] - return tree_list([ - row('schema', number(1)), row('io-version', number(2)), - row('backend', text('haskell-native')), row('authority', text('os-process')), - row('core', strings(['pure', 'bind', 'fail', 'scope', 'current-scope', 'spawn', 'join', 'discard', 'cancel', 'cancelled'])), - row('groups', tree_list([tree_list([text(g), number(1), strings(ops)]) for g, ops in groups])), - row('limits', tree_list([row('chunk-bytes', number(4096)), row('transfer-handles', number(256)), row('file-offset-max', number(9223372036854775807)), row('directory-name-scalars', number(4096)), row('tcp-backlog', number(4096)), row('resolver-host-characters', number(253)), row('resolver-addresses', number(64)), row('indexed-sections', number(64)), row('indexed-manifest-bytes', number(65536)), row('utf8-encode-scalars', number(1024)), row('sleep-milliseconds-max', number(9223372036854775807)), row('process-arguments', number(256)), row('process-environment-entries', number(256)), row('process-text-scalars', number(4096)), row('process-total-scalars', number(65536)), row('process-grace-milliseconds-max', number(9223372036854775807))])), - row('tcp-address-families', strings(['ipv4', 'ipv6'])), - row('digest-algorithms', strings(['sha256'])), - row('restrictions', strings(['action-boundary-cancellation', 'no-hard-deadlines', - 'stage-same-filesystem', 'stage-no-copy-fallback', 'stage-private-namespace', 'publish-new-requires-hard-links', 'no-crash-recovery', 'borrowed-standard-streams', 'standard-stream-leases-per-run', 'file-single-cursor-lease', 'append-no-position-or-resize', 'file-host-handle-locking', 'directory-unsorted-non-snapshot', 'filesystem-host-encoding', 'directory-failure-poisons-cursor', 'tcp-numeric-addresses', 'tcp-ipv6-only-listeners', 'tcp-direction-leases', 'tcp-failure-poisons-direction', 'resolver-host-order-and-buffering', 'resolver-may-delay-cancellation', 'indexed-authenticated-snapshot', 'indexed-caller-frame-and-disk-limit', 'indexed-dag-resident', 'indexed-writer-preflight', 'indexed-budget-not-heap-quota', 'sync-host-filesystem-guarantees', 'utf8-strict-scalars', 'clock-host-monotonic-word64', 'sleep-may-run-late', 'yield-no-ordering-guarantee', 'process-direct-child-only', 'process-normal-exit-joins', 'process-explicit-pipe-draining']))]) - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('tricu', type=Path) - parser.add_argument('--directory', type=Path, required=True) - args = parser.parse_args() - directory = args.directory.resolve() - directory.mkdir(parents=True, exist_ok=False) - runner = Runner(args.tricu, directory) - bundle = directory/'protocol.arboricx' - source = ROOT/'test/io-v2/protocol.tri' - runner.run('compile', ['compile', '--file', source, '--store', runner.store, '--output', bundle]) - pin = runner.imported(bundle, 'import') - victim = directory/'must-remain' - victim.write_bytes(b'not truncated\x00\xff') - path = directory/'path.tree' - path.write_bytes(byte_tree(str(victim).encode())) - seed = directory/'seed.tree' - seed.write_bytes(number(7)) - cases = { - 'capabilities': (result(True, expected_capabilities()), []), - 'handledCapabilities': (result(True, expected_capabilities()), []), - 'inspectWriter': (result(True, tree_list([text('facility'), text('file'), number(1), - text('open-writer'), text('available')])), [path]), - 'inspectLazyBind': (result(True, strings(['core', 'bind'])), [seed]), - 'inspectLazyScope': (result(True, strings(['core', 'scope'])), [seed]), - 'inspectFuture': (result(True, tree_list([text('facility'), text('future-tcp'), number(1), - text('connect'), text('unsupported')])), [seed]), - 'unsupported': (result(False, strings(['unsupported', 'future-tcp/connect', 'group revision 1', 'none'])), [seed])} - for name, (expected, inputs) in cases.items(): - argv = ['--io'] - for arg in inputs: - argv += ['--arg-tree-file', arg] - assert runner.invoke(name, pin, name, argv) == expected, name - assert victim.read_bytes() == b'not truncated\x00\xff' - for name in ['invalidHeader', 'retired']: - try: - runner.invoke(name, pin, name, ['--io']) - except Exception as error: - assert 'IO v2 task failed' in str(getattr(error, 'output', error)), error - else: - raise AssertionError('malformed protocol executed: ' + name) - observations = runner.records - for row in observations: - stats = row['rts_process'] - assert all(k in stats for k in ['copied_bytes', 'mutator_cpu_seconds', 'gc_cpu_seconds', - 'productivity_cpu_percent']), row - paths = [source, ROOT/'lib/io-v2.tri', ROOT/'src/IOV2/Protocol.hs', ROOT/'src/IOV2/Driver.hs', - ROOT/'test/source_order.py', ROOT/'test/rts_metrics.py', Path(__file__).resolve()] - report = {'checks': list(cases)+['invalidHeader', 'retired'], 'observations': observations, - 'sources': {str(p.relative_to(ROOT)): hashlib.sha256(p.read_bytes()).hexdigest() for p in paths}} - (directory/'report.json').write_text(json.dumps(report, indent=2)+'\n') - print('PASS: 9 independent capability/inspection/protocol checks') - -if __name__ == '__main__': - main() diff --git a/test/io_v2_publication.py b/test/io_v2_publication.py @@ -1,154 +0,0 @@ -#!/usr/bin/env python3 -"""Source-owned exact length/digest policy over scoped staged writers and hashes.""" -import argparse -import hashlib -import json -from pathlib import Path -import re -import subprocess -from source_order import Runner, field -from stable_bundle import byte_tree -from http_headers import number, result, tree_list - -ROOT = Path(__file__).resolve().parent.parent - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('tricu', type=Path) - parser.add_argument('--directory', type=Path, required=True) - args = parser.parse_args() - args.directory.mkdir(parents=True, exist_ok=False) - directory = args.directory.resolve() - runner = Runner(args.tricu, directory) - bundle = directory/'publication.arboricx' - runner.run('compile', ['compile', '--file', ROOT/'demos/io-v2/publish.tri', - '--store', runner.store, '--output', bundle]) - pin = runner.imported(bundle, 'import') - checks = [] - def invoke(label, name, values): - argv = ['--io'] - for index, value in enumerate(values): - arg = directory/f'{label}-{index}.tree' - arg.write_bytes(number(value) if isinstance(value, int) else tree_list([number(ord(c)) for c in str(value)])) - argv += ['--arg-tree-file', arg] - return runner.invoke(label, pin, name, argv) - callback = directory/'callback'; callback.mkdir() - assert invoke('rejected-destination', 'rejectedDestination', [callback]) == result(False, byte_tree(b'Destination rejected')) - failure = tree_list([byte_tree(b'invalid-response'), byte_tree(b'artifact-stream'), - byte_tree(b'destination'), byte_tree(b'Expected destination Result'), b'\0']) - assert invoke('malformed-destination', 'malformedDestination', [callback]) == tree_list([byte_tree(b'failed'), failure, b'\0']) - assert list(callback.iterdir()) == [] - checks += ['rejected-destination', 'malformed-destination'] - for size in (0, 1, 4095, 4096, 4097, 65536, 1048576): - body = (bytes(range(256))*((size+255)//256))[:size] - source = directory/f'input-{size}' - source.write_bytes(body) - target = directory/f'objects-{size}' - target.mkdir() - digest = hashlib.sha256(body).hexdigest() - for state in ('new', 'existing'): - label = f'{state}-{size}' - actual = invoke(label, 'publish', [source, target, size]) - assert actual == result(True, byte_tree(digest.encode())), label - assert (target/digest).read_bytes() == body, label - assert (target/digest).stat().st_mode & 0o077 == 0, label - assert sorted(p.name for p in target.iterdir()) == [digest], label - checks.append(label) - source, target = directory/'source-λ-树', directory/'objects-λ-树' - source.write_bytes(b'\0\xffbinary') - target.mkdir() - digest = hashlib.sha256(source.read_bytes()).hexdigest() - assert invoke('unicode-paths', 'publish', [source, target, 8]) == result(True, byte_tree(digest.encode())) - assert (target/digest).read_bytes() == source.read_bytes() - assert sorted(p.name for p in target.iterdir()) == [digest] - checks.append('unicode-paths') - source = directory/'negative-input' - source.write_bytes(b'abc') - digest = hashlib.sha256(b'abc').hexdigest() - for label, count, winner, message in ( - ('short', 4, None, 'Incomplete artifact stream'), - ('excess', 2, None, 'Artifact exceeds declared length'), - ('bad-winner', 3, b'xyz', 'Existing artifact digest mismatch'), - ('short-winner', 3, b'ab', 'Existing artifact length mismatch'), - ('long-winner', 3, b'abcd', 'Existing artifact length mismatch')): - target = directory/label - target.mkdir() - if winner is not None: - (target/digest).write_bytes(winner) - actual = invoke(label, 'publish', [source, target, count]) - assert actual == result(False, byte_tree(message.encode())), label - assert sorted(p.name for p in target.iterdir()) == ([] if winner is None else [digest]), label - if winner is not None: - assert (target/digest).read_bytes() == winner, label - checks.append(label) - for name, message in ( - ('earlySuccess', 'Incomplete artifact stream'), - ('ignoresWriteFailure', 'Artifact exceeds declared length'), - ('emptyChunk', 'Empty artifact chunk'), - ('overrideWrite', 'Incomplete artifact stream'), - ('failedAfterFull', 'Producer failed')): - target = directory/name - target.mkdir() - actual = invoke(name, name, [target]) - assert actual == result(False, byte_tree(message.encode())), name - assert list(target.iterdir()) == [], name - checks.append(name) - target = directory/'invalidChunk' - target.mkdir() - actual = invoke('invalidChunk', 'invalidChunk', [target]) - assert field(actual, 0) == b'\0' - assert field(actual, 1, 0, 0) == byte_tree(b'write-all') - assert field(actual, 1, 0, 1, 0) == number(0) - assert field(actual, 1, 0, 1, 1, 0, 0) == byte_tree(b'invalid-argument') - assert list(target.iterdir()) == [] - checks.append('invalidChunk') - for name, expected in [('cancelled', 'IO v2 task cancelled')]: - target = directory/name - target.mkdir() - try: - invoke(name, name, [target]) - except subprocess.CalledProcessError as error: - assert expected in error.output, error.output - else: - raise AssertionError(name+' resumed normally') - assert list(target.iterdir()) == [] - assert not (directory/'escape').exists() - checks.append(name) - target = directory/'badDigest' - target.mkdir() - expected = tree_list([byte_tree(b'failed'), tree_list([ - byte_tree(b'invalid-response'), byte_tree(b'artifact-stream'), byte_tree(b'digest'), - byte_tree(b'Invalid digest reply'), b'\0']), b'\0']) - assert invoke('badDigest', 'badDigest', [target]) == expected - assert list(target.iterdir()) == [] - assert not (directory/'escape').exists() - checks.append('badDigest') - for name in ('nestedProducer', 'forkedProducer'): - target = directory/name - target.mkdir() - expected = tree_list([byte_tree(b'failed'), tree_list([ - byte_tree(b'producer-boundary'), byte_tree(b'artifact-stream'), byte_tree(b'publish'), - byte_tree(b'Producer must be sequential'), - tree_list([byte_tree(b'outer'), byte_tree(b'inner')])]), b'\0']) - assert invoke(name, name, [target]) == expected, name - assert list(target.iterdir()) == [], name - checks.append(name) - observations = [dict(row) for row in runner.records if row['label'].endswith(('-65536', '-1048576'))] - for row in observations: - log = (directory/(row['label']+'.log')).read_text() - matched = re.search(r'([0-9,]+) bytes maximum residency', log) - assert matched, row - row['maximum_live_bytes'] = int(matched[1].replace(',', '')) - assert row['maximum_live_bytes'] <= 16*1024*1024, row - assert row['peak_kib'] <= 128*1024, row - paths = [Path(__file__), ROOT/'src/IOV2/Resources.hs', ROOT/'src/IOV2/Staging.hs', - ROOT/'src/IOV2/Digest.hs', ROOT/'src/IOV2/Driver.hs', ROOT/'src/IOV2/Protocol.hs', - ROOT/'test/source_order.py', ROOT/'test/rts_metrics.py', ROOT/'lib/io-v2.tri', - ROOT/'lib/io-v2/artifacts.tri', ROOT/'lib/handlers.tri', ROOT/'demos/io-v2/publish.tri'] - report = {'checks': checks, 'observations': observations, 'sources': { - str(path.resolve().relative_to(ROOT)): hashlib.sha256(path.read_bytes()).hexdigest() for path in paths}} - (directory/'report.json').write_text(json.dumps(report, indent=2)+'\n') - print(f'PASS: {len(checks)} publication and independent policy checks') - -if __name__ == '__main__': - main() diff --git a/test/io_v2_storage_cost.py b/test/io_v2_storage_cost.py @@ -1,112 +0,0 @@ -#!/usr/bin/env python3 -"""Isolated storage hash/write cost guards; no compiler generations or bootstrap changes. - -Optional --reference-storage compiles an explicitly selected historical source -with the same inputs and probe. Its results are measured without the candidate -allocation guard. Wall timings are observations, not portable acceptance limits. -""" -import argparse -import hashlib -import json -import statistics -from pathlib import Path - -from http_headers import number, result -from source_order import Runner -from stable_bundle import byte_tree - -ROOT = Path(__file__).resolve().parent.parent -SOURCE = '''!import io_v2 as IO -!import io_v2_storage as S -!export hash -!export write -hash limit bytes = IO.io (S.hashBytes limit bytes) -write path bytes = IO.io <| IO.scope (owner : do IO.onOk - writer <- IO.openWriter owner path - IO.writeAll writer bytes) -''' - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('tricu', type=Path) - parser.add_argument('--directory', type=Path, required=True) - parser.add_argument('--reference-storage', type=Path) - args = parser.parse_args() - root = args.directory.resolve() - root.mkdir(parents=True, exist_ok=False) - source = root/'source' - source.mkdir() - inputs = source/'inputs' - inputs.mkdir() - paths = dict(base=ROOT/'lib/base.tri', native_math=ROOT/'lib/math/native.tri', - io_v2=ROOT/'lib/io-v2.tri', - io_v2_storage=args.reference_storage or ROOT/'lib/io-v2/storage.tri') - workspace = ['tricu.workspace.v1'] - for name, path in paths.items(): - (inputs/(name+'.tri')).write_bytes(path.read_bytes()) - workspace.append(name+'\tinputs/'+name+'.tri') - (source/'tricu.workspace').write_text('\n'.join(workspace)+'\n') - (source/'probe.tri').write_text(SOURCE) - runner = Runner(args.tricu, root) - bundle = root/'probe.arboricx' - runner.run('compile', ['compile', '--file', source/'probe.tri', '--store', runner.store, - '--output', bundle]) - pin = runner.imported(bundle, 'import') - summaries = [] - for size in [1024, 4096, 65536]: - payload = bytes(range(256))*(size//256) - argument = root/f'bytes-{size}.tree' - argument.write_bytes(byte_tree(payload)) - expected = result(True, byte_tree(hashlib.sha256(payload).hexdigest().encode())) - for label, limit in [('exact', size), ('roomy', 67108864)]: - bound = root/f'limit-{size}-{label}.tree' - bound.write_bytes(number(limit)) - rows = [] - for repeat in range(3): - actual = runner.invoke(f'hash-{size}-{label}-{repeat}', pin, 'hash', - ['--io', '--arg-tree-file', bound, '--arg-tree-file', argument]) - assert actual == expected, (size, label, repeat) - rows.append(runner.records[-1]) - summary = dict(bytes=size, limit=limit, repeats=len(rows), - median_invocation_seconds=statistics.median(r['invoke_seconds'] for r in rows), - median_invocation_allocated_bytes=statistics.median(r['allocated_bytes'] for r in rows)) - # Includes result observation. Allow ample headroom over the measured - # fixed-step helper while rejecting the numeric-countdown/list-contract - # path (~406 MB for one 4-KiB chunk on the reference compiler). - if not args.reference_storage and size == 4096 and label == 'roomy': - assert summary['median_invocation_allocated_bytes'] < 160_000_000, summary - summaries.append(summary) - writes = [] - # Full-bound native acknowledgements exhaust the validated input chunk. - # The neighboring short chunk continues to exercise ordinary dropping. - for size in [4095,4096]: - payload=(bytes(range(256))*16)[:size] - argument=root/f'write-bytes-{size}.tree' - argument.write_bytes(byte_tree(payload)) - destination=root/f'write-{size}.bin' - path=root/f'write-path-{size}.tree' - path.write_bytes(byte_tree(str(destination).encode())) - rows=[] - for repeat in range(3): - actual=runner.invoke(f'write-{size}-{repeat}',pin,'write', - ['--io','--arg-tree-file',path,'--arg-tree-file',argument]) - assert actual == result(True,b'\0') - assert destination.read_bytes() == payload - rows.append(runner.records[-1]) - summary=dict(bytes=size,repeats=len(rows), - median_invocation_seconds=statistics.median(r['invoke_seconds'] for r in rows), - median_invocation_allocated_bytes=statistics.median(r['allocated_bytes'] for r in rows)) - if size == 4096: - assert summary['median_invocation_allocated_bytes'] < 32_000_000,summary - writes.append(summary) - report = dict(reference_storage=str(args.reference_storage) if args.reference_storage else None, - source_hashes={name: hashlib.sha256(path.read_bytes()).hexdigest() for name, path in paths.items()}, - bundle_sha256=hashlib.sha256(bundle.read_bytes()).hexdigest(), - measurements=summaries, write_measurements=writes, records=runner.records) - (root/'report.json').write_text(json.dumps(report, indent=2)+'\n') - print(json.dumps(dict(hashes=summaries,writes=writes), indent=2)) - - -if __name__ == '__main__': - main() diff --git a/test/io_v2_streams.py b/test/io_v2_streams.py @@ -1,109 +0,0 @@ -#!/usr/bin/env python3 -"""Real binary pipes/redirects, ownership errors, and bounded native residency.""" -import argparse -import concurrent.futures -import hashlib -import json -import os -from pathlib import Path -import subprocess -import time -from source_order import Runner -from process_rss import ChildRSS -from rts_metrics import parse_rts_stats -from http_headers import result, tree_list -from stable_bundle import byte_tree - -ROOT = Path(__file__).resolve().parent.parent - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('tricu', type=Path) - parser.add_argument('--directory', type=Path, required=True) - args = parser.parse_args() - directory = args.directory.resolve() - directory.mkdir(parents=True, exist_ok=False) - runner = Runner(args.tricu, directory) - source = ROOT/'demos/io-v2/streams.tri' - bundle = directory/'streams.arboricx' - runner.run('compile', ['compile', '--file', source, '--store', runner.store, '--output', bundle]) - pin = runner.imported(bundle, 'import') - checks, observations = [], [] - - def command(name, stats=True): - return [str(runner.exe), 'arboricx', 'run', '--module', pin, '--name', name, - '--store', str(runner.store), '--io', '--no-result', - '+RTS', '-N1', '-M128m', *(['-T', '-s'] if stats else []), '-RTS'] - - def invoke(label, name, body, redirected=False): - argv = command(name) - started = time.monotonic() - input_path, output_path = directory/(label+'.in'), directory/(label+'.out') - input_path.write_bytes(body) - with input_path.open('rb') as src, output_path.open('wb') as dst: - proc = subprocess.Popen(argv, stdin=src if redirected else subprocess.PIPE, - stdout=dst if redirected else subprocess.PIPE, stderr=subprocess.PIPE) - peak = None - try: - rss = ChildRSS(proc) - with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: - future = pool.submit(proc.communicate, None if redirected else body, timeout=120) - while not future.done(): - current = rss.sample() - if current is not None: - peak = max(peak or 0, current) - if peak > 128*1024: - proc.kill() - raise AssertionError('RSS bound exceeded') - time.sleep(.01) - stdout, stderr = future.result() - finally: - if proc.poll() is None: - proc.kill(); proc.communicate() - assert proc.returncode == 0, stderr - if redirected: - stdout = output_path.read_bytes() - else: - output_path.write_bytes(stdout) - assert stdout == body, label - text = stderr.decode() - (directory/(label+'.log')).write_text(text) - stats = parse_rts_stats(text) - assert stats['maximum_live_bytes'] <= 16*1024*1024, stats - assert peak is not None, 'RSS unavailable' - observations.append(dict(label=label, command=argv, seconds=time.monotonic()-started, - peak_kib=peak, rts_process=stats, sha256=hashlib.sha256(stdout).hexdigest())) - checks.append(label) - - for name in ['main', 'handled', 'child']: - for size in [0, 1, 4095, 4096, 4097, 65536, 1048576]: - body = (bytes(range(256))*((size+255)//256))[:size] - invoke(f'{name}-{size}', name, body) - invoke('redirected', 'main', bytes(range(256))*257, True) - for name, operation in [('closeDenied', 'resource/close'), ('transferDenied', 'core/spawn')]: - expected = result(False, tree_list([byte_tree(x.encode()) for x in - ['borrowed-resource', operation, '', 'none']])) - assert runner.invoke(name, pin, name, ['--io']) == expected, name - checks.append(name) - run = subprocess.run(command('diagnostic', False), input=b'', capture_output=True, timeout=20) - assert (run.returncode, run.stdout, run.stderr) == (0, b'', bytes([0,255,13,10])), run - checks.append('binary-stderr') - reader, writer = os.pipe() - os.close(reader) - try: - proc = subprocess.Popen(command('broken', False), stdin=subprocess.DEVNULL, - stdout=writer, stderr=subprocess.PIPE) - finally: - os.close(writer) - _, diagnostic = proc.communicate(timeout=20) - assert (proc.returncode, diagnostic) == (0, b'failed'), (proc.returncode, diagnostic) - checks.append('broken-pipe-ordinary-result') - paths = [source, Path(__file__).resolve(), ROOT/'lib/io-v2.tri', - ROOT/'src/IOV2/Resources.hs', ROOT/'src/IOV2/Driver.hs', ROOT/'src/IOV2/Protocol.hs', - ROOT/'test/rts_metrics.py', ROOT/'bootstrap/build-stable.arboricx'] - (directory/'report.json').write_text(json.dumps(dict(checks=checks, observations=observations, - sources={str(p.relative_to(ROOT)): hashlib.sha256(p.read_bytes()).hexdigest() for p in paths}), indent=2)+'\n') - print(f'PASS: {len(checks)} binary stream, lifecycle and pipe checks') - -if __name__ == '__main__': - main() diff --git a/test/io_v2_tasks.py b/test/io_v2_tasks.py @@ -1,129 +0,0 @@ -#!/usr/bin/env python3 -"""Independent source-level task/handler oracles, using a Nix-built native host.""" -import argparse -import hashlib -import json -import re -from pathlib import Path -from source_order import Runner -from stable_bundle import byte_tree -from http_headers import tree_list, number, result - -ROOT = Path(__file__).resolve().parent.parent - -def text(s): - return byte_tree(s.encode()) -def completed(value): - return tree_list([text('completed'), value]) -def failed(message): - return tree_list([text('failed'), text(message), b'\0']) - -def core_cases(): - return { - 'joined': completed(number(42)), - 'supervised': failed('fault'), - 'cancelled': tree_list([text('cancelled'), text('shutdown'), b'\0']), - 'ordinary': completed(result(False, text('domain'))), - 'consumed': tree_list([result(True, completed(number(42))), - result(False, tree_list([text('stale-task'), text('core/join'), b'\0', text('none')]))]), - 'inherited': tree_list([completed(number(7)), number(7)]), - 'changed': tree_list([completed(number(9)), number(7)]), - 'callback': tree_list([completed(number(7)), number(9)]), - 'lazyLabels': completed(number(7)), - 'labelledFailure': tree_list([text('failed'), tree_list([text('problem'), text('service'), - text('op'), text('detail'), tree_list([text('outer'), text('inner')])]), b'\0']), - 'labelledStateBoundary': tree_list([text('failed'), tree_list([text('state-boundary'), - text('log'), text('write'), text('Install child-local collection explicitly'), - tree_list([text('outer'), text('inner')])]), b'\0']), - 'stateBoundary': tree_list([text('failed'), tree_list([text('state-boundary'), - text('log'), text('write'), text('Install child-local collection explicitly'), b'\0']), b'\0']), - } - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('tricu', type=Path) - parser.add_argument('--directory', type=Path, required=True) - args = parser.parse_args() - args.directory.mkdir(parents=True, exist_ok=False) - runner = Runner(args.tricu, args.directory.resolve()) - source = ROOT/'test/io-v2/tasks.tri' - bundle = args.directory.resolve()/'tasks.arboricx' - runner.run('compile', ['compile', '--file', source, '--store', runner.store, '--output', bundle]) - pin = runner.imported(bundle, 'import') - cases = core_cases() - for name, expected in cases.items(): - argv = ['--io'] - if name == 'lazyLabels': - arg = args.directory.resolve()/'unit.tree' - arg.write_bytes(b'\0') - argv += ['--arg-tree-file', arg] - actual = runner.invoke(name, pin, name, argv) - assert actual == expected, (name, actual.hex(), expected.hex()) - partial_checks = [] - for name in ('partialWrites', 'failedPartialWrite'): - for body in (b'', b'\xff', bytes([0, 255, 1, 128, 2, 254, 3])): - argument = args.directory.resolve()/f'{name}-{len(body)}.tree' - argument.write_bytes(byte_tree(body)) - label = f'{name}-{len(body)}' - actual = runner.invoke(label, pin, name, ['--io', '--arg-tree-file', argument]) - fails = name == 'failedPartialWrite' and len(body) > 2 - expected = tree_list([result(False, tree_list([text('write-all'), number(2), text('write fault')])) if fails else result(True, b'\0'), - byte_tree(body[:2] if fails else body)]) - assert actual == expected, label - partial_checks.append(label) - body=bytes(range(256))*16 - for i,counts in enumerate([[4096],[4095,1],[1,4095],[2048,2048], - [1,2048,2047],[],[4095],[2048],[1]]): - supplied=args.directory.resolve()/f'acks-{i}.tree' - supplied.write_bytes(tree_list([number(n) for n in counts])) - argument=args.directory.resolve()/f'ack-body-{i}.tree' - argument.write_bytes(byte_tree(body)) - offset=0; requests=[] - for count in counts: - assert 0 < count <= len(body)-offset - requests.append(byte_tree(body[offset:])) - offset+=count - if offset < len(body): - requests.append(byte_tree(body[offset:])) - answer=result(False,tree_list([text('write-all'),number(offset),text('write fault')])) - else: - answer=result(True,b'\0') - expected=tree_list([answer,tree_list(requests)]) - label=f'acknowledged-writes-{i}' - actual=runner.invoke(label,pin,'acknowledgedWrites', - ['--io','--arg-tree-file',supplied,'--arg-tree-file',argument]) - assert actual==expected,label - partial_checks.append(label) - retention = [] - for name in ('churnJoined', 'churnDiscarded'): - for count in (16, 256, 1024): - payload = args.directory.resolve()/f'{name}-{count}.bin' - payload.write_bytes(bytes(range(256))*(count*16)) - count_arg = args.directory.resolve()/f'count-{count}.tree' - count_arg.write_bytes(number(count)) - path_arg = args.directory.resolve()/f'{name}-{count}.path' - path_arg.write_bytes(byte_tree(str(payload).encode())) - label = f'{name}-{count}' - actual = runner.invoke(label, pin, name, ['--io', '--arg-tree-file', count_arg, - '--arg-tree-file', path_arg]) - assert actual == b'\0', label - row = dict(runner.records[-1]) - log = (args.directory/(label+'.log')).read_text() - match = re.search(r'([0-9,]+) bytes maximum residency', log) - assert match, label - row['maximum_live_bytes'] = int(match[1].replace(',', '')) - assert row['maximum_live_bytes'] <= 16*1024*1024, row - assert row['peak_kib'] <= 128*1024, row - retention.append(row) - paths = [source, ROOT/'lib/handlers.tri', ROOT/'lib/io-v2.tri', - ROOT/'src/IOV2/Tasks.hs', ROOT/'src/IOV2/Driver.hs', ROOT/'src/IOV2/Protocol.hs', - ROOT/'test/source_order.py', ROOT/'test/rts_metrics.py', ROOT/'src/IOV2/Resources.hs', - ROOT/'lib/http.tri', ROOT/'lib/math/native.tri', ROOT/'lib/base.tri', - ROOT/'bootstrap/build-stable.arboricx'] - (args.directory/'report.json').write_text(json.dumps({'checks': list(cases)+partial_checks, - 'sources': {str(p.relative_to(ROOT)): hashlib.sha256(p.read_bytes()).hexdigest() for p in paths}, - 'retention': retention, 'observations': runner.records}, indent=2)+'\n') - print(f'PASS: {len(cases)+len(partial_checks)} task/handler source oracles and {len(retention)} fresh-chunk retention workloads') - -if __name__ == '__main__': - main() diff --git a/test/io_v2_tcp.py b/test/io_v2_tcp.py @@ -1,296 +0,0 @@ -#!/usr/bin/env python3 -"""Real socket source workflows: full binary echo, directional pipelines and handoff.""" -import argparse -import concurrent.futures -import hashlib -import json -from pathlib import Path -import socket -import subprocess -import struct -import threading -import time -from source_order import Runner -from process_rss import ChildRSS -from rts_metrics import parse_rts_stats -from http_headers import number, result, tree_list -from io_v2_positioned import text -from io_v2_directory import parse_tree, items, string -ROOT = Path(__file__).resolve().parent.parent - -def endpoint(family, port): - if family == 4: - return tree_list([text('ipv4'),tree_list([number(x) for x in [127,0,0,1]]),number(port)]) - return tree_list([text('ipv6'),tree_list([number(x) for x in [0,0,0,0,0,0,0,1]]),number(0),number(port)]) - -def exchange(family, port, body, prefix): - with socket.socket(socket.AF_INET if family == 4 else socket.AF_INET6) as peer: - peer.settimeout(90) - peer.connect(('127.0.0.1' if family == 4 else '::1',port)) - def send(): - for start in range(0,len(body),997): - peer.sendall(body[start:start+997]) - peer.shutdown(socket.SHUT_WR) - with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: - writer = pool.submit(send) - chunks = [] - while True: - chunk = peer.recv(4096) - if not chunk: - break - chunks.append(chunk) - writer.result(timeout=90) - actual = b''.join(chunks) - assert actual == prefix+body, (len(actual),len(prefix+body)) - return hashlib.sha256(actual).hexdigest() - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('tricu', type=Path) - parser.add_argument('--directory', type=Path, required=True) - args = parser.parse_args() - directory = args.directory.resolve() - directory.mkdir(parents=True, exist_ok=False) - runner = Runner(args.tricu,directory) - source = ROOT/'demos/io-v2/tcp.tri' - bundle = directory/'tcp.arboricx' - runner.run('compile',['compile','--file',source,'--store',runner.store,'--output',bundle]) - pin = runner.imported(bundle,'import') - checks, observations = [], [] - def command(label, name, values): - argv = [str(runner.exe),'arboricx','run','--module',pin,'--name',name,'--store',str(runner.store),'--io','--no-result'] - for index,value in enumerate(values): - path = directory/f'{label}-{index}.tree' - path.write_bytes(value) - argv += ['--arg-tree-file',str(path)] - return argv+['+RTS','-N1','-M128m','-T','-s','-RTS'] - def launch(label,name,values): - argv = command(label,name,values) - proc = subprocess.Popen(argv,stdin=subprocess.DEVNULL,stdout=subprocess.PIPE,stderr=subprocess.PIPE) - data = dict(label=label, command=argv, started=time.monotonic(), peak_kib=0) - stopped = threading.Event() - def sample(): - try: - rss = ChildRSS(proc) - while not stopped.is_set(): - current = rss.sample() - if current is not None: - data['peak_kib'] = max(data['peak_kib'],current) - if current > 128*1024: - raise AssertionError('RSS bound exceeded') - stopped.wait(.01) - except Exception as error: - data['monitor_error'] = repr(error) - try: - proc.kill() - except ProcessLookupError: - pass - thread = threading.Thread(target=sample,daemon=True) - thread.start() - return proc,data,stopped,thread - def finish(run): - proc,data,stopped,thread = run - try: - stdout,stderr = proc.communicate(timeout=120) - stopped.set(); thread.join() - assert 'monitor_error' not in data, data - assert proc.returncode == 0, stderr - assert not stdout, stdout - stats = parse_rts_stats(stderr.decode()) - assert stats['maximum_live_bytes'] <= 16*1024*1024, stats - assert 0 < data['peak_kib'] <= 128*1024, data - (directory/(data['label']+'.log')).write_bytes(stderr) - data['seconds'] = time.monotonic()-data.pop('started') - data['rts_process'] = stats - observations.append(data) - finally: - stopped.set(); thread.join() - if proc.poll() is None: - proc.kill(); proc.communicate() - def abort(run): - proc,_,stopped,thread = run - stopped.set(); thread.join() - if proc.poll() is None: - proc.kill(); proc.communicate() - def server(label, name, family, bodies, parallel=False): - run = launch(label,name,[endpoint(family,0),number(len(bodies))]) - proc = run[0] - try: - with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: - # A timed readiness channel, never a probe connection consuming - # one of the finite server's accepted-connection slots. - ready = pool.submit(proc.stdout.readline) - try: - line = ready.result(timeout=20) - if not line: - _, diagnostic = proc.communicate(timeout=5) - (directory/(label+'.startup-error.log')).write_bytes(diagnostic) - raise AssertionError(diagnostic) - port = int(line) - except BaseException: - abort(run) - raise - prefix = bytes([66,0,255,10]) if name == 'handledServer' else b'' - if parallel: - with concurrent.futures.ThreadPoolExecutor(max_workers=len(bodies)) as pool: - sums = list(pool.map(lambda body: exchange(family,port,body,prefix),bodies)) - else: - sums = [exchange(family,port,body,prefix) for body in bodies] - finish(run) - checks.append(dict(label=label,sha256=sums)) - finally: - abort(run) - body = lambda size: (bytes(range(256))*((size+255)//256))[:size] - for name in ['server','handledServer']: - server(name+'-boundaries',name,4,[body(n) for n in [0,1,4095,4096,4097,65536,1048576]]) - server(name+'-ipv6',name,6,[body(4097)]) - server(name+'-concurrent',name,4,[bytes([i])+body(65535) for i in range(4)],True) - server(name+'-churn',name,4,[bytes([i])*64 for i in range(128)]) - for name in ['handledServer','supervisedHandledServer']: - label = name+'-reset' - run = launch(label,name,[endpoint(4,0),number(2)]) - proc = run[0] - try: - with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: - ready = pool.submit(proc.stdout.readline) - try: - port = int(ready.result(timeout=20)) - except BaseException: - abort(run) - raise - with socket.create_connection(('127.0.0.1',port),timeout=20) as peer: - banner = b'' - while len(banner) < 4: - chunk = peer.recv(4-len(banner)) - assert chunk, banner - banner += chunk - assert banner == bytes([66,0,255,10]) - # Banner proves accept/handoff completed before aborting the peer. - peer.setsockopt(socket.SOL_SOCKET,socket.SO_LINGER,struct.pack('ii',1,0)) - if name == 'supervisedHandledServer': - exchange(4,port,body(4097),bytes([66,0,255,10])) - finish(run) - else: - stdout,stderr = proc.communicate(timeout=20) - assert proc.returncode != 0 and b'IO v2 task failed' in stderr, stderr - assert stdout == b'' - (directory/(label+'.log')).write_bytes(stderr) - checks.append(dict(label=label)) - finally: - abort(run) - # Native source client reads/writes its socket concurrently. A real peer - # echoes with small buffers, forcing meaningful bidirectional backpressure. - for family in [4,6]: - for size in ([0,4097,1048576] if family == 4 else [4097]): - label = f'client-{family}-{size}' - incoming, outgoing = directory/(label+'.in'), directory/(label+'.out') - incoming.write_bytes(body(size)) - with socket.socket(socket.AF_INET if family == 4 else socket.AF_INET6) as listener: - listener.bind(('127.0.0.1' if family == 4 else '::1',0)); listener.listen(1); listener.settimeout(20) - port = listener.getsockname()[1] - def echo(): - peer,_ = listener.accept() - with peer: - peer.settimeout(90) - while True: - chunk = peer.recv(997) - if not chunk: - break - peer.sendall(chunk) - peer.shutdown(socket.SHUT_WR) - with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: - worker = pool.submit(echo) - run = launch(label,'client',[endpoint(family,port),text(str(incoming)),text(str(outgoing))]) - try: - finish(run) - worker.result(timeout=90) - finally: - abort(run) - assert outgoing.read_bytes() == incoming.read_bytes(), label - checks.append(dict(label=label,sha256=hashlib.sha256(outgoing.read_bytes()).hexdigest())) - # Explicit host resolution plus source fallback, using only local services. - for host in ['127.0.0.1','localhost']: - label = 'named-client-'+host - incoming, outgoing = directory/(label+'.in'), directory/(label+'.out') - incoming.write_bytes(body(65536)) - with socket.socket() as listener: - listener.bind(('127.0.0.1',0)); listener.listen(1); listener.settimeout(20) - port = listener.getsockname()[1] - def echo_named(): - peer,_ = listener.accept() - with peer: - peer.settimeout(30) - while True: - chunk = peer.recv(997) - if not chunk: - break - peer.sendall(chunk) - peer.shutdown(socket.SHUT_WR) - with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: - peer = pool.submit(echo_named) - run = launch(label,'namedClient',[text(host),text('any'),number(port),text(str(incoming)),text(str(outgoing))]) - try: - finish(run) - peer.result(timeout=30) - finally: - abort(run) - assert outgoing.read_bytes() == incoming.read_bytes() - checks.append(dict(label=label,sha256=hashlib.sha256(outgoing.read_bytes()).hexdigest())) - def invoke_values(label,name,values): - argv = ['--io'] - for index,value in enumerate(values): - path = directory/f'{label}-{index}.tree'; path.write_bytes(value) - argv += ['--arg-tree-file',path] - return runner.invoke(label,pin,name,argv) - actual = parse_tree(invoke_values('resolve-numeric','resolve',[text('127.0.0.1'),text('ipv4'),number(1234),number(64)])) - wrapped = items(actual) - assert wrapped[0] == (1,(0,)) - addresses,truncated = items(wrapped[1]) - assert items(addresses) and all(x == parse_tree(endpoint(4,1234)) for x in items(addresses)) - assert truncated == (0,) - checks.append(dict(label='resolve-numeric')) - for label,host,limit,category,detail in [ - ('resolve-host','bad/name',1,'invalid-argument','expected bounded ASCII host name'), - ('resolve-limit','localhost',0,'invalid-bound',''), - ]: - expected_error = result(False,tree_list([text(x) for x in [category,'resolver/resolve',detail,'none']])) - assert invoke_values(label,'resolve',[text(host),text('any'),number(80),number(limit)]) == expected_error - checks.append(dict(label=label)) - with socket.socket() as listener: - listener.bind(('127.0.0.1',0)); listener.listen(1); listener.settimeout(20) - selected = listener.getsockname()[1] - # Invalid first candidate, valid second, malformed third. Source policy - # retries ordinary failure but must stop before inspecting the third. - reply = items(parse_tree(invoke_values('candidate-fallback','candidates', - [tree_list([endpoint(4,0),endpoint(4,selected),b'\1\0'])]))) - assert reply[0] == (1,(0,)) - token = items(reply[1]) - assert len(token) == 3 and string(token[0]) == 'io.v2.resource' - peer,_ = listener.accept() - with peer: - peer.settimeout(20) - assert peer.recv(1) == b'' # returned token did not extend its scope - checks.append(dict(label='candidate-fallback')) - empty_error = result(False,tree_list([text(x) for x in ['not-found','tcp/connect-first','No endpoint candidates','none']])) - assert invoke_values('empty-candidates','candidates',[b'\0']) == empty_error - checks.append(dict(label='empty-candidates')) - expected = result(False,tree_list([text(x) for x in ['invalid-argument','tcp/connect','','none']])) - assert runner.invoke('invalid-address',pin,'invalidAddress',['--io']) == expected - checks.append(dict(label='invalid-address')) - with socket.socket() as unused: - unused.bind(('127.0.0.1',0)) - port = unused.getsockname()[1] - path = directory/'refused.tree'; path.write_bytes(endpoint(4,port)) - reply = items(parse_tree(runner.invoke('refused',pin,'connectResult',['--io','--arg-tree-file',path]))) - assert reply[0] == (0,) - problem = items(reply[1]) - assert [string(problem[i]) for i in [0,1,3]] == ['connection-refused','tcp/connect','unknown'] - checks.append(dict(label='refused')) - paths = [source,Path(__file__).resolve(),ROOT/'lib/io-v2.tri',ROOT/'lib/handlers.tri', - ROOT/'src/IOV2/Network.hs',ROOT/'src/IOV2/Resolver.hs',ROOT/'src/IOV2/Resources.hs',ROOT/'src/IOV2/Driver.hs', - ROOT/'src/IOV2/Protocol.hs',ROOT/'test/rts_metrics.py',ROOT/'test/process_rss.py',ROOT/'bootstrap/build-stable.arboricx'] - (directory/'report.json').write_text(json.dumps(dict(checks=checks,observations=observations, - sources={str(p.relative_to(ROOT)):hashlib.sha256(p.read_bytes()).hexdigest() for p in paths}),indent=2)+'\n') - print(f'PASS: {len(checks)} actual TCP workflow groups') -if __name__ == '__main__': - main() diff --git a/test/io_v2_text.py b/test/io_v2_text.py @@ -1,126 +0,0 @@ -#!/usr/bin/env python3 -"""Independent UTF-8 boundaries, exact source provenance and text pipelines.""" -import argparse -import hashlib -import json -from pathlib import Path -from source_order import Runner -from http_headers import number, result, pair, tree_list -from stable_bundle import byte_tree -from process_rss import run_guarded - -ROOT = Path(__file__).resolve().parent.parent - -def text(s): - return tree_list([number(ord(c)) for c in s]) - -def problem(category, op, detail, progress='none'): - return tree_list([text(category),text(op),text(detail),text(progress)]) - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('tricu', type=Path) - parser.add_argument('--directory', type=Path, required=True) - args = parser.parse_args() - directory = args.directory.resolve() - directory.mkdir(parents=True, exist_ok=False) - runner = Runner(args.tricu,directory) - source = ROOT/'demos/io-v2/text.tri' - bundle = directory/'text.arboricx' - runner.run('compile',['compile','--file',source,'--store',runner.store,'--output',bundle]) - pin = runner.imported(bundle,'import') - checks = [] - def invoke(label,name,values,expected): - argv = ['--io'] - for i,value in enumerate(values): - path = directory/f'{label}-{i}.tree' - path.write_bytes(value) - argv += ['--arg-tree-file',path] - assert runner.invoke(label,pin,name,argv) == expected,label - checks.append(label) - for i,value in enumerate(['','ascii\r\n\0','λ😀e\u0301é','\ufeff','\uffff\U0010ffff','😀'*1025,'a'*1024+'😀'+'b'*1024]): - payload = value.encode('utf-8') - invoke(f'encode-{i}','encode',[number(len(payload)),text(value)],result(True,byte_tree(payload))) - output = directory/f'write-{i}.bin' - invoke(f'write-{i}','write',[text(str(output)),text(value)],result(True,b'\0')) - assert output.read_bytes() == payload - for i,payload in enumerate([b'',b'a\r\n\0', 'λ😀e\u0301é'.encode(), - b'a'*4092+'😀'.encode(), b'a'*4091+'😀x'.encode(), - ('a\ufeffe\u0301\r\n'*100).encode()]): - path = directory/f'source-{i}.bin' - path.write_bytes(payload) - invoke(f'source-{i}','source',[text(str(path)),number(len(payload))], - result(True,pair(byte_tree(payload),text(payload.decode('utf-8'))))) - assert path.read_bytes() == payload - # Confirm accounting at the 4093-byte read boundary and its one-byte EOF - # probe, including multiple chunks. These bytes are independently decoded. - for size in [4092,4093,4094,8186,8187]: - payload=b'a'*size - path=directory/f'count-boundary-{size}.bin' - path.write_bytes(payload) - invoke(f'count-exact-{size}','source',[text(str(path)),number(size)], - result(True,pair(byte_tree(payload),text(payload.decode())))) - invoke(f'count-short-{size}','source',[text(str(path)),number(size-1)], - result(False,tree_list([text('read-source'),number(size), - problem('invalid-bound','text/read-source','source exceeds byte limit')]))) - payload = 'A😀λ'.encode() - # Every split through the multibyte suffix, with independent expected carry. - for i in range(len(payload)+1): - prefix = payload[:i] - import codecs - decoder = codecs.getincrementaldecoder('utf-8')('strict') - decoded = decoder.decode(prefix, final=False) - carry = decoder.getstate()[0] - invoke(f'prefix-{i}','decode',[number(0),byte_tree(prefix)], - result(True,tree_list([text(decoded),byte_tree(carry)]))) - for i,(payload,cause) in enumerate([ - (b'\xef\xbb\xbfabc',problem('invalid-data','text/read-source','initial source BOM is not permitted')), - (b'good\xff',problem('invalid-data','utf8/decode','invalid UTF-8 leading byte at byte 4')), - (b'\xe2\x82',problem('invalid-data','utf8/decode','incomplete UTF-8 sequence at byte 0')), - (b'\xed\xa0\x80',problem('invalid-data','utf8/decode','invalid UTF-8 sequence at byte 0'))]): - path = directory/f'invalid-{i}.bin'; path.write_bytes(payload) - invoke(f'invalid-source-{i}','source',[text(str(path)),number(len(payload))], - result(False,tree_list([text('read-source'),number(len(payload)),cause]))) - path = directory/'excess.bin'; path.write_bytes(b'x') - invoke('source-limit','source',[text(str(path)),number(0)],result(False,tree_list([ - text('read-source'),number(1),problem('invalid-bound','text/read-source','source exceeds byte limit')]))) - invoke('encode-limit','encode',[number(1),text('λ')],result(False, - problem('invalid-bound','text/encode','encoded text exceeds byte limit'))) - invoke('surrogate','encode',[number(4096),tree_list([number(0xd800)])],result(False, - problem('invalid-argument','utf8/encode',''))) - partial = directory/'partial.bin' - invalid_text = tree_list([number(97)]*1024+[number(0xd800)]) - invoke('write-invalid-tail','write',[text(str(partial)),invalid_text],result(False,tree_list([ - text('write-text'),number(1024),problem('invalid-argument','utf8/encode','')]))) - assert partial.read_bytes() == b'a'*1024 - for size in [4096,65536]: - payload = (b'ab\r\n\0'+ 'λ😀'.encode())*(size//11) - input_path = directory/f'pipeline-{size}.bin'; output = directory/f'pipeline-{size}-out.bin' - input_path.write_bytes(payload) - invoke(f'pipeline-{size}','roundTrip',[text(str(input_path)),text(str(output)),number(len(payload))],result(True,b'\0')) - assert output.read_bytes() == payload - # Independent raw-protocol boundaries; excess heads AND tails diverge. - # Reject without demanding excess cells or writing a prefix. - boundary = directory/'boundaries.arboricx' - runner.run('compile-boundaries',['compile','--file',ROOT/'test/io-v2/utf8-boundaries.tri','--store',runner.store,'--output',boundary]) - original_bundle, original_pin = bundle, pin - bundle, pin = boundary, runner.imported(boundary,'import-boundaries') - for name,operation in [('excessEncode','utf8/encode'),('excessDecode','utf8/decode'),('excessDigest','digest/update')]: - invoke(name,name,[number(0)],result(False,problem('invalid-argument',operation,''))) - path = directory/'excess-write' - invoke('excessWrite','excessWrite',[text(str(path)),number(0)],result(False,problem('invalid-argument','stream/write',''))) - assert path.read_bytes() == b'' - bundle, pin = original_bundle, original_pin - observations = [r for r in runner.records if r['label'].startswith('pipeline-')] - for row in observations: - assert row['peak_kib'] <= 256*1024,row - assert row['rts_process']['maximum_live_bytes'] <= 128*1024*1024,row - files = ['lib/io-v2.tri','lib/io-v2/text.tri','demos/io-v2/text.tri','test/io_v2_text.py', - 'test/source_order.py','test/rts_metrics.py','src/IOV2/UTF8.hs','src/IOV2/Driver.hs', - 'src/IOV2/Protocol.hs','bootstrap/build-stable.arboricx'] - (directory/'report.json').write_text(json.dumps(dict(checks=checks,observations=observations, - - source_hashes={p:hashlib.sha256((ROOT/p).read_bytes()).hexdigest() for p in files}),indent=2)+'\n') - print(f'{len(checks)} independent text/UTF-8 checks passed'+('')) - -if __name__ == '__main__': main() diff --git a/test/library_format.py b/test/library_format.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +"""Check every current library with an explicitly prepared portable formatter.""" +import argparse +import json +from pathlib import Path +import subprocess + +ROOT = Path(__file__).resolve().parent.parent + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('tricu', type=Path) + parser.add_argument('--formatter-directory', type=Path, required=True) + args = parser.parse_args() + preparation = args.formatter_directory.resolve() + metadata = json.loads((preparation/'report.json').read_text()) + if not metadata['complete']: + raise ValueError('formatter preparation incomplete') + paths = sorted((ROOT/'lib').rglob('*.tri')) + for path in paths: + subprocess.run([str(args.tricu.resolve()), 'format', str(path), '--check', + '--formatter', metadata['formatter_manifest'], '--tool-store', str(preparation/'store'), + '+RTS', '-N1', '-M8192m', '-RTS'], check=True, timeout=90) + print(f'{len(paths)} current libraries pass equation-alignment checks') + + +if __name__ == '__main__': + main() diff --git a/test/library_inspector.py b/test/library_inspector.py @@ -181,7 +181,7 @@ def main(): arboricx_common='lib/arboricx/common.tri', arboricx_manifest='lib/arboricx/manifest.tri', arboricx_nodes='lib/arboricx/nodes.tri') sources.update({alias: path for _, _, alias, path in releases}) - sources.update(io_v2='lib/io-v2.tri', io_v2_storage='lib/io-v2/storage.tri', io_v2_text='lib/io-v2/text.tri') + sources.update(io='lib/io.tri', io_storage='lib/io/storage.tri', io_text='lib/io/text.tri') for alias, path in sources.items(): data = (ROOT / path).read_bytes(); (workspace / (alias + '.tri')).write_bytes(data) report['sources'][path] = hashlib.sha256(data).hexdigest() diff --git a/test/native_indexed_store.py b/test/native_indexed_store.py @@ -41,7 +41,7 @@ def main(): parser.add_argument('--compiler', type=Path, required=True) parser.add_argument('--directory', type=Path, required=True) parser.parse_args() - parser.error("Retired v1 source-splicing probe; use io_v2_indexed.py, io_v2_indexed_write.py and io_v2_compiler_store.py with the current compiler.") + parser.error("Retired v1 source-splicing probe; use io_indexed.py, io_indexed_write.py and io_compiler_store.py with the current compiler.") if __name__ == '__main__': main() diff --git a/test/native_library_migration.py b/test/native_library_migration.py @@ -48,20 +48,20 @@ for path in sorted((REPO / 'demos').glob('*.tri')) + sorted((REPO / 'demos/inter LIBRARIES['demos.' + '.'.join(path.relative_to(REPO / 'demos').with_suffix('').parts)] = str(path.relative_to(REPO)) -# Explicit IO-v2 support-cohort additions; aliases match authored workspace imports. -IO_V2_SUPPORT = { +# Explicit IO support-cohort additions; aliases match authored workspace imports. +IO_SUPPORT = { 'arboricx_handlers': 'lib/arboricx/handlers.tri', - 'arboricx_streaming_v2': 'lib/arboricx/streaming-v2.tri', + 'arboricx_streaming': 'lib/arboricx/streaming.tri', 'handler_artifacts': 'lib/handlers/artifacts.tri', 'handler_log': 'lib/handlers/log.tri', 'handlers': 'lib/handlers.tri', - 'io_v2': 'lib/io-v2.tri', - 'io_v2_artifacts': 'lib/io-v2/artifacts.tri', - 'io_v2_http': 'lib/io-v2/http-transport.tri', - 'io_v2_storage': 'lib/io-v2/storage.tri', - 'io_v2_text': 'lib/io-v2/text.tri', + 'io': 'lib/io.tri', + 'io_artifacts': 'lib/io/artifacts.tri', + 'io_http': 'lib/io/http-transport.tri', + 'io_storage': 'lib/io/storage.tri', + 'io_text': 'lib/io/text.tri', } -LIBRARIES.update(IO_V2_SUPPORT) +LIBRARIES.update(IO_SUPPORT) def alias(name): diff --git a/test/native_source_utf8.py b/test/native_source_utf8.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Current IO v2 source-boundary check with independent byte/token oracles. +"""Current IO source-boundary check with independent byte/token oracles. Compile complete workspace modules through the selected portable compiler, not source-spliced consumers bound to retained v1 support. No fixture publication. diff --git a/test/package_registry.py b/test/package_registry.py @@ -26,7 +26,7 @@ def main(): p = argparse.ArgumentParser(description=__doc__) p.add_argument('tricu', type=Path) group = p.add_mutually_exclusive_group() - group.add_argument('--native-server', type=Path, help='IO v2 server bundle exporting main root port') + group.add_argument('--native-server', type=Path, help='IO server bundle exporting main root port') group.add_argument('--prepare-server', type=Path, help='Compile current native server into a new retained directory') args = p.parse_args() tricu = args.tricu.resolve() @@ -45,9 +45,9 @@ def main(): (workspace / (alias + '.tri')).write_bytes(data) hashes[path] = hashlib.sha256(data).hexdigest() (workspace / 'main.tri').write_text( - '!import io_v2 as IO\n!import arboricx_streaming_v2 as Server\n' + '!import io as IO\n!import arboricx_streaming as Server\n' '!import arboricx_handlers as Registry\n!export main\n' - 'main root port = IO.io (Server.serve root 1048576 Registry.stdoutLogV2 ' + 'main root port = IO.io (Server.serve root 1048576 Registry.stdoutLog ' '(IO.ipv4 [127 0 0 1] port) 16 (_ : IO.pure t))\n') args.native_server = prepared / 'server.arboricx' command = [str(tricu), 'arboricx', 'compile', '--file', str(workspace / 'main.tri'), diff --git a/test/prepare_compiler_kit.py b/test/prepare_compiler_kit.py @@ -28,7 +28,9 @@ def digest(data): def project(mapping, source_root, io_version, backend): text, entries, _ = source_map(mapping, source_root, 'source_map') - io_alias = 'io_v2' if io_version == 2 else 'io' + if io_version != 2 or isinstance(io_version, bool): + raise ValueError('current source kit requires IO protocol 2') + io_alias = 'io' if io_alias not in entries: raise ValueError('selected map lacks adapter IO module: ' + io_alias) sources = {alias: (source_root / relative).read_bytes() diff --git a/test/repair_support_fixture.py b/test/repair_support_fixture.py @@ -114,7 +114,7 @@ def main(): parser.add_argument('--replace', action='append', default=[], metavar='ALIAS') parser.add_argument('--relocate-patterns', action='store_true', help='explicitly move rebuilt patterns from compiler to support membership') - parser.add_argument('--add-io-v2-support', action='store_true', help='explicit complete ten-module IO-v2 support expansion; genuine rebuilds required') + parser.add_argument('--add-io-support', action='store_true', help='explicit complete ten-module IO support expansion; genuine rebuilds required') parser.add_argument('--compiler-io-cohort', action='store_true', help='explicit v2 compiler ownership of core/text/storage; rebuild general io in support') parser.add_argument('--compiler-fixture', type=Path, help='explicit freshly packed compiler candidate; default is the published fixture') parser.add_argument('--source-root', type=Path, default=ROOT, help='authored source snapshot; must exactly match each prepared source/digest') @@ -185,35 +185,31 @@ def main(): identities[alias] = re.search(r'sourceBuildGraph -> ([0-9a-f]{64})', imported)[1] membership = relocate_patterns(old, candidates) if args.relocate_patterns else old additions = [] - compiler_owned = {'io_v2': 'lib/io-v2.tri', 'io_v2_storage': 'lib/io-v2/storage.tri', - 'io_v2_text': 'lib/io-v2/text.tri'} if args.compiler_io_cohort else {} + compiler_owned = {'io': 'lib/io.tri', 'io_storage': 'lib/io/storage.tri', + 'io_text': 'lib/io/text.tri'} if args.compiler_io_cohort else {} if args.compiler_io_cohort: - if not args.add_io_v2_support: - raise ValueError('compiler IO cohort requires explicit IO-v2 support expansion') + if not args.add_io_support: + raise ValueError('compiler IO cohort requires explicit IO support expansion') rows = [line.split('\t') for line in compiler.with_suffix('.v1').read_text().splitlines()] if ['bundle', digest('tricu.compiler-library-fixture.v1', compiler_bytes)] not in rows: raise ValueError('compiler metadata identity mismatch') - required = dict(compiler_owned, compiler_io='lib/source/io-v2.tri') + required = dict(compiler_owned, compiler_io='lib/source/io.tri') for alias, path in required.items(): owned = [row for row in rows if row[:1] == ['module'] and row[2] == alias] if len(owned) != 1 or owned[0][3:] != [path, digest('arboricx.source.v1', (source_root/path).read_bytes())]: raise ValueError('compiler source ownership mismatch: ' + alias) - if any(row[:1] == ['module'] and row[2] == 'io' for row in rows): - raise ValueError('general IO must not remain compiler-owned') if set(compiler_owned) & candidates.keys(): raise ValueError('compiler-owned modules submitted as support candidates') - if args.add_io_v2_support: + if args.add_io_support: if not args.relocate_patterns: - raise ValueError('IO-v2 support expansion requires the existing patterns relocation') - from native_library_migration import IO_V2_SUPPORT - expanded = {alias: path for alias, path in IO_V2_SUPPORT.items() if alias not in compiler_owned} - if args.compiler_io_cohort: - expanded['io'] = 'lib/io.tri' + raise ValueError('IO support expansion requires the existing patterns relocation') + from native_library_migration import IO_SUPPORT + expanded = {alias: path for alias, path in IO_SUPPORT.items() if alias not in compiler_owned} for alias, path in expanded.items(): if alias not in candidates or (candidates[alias][0]['legacy'], candidates[alias][0]['path']) != (alias, path): - raise ValueError('missing/mismatched rebuilt IO-v2 provider: ' + alias) + raise ValueError('missing/mismatched rebuilt IO provider: ' + alias) if any(row.startswith('module\t') and row.split('\t')[2] == alias for row in membership): - raise ValueError('IO-v2 addition already in original cohort') + raise ValueError('IO addition already in original cohort') membership.append('\t'.join(['module', alias, alias, path, digest('arboricx.source.v1', candidates[alias][1])])) additions.append('\t'.join(['added', alias, alias, path])) modules = updated_module_rows(membership, candidates) @@ -262,8 +258,8 @@ def main(): check_source_dependencies(directory, source_root) if compiler.read_bytes() != compiler_bytes: raise ValueError('compiler fixture changed during publication') - metadata = ['tricu.support-library-fixture.v5' if args.compiler_io_cohort else - 'tricu.support-library-fixture.v4' if args.add_io_v2_support else + metadata = ['tricu.support-library-fixture.v6' if args.compiler_io_cohort else + 'tricu.support-library-fixture.v4' if args.add_io_support else 'tricu.support-library-fixture.v3' if args.relocate_patterns else 'tricu.support-library-fixture.v2', *modules] if args.relocate_patterns: diff --git a/test/shared_library_generation.py b/test/shared_library_generation.py @@ -15,11 +15,11 @@ ROOT = Path(__file__).resolve().parent.parent def published_objects(directory): - """Committed names only; SIGKILL can leave private IO-v2 stages behind.""" + """Committed names only; SIGKILL can leave private IO stages behind.""" objects = {} for path in (directory / 'objects').glob('*/*'): if '.tricu-stage-' in path.name: - continue # Reserved by IOV2.Staging; never a publishable object name. + continue # Reserved by IO.Staging; never a publishable object name. assert len(path.name) == 64 and all(c in '0123456789abcdef' for c in path.name), path assert path.parent.name == path.name[:3], path objects[path.name] = path.read_bytes() diff --git a/test/streaming_protocol.py b/test/streaming_protocol.py @@ -137,7 +137,7 @@ def main(): assert len(list(staging.iterdir())) == 16 assert request(b'GET /_arboricx/health HTTP/1.1\r\nHost: local\r\n\r\n', 200) == b'OK\n' else: - # V2 bounds connections, not a separate native upload + # HostIO bounds connections, not a separate native upload # table. The seventeenth connection waits in the backlog # until the oldest owned child is joined. with connect() as queued: diff --git a/test/unit.py b/test/unit.py @@ -513,18 +513,18 @@ class BootstrapAcceptanceTests(unittest.TestCase): class SourceMappedKitTests(unittest.TestCase): def test_projection_keeps_authored_sources_and_explicit_framing(self): from prepare_compiler_kit import project - _, sources, worker, adapter = project(ROOT/'bootstrap/compiler-source-map.io-v2.v1', ROOT, 2, 'stable') - self.assertEqual(sources['compiler_io'], (ROOT/'lib/source/io-v2.tri').read_bytes()) + _, sources, worker, adapter = project(ROOT/'bootstrap/compiler-source-map.v1', ROOT, 2, 'stable') + self.assertEqual(sources['compiler_io'], (ROOT/'lib/source/io.tri').read_bytes()) self.assertIn('sourceMapBuildStable = M.sourceMapBuildStable', worker) - self.assertIn('!import io_v2 as IO', adapter) + self.assertIn('!import io as IO', adapter) self.assertEqual(sources['source_object'], (ROOT/'lib/source/object.tri').read_bytes()) - with self.assertRaisesRegex(ValueError, 'lacks adapter IO module'): + with self.assertRaisesRegex(ValueError, 'requires IO protocol 2'): project(ROOT/'bootstrap/compiler-source-map.v1', ROOT, 1, 'stable') - self.assertNotIn('io', sources) + self.assertEqual(sources['io'], (ROOT/'lib/io.tri').read_bytes()) self.assertIn('!import compiler_io *', sources['source_object'].decode()) _, _, worker, adapter = project(ROOT/'bootstrap/compiler-source-map.v1', ROOT, 2, 'optimized') self.assertIn('sourceMapBuild = M.sourceMapBuild', worker) - self.assertIn('!import io_v2 as IO', adapter) + self.assertIn('!import io as IO', adapter) class ArchiveRecoveryPolicyTests(unittest.TestCase): @@ -672,8 +672,8 @@ class CompilerDependencyTests(unittest.TestCase): from bootstrap_generations import source_map _, entries, required = source_map( ROOT / 'bootstrap/compiler-source-map.v1', ROOT, 'source_map') - self.assertEqual(entries['compiler_io'][2], ['base', 'io_v2', 'io_v2_storage', 'io_v2_text']) - self.assertNotIn('io', entries) + self.assertEqual(entries['compiler_io'][2], ['base', 'io', 'io_storage', 'io_text']) + self.assertEqual(entries['io'][1], 'lib/io.tri') self.assertNotIn('patterns', entries) self.assertEqual(set(entries), required) diff --git a/test/verified_tree_loading.py b/test/verified_tree_loading.py @@ -13,4 +13,4 @@ parser.add_argument('--publish',action='store_true',help='also publish raw parse parser.add_argument('--memory-mib',type=int,default=32768) parser.add_argument('--seconds',type=float,default=240) parser.parse_args() -parser.error("Retired v1 source-splicing probe; use io_v2_indexed.py, io_v2_indexed_write.py and io_v2_compiler_store.py with the current compiler.") +parser.error("Retired v1 source-splicing probe; use io_indexed.py, io_indexed_write.py and io_compiler_store.py with the current compiler.") diff --git a/tricu-apps/arboricxServer.tri b/tricu-apps/arboricxServer.tri @@ -1,10 +1,10 @@ -!import io_v2 as IO +!import io as IO !import arboricx_handlers as Registry -!import arboricx_streaming_v2 as Server +!import arboricx_streaming as Server !export main -- Scoped registry with a 1-MiB body/object budget and FIFO16 admission. -- Store initialization and listener failures terminate startup. -- Build/deploy: nix build .#arboricxServer (see docs/build-and-run.md). -main = IO.io (Server.serve "./store" 1048576 Registry.stdoutLogV2 +main = IO.io (Server.serve "./store" 1048576 Registry.stdoutLog (IO.ipv4 [0 0 0 0] 8080) 16 (_ : IO.pure t)) diff --git a/tricu-apps/arboricxStreaming.tri b/tricu-apps/arboricxStreaming.tri @@ -0,0 +1,8 @@ +!import base as B +!import io as IO +!import arboricx_handlers as Registry +!import arboricx_streaming as Server +!export main +-- Opt-in native OS authority. Bounded FIFO connection admission; no deadlines. +main = IO.io (Server.serve "./store" 1048576 Registry.stdoutLog + (IO.ipv4 [0 0 0 0] 8080) 16 (_ : IO.pure t)) diff --git a/tricu-apps/arboricxStreamingV2.tri b/tricu-apps/arboricxStreamingV2.tri @@ -1,8 +0,0 @@ -!import base as B -!import io_v2 as IO -!import arboricx_handlers as Registry -!import arboricx_streaming_v2 as Server -!export main --- Opt-in native OS authority. Bounded FIFO connection admission; no deadlines. -main = IO.io (Server.serve "./store" 1048576 Registry.stdoutLogV2 - (IO.ipv4 [0 0 0 0] 8080) 16 (_ : IO.pure t)) diff --git a/tricu.cabal b/tricu.cabal @@ -85,23 +85,23 @@ executable tricu ContentStore.Object ContentStore.Resolver IODriver - IOV2.Resolver - IOV2.Network - IOV2.Filesystem - IOV2.Resources - IOV2.Driver - IOV2.Protocol - IOV2.Wire - IOV2.Tasks - IOV2.Staging - IOV2.Digest - IOV2.Codec - IOV2.ByteStream - IOV2.Clock - IOV2.Process - IOV2.Context - IOV2.UTF8 - IOV2.Descriptor + IO.Resolver + IO.Network + IO.Filesystem + IO.Resources + IO.Driver + IO.Protocol + IO.Wire + IO.Tasks + IO.Staging + IO.Digest + IO.Codec + IO.ByteStream + IO.Clock + IO.Process + IO.Context + IO.UTF8 + IO.Descriptor Module.Manifest Paths_tricu REPL @@ -215,23 +215,23 @@ test-suite tricu-tests ContentStore.Object ContentStore.Resolver IODriver - IOV2.Resolver - IOV2.Network - IOV2.Filesystem - IOV2.Resources - IOV2.Driver - IOV2.Protocol - IOV2.Wire - IOV2.Tasks - IOV2.Staging - IOV2.Digest - IOV2.Codec - IOV2.ByteStream - IOV2.Clock - IOV2.Process - IOV2.Context - IOV2.UTF8 - IOV2.Descriptor + IO.Resolver + IO.Network + IO.Filesystem + IO.Resources + IO.Driver + IO.Protocol + IO.Wire + IO.Tasks + IO.Staging + IO.Digest + IO.Codec + IO.ByteStream + IO.Clock + IO.Process + IO.Context + IO.UTF8 + IO.Descriptor Module.Manifest Paths_tricu REPL @@ -250,22 +250,22 @@ test-suite tricu-tests ParserValueTests PortableTestCompiler IODriverTests - IOV2Tests - IOV2ClockTests - IOV2ProcessTests - IOV2ContextTests - IOV2ProtocolTests - IOV2TaskTests - IOV2ResolverTests + IOTests + IOClockTests + IOProcessTests + IOContextTests + IOProtocolTests + IOTaskTests + IOResolverTests SnapshotTests IndexedWriterTests - IOV2SyncTests - IOV2ByteStreamTests - IOV2UTF8Tests - IOV2NetworkTests - IOV2FilesystemTests - IOV2PositionTests - IOV2StandardTests - IOV2StorageTests + IOSyncTests + IOByteStreamTests + IOUTF8Tests + IONetworkTests + IOFilesystemTests + IOPositionTests + IOStandardTests + IOStorageTests IOBoundaryTests ReferenceFixtures diff --git a/tricu.workspace b/tricu.workspace @@ -47,11 +47,9 @@ handler_testing lib/handlers/testing.tri arboricx_handlers lib/arboricx/handlers.tri handler_upload lib/handlers/upload.tri arboricx_streaming lib/arboricx/streaming.tri -io_v2 lib/io-v2.tri -io_v2_artifacts lib/io-v2/artifacts.tri -io_v2_objects lib/io-v2/objects.tri -io_v2_text lib/io-v2/text.tri -io_v2_storage lib/io-v2/storage.tri -io_v2_http lib/io-v2/http-transport.tri -arboricx_streaming_v2 lib/arboricx/streaming-v2.tri -compiler_io lib/source/io-v2.tri +io_artifacts lib/io/artifacts.tri +io_objects lib/io/objects.tri +io_text lib/io/text.tri +io_storage lib/io/storage.tri +io_http lib/io/http-transport.tri +compiler_io lib/source/io.tri