Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
4729517
fix(perf): アニメーション倍率の基準時間を global.css に揃え、medium を足す (#1224)
hitalin Oct 10, 2026
e5b6c66
chore: merge fix/anim-scale-base-durations into develop
hitalin Oct 10, 2026
8c740dc
fix(tasks): ID が空・使えない文字のときも欄の直下に保存されない理由を出す (#1225)
hitalin Oct 10, 2026
39278ce
chore: merge fix/task-id-validation into develop
hitalin Oct 10, 2026
334f601
fix(media): 動画を切り替えたらポスターの読み込みもやり直す (#1226)
hitalin Oct 10, 2026
963381f
chore: merge fix/lightbox-video-poster-reset into develop
hitalin Oct 10, 2026
e504b77
chore(dev): 画像の中継が断った要求の理由と出どころをログに残す (#1229)
hitalin Oct 10, 2026
f4eedb8
chore: merge chore/media-proxy-reject-log into develop
hitalin Oct 10, 2026
cd4bdde
fix(profiles): 起動時の読み込みに失敗したセッションではプロファイルのファイルに書かない
hitalin Oct 10, 2026
e1c44c5
chore: merge fix/profile-load-failure-guard into develop
hitalin Oct 10, 2026
3e0d447
fix(themes): 同じ ID のテーマファイルが別名で増えないようにする
hitalin Oct 10, 2026
6bcd949
chore: merge fix/theme-duplicate-file-guard into develop
hitalin Oct 10, 2026
8d4a0df
revert(media): 取得の取り消しまわりの 2 コミットを戻す (Windows で絵文字などの画像が出ない)
hitalin Oct 10, 2026
1978392
chore: merge fix/revert-inflight-cancel into develop
hitalin Oct 10, 2026
f438e93
chore: bump version to 1.83.2
hitalin Oct 10, 2026
4d429e6
fix(i18n): プロファイルの読込失敗の文言を「デフォルトのデッキ」に揃える
hitalin Oct 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 16 additions & 1 deletion crates/notecore/src/http_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -505,7 +505,22 @@ async fn proxy_auth_middleware(
if bool::from(presented.as_bytes().ct_eq(token.0.as_bytes())) {
Ok(next.run(req).await)
} else {
tracing::warn!(uri = %req.uri().path(), "media proxy request without a valid token");
// 出どころを突き止めるため、トークンが無いのか違うのか (古いトークン等) と、
// どの面が要求したかを残す (#1229)。トークン自体はログに出さない
let header = |name: &str| {
req.headers()
.get(name)
.and_then(|v| v.to_str().ok())
.unwrap_or("")
};
tracing::warn!(
uri = %req.uri().path(),
reason = if presented.is_empty() { "missing" } else { "mismatch" },
referer = header("referer"),
fetch_dest = header("sec-fetch-dest"),
user_agent = header("user-agent"),
"media proxy request without a valid token"
);
Err((
StatusCode::FORBIDDEN,
Json(json!({ "error": "FORBIDDEN", "message": "Invalid media proxy token" })),
Expand Down
151 changes: 9 additions & 142 deletions crates/notecore/src/image_cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -202,36 +202,6 @@ struct MemCacheState {
total_size: usize,
}

/// 「取得中」の登録の後始末。取得の future が途中で取り消される (要求した
/// `<img>` が消えた等) と、登録が残ったままになり、以降の同じ URL の要求が
/// 来ない結果を待ち続けて失敗する。取り消されたら自分の登録だけを外す
/// (同じ URL を後から取り直した別の登録は消さない)
struct InflightGuard {
inflight: Arc<Mutex<InflightMap>>,
hash: String,
rx: Option<watch::Receiver<Option<Result<CacheEntry, String>>>>,
}

impl InflightGuard {
fn disarm(&mut self) {
self.rx = None;
}
}

impl Drop for InflightGuard {
fn drop(&mut self) {
let Some(rx) = self.rx.take() else { return };
let inflight = self.inflight.clone();
let hash = std::mem::take(&mut self.hash);
tokio::spawn(async move {
let mut map = inflight.lock().await;
if map.get(&hash).is_some_and(|cur| cur.same_channel(&rx)) {
map.remove(&hash);
}
});
}
}

pub struct ImageCache {
cache_dir: PathBuf,
inflight: Arc<Mutex<InflightMap>>,
Expand Down Expand Up @@ -523,39 +493,23 @@ impl ImageCache {
}
}

// Inflight dedup: wait for existing fetch, then return from cache.
// Inflight dedup: wait for existing fetch, then return from cache
let mut inflight = self.inflight.lock().await;
if let Some(rx) = inflight.get(&hash) {
if rx.has_changed().is_err() && rx.borrow().is_none() {
// 送り手が結果を送らずに消えた登録 (取り消された取得の残骸)。
// 残すと以降の同じ URL が全部即座に失敗し続けるので外し、
// この要求が先頭として取りに行く
inflight.remove(&hash);
} else {
let mut rx = rx.clone();
drop(inflight);
while rx.changed().await.is_ok() {
if let Some(result) = rx.borrow().as_ref() {
return result.clone().map(StreamingFetchResult::Cached);
}
let mut rx = rx.clone();
drop(inflight);
while rx.changed().await.is_ok() {
if let Some(result) = rx.borrow().as_ref() {
return result.clone().map(StreamingFetchResult::Cached);
}
// 待っている間に先頭が取り消された。ここで取り直さずすぐ返す:
// 待つ側が取り直すと、上流の 429 待ちなどで WebView の同一宛先の
// 同時接続 (6 本) を長く塞ぎ、後ろの画像が全部順番待ちになる。
// 表示側は間を置いて取り直し、そのときは片付いた登録から取れる
return Err("Inflight request dropped".to_string());
}
return Err("Inflight request dropped".to_string());
}

// Register inflight。取得が途中で取り消されたら guard が登録を片付ける
// Register inflight
let (tx, rx) = watch::channel(None);
inflight.insert(hash.clone(), rx.clone());
inflight.insert(hash.clone(), rx);
drop(inflight);
let mut inflight_guard = InflightGuard {
inflight: self.inflight.clone(),
hash: hash.clone(),
rx: Some(rx),
};

// Start HTTP request (headers only, don't consume body yet)
// Some hosts (e.g. i.pximg.net) require a valid Referer header.
Expand Down Expand Up @@ -652,8 +606,6 @@ impl ImageCache {
let host_circuits = self.host_circuits.clone();
let perf = self.perf.clone();

// ここから先の登録の片付けは下の task (と record_negative_and_notify) が持つ
inflight_guard.disarm();
tokio::spawn(async move {
let _permit = _permit; // move permit into task to hold it
let mut all_bytes = Vec::new();
Expand Down Expand Up @@ -1586,89 +1538,4 @@ mod tests {
Ok(_) => panic!("Expected error for HTTP URL"),
}
}

/// 取得の途中 (上流の 429 で待っている間など) で要求が取り消されても、
/// 「取得中」の登録を残さない。残ると以降の同じ URL の要求がすべて
/// 来ない結果を待って即座に失敗し、再起動までその画像が出なくなる
/// (実機ログ 2026-10-09: サーバーアイコンが 1 秒おきに失敗し続けた)
#[tokio::test]
async fn cancelled_leader_does_not_leave_inflight_entry() {
let dir = tempfile::tempdir().unwrap();
let cache = Arc::new(ImageCache::new(dir.path()));
let url = "https://example.com/icon.png";
// 上流に出る前の throttle 待ちで止めておく (ネットワークに出ない)
cache
.throttle_host("example.com", Duration::from_secs(60))
.await;

let c = cache.clone();
let task = tokio::spawn(async move { c.fetch_streaming(url).await });
tokio::time::sleep(Duration::from_millis(50)).await;
assert!(
cache.inflight.lock().await.contains_key(&hex_hash(url)),
"取得中として登録されている"
);

task.abort();
let _ = task.await;
tokio::time::sleep(Duration::from_millis(50)).await;
assert!(
!cache.inflight.lock().await.contains_key(&hex_hash(url)),
"取り消された取得の登録は片付く"
);
}

/// 結果を送らずに消えた登録 (取り消された取得の残骸) が残っていても、
/// 次の要求は即座に失敗せず、登録を外して自分で取りに行く
#[tokio::test]
async fn stale_inflight_entry_is_replaced_by_next_request() {
let dir = tempfile::tempdir().unwrap();
let cache = Arc::new(ImageCache::new(dir.path()));
let url = "https://example.com/emoji.png";
cache
.throttle_host("example.com", Duration::from_secs(60))
.await;
// 結果を送らずに消えた先頭の取得 (送り手を落とした登録)
{
let (tx, rx) = watch::channel(None);
cache.inflight.lock().await.insert(hex_hash(url), rx);
drop(tx);
}

let c = cache.clone();
let task = tokio::spawn(async move { c.fetch_streaming(url).await });
tokio::time::sleep(Duration::from_millis(100)).await;
assert!(
!task.is_finished(),
"即座に Inflight request dropped で失敗せず、自分の取得 (throttle 待ち) に入る"
);
task.abort();
}

/// 待っている間に先頭が取り消されたら、待っていた側は取り直さずすぐ
/// 失敗を返す。取り直すと上流の 429 待ちなどで WebView の同一宛先の
/// 同時接続を長く塞ぎ、後ろの画像が全部止まる (v1.83.0 の Windows で発生)
#[tokio::test]
async fn waiter_fails_fast_when_leader_is_cancelled() {
let dir = tempfile::tempdir().unwrap();
let cache = Arc::new(ImageCache::new(dir.path()));
let url = "https://example.com/avatar.png";
cache
.throttle_host("example.com", Duration::from_secs(60))
.await;

let c = cache.clone();
let leader = tokio::spawn(async move { c.fetch_streaming(url).await });
tokio::time::sleep(Duration::from_millis(50)).await;
let c = cache.clone();
let waiter = tokio::spawn(async move { c.fetch_streaming(url).await });
tokio::time::sleep(Duration::from_millis(50)).await;

leader.abort();
let result = tokio::time::timeout(Duration::from_secs(1), waiter)
.await
.expect("待っていた側はすぐ返る")
.unwrap();
assert!(result.is_err());
}
}
43 changes: 8 additions & 35 deletions crates/notecore/src/ogp/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -238,24 +238,19 @@ impl OgpCache {
return Ok(data);
}

// Inflight dedup。送り手が結果を送らずに消えた登録 (取得の future が
// 取り消された残骸) は外して、この要求が取りに行く。残すと以降の同じ
// URL の要求が全部即座に失敗し続ける
// Inflight dedup
let mut inflight = self.inflight.lock().await;
if let Some(rx) = inflight.get(url) {
if rx.has_changed().is_err() && rx.borrow().is_none() {
inflight.remove(url);
} else {
let mut rx = rx.clone();
drop(inflight);
while rx.changed().await.is_ok() {
if let Some(result) = rx.borrow().as_ref() {
return result.clone();
}
let mut rx = rx.clone();
drop(inflight);
while rx.changed().await.is_ok() {
if let Some(result) = rx.borrow().as_ref() {
return result.clone();
}
return Err("Inflight request dropped".to_string());
}
return Err("Inflight request dropped".to_string());
}

let (tx, rx) = watch::channel(None);
inflight.insert(url.to_string(), rx);
drop(inflight);
Expand Down Expand Up @@ -674,28 +669,6 @@ mod tests {
OgpCache::sanitize_player(&mut data);
assert!(data.player.is_some());
}

/// 先頭の取得が結果を送らずに消えた登録が残っていても、次の要求は
/// 失敗せず自分で取りに行く (以前は再起動までその URL が失敗し続けた)
#[tokio::test]
async fn stale_inflight_entry_does_not_block_next_fetch() {
let dir = tempfile::tempdir().unwrap();
let db = Arc::new(notecli::db::Database::open(&dir.path().join("test.db")).unwrap());
let cache = OgpCache::new(db);
let url = "https://example.com/page";
{
let (tx, rx) = watch::channel(None);
cache.inflight.lock().await.insert(url.to_string(), rx);
drop(tx);
}
let result = cache
.cached_or_fetch(url, |_| {
Box::pin(async { Ok(SummaryData::from_row(&sample_row())) })
})
.await;
assert!(result.is_ok(), "{result:?}");
assert!(!cache.inflight.lock().await.contains_key(url));
}
}

/// Response from Misskey's POST /api/url endpoint (summaly format)
Expand Down
2 changes: 1 addition & 1 deletion crates/notemaid/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "notemaid"
version = "1.83.1"
version = "1.83.2"
edition = "2021"
description = "NoteDeck の AI (メイド): エージェントループ / HEARTBEAT / capability の実行 / セッション / skill / メモ。アプリが使うライブラリと、別プロセスとして走るバイナリの 1 クレート (#1106)"
repository = "https://github.com/notedeck-dev/notedeck"
Expand Down
4 changes: 4 additions & 0 deletions locales/en-US.source.json
Original file line number Diff line number Diff line change
Expand Up @@ -1269,6 +1269,7 @@
"_deckPluginManagerColumn.searchInstalled": "58f1c358bd5d",
"_deckProfile.consoleWidgetsRemoved_plural": "44d14769184d",
"_deckProfile.defaultName": "e46ce51f901f",
"_deckProfile.loadFailed": "08215b65ac44",
"_deckProfileMenu.deleteConfirm": "15854fa0f715",
"_deckProfileMenu.deleteTitle": "b37d985bd968",
"_deckProfileMenu.deleted": "0ccafffd4268",
Expand Down Expand Up @@ -2536,8 +2537,10 @@
"_tasksEditorContent.dragToReorder": "ffdd225c08b2",
"_tasksEditorContent.duplicateId": "122f9194a900",
"_tasksEditorContent.empty": "fcc12e0330c4",
"_tasksEditorContent.emptyId": "23bf92dfde94",
"_tasksEditorContent.groupPlaceholder": "c6995d781d05",
"_tasksEditorContent.inputFields": "09e30c9f6451",
"_tasksEditorContent.invalidId": "938867c0566b",
"_tasksEditorContent.label": "7b06e1c8563f",
"_tasksEditorContent.labelPlaceholder": "e23186b35e16",
"_tasksEditorContent.loadFailed": "e015910cc54e",
Expand All @@ -2559,6 +2562,7 @@
"_tasksEditorContent.variables": "e870538721fa",
"_tasksEditorContent.visualHint": "e93c098a3e29",
"_theme.adoptedDropIns": "8ecd90ed8f44",
"_theme.loadFailed": "1de62c5196cc",
"_theme.quotedName": "3cbcfc9fa9a0",
"_themeCard.addToAccount": "cd8aa1368f14",
"_themeCard.clearAccount": "b2284b5c9dc8",
Expand Down
4 changes: 4 additions & 0 deletions locales/en-US.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1860,6 +1860,8 @@ _tasksEditorContent:
saveFailed: "Couldn’t save: {error}"
objectRequired: "An object ({}) is required"
duplicateId: "Another task already uses this ID. Changes are not saved while IDs are duplicated."
emptyId: "Enter an ID. Changes are not saved while it is empty."
invalidId: "IDs may contain only letters, digits, _ and -. Changes are not saved until this is fixed."
resetFailed: "Couldn’t reset: {error}"
newTaskLabel: "New task"
newInputPrompt: "Enter a value"
Expand Down Expand Up @@ -3344,6 +3346,7 @@ _deckProfile:
one: "Removed {count} legacy AiScript Console widget. Its code is lost (the scratchpad column offers the same functionality)."
other: "Removed {count} legacy AiScript Console widgets. Their code is lost (the scratchpad column offers the same functionality)."
defaultName: "Profile {n}"
loadFailed: "Could not load deck profiles. Showing the default deck. Changes made during this session will not be saved."

_misstore:
hashMismatch: "Hash mismatch. The source may have been tampered with"
Expand Down Expand Up @@ -3379,6 +3382,7 @@ _taskRunner:
_theme:
quotedName: "\"{name}\""
adoptedDropIns: "Imported theme {names} from themes/"
loadFailed: "Could not load themes. Adding, changing, or removing themes during this session will not be saved."

_cacheEviction:
searchPriority: "Search first"
Expand Down
4 changes: 4 additions & 0 deletions locales/ja-JP.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1621,6 +1621,8 @@ _tasksEditorContent:
saveFailed: "保存に失敗しました: {error}"
objectRequired: "オブジェクト ({}) が必要です"
duplicateId: "この ID は他のタスクでも使われています。重複している間は保存されません"
emptyId: "ID を入力してください。空の間は保存されません"
invalidId: "ID に使えるのは英数字・_・- だけです。直すまで保存されません"
resetFailed: "リセットに失敗しました: {error}"
newTaskLabel: "新しいタスク"
newInputPrompt: "入力してください"
Expand Down Expand Up @@ -3069,6 +3071,7 @@ _deckProfile:
consoleWidgetsRemoved_plural:
other: "旧 AiScript Console widget を {count} 件削除しました。コードは失われています (スクラッチパッドカラムで同等の機能が使えます)。"
defaultName: "プロファイル {n}"
loadFailed: "デッキのプロファイルを読み込めませんでした。デフォルトのデッキで表示しています。この起動中の変更は保存されません"

_misstore:
hashMismatch: "ハッシュが一致しません。ソースが改ざんされている可能性があります"
Expand Down Expand Up @@ -3104,6 +3107,7 @@ _taskRunner:
_theme:
quotedName: "「{name}」"
adoptedDropIns: "テーマ {names} を themes/ から取り込みました"
loadFailed: "テーマを読み込めませんでした。この起動中のテーマの追加・変更・削除は保存されません"

_cacheEviction:
searchPriority: "検索優先"
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "notedeck",
"description": "Misskey Pro — integrated deck environment (IDE) for Misskey power users",
"private": true,
"version": "1.83.1",
"version": "1.83.2",
"type": "module",
"packageManager": "pnpm@11.18.0",
"engines": {
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "notedeck"
version = "1.83.1"
version = "1.83.2"
description = "Misskey Pro — integrated deck environment (IDE) for Misskey power users"
edition = "2021"
license = "AGPL-3.0-only"
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"license": {
"name": "MIT"
},
"version": "1.83.1"
"version": "1.83.2"
},
"paths": {
"/api": {
Expand Down
Loading
Loading