Merge v3-t44: conversation chain to Exec, grant inheritance (T44, P3-f)
This commit is contained in:
+29
-2
@@ -53126,6 +53126,31 @@ async function openStorage(path3) {
|
||||
}
|
||||
|
||||
// environment.ts
|
||||
var Started = defineDoc({
|
||||
kind: "board.started",
|
||||
version: 1,
|
||||
checkpointWhen: () => false,
|
||||
scope: "conversation",
|
||||
history: "latest",
|
||||
fork: "initial",
|
||||
initial: () => ({})
|
||||
});
|
||||
function conversationChains(harness2, context) {
|
||||
const cache = /* @__PURE__ */ new Map();
|
||||
const chain2 = (id) => {
|
||||
let result = cache.get(id);
|
||||
if (!result) {
|
||||
result = (async () => {
|
||||
const record = await harness2.commit((tx) => tx.conversation(id), context);
|
||||
const up = record?.owner?.conversationId ?? record?.parent?.conversationId ?? (await harness2.snapshot(Started, id, context))?.by;
|
||||
return [String(id), ...up === void 0 ? [] : await chain2(up)];
|
||||
})();
|
||||
cache.set(id, result);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
return chain2;
|
||||
}
|
||||
var ops = Deno.core.ops;
|
||||
var ok = (value2) => ({ ok: true, value: value2 });
|
||||
var path2 = (cwd, input) => {
|
||||
@@ -53137,6 +53162,7 @@ var path2 = (cwd, input) => {
|
||||
return "/" + parts.join("/");
|
||||
};
|
||||
function environment(cwd = "/", conversation) {
|
||||
const conversations = Array.isArray(conversation) ? conversation : conversation === void 0 ? [] : [String(conversation)];
|
||||
const call = (kind, p, bytes2 = []) => ops.op_file(kind, path2(cwd, p), bytes2);
|
||||
const env = {
|
||||
id: "world",
|
||||
@@ -53215,7 +53241,7 @@ function environment(cwd = "/", conversation) {
|
||||
}
|
||||
};
|
||||
try {
|
||||
const rid = ops.op_exec(command, options?.cwd ?? cwd, options?.timeout, conversation === void 0 ? void 0 : String(conversation));
|
||||
const rid = ops.op_exec(command, options?.cwd ?? cwd, options?.timeout, void 0, void 0, conversations);
|
||||
const decoders = { stdout: new TextDecoder(), stderr: new TextDecoder() };
|
||||
let result;
|
||||
while (true) {
|
||||
@@ -54219,8 +54245,9 @@ var harness = await Harness.open(storage, {
|
||||
settings: { followUpMode: "all", get extensions() {
|
||||
return registry.snapshot().installed();
|
||||
} },
|
||||
env: (target) => environment(target.cwd ?? "/", target.conversationId)
|
||||
env: async (target) => environment(target.cwd ?? "/", await chain(target.conversationId))
|
||||
}, BACKGROUND_CONTEXT);
|
||||
var chain = conversationChains(harness, BACKGROUND_CONTEXT);
|
||||
var root = await harness.root(BACKGROUND_CONTEXT, { agent: { model: { provider: "openai", modelId: model2.id }, thinkingLevel: "low", cwd: "/" } });
|
||||
var storedAgent = await harness.snapshot(AgentDoc, root.id, BACKGROUND_CONTEXT);
|
||||
if (Array.isArray(storedAgent?.extensions) && storedAgent.extensions.length === 2 && [CodingTools, CredentialRequests].every((extension) => storedAgent.extensions.includes(extension.name))) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,81 @@
|
||||
// Real bash tools and extension commands retain the nearest-first grant ancestry (D47, D63).
|
||||
import { BACKGROUND_CONTEXT as context } from '@earendil-works/chord/context';
|
||||
import { createModels } from '@earendil-works/pi-ai/models';
|
||||
import { fauxProvider, fauxAssistantMessage, fauxToolCall } from '@earendil-works/pi-ai/providers/faux';
|
||||
import { Harness, createRegistry, defineDoc, defineExtension, defineTask } from '@earendil-works/pi-durable';
|
||||
import { CodingTools } from '@earendil-works/pi-durable/tools';
|
||||
import { environment, conversationChains } from './environment';
|
||||
import { exec, fs } from './extensions';
|
||||
import { openStorage } from './sqlite';
|
||||
const assert = (condition: any, message: string) => { if (!condition) throw new Error(message); };
|
||||
const equal = (a: any, b: any) => assert(JSON.stringify(a) === JSON.stringify(b), 'Wrong conversation chain');
|
||||
const Started = defineDoc<{ by?: number }>({ kind: 'board.started', version: 1, checkpointWhen: () => false,
|
||||
scope: 'conversation', history: 'latest', fork: 'initial', initial: () => ({}) });
|
||||
const Anchor = defineTask({ name: 'chain.anchor', version: 1, initial: () => ({ phase: 'done' }),
|
||||
phases: { done: (_task: any, runtime: any, ctx: any) => runtime.commit(() => ({ status: 'terminal', outcome: { status: 'completed', result: null } }), ctx) } });
|
||||
const models = createModels(), faux = fauxProvider({ tokensPerSecond: 100000 }); models.setProvider(faux.provider);
|
||||
const registry = createRegistry(); registry.install(CodingTools); registry.install(defineExtension({ name: 'chain', tasks: [Anchor] }));
|
||||
const harness = await Harness.open(await openStorage(globalThis.storagePath), { models, registry,
|
||||
env: async (target: any) => environment('/', await chain(target.conversationId)) }, context);
|
||||
const chain = conversationChains(harness, context);
|
||||
const agent = { model: { provider: 'faux', modelId: 'faux-1' }, extensions: [CodingTools], cwd: '/' };
|
||||
const root = await harness.root(context, { agent });
|
||||
const owned = async (parent: any) => parent.commit(async (tx: any) => {
|
||||
const taskId = await tx.createTask(Anchor, null, { ownership: { kind: 'conversation' } });
|
||||
return tx.createConversation({ ownership: { kind: 'task', taskId } });
|
||||
}, context);
|
||||
let records: any;
|
||||
if (!globalThis.chainReady) {
|
||||
const parentRecord = await owned(root);
|
||||
const parent = await harness.conversation(parentRecord.id, context);
|
||||
const child = await owned(parent);
|
||||
const entry = await parent.commit((tx: any) => tx.appendEntry(parent.id, { kind: 'chain.origin', data: {} }), context);
|
||||
const fork = await parent.commit((tx: any) => tx.forkConversation(parent.id, entry.id, { ownership: { kind: 'ownerless' } }), context);
|
||||
const unrelated = await harness.commit((tx: any) => tx.createConversation({ ownership: { kind: 'ownerless' } }), context);
|
||||
const branch = await harness.commit(async (tx: any) => {
|
||||
const record = await tx.createConversation({ ownership: { kind: 'ownerless' } });
|
||||
(await tx.doc(Started, record.id)).by = parent.id;
|
||||
return record;
|
||||
}, context);
|
||||
const ownedFork = await root.commit(async (tx: any) => {
|
||||
const taskId = await tx.createTask(Anchor, null, { ownership: { kind: 'conversation' } });
|
||||
(await tx.doc(Started, fork.id)).by = unrelated.id;
|
||||
return tx.forkConversation(parent.id, fork.parent.at, { ownership: { kind: 'task', taskId } });
|
||||
}, context);
|
||||
records = { parent: parentRecord, child, fork, branch, unrelated, ownedFork };
|
||||
await fs.write('/chain-ids', JSON.stringify(records));
|
||||
await harness.close(context);
|
||||
} else {
|
||||
records = JSON.parse(await fs.read('/chain-ids'));
|
||||
const { parent, child, fork, branch, unrelated, ownedFork } = records;
|
||||
harness.resume();
|
||||
try {
|
||||
for (const record of [child, fork, branch, unrelated]) {
|
||||
const conversation = await harness.conversation(record.id, context);
|
||||
await conversation.configure(agent, context);
|
||||
faux.setResponses([
|
||||
fauxAssistantMessage([fauxToolCall('bash', { command: `curl http://host.sausage/whoami` }, { id: `identity-${record.id}` })], { stopReason: 'toolUse' }),
|
||||
fauxAssistantMessage([fauxToolCall('bash', { command: `curl -i http://127.0.0.1:${globalThis.chainPort}/token` }, { id: `fetch-${record.id}` })], { stopReason: 'toolUse' }),
|
||||
fauxAssistantMessage('done'),
|
||||
]);
|
||||
await (await conversation.submit({ type: 'input', content: 'Check inherited access' }, context)).wait(context);
|
||||
const entries = (await conversation.context(context)).entries;
|
||||
const tools = entries.filter((entry: any) => entry.kind === 'pi.tool-result');
|
||||
const output = (entry: any) => entry.model.flatMap((message: any) => message.content ?? []).filter((block: any) => block.type === 'text').map((block: any) => block.text).join('');
|
||||
const identity = JSON.parse(output(tools[0]));
|
||||
equal(identity.conversations, record.id === unrelated.id ? [String(record.id)] : [String(record.id), String(parent.id), String(root.id)]);
|
||||
const response = output(tools[1]);
|
||||
assert(response.includes(record.id === unrelated.id ? '403' : 'accepted'), 'Wrong inherited authorization');
|
||||
}
|
||||
equal(await chain(ownedFork.id), [String(ownedFork.id), String(root.id)]);
|
||||
assert(await harness.snapshot(Started, unrelated.id, context) === undefined, 'Missing starter must remain absent');
|
||||
assert(chain(child.id) === chain(child.id), 'The chain must use its cached promise');
|
||||
const denied = await exec(['curl', '-i', `http://127.0.0.1:${globalThis.chainPort}/unrelated`], { cwd: '/', conversations: await chain(child.id) });
|
||||
assert(denied.stdout.includes('403'), 'An unrelated grant must not cover the child');
|
||||
const delegated = await exec(['curl', 'http://host.sausage/whoami'], { cwd: '/', conversations: await chain(child.id) });
|
||||
assert(delegated.code === 0, 'Extension exec failed');
|
||||
equal(JSON.parse(delegated.stdout).conversations, [String(child.id), String(parent.id), String(root.id)]);
|
||||
const cleared = await exec(['curl', 'http://host.sausage/whoami'], { cwd: '/', conversations: [] });
|
||||
assert(cleared.code === 0, 'Explicit empty chain failed'); equal(JSON.parse(cleared.stdout).conversations, []);
|
||||
} finally { await harness.close(context); }
|
||||
}
|
||||
+25
-2
@@ -1,5 +1,27 @@
|
||||
// The coding tools see only world paths. Rust owns the VFS and process execution.
|
||||
import { LineScanner } from '@earendil-works/pi-durable/env';
|
||||
import { defineDoc } from '@earendil-works/pi-durable';
|
||||
|
||||
// D63: read the optional branch starter without creating a document.
|
||||
const Started = defineDoc<{ by?: number }>({ kind: 'board.started', version: 1,
|
||||
checkpointWhen: () => false, scope: 'conversation', history: 'latest', fork: 'initial', initial: () => ({}) });
|
||||
export function conversationChains(harness: any, context: any) {
|
||||
const cache = new Map<number, Promise<string[]>>();
|
||||
const chain = (id: number): Promise<string[]> => {
|
||||
let result = cache.get(id);
|
||||
if (!result) {
|
||||
result = (async () => {
|
||||
const record = await harness.commit((tx: any) => tx.conversation(id), context);
|
||||
const up = record?.owner?.conversationId ?? record?.parent?.conversationId ??
|
||||
(await harness.snapshot(Started, id, context))?.by;
|
||||
return [String(id), ...(up === undefined ? [] : await chain(up))];
|
||||
})();
|
||||
cache.set(id, result);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
return chain;
|
||||
}
|
||||
const ops = Deno.core.ops;
|
||||
const ok = (value: any) => ({ ok: true, value });
|
||||
const path = (cwd: string, input: string) => {
|
||||
@@ -10,7 +32,8 @@ const path = (cwd: string, input: string) => {
|
||||
}
|
||||
return '/' + parts.join('/');
|
||||
};
|
||||
export function environment(cwd = '/', conversation?: string | number) {
|
||||
export function environment(cwd = '/', conversation?: string | number | string[]) {
|
||||
const conversations = Array.isArray(conversation) ? conversation : conversation === undefined ? [] : [String(conversation)];
|
||||
const call = (kind: string, p: string, bytes: number[] = []) => ops.op_file(kind, path(cwd, p), bytes);
|
||||
const env: any = {
|
||||
id: 'world', cwd,
|
||||
@@ -87,7 +110,7 @@ export function environment(cwd = '/', conversation?: string | number) {
|
||||
}
|
||||
};
|
||||
try {
|
||||
const rid = ops.op_exec(command, options?.cwd ?? cwd, options?.timeout, conversation === undefined ? undefined : String(conversation));
|
||||
const rid = ops.op_exec(command, options?.cwd ?? cwd, options?.timeout, undefined, undefined, conversations);
|
||||
const decoders = { stdout: new TextDecoder(), stderr: new TextDecoder() };
|
||||
let result;
|
||||
while (true) {
|
||||
|
||||
+3
-2
@@ -5,7 +5,7 @@ import { openaiProvider } from '@earendil-works/pi-ai/providers/openai';
|
||||
import { Harness, AgentDoc, createRegistry } from '@earendil-works/pi-durable';
|
||||
import { CodingTools } from '@earendil-works/pi-durable/tools';
|
||||
import { openStorage } from './sqlite';
|
||||
import { environment } from './environment';
|
||||
import { environment, conversationChains } from './environment';
|
||||
import { CredentialRequests, startRequests } from './credentials';
|
||||
import { serve } from './input';
|
||||
import { loadExtensions } from './extensions';
|
||||
@@ -32,8 +32,9 @@ registry.install(CredentialRequests);
|
||||
export const storage = await openStorage(globalThis.storagePath);
|
||||
const harness = await Harness.open(storage, {
|
||||
models, registry, settings: { followUpMode: 'all', get extensions() { return registry.snapshot().installed(); } },
|
||||
env: (target: any) => environment(target.cwd ?? '/', target.conversationId),
|
||||
env: async (target: any) => environment(target.cwd ?? '/', await chain(target.conversationId)),
|
||||
}, context);
|
||||
const chain = conversationChains(harness, context);
|
||||
const root = await harness.root(context, { agent: { model: { provider: 'openai', modelId: model.id }, thinkingLevel: 'low', cwd: '/' } });
|
||||
// T32 stored this exact built-in list in the console root. Use registry defaults now.
|
||||
const storedAgent = await harness.snapshot(AgentDoc, root.id, context);
|
||||
|
||||
@@ -21249,6 +21249,15 @@ function createCodingTools(options = {}) {
|
||||
var CodingTools = createCodingTools();
|
||||
|
||||
// environment.ts
|
||||
var Started = defineDoc({
|
||||
kind: "board.started",
|
||||
version: 1,
|
||||
checkpointWhen: () => false,
|
||||
scope: "conversation",
|
||||
history: "latest",
|
||||
fork: "initial",
|
||||
initial: () => ({})
|
||||
});
|
||||
var ops = Deno.core.ops;
|
||||
var ok = (value2) => ({ ok: true, value: value2 });
|
||||
var path = (cwd, input) => {
|
||||
@@ -21260,6 +21269,7 @@ var path = (cwd, input) => {
|
||||
return "/" + parts.join("/");
|
||||
};
|
||||
function environment(cwd = "/", conversation) {
|
||||
const conversations = Array.isArray(conversation) ? conversation : conversation === void 0 ? [] : [String(conversation)];
|
||||
const call = (kind, p, bytes2 = []) => ops.op_file(kind, path(cwd, p), bytes2);
|
||||
const env = {
|
||||
id: "world",
|
||||
@@ -21338,7 +21348,7 @@ function environment(cwd = "/", conversation) {
|
||||
}
|
||||
};
|
||||
try {
|
||||
const rid = ops.op_exec(command, options?.cwd ?? cwd, options?.timeout, conversation === void 0 ? void 0 : String(conversation));
|
||||
const rid = ops.op_exec(command, options?.cwd ?? cwd, options?.timeout, void 0, void 0, conversations);
|
||||
const decoders = { stdout: new TextDecoder(), stderr: new TextDecoder() };
|
||||
let result;
|
||||
while (true) {
|
||||
|
||||
+11
-1
@@ -21249,6 +21249,15 @@ function createCodingTools(options = {}) {
|
||||
var CodingTools = createCodingTools();
|
||||
|
||||
// environment.ts
|
||||
var Started = defineDoc({
|
||||
kind: "board.started",
|
||||
version: 1,
|
||||
checkpointWhen: () => false,
|
||||
scope: "conversation",
|
||||
history: "latest",
|
||||
fork: "initial",
|
||||
initial: () => ({})
|
||||
});
|
||||
var ops = Deno.core.ops;
|
||||
var ok = (value2) => ({ ok: true, value: value2 });
|
||||
var path = (cwd, input) => {
|
||||
@@ -21260,6 +21269,7 @@ var path = (cwd, input) => {
|
||||
return "/" + parts.join("/");
|
||||
};
|
||||
function environment(cwd = "/", conversation) {
|
||||
const conversations = Array.isArray(conversation) ? conversation : conversation === void 0 ? [] : [String(conversation)];
|
||||
const call = (kind, p, bytes2 = []) => ops.op_file(kind, path(cwd, p), bytes2);
|
||||
const env = {
|
||||
id: "world",
|
||||
@@ -21338,7 +21348,7 @@ function environment(cwd = "/", conversation) {
|
||||
}
|
||||
};
|
||||
try {
|
||||
const rid = ops.op_exec(command, options?.cwd ?? cwd, options?.timeout, conversation === void 0 ? void 0 : String(conversation));
|
||||
const rid = ops.op_exec(command, options?.cwd ?? cwd, options?.timeout, void 0, void 0, conversations);
|
||||
const decoders = { stdout: new TextDecoder(), stderr: new TextDecoder() };
|
||||
let result2;
|
||||
while (true) {
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
//! A faux model runs real bash and curl with inherited conversation grants through the host hook.
|
||||
use anyhow::{Result, ensure};
|
||||
use sausage::{
|
||||
api::Api,
|
||||
cred::{Caller, Cred, grants::Grant, types::Subject},
|
||||
world::World,
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
fn components() -> Result<PathBuf> {
|
||||
let userland = Path::new(env!("CARGO_MANIFEST_DIR")).join("../userland");
|
||||
let target = Path::new(env!("CARGO_TARGET_TMPDIR")).parent().unwrap();
|
||||
if !userland.join("cwd-init/init.o").is_file() {
|
||||
ensure!(
|
||||
std::process::Command::new("nu")
|
||||
.args(["--no-config-file", "cwd-init/build.nu"])
|
||||
.current_dir(&userland)
|
||||
.status()?
|
||||
.success(),
|
||||
"cwd fixture build failed"
|
||||
);
|
||||
}
|
||||
ensure!(
|
||||
std::process::Command::new("cargo")
|
||||
.args([
|
||||
"build",
|
||||
"--release",
|
||||
"--locked",
|
||||
"--target",
|
||||
"wasm32-wasip2",
|
||||
"-j",
|
||||
"2",
|
||||
"-p",
|
||||
"curl",
|
||||
"--target-dir"
|
||||
])
|
||||
.arg(target)
|
||||
.current_dir(&userland)
|
||||
.status()?
|
||||
.success(),
|
||||
"curl fixture build failed"
|
||||
);
|
||||
let release = target.join("wasm32-wasip2/release");
|
||||
if !release.join("brush.wasm").is_file() {
|
||||
ensure!(
|
||||
std::process::Command::new("nu")
|
||||
.arg("brush/build.nu")
|
||||
.current_dir(&userland)
|
||||
.status()?
|
||||
.success(),
|
||||
"shell fixture build failed"
|
||||
);
|
||||
}
|
||||
Ok(release)
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn ancestry_reaches_the_real_hook() -> Result<()> {
|
||||
let components = components()?;
|
||||
let temp = tempfile::tempdir()?;
|
||||
let world = World::open(temp.path())?;
|
||||
let disk = temp.path().join("world");
|
||||
std::fs::create_dir_all(disk.join("bin"))?;
|
||||
for (source, name) in [("curl.wasm", "curl.wasm"), ("brush.wasm", "sh.wasm")] {
|
||||
std::fs::copy(components.join(source), disk.join("bin").join(name))?;
|
||||
}
|
||||
let server = Arc::new(
|
||||
tiny_http::Server::http("127.0.0.1:0")
|
||||
.map_err(|_| anyhow::anyhow!("fixture bind failed"))?,
|
||||
);
|
||||
let port = server.server_addr().to_ip().unwrap().port();
|
||||
let cred = Arc::new(Cred::open(&world.host_dir)?);
|
||||
let caller = Caller::default();
|
||||
let token = uuid::Uuid::new_v4().to_string();
|
||||
let api = Arc::new(Api::open(
|
||||
cred.clone(),
|
||||
&world.host_dir,
|
||||
"localhost",
|
||||
"http://localhost:8081",
|
||||
)?);
|
||||
let runtime = Arc::new(sausage::process::Runtime::new_with_hooks(
|
||||
World {
|
||||
root: world.root.clone(),
|
||||
host_dir: world.host_dir.clone(),
|
||||
processes: world.processes.clone(),
|
||||
},
|
||||
cred.clone(),
|
||||
sausage::process::default_script(),
|
||||
Some(api),
|
||||
)?);
|
||||
sausage::harness::run_module_with_credentials(
|
||||
World {
|
||||
root: world.root.clone(),
|
||||
host_dir: world.host_dir.clone(),
|
||||
processes: world.processes.clone(),
|
||||
},
|
||||
runtime.clone(),
|
||||
cred.clone(),
|
||||
include_str!("../js/chain-test-bundle.js"),
|
||||
)
|
||||
.await?;
|
||||
let records: serde_json::Value =
|
||||
serde_json::from_slice(&std::fs::read(disk.join("chain-ids"))?)?;
|
||||
for (path, subject) in [
|
||||
("/token", records["parent"]["id"].to_string()),
|
||||
("/unrelated", records["unrelated"]["id"].to_string()),
|
||||
] {
|
||||
let item = cred.create_item(serde_json::from_value(json!({"name":path,"rules":[{"scheme":"http","hosts":["127.0.0.1"],"port":port,"path_prefixes":[path]}],"transform_js":sausage::cred::summary::script("bearer")?,"fields":[{"name":"token","field_type":"text"}]}))?, &caller, cred.unlocked()?)?;
|
||||
cred.set_field(
|
||||
&item.id,
|
||||
&item.fields[0].field_id,
|
||||
&token,
|
||||
&caller,
|
||||
cred.unlocked()?,
|
||||
)?;
|
||||
cred.grant_create(
|
||||
Grant {
|
||||
id: String::new(),
|
||||
items: vec![item.id],
|
||||
subject: Subject::Conversation(subject),
|
||||
exp: None,
|
||||
max_uses: None,
|
||||
uses: 0,
|
||||
revoked: false,
|
||||
validator: None,
|
||||
note: None,
|
||||
},
|
||||
&caller,
|
||||
cred.unlocked()?,
|
||||
)?;
|
||||
}
|
||||
let serving = server.clone();
|
||||
let expected = format!("Bearer {token}");
|
||||
let thread = std::thread::spawn(move || {
|
||||
let mut accepted = 0;
|
||||
while let Ok(request) = serving.recv() {
|
||||
let valid = request.headers().iter().any(|header| {
|
||||
header.field.equiv("Authorization") && header.value.as_str() == expected
|
||||
});
|
||||
if valid {
|
||||
accepted += 1;
|
||||
}
|
||||
let _ = request.respond(tiny_http::Response::from_string(if valid {
|
||||
"accepted"
|
||||
} else {
|
||||
"missing"
|
||||
}));
|
||||
}
|
||||
accepted
|
||||
});
|
||||
let source = format!(
|
||||
"globalThis.chainReady = true; globalThis.chainPort = {port};\n{}",
|
||||
include_str!("../js/chain-test-bundle.js")
|
||||
);
|
||||
let result = tokio::time::timeout(
|
||||
Duration::from_secs(90),
|
||||
sausage::harness::run_module_with_credentials(world, runtime, cred, &source),
|
||||
)
|
||||
.await;
|
||||
server.unblock();
|
||||
let accepted = thread.join().unwrap();
|
||||
result??;
|
||||
ensure!(accepted == 3, "unexpected number of injected requests");
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user